Files
oidc-client-rx/src/validation/state-validation.service.ts

539 lines
17 KiB
TypeScript

import { Injectable, inject } from '@outposts/injection-js';
import { type Observable, of } from 'rxjs';
import { map, mergeMap } from 'rxjs/operators';
import type { OpenIdConfiguration } from '../config/openid-configuration';
import type { CallbackContext } from '../flows/callback-context';
import { LoggerService } from '../logging/logger.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { EqualityService } from '../utils/equality/equality.service';
import { FlowHelper } from '../utils/flowHelper/flow-helper.service';
import { TokenHelperService } from '../utils/tokenHelper/token-helper.service';
import { StateValidationResult } from './state-validation-result';
import { TokenValidationService } from './token-validation.service';
import { ValidationResult } from './validation-result';
@Injectable()
export class StateValidationService {
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
private readonly tokenValidationService = inject(TokenValidationService);
private readonly tokenHelperService = inject(TokenHelperService);
private readonly loggerService = inject(LoggerService);
private readonly equalityService = inject(EqualityService);
private readonly flowHelper = inject(FlowHelper);
getValidatedStateResult(
callbackContext: CallbackContext,
configuration: OpenIdConfiguration
): Observable<StateValidationResult> {
const hasError = Boolean(callbackContext.authResult?.error);
const hasCallbackContext = Boolean(callbackContext);
if (!hasCallbackContext || hasError) {
return of(new StateValidationResult('', '', false, {}));
}
return this.validateState(callbackContext, configuration);
}
private validateState(
callbackContext: CallbackContext,
configuration: OpenIdConfiguration
): Observable<StateValidationResult> {
const toReturn = new StateValidationResult();
const authStateControl = this.storagePersistenceService.read(
'authStateControl',
configuration
);
if (
!this.tokenValidationService.validateStateFromHashCallback(
callbackContext.authResult?.state,
authStateControl,
configuration
)
) {
this.loggerService.logWarning(
configuration,
'authCallback incorrect state'
);
toReturn.state = ValidationResult.StatesDoNotMatch;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
const isCurrentFlowImplicitFlowWithAccessToken =
this.flowHelper.isCurrentFlowImplicitFlowWithAccessToken(configuration);
const isCurrentFlowCodeFlow =
this.flowHelper.isCurrentFlowCodeFlow(configuration);
if (isCurrentFlowImplicitFlowWithAccessToken || isCurrentFlowCodeFlow) {
toReturn.accessToken = callbackContext.authResult?.access_token ?? '';
}
const disableIdTokenValidation = configuration.disableIdTokenValidation;
if (disableIdTokenValidation) {
toReturn.state = ValidationResult.Ok;
toReturn.authResponseIsValid = true;
return of(toReturn);
}
const isInRefreshTokenFlow =
callbackContext.isRenewProcess && !!callbackContext.refreshToken;
const hasIdToken = Boolean(callbackContext.authResult?.id_token);
if (isInRefreshTokenFlow && !hasIdToken) {
toReturn.state = ValidationResult.Ok;
toReturn.authResponseIsValid = true;
return of(toReturn);
}
if (hasIdToken) {
const {
clientId,
issValidationOff,
maxIdTokenIatOffsetAllowedInSeconds,
disableIatOffsetValidation,
ignoreNonceAfterRefresh,
renewTimeBeforeTokenExpiresInSeconds,
} = configuration;
toReturn.idToken = callbackContext.authResult?.id_token ?? '';
toReturn.decodedIdToken = this.tokenHelperService.getPayloadFromToken(
toReturn.idToken,
false,
configuration
);
return this.tokenValidationService
.validateSignatureIdToken(
toReturn.idToken,
callbackContext.jwtKeys,
configuration
)
.pipe(
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: <explanation>
mergeMap((isSignatureIdTokenValid: boolean) => {
if (!isSignatureIdTokenValid) {
this.loggerService.logDebug(
configuration,
'authCallback Signature validation failed id_token'
);
toReturn.state = ValidationResult.SignatureFailed;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
const authNonce = this.storagePersistenceService.read(
'authNonce',
configuration
);
if (
!this.tokenValidationService.validateIdTokenNonce(
toReturn.decodedIdToken,
authNonce,
Boolean(ignoreNonceAfterRefresh),
configuration
)
) {
this.loggerService.logWarning(
configuration,
'authCallback incorrect nonce, did you call the checkAuth() method multiple times?'
);
toReturn.state = ValidationResult.IncorrectNonce;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
if (
!this.tokenValidationService.validateRequiredIdToken(
toReturn.decodedIdToken,
configuration
)
) {
this.loggerService.logDebug(
configuration,
'authCallback Validation, one of the REQUIRED properties missing from id_token'
);
toReturn.state = ValidationResult.RequiredPropertyMissing;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
if (
!isInRefreshTokenFlow &&
!this.tokenValidationService.validateIdTokenIatMaxOffset(
toReturn.decodedIdToken,
maxIdTokenIatOffsetAllowedInSeconds ?? 120,
Boolean(disableIatOffsetValidation),
configuration
)
) {
this.loggerService.logWarning(
configuration,
'authCallback Validation, iat rejected id_token was issued too far away from the current time'
);
toReturn.state = ValidationResult.MaxOffsetExpired;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
const authWellKnownEndPoints = this.storagePersistenceService.read(
'authWellKnownEndPoints',
configuration
);
if (authWellKnownEndPoints) {
if (issValidationOff) {
this.loggerService.logDebug(
configuration,
'iss validation is turned off, this is not recommended!'
);
} else if (
!issValidationOff &&
!this.tokenValidationService.validateIdTokenIss(
toReturn.decodedIdToken,
authWellKnownEndPoints.issuer,
configuration
)
) {
this.loggerService.logWarning(
configuration,
'authCallback incorrect iss does not match authWellKnownEndpoints issuer'
);
toReturn.state = ValidationResult.IssDoesNotMatchIssuer;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
} else {
this.loggerService.logWarning(
configuration,
'authWellKnownEndpoints is undefined'
);
toReturn.state = ValidationResult.NoAuthWellKnownEndPoints;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
if (
!this.tokenValidationService.validateIdTokenAud(
toReturn.decodedIdToken,
clientId,
configuration
)
) {
this.loggerService.logWarning(
configuration,
'authCallback incorrect aud'
);
toReturn.state = ValidationResult.IncorrectAud;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
if (
!this.tokenValidationService.validateIdTokenAzpExistsIfMoreThanOneAud(
toReturn.decodedIdToken
)
) {
this.loggerService.logWarning(
configuration,
'authCallback missing azp'
);
toReturn.state = ValidationResult.IncorrectAzp;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
if (
!this.tokenValidationService.validateIdTokenAzpValid(
toReturn.decodedIdToken,
clientId
)
) {
this.loggerService.logWarning(
configuration,
'authCallback incorrect azp'
);
toReturn.state = ValidationResult.IncorrectAzp;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
if (
!this.isIdTokenAfterRefreshTokenRequestValid(
callbackContext,
toReturn.decodedIdToken,
configuration
)
) {
this.loggerService.logWarning(
configuration,
'authCallback pre, post id_token claims do not match in refresh'
);
toReturn.state =
ValidationResult.IncorrectIdTokenClaimsAfterRefresh;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
if (
!isInRefreshTokenFlow &&
!this.tokenValidationService.validateIdTokenExpNotExpired(
toReturn.decodedIdToken,
configuration,
renewTimeBeforeTokenExpiresInSeconds
)
) {
this.loggerService.logWarning(
configuration,
'authCallback id token expired'
);
toReturn.state = ValidationResult.TokenExpired;
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
return this.validateDefault(
isCurrentFlowImplicitFlowWithAccessToken,
isCurrentFlowCodeFlow,
toReturn,
configuration,
callbackContext
);
})
);
}
this.loggerService.logDebug(
configuration,
'No id_token found, skipping id_token validation'
);
return this.validateDefault(
isCurrentFlowImplicitFlowWithAccessToken,
isCurrentFlowCodeFlow,
toReturn,
configuration,
callbackContext
);
}
private validateDefault(
isCurrentFlowImplicitFlowWithAccessToken: boolean,
isCurrentFlowCodeFlow: boolean,
toReturn: StateValidationResult,
configuration: OpenIdConfiguration,
callbackContext: CallbackContext
): Observable<StateValidationResult> {
// flow id_token
if (!isCurrentFlowImplicitFlowWithAccessToken && !isCurrentFlowCodeFlow) {
toReturn.authResponseIsValid = true;
toReturn.state = ValidationResult.Ok;
this.handleSuccessfulValidation(configuration);
this.handleUnsuccessfulValidation(configuration);
return of(toReturn);
}
// only do check if id_token returned, no always the case when using refresh tokens
if (callbackContext.authResult?.id_token) {
const idTokenHeader = this.tokenHelperService.getHeaderFromToken(
toReturn.idToken,
false,
configuration
);
if (
isCurrentFlowCodeFlow &&
!(toReturn.decodedIdToken.at_hash as string)
) {
this.loggerService.logDebug(
configuration,
'Code Flow active, and no at_hash in the id_token, skipping check!'
);
} else {
return this.tokenValidationService
.validateIdTokenAtHash(
toReturn.accessToken,
toReturn.decodedIdToken.at_hash,
idTokenHeader.alg, // 'RS256'
configuration
)
.pipe(
map((valid: boolean) => {
if (!valid || !toReturn.accessToken) {
this.loggerService.logWarning(
configuration,
'authCallback incorrect at_hash'
);
toReturn.state = ValidationResult.IncorrectAtHash;
this.handleUnsuccessfulValidation(configuration);
return toReturn;
}
toReturn.authResponseIsValid = true;
toReturn.state = ValidationResult.Ok;
this.handleSuccessfulValidation(configuration);
return toReturn;
})
);
}
}
toReturn.authResponseIsValid = true;
toReturn.state = ValidationResult.Ok;
this.handleSuccessfulValidation(configuration);
return of(toReturn);
}
private isIdTokenAfterRefreshTokenRequestValid(
callbackContext: CallbackContext,
newIdToken: any,
configuration: OpenIdConfiguration
): boolean {
const { useRefreshToken, disableRefreshIdTokenAuthTimeValidation } =
configuration;
if (!useRefreshToken) {
return true;
}
if (!callbackContext.existingIdToken) {
return true;
}
const decodedIdToken = this.tokenHelperService.getPayloadFromToken(
callbackContext.existingIdToken,
false,
configuration
);
// Upon successful validation of the Refresh Token, the response body is the Token Response of Section 3.1.3.3
// except that it might not contain an id_token.
// If an ID Token is returned as a result of a token refresh request, the following requirements apply:
// its iss Claim Value MUST be the same as in the ID Token issued when the original authentication occurred,
if (decodedIdToken.iss !== newIdToken.iss) {
this.loggerService.logDebug(
configuration,
`iss do not match: ${decodedIdToken.iss} ${newIdToken.iss}`
);
return false;
}
// its azp Claim Value MUST be the same as in the ID Token issued when the original authentication occurred;
// if no azp Claim was present in the original ID Token, one MUST NOT be present in the new ID Token, and
// otherwise, the same rules apply as apply when issuing an ID Token at the time of the original authentication.
if (decodedIdToken.azp !== newIdToken.azp) {
this.loggerService.logDebug(
configuration,
`azp do not match: ${decodedIdToken.azp} ${newIdToken.azp}`
);
return false;
}
// its sub Claim Value MUST be the same as in the ID Token issued when the original authentication occurred,
if (decodedIdToken.sub !== newIdToken.sub) {
this.loggerService.logDebug(
configuration,
`sub do not match: ${decodedIdToken.sub} ${newIdToken.sub}`
);
return false;
}
// its aud Claim Value MUST be the same as in the ID Token issued when the original authentication occurred,
if (
!this.equalityService.isStringEqualOrNonOrderedArrayEqual(
decodedIdToken?.aud,
newIdToken?.aud
)
) {
this.loggerService.logDebug(
configuration,
`aud in new id_token is not valid: '${decodedIdToken?.aud}' '${newIdToken.aud}'`
);
return false;
}
if (disableRefreshIdTokenAuthTimeValidation) {
return true;
}
// its iat Claim MUST represent the time that the new ID Token is issued,
// if the ID Token contains an auth_time Claim, its value MUST represent the time of the original authentication
// - not the time that the new ID token is issued,
if (decodedIdToken.auth_time !== newIdToken.auth_time) {
this.loggerService.logDebug(
configuration,
`auth_time do not match: ${decodedIdToken.auth_time} ${newIdToken.auth_time}`
);
return false;
}
return true;
}
private handleSuccessfulValidation(configuration: OpenIdConfiguration): void {
const { autoCleanStateAfterAuthentication } = configuration;
this.storagePersistenceService.write('authNonce', null, configuration);
if (autoCleanStateAfterAuthentication) {
this.storagePersistenceService.write(
'authStateControl',
'',
configuration
);
}
this.loggerService.logDebug(
configuration,
'authCallback token(s) validated, continue'
);
}
private handleUnsuccessfulValidation(
configuration: OpenIdConfiguration
): void {
const { autoCleanStateAfterAuthentication } = configuration;
this.storagePersistenceService.write('authNonce', null, configuration);
if (autoCleanStateAfterAuthentication) {
this.storagePersistenceService.write(
'authStateControl',
'',
configuration
);
}
this.loggerService.logDebug(configuration, 'authCallback token(s) invalid');
}
}