8dc351ab21
JwtStrategy read `role` straight from the JWT payload, so a role change or deactivation stayed ineffective until the 1h access token expired: after a promotion the menus showed (via /users/me) but admin endpoints returned 403 because RolesGuard still saw the old token role; after a demotion the old admin kept API access. Load the user from the DB in validate() and use the current role; reject inactive users. Frontend: poll /users/me in the dashboard layout (+ on window focus) so the sidebar reflects role changes without a hard reload, and redirect away from pages the new role can no longer reach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
49 lines
1.6 KiB
TypeScript
49 lines
1.6 KiB
TypeScript
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
import { PassportStrategy } from '@nestjs/passport';
|
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { UsersService } from '../../users/users.service';
|
|
|
|
interface JwtPayload {
|
|
sub: string;
|
|
email: string;
|
|
role: string;
|
|
/** Present on impersonation tokens: the acting admin. */
|
|
act?: { sub: string; role: string };
|
|
}
|
|
|
|
@Injectable()
|
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
constructor(
|
|
configService: ConfigService,
|
|
private readonly usersService: UsersService,
|
|
) {
|
|
super({
|
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
ignoreExpiration: false,
|
|
secretOrKey: configService.getOrThrow<string>('jwt.secret'),
|
|
});
|
|
}
|
|
|
|
async validate(payload: JwtPayload) {
|
|
// Resolve the role (and active status) from the database on every request
|
|
// rather than trusting the token. A token's `role` claim is frozen at login,
|
|
// so a role change or deactivation would otherwise stay ineffective until the
|
|
// token expires. `payload.sub` is the impersonated user on impersonation
|
|
// tokens, so this keeps "login as user" working with the target's live role.
|
|
const user = await this.usersService.findById(payload.sub);
|
|
if (!user || !user.isActive) {
|
|
throw new UnauthorizedException();
|
|
}
|
|
|
|
return {
|
|
id: user.id,
|
|
email: user.email ?? user.phone,
|
|
role: user.role,
|
|
// Non-null only while an admin is impersonating this user.
|
|
impersonatedBy: payload.act?.sub ?? null,
|
|
impersonatorRole: payload.act?.role ?? null,
|
|
};
|
|
}
|
|
}
|