fix(auth): one-time SSO and MFA tokens, permission changes effective on every replica
- Every JWT carries a jti. The SSO completion token and the MFA challenge (once a code was accepted) are recorded as used in ConsumedToken; a replayed request or copied cookie cannot open a second session. Used ids are cleaned up after they expire. - The token-validity check (active user, active role, password change) reads fresh on every request and refreshes the permission cache, so a deactivation or role change made on another replica applies to the very next request instead of up to 30 s later. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
fc6af8737b
commit
45efb08369
16 files changed
+166
-23
No files matched your search
@@ -17,6 +17,7 @@ import { MfaRequiredResponse } from '../auth.controller';
|
||||
import { AccessTokenDto } from '../dto/access-token.dto';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
|
||||
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
|
||||
import { OneTimeTokenService } from '../one-time-token.service';
|
||||
import { SsoService } from './sso.service';
|
||||
|
||||
const PKCE_COOKIE = 'oidcPkceVerifier';
|
||||
@@ -40,6 +41,7 @@ export class SsoController {
|
||||
constructor(
|
||||
private readonly ssoService: SsoService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly oneTimeTokens: OneTimeTokenService,
|
||||
) {}
|
||||
|
||||
private frontendLoginUrl(): string {
|
||||
@@ -135,7 +137,8 @@ export class SsoController {
|
||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||
);
|
||||
}
|
||||
const { userId } = this.ssoService.verifySsoCode(ssoCode);
|
||||
const { userId, claims } = this.ssoService.verifySsoCode(ssoCode);
|
||||
await this.oneTimeTokens.consume(claims);
|
||||
const result = await this.authService.issueTokensForSsoUser(userId);
|
||||
if (result.mfaRequired) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
|
||||
@@ -263,8 +263,13 @@ describe('SsoService', () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
purpose: 'sso-complete',
|
||||
jti: 'jti-1',
|
||||
exp: 1_900_000_000,
|
||||
});
|
||||
expect(ssoService.verifySsoCode('token')).toEqual({
|
||||
userId: 'user-1',
|
||||
claims: { jti: 'jti-1', exp: 1_900_000_000 },
|
||||
});
|
||||
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('throws UnauthorizedException when verification fails', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import * as client from 'openid-client';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import {
|
||||
OneTimeTokenClaims,
|
||||
TOKEN_AUDIENCE,
|
||||
jwtSignOptions,
|
||||
jwtVerifyOptions,
|
||||
@@ -178,13 +179,15 @@ export class SsoService {
|
||||
);
|
||||
}
|
||||
|
||||
verifySsoCode(ssoCode: string): { userId: string } {
|
||||
let payload: SsoCompleteTokenPayload;
|
||||
verifySsoCode(ssoCode: string): {
|
||||
userId: string;
|
||||
claims: OneTimeTokenClaims;
|
||||
} {
|
||||
let payload: SsoCompleteTokenPayload & OneTimeTokenClaims;
|
||||
try {
|
||||
payload = this.jwtService.verify<SsoCompleteTokenPayload>(
|
||||
ssoCode,
|
||||
jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete),
|
||||
);
|
||||
payload = this.jwtService.verify<
|
||||
SsoCompleteTokenPayload & OneTimeTokenClaims
|
||||
>(ssoCode, jwtVerifyOptions(TOKEN_AUDIENCE.ssoComplete));
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
|
||||
@@ -193,6 +196,9 @@ export class SsoService {
|
||||
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
|
||||
throw new UnauthorizedException('Ungültiger Token');
|
||||
}
|
||||
return { userId: payload.sub };
|
||||
return {
|
||||
userId: payload.sub,
|
||||
claims: { jti: payload.jti, exp: payload.exp },
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user