feat: init and fork some code from angular-auth-oidc-client
Some checks are pending
Build, Lint & Test Lib / Built, Lint and Test Library (push) Waiting to run
Build, Lint & Test Lib / Angular latest (push) Blocked by required conditions
Build, Lint & Test Lib / Angular latest & Schematics Job (push) Blocked by required conditions
Build, Lint & Test Lib / Angular latest Standalone & Schematics Job (push) Blocked by required conditions
Build, Lint & Test Lib / Angular 16 & RxJs 6 (push) Blocked by required conditions
Build, Lint & Test Lib / Angular V16 (push) Blocked by required conditions
Docs / Build and Deploy Docs Job (push) Waiting to run
Docs / Close Pull Request Job (push) Waiting to run

This commit is contained in:
2025-01-18 01:05:00 +08:00
parent 276d9fbda8
commit 983254164b
201 changed files with 35689 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { JwtKeys } from '../validation/jwtkeys';
import { StateValidationResult } from '../validation/state-validation-result';
export interface CallbackContext {
code: string;
refreshToken: string;
state: string;
sessionState: string | null;
authResult: AuthResult | null;
isRenewProcess: boolean;
jwtKeys: JwtKeys | null;
validationResult: StateValidationResult | null;
existingIdToken: string | null;
}
export interface AuthResult {
// eslint-disable-next-line @typescript-eslint/naming-convention
id_token?: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
access_token?: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
refresh_token?: string;
error?: any;
// eslint-disable-next-line @typescript-eslint/naming-convention
session_state?: any;
state?: any;
scope?: string;
expires_in?: number;
token_type?: string;
}

View File

@@ -0,0 +1,336 @@
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { mockProvider } from '../../../test/auto-mock';
import { createRetriableStream } from '../../../test/create-retriable-stream.helper';
import { DataService } from '../../api/data.service';
import { LoggerService } from '../../logging/logger.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { UrlService } from '../../utils/url/url.service';
import { TokenValidationService } from '../../validation/token-validation.service';
import { CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { CodeFlowCallbackHandlerService } from './code-flow-callback-handler.service';
describe('CodeFlowCallbackHandlerService', () => {
let service: CodeFlowCallbackHandlerService;
let dataService: DataService;
let storagePersistenceService: StoragePersistenceService;
let tokenValidationService: TokenValidationService;
let urlService: UrlService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
CodeFlowCallbackHandlerService,
mockProvider(UrlService),
mockProvider(LoggerService),
mockProvider(TokenValidationService),
mockProvider(FlowsDataService),
mockProvider(StoragePersistenceService),
mockProvider(DataService),
],
});
});
beforeEach(() => {
service = TestBed.inject(CodeFlowCallbackHandlerService);
dataService = TestBed.inject(DataService);
urlService = TestBed.inject(UrlService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
tokenValidationService = TestBed.inject(TokenValidationService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('codeFlowCallback', () => {
it('throws error if no state is given', waitForAsync(() => {
const getUrlParameterSpy = spyOn(
urlService,
'getUrlParameter'
).and.returnValue('params');
getUrlParameterSpy.withArgs('test-url', 'state').and.returnValue('');
service
.codeFlowCallback('test-url', { configId: 'configId1' })
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('throws error if no code is given', waitForAsync(() => {
const getUrlParameterSpy = spyOn(
urlService,
'getUrlParameter'
).and.returnValue('params');
getUrlParameterSpy.withArgs('test-url', 'code').and.returnValue('');
service
.codeFlowCallback('test-url', { configId: 'configId1' })
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('returns callbackContext if all params are good', waitForAsync(() => {
spyOn(urlService, 'getUrlParameter').and.returnValue('params');
const expectedCallbackContext = {
code: 'params',
refreshToken: '',
state: 'params',
sessionState: 'params',
authResult: null,
isRenewProcess: false,
jwtKeys: null,
validationResult: null,
existingIdToken: null,
} as CallbackContext;
service
.codeFlowCallback('test-url', { configId: 'configId1' })
.subscribe((callbackContext) => {
expect(callbackContext).toEqual(expectedCallbackContext);
});
}));
});
describe('codeFlowCodeRequest ', () => {
const HTTP_ERROR = new HttpErrorResponse({});
const CONNECTION_ERROR = new HttpErrorResponse({
error: new ProgressEvent('error'),
status: 0,
statusText: 'Unknown Error',
url: 'https://identity-server.test/openid-connect/token',
});
it('throws error if state is not correct', waitForAsync(() => {
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(false);
service
.codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' })
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('throws error if authWellknownEndpoints is null is given', waitForAsync(() => {
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(true);
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue(null);
service
.codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' })
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('throws error if tokenendpoint is null is given', waitForAsync(() => {
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(true);
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ tokenEndpoint: null });
service
.codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' })
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('calls dataService if all params are good', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(of({}));
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(true);
service
.codeFlowCodeRequest({} as CallbackContext, { configId: 'configId1' })
.subscribe(() => {
expect(postSpy).toHaveBeenCalledOnceWith(
'tokenEndpoint',
undefined,
{ configId: 'configId1' },
jasmine.any(HttpHeaders)
);
});
}));
it('calls url service with custom token params', waitForAsync(() => {
const urlServiceSpy = spyOn(
urlService,
'createBodyForCodeFlowCodeRequest'
);
const config = {
configId: 'configId1',
customParamsCodeRequest: { foo: 'bar' },
};
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(true);
const postSpy = spyOn(dataService, 'post').and.returnValue(of({}));
service
.codeFlowCodeRequest({ code: 'foo' } as CallbackContext, config)
.subscribe(() => {
expect(urlServiceSpy).toHaveBeenCalledOnceWith('foo', config, {
foo: 'bar',
});
expect(postSpy).toHaveBeenCalledTimes(1);
});
}));
it('calls dataService with correct headers if all params are good', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(of({}));
const config = {
configId: 'configId1',
customParamsCodeRequest: { foo: 'bar' },
};
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(true);
service
.codeFlowCodeRequest({} as CallbackContext, config)
.subscribe(() => {
const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders;
expect(httpHeaders.has('Content-Type')).toBeTrue();
expect(httpHeaders.get('Content-Type')).toBe(
'application/x-www-form-urlencoded'
);
});
}));
it('returns error in case of http error', waitForAsync(() => {
spyOn(dataService, 'post').and.returnValue(throwError(() => HTTP_ERROR));
const config = {
configId: 'configId1',
customParamsCodeRequest: { foo: 'bar' },
authority: 'authority',
};
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
service.codeFlowCodeRequest({} as CallbackContext, config).subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('retries request in case of no connection http error and succeeds', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(
createRetriableStream(
throwError(() => CONNECTION_ERROR),
of({})
)
);
const config = {
configId: 'configId1',
customParamsCodeRequest: { foo: 'bar' },
authority: 'authority',
};
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(true);
service.codeFlowCodeRequest({} as CallbackContext, config).subscribe({
next: (res) => {
expect(res).toBeTruthy();
expect(postSpy).toHaveBeenCalledTimes(1);
},
error: (err) => {
// fails if there should be a result
expect(err).toBeFalsy();
},
});
}));
it('retries request in case of no connection http error and fails because of http error afterwards', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(
createRetriableStream(
throwError(() => CONNECTION_ERROR),
throwError(() => HTTP_ERROR)
)
);
const config = {
configId: 'configId1',
customParamsCodeRequest: { foo: 'bar' },
authority: 'authority',
};
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
spyOn(
tokenValidationService,
'validateStateFromHashCallback'
).and.returnValue(true);
service.codeFlowCodeRequest({} as CallbackContext, config).subscribe({
next: (res) => {
// fails if there should be a result
expect(res).toBeFalsy();
},
error: (err) => {
expect(err).toBeTruthy();
expect(postSpy).toHaveBeenCalledTimes(1);
},
});
}));
});
});

View File

@@ -0,0 +1,161 @@
import { HttpHeaders } from '@ngify/http';
import { inject, Injectable } from 'injection-js';
import { Observable, of, throwError, timer } from 'rxjs';
import { catchError, mergeMap, retryWhen, switchMap } from 'rxjs/operators';
import { DataService } from '../../api/data.service';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { UrlService } from '../../utils/url/url.service';
import { TokenValidationService } from '../../validation/token-validation.service';
import { AuthResult, CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { isNetworkError } from './error-helper';
@Injectable()
export class CodeFlowCallbackHandlerService {
private readonly urlService = inject(UrlService);
private readonly loggerService = inject(LoggerService);
private readonly tokenValidationService = inject(TokenValidationService);
private readonly flowsDataService = inject(FlowsDataService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
private readonly dataService = inject(DataService);
// STEP 1 Code Flow
codeFlowCallback(
urlToCheck: string,
config: OpenIdConfiguration
): Observable<CallbackContext> {
const code = this.urlService.getUrlParameter(urlToCheck, 'code');
const state = this.urlService.getUrlParameter(urlToCheck, 'state');
const sessionState = this.urlService.getUrlParameter(
urlToCheck,
'session_state'
);
if (!state) {
this.loggerService.logDebug(config, 'no state in url');
return throwError(() => new Error('no state in url'));
}
if (!code) {
this.loggerService.logDebug(config, 'no code in url');
return throwError(() => new Error('no code in url'));
}
this.loggerService.logDebug(
config,
'running validation for callback',
urlToCheck
);
const initialCallbackContext: CallbackContext = {
code,
refreshToken: '',
state,
sessionState,
authResult: null,
isRenewProcess: false,
jwtKeys: null,
validationResult: null,
existingIdToken: null,
};
return of(initialCallbackContext);
}
// STEP 2 Code Flow // Code Flow Silent Renew starts here
codeFlowCodeRequest(
callbackContext: CallbackContext,
config: OpenIdConfiguration
): Observable<CallbackContext> {
const authStateControl = this.flowsDataService.getAuthStateControl(config);
const isStateCorrect =
this.tokenValidationService.validateStateFromHashCallback(
callbackContext.state,
authStateControl,
config
);
if (!isStateCorrect) {
return throwError(() => new Error('codeFlowCodeRequest incorrect state'));
}
const authWellknownEndpoints = this.storagePersistenceService.read(
'authWellKnownEndPoints',
config
);
const tokenEndpoint = authWellknownEndpoints?.tokenEndpoint;
if (!tokenEndpoint) {
return throwError(() => new Error('Token Endpoint not defined'));
}
let headers: HttpHeaders = new HttpHeaders();
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
const bodyForCodeFlow = this.urlService.createBodyForCodeFlowCodeRequest(
callbackContext.code,
config,
config?.customParamsCodeRequest
);
return this.dataService
.post(tokenEndpoint, bodyForCodeFlow, config, headers)
.pipe(
switchMap((response) => {
if (response) {
const authResult: AuthResult = {
...response,
state: callbackContext.state,
session_state: callbackContext.sessionState,
};
callbackContext.authResult = authResult;
}
return of(callbackContext);
}),
retryWhen((error) => this.handleRefreshRetry(error, config)),
catchError((error) => {
const { authority } = config;
const errorMessage = `OidcService code request ${authority}`;
this.loggerService.logError(config, errorMessage, error);
return throwError(() => new Error(errorMessage));
})
);
}
private handleRefreshRetry(
errors: Observable<unknown>,
config: OpenIdConfiguration
): Observable<unknown> {
return errors.pipe(
mergeMap((error) => {
// retry token refresh if there is no internet connection
if (isNetworkError(error)) {
const { authority, refreshTokenRetryInSeconds } = config;
const errorMessage = `OidcService code request ${authority} - no internet connection`;
this.loggerService.logWarning(config, errorMessage, error);
return timer((refreshTokenRetryInSeconds ?? 0) * 1000);
}
return throwError(() => error);
})
);
}
}

View File

@@ -0,0 +1,57 @@
import { HttpErrorResponse } from '@angular/common/http';
import { isNetworkError } from './error-helper';
describe('error helper', () => {
describe('isNetworkError ', () => {
const HTTP_ERROR = new HttpErrorResponse({});
const CONNECTION_ERROR = new HttpErrorResponse({
error: new ProgressEvent('error'),
status: 0,
statusText: 'Unknown Error',
url: 'https://identity-server.test/openid-connect/token',
});
const UNKNOWN_CONNECTION_ERROR = new HttpErrorResponse({
error: new Error('error'),
status: 0,
statusText: 'Unknown Error',
url: 'https://identity-server.test/openid-connect/token',
});
const PARTIAL_CONNECTION_ERROR = new HttpErrorResponse({
error: new ProgressEvent('error'),
status: 418, // i am a teapot
statusText: 'Unknown Error',
url: 'https://identity-server.test/openid-connect/token',
});
it('returns true on http error with status = 0', () => {
expect(isNetworkError(CONNECTION_ERROR)).toBeTrue();
});
it('returns true on http error with status = 0 and unknown error', () => {
expect(isNetworkError(UNKNOWN_CONNECTION_ERROR)).toBeTrue();
});
it('returns true on http error with status <> 0 and error ProgressEvent', () => {
expect(isNetworkError(PARTIAL_CONNECTION_ERROR)).toBeTrue();
});
it('returns false on non http error', () => {
expect(isNetworkError(new Error('not a HttpErrorResponse'))).toBeFalse();
});
it('returns false on string error', () => {
expect(isNetworkError('not a HttpErrorResponse')).toBeFalse();
});
it('returns false on undefined', () => {
expect(isNetworkError(undefined)).toBeFalse();
});
it('returns false on empty http error', () => {
expect(isNetworkError(HTTP_ERROR)).toBeFalse();
});
});
});

View File

@@ -0,0 +1,14 @@
import { HttpErrorResponse } from '@ngify/http';
/**
* checks if the error is a network error
* by checking if either internal error is a ProgressEvent with type error
* or another error with status 0
* @param error
* @returns true if the error is a network error
*/
export const isNetworkError = (error: unknown): boolean =>
!!error &&
error instanceof HttpErrorResponse &&
((error.error instanceof ProgressEvent && error.error.type === 'error') ||
(error.status === 0 && !!error.error));

View File

@@ -0,0 +1,621 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { mockProvider } from '../../../test/auto-mock';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { LoggerService } from '../../logging/logger.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { JwtKey, JwtKeys } from '../../validation/jwtkeys';
import { ValidationResult } from '../../validation/validation-result';
import { AuthResult, CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { ResetAuthDataService } from '../reset-auth-data.service';
import { SigninKeyDataService } from '../signin-key-data.service';
import { HistoryJwtKeysCallbackHandlerService } from './history-jwt-keys-callback-handler.service';
const DUMMY_JWT_KEYS: JwtKeys = {
keys: [
{
kty: 'some-value1',
use: 'some-value2',
kid: 'some-value3',
x5t: 'some-value4',
e: 'some-value5',
n: 'some-value6',
x5c: ['some-value7'],
},
],
};
describe('HistoryJwtKeysCallbackHandlerService', () => {
let service: HistoryJwtKeysCallbackHandlerService;
let storagePersistenceService: StoragePersistenceService;
let signInKeyDataService: SigninKeyDataService;
let resetAuthDataService: ResetAuthDataService;
let flowsDataService: FlowsDataService;
let authStateService: AuthStateService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
HistoryJwtKeysCallbackHandlerService,
mockProvider(LoggerService),
mockProvider(AuthStateService),
mockProvider(FlowsDataService),
mockProvider(SigninKeyDataService),
mockProvider(StoragePersistenceService),
mockProvider(ResetAuthDataService),
],
});
});
beforeEach(() => {
service = TestBed.inject(HistoryJwtKeysCallbackHandlerService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
resetAuthDataService = TestBed.inject(ResetAuthDataService);
signInKeyDataService = TestBed.inject(SigninKeyDataService);
flowsDataService = TestBed.inject(FlowsDataService);
authStateService = TestBed.inject(AuthStateService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('callbackHistoryAndResetJwtKeys', () => {
it('writes authResult into the storage', waitForAsync(() => {
const storagePersistenceServiceSpy = spyOn(
storagePersistenceService,
'write'
);
const DUMMY_AUTH_RESULT = {
refresh_token: 'dummy_refresh_token',
id_token: 'some-id-token',
};
const callbackContext = {
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
of({ keys: [] } as JwtKeys)
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe(() => {
expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([
['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]],
['jwtKeys', { keys: [] }, allconfigs[0]],
]);
// write authnResult & jwtKeys
expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(2);
});
}));
it('writes refresh_token into the storage without reuse (refresh token rotation)', waitForAsync(() => {
const DUMMY_AUTH_RESULT = {
refresh_token: 'dummy_refresh_token',
id_token: 'some-id-token',
};
const storagePersistenceServiceSpy = spyOn(
storagePersistenceService,
'write'
);
const callbackContext = {
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
of({ keys: [] } as JwtKeys)
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe(() => {
expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([
['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]],
['jwtKeys', { keys: [] }, allconfigs[0]],
]);
// write authnResult & refresh_token & jwtKeys
expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(2);
});
}));
it('writes refresh_token into the storage with reuse (without refresh token rotation)', waitForAsync(() => {
const DUMMY_AUTH_RESULT = {
refresh_token: 'dummy_refresh_token',
id_token: 'some-id-token',
};
const storagePersistenceServiceSpy = spyOn(
storagePersistenceService,
'write'
);
const callbackContext = {
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
allowUnsafeReuseRefreshToken: true,
},
];
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
of({ keys: [] } as JwtKeys)
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe(() => {
expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([
['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]],
['reusable_refresh_token', 'dummy_refresh_token', allconfigs[0]],
['jwtKeys', { keys: [] }, allconfigs[0]],
]);
// write authnResult & refresh_token & jwtKeys
expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(3);
});
}));
it('resetBrowserHistory if historyCleanup is turned on and is not in a renewProcess', waitForAsync(() => {
const DUMMY_AUTH_RESULT = {
id_token: 'some-id-token',
};
const callbackContext = {
isRenewProcess: false,
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: false,
},
];
const windowSpy = spyOn(window.history, 'replaceState');
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
of({ keys: [] } as JwtKeys)
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe(() => {
expect(windowSpy).toHaveBeenCalledTimes(1);
});
}));
it('returns callbackContext with jwtkeys filled if everything works fine', waitForAsync(() => {
const DUMMY_AUTH_RESULT = {
id_token: 'some-id-token',
};
const callbackContext = {
isRenewProcess: false,
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: false,
},
];
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
of({ keys: [{ kty: 'henlo' } as JwtKey] } as JwtKeys)
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe((result) => {
expect(result).toEqual({
isRenewProcess: false,
authResult: DUMMY_AUTH_RESULT,
jwtKeys: { keys: [{ kty: 'henlo' }] },
} as CallbackContext);
});
}));
it('returns error if no jwtKeys have been in the call --> keys are null', waitForAsync(() => {
const DUMMY_AUTH_RESULT = {
id_token: 'some-id-token',
};
const callbackContext = {
isRenewProcess: false,
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: false,
},
];
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
of({} as JwtKeys)
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
error: (err) => {
expect(err.message).toEqual(
`Failed to retrieve signing key with error: Error: Failed to retrieve signing key`
);
},
});
}));
it('returns error if no jwtKeys have been in the call --> keys throw an error', waitForAsync(() => {
const DUMMY_AUTH_RESULT = {
id_token: 'some-id-token',
};
const callbackContext = {
isRenewProcess: false,
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: false,
},
];
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
throwError(() => new Error('error'))
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
error: (err) => {
expect(err.message).toEqual(
`Failed to retrieve signing key with error: Error: Error: error`
);
},
});
}));
it('returns error if callbackContext.authresult has an error property filled', waitForAsync(() => {
const callbackContext = {
authResult: { error: 'someError' },
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
error: (err) => {
expect(err.message).toEqual(
`AuthCallback AuthResult came with error: someError`
);
},
});
}));
it('calls resetAuthorizationData, resets nonce and authStateService in case of an error', waitForAsync(() => {
const callbackContext = {
authResult: { error: 'someError' },
isRenewProcess: false,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
const resetAuthorizationDataSpy = spyOn(
resetAuthDataService,
'resetAuthorizationData'
);
const setNonceSpy = spyOn(flowsDataService, 'setNonce');
const updateAndPublishAuthStateSpy = spyOn(
authStateService,
'updateAndPublishAuthState'
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
error: () => {
expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1);
expect(setNonceSpy).toHaveBeenCalledTimes(1);
expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({
isAuthenticated: false,
validationResult: ValidationResult.SecureTokenServerError,
isRenewProcess: false,
});
},
});
}));
it('calls authStateService.updateAndPublishAuthState with login required if the error is `login_required`', waitForAsync(() => {
const callbackContext = {
authResult: { error: 'login_required' },
isRenewProcess: false,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
const resetAuthorizationDataSpy = spyOn(
resetAuthDataService,
'resetAuthorizationData'
);
const setNonceSpy = spyOn(flowsDataService, 'setNonce');
const updateAndPublishAuthStateSpy = spyOn(
authStateService,
'updateAndPublishAuthState'
);
service
.callbackHistoryAndResetJwtKeys(
callbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
error: () => {
expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1);
expect(setNonceSpy).toHaveBeenCalledTimes(1);
expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({
isAuthenticated: false,
validationResult: ValidationResult.LoginRequired,
isRenewProcess: false,
});
},
});
}));
it('should store jwtKeys', waitForAsync(() => {
const DUMMY_AUTH_RESULT = {
id_token: 'some-id-token',
};
const initialCallbackContext = {
authResult: DUMMY_AUTH_RESULT,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
const storagePersistenceServiceSpy = spyOn(
storagePersistenceService,
'write'
);
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
of(DUMMY_JWT_KEYS)
);
service
.callbackHistoryAndResetJwtKeys(
initialCallbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
next: (callbackContext: CallbackContext) => {
expect(storagePersistenceServiceSpy).toHaveBeenCalledTimes(2);
expect(storagePersistenceServiceSpy.calls.allArgs()).toEqual([
['authnResult', DUMMY_AUTH_RESULT, allconfigs[0]],
['jwtKeys', DUMMY_JWT_KEYS, allconfigs[0]],
]);
expect(callbackContext.jwtKeys).toEqual(DUMMY_JWT_KEYS);
},
error: (err) => {
expect(err).toBeFalsy();
},
});
}));
it('should not store jwtKeys on error', waitForAsync(() => {
const authResult = {
id_token: 'some-id-token',
access_token: 'some-access-token',
} as AuthResult;
const initialCallbackContext = {
authResult,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
const storagePersistenceServiceSpy = spyOn(
storagePersistenceService,
'write'
);
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
throwError(() => new Error('Error'))
);
service
.callbackHistoryAndResetJwtKeys(
initialCallbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
next: (callbackContext: CallbackContext) => {
expect(callbackContext).toBeFalsy();
},
error: (err) => {
expect(err).toBeTruthy();
// storagePersistenceService.write() should not have been called with jwtKeys
expect(storagePersistenceServiceSpy).toHaveBeenCalledOnceWith(
'authnResult',
authResult,
allconfigs[0]
);
},
});
}));
it('should fallback to stored jwtKeys on error', waitForAsync(() => {
const authResult = {
id_token: 'some-id-token',
access_token: 'some-access-token',
} as AuthResult;
const initialCallbackContext = {
authResult,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
const storagePersistenceServiceSpy = spyOn(
storagePersistenceService,
'read'
);
storagePersistenceServiceSpy.and.returnValue(DUMMY_JWT_KEYS);
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
throwError(() => new Error('Error'))
);
service
.callbackHistoryAndResetJwtKeys(
initialCallbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
next: (callbackContext: CallbackContext) => {
expect(storagePersistenceServiceSpy).toHaveBeenCalledOnceWith(
'jwtKeys',
allconfigs[0]
);
expect(callbackContext.jwtKeys).toEqual(DUMMY_JWT_KEYS);
},
error: (err) => {
expect(err).toBeFalsy();
},
});
}));
it('should throw error if no jwtKeys are stored', waitForAsync(() => {
const authResult = {
id_token: 'some-id-token',
access_token: 'some-access-token',
} as AuthResult;
const initialCallbackContext = { authResult } as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
historyCleanupOff: true,
},
];
spyOn(storagePersistenceService, 'read').and.returnValue(null);
spyOn(signInKeyDataService, 'getSigningKeys').and.returnValue(
throwError(() => new Error('Error'))
);
service
.callbackHistoryAndResetJwtKeys(
initialCallbackContext,
allconfigs[0],
allconfigs
)
.subscribe({
next: (callbackContext: CallbackContext) => {
expect(callbackContext).toBeFalsy();
},
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
});
describe('historyCleanUpTurnedOn ', () => {
it('check for false if historyCleanUpTurnedOn is on', () => {
const config = {
configId: 'configId1',
historyCleanupOff: true,
};
const value = (service as any).historyCleanUpTurnedOn(config);
expect(value).toEqual(false);
});
it('check for true if historyCleanUpTurnedOn is off', () => {
const config = {
configId: 'configId1',
historyCleanupOff: false,
};
const value = (service as any).historyCleanUpTurnedOn(config);
expect(value).toEqual(true);
});
});
});

View File

@@ -0,0 +1,186 @@
import { DOCUMENT } from '../../dom';
import { inject, Injectable } from 'injection-js';
import { Observable, of, throwError } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { JwtKeys } from '../../validation/jwtkeys';
import { ValidationResult } from '../../validation/validation-result';
import { CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { ResetAuthDataService } from '../reset-auth-data.service';
import { SigninKeyDataService } from '../signin-key-data.service';
const JWT_KEYS = 'jwtKeys';
@Injectable()
export class HistoryJwtKeysCallbackHandlerService {
private readonly loggerService = inject(LoggerService);
private readonly authStateService = inject(AuthStateService);
private readonly flowsDataService = inject(FlowsDataService);
private readonly signInKeyDataService = inject(SigninKeyDataService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
private readonly resetAuthDataService = inject(ResetAuthDataService);
private readonly document = inject(DOCUMENT);
// STEP 3 Code Flow, STEP 2 Implicit Flow, STEP 3 Refresh Token
callbackHistoryAndResetJwtKeys(
callbackContext: CallbackContext,
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): Observable<CallbackContext> {
let toWrite = { ...callbackContext.authResult };
if (!this.responseHasIdToken(callbackContext)) {
const existingIdToken = this.storagePersistenceService.getIdToken(config);
toWrite = {
...toWrite,
id_token: existingIdToken,
};
}
this.storagePersistenceService.write('authnResult', toWrite, config);
if (
config.allowUnsafeReuseRefreshToken &&
callbackContext.authResult?.refresh_token
) {
this.storagePersistenceService.write(
'reusable_refresh_token',
callbackContext.authResult.refresh_token,
config
);
}
if (
this.historyCleanUpTurnedOn(config) &&
!callbackContext.isRenewProcess
) {
this.resetBrowserHistory();
} else {
this.loggerService.logDebug(config, 'history clean up inactive');
}
if (callbackContext.authResult?.error) {
const errorMessage = `AuthCallback AuthResult came with error: ${callbackContext.authResult.error}`;
this.loggerService.logDebug(config, errorMessage);
this.resetAuthDataService.resetAuthorizationData(config, allConfigs);
this.flowsDataService.setNonce('', config);
this.handleResultErrorFromCallback(
callbackContext.authResult,
callbackContext.isRenewProcess
);
return throwError(() => new Error(errorMessage));
}
this.loggerService.logDebug(
config,
`AuthResult '${JSON.stringify(callbackContext.authResult, null, 2)}'.
AuthCallback created, begin token validation`
);
return this.signInKeyDataService.getSigningKeys(config).pipe(
tap((jwtKeys: JwtKeys) => this.storeSigningKeys(jwtKeys, config)),
catchError((err) => {
// fallback: try to load jwtKeys from storage
const storedJwtKeys = this.readSigningKeys(config);
if (!!storedJwtKeys) {
this.loggerService.logWarning(
config,
`Failed to retrieve signing keys, fallback to stored keys`
);
return of(storedJwtKeys);
}
return throwError(() => new Error(err));
}),
switchMap((jwtKeys) => {
if (jwtKeys) {
callbackContext.jwtKeys = jwtKeys;
return of(callbackContext);
}
const errorMessage = `Failed to retrieve signing key`;
this.loggerService.logWarning(config, errorMessage);
return throwError(() => new Error(errorMessage));
}),
catchError((err) => {
const errorMessage = `Failed to retrieve signing key with error: ${err}`;
this.loggerService.logWarning(config, errorMessage);
return throwError(() => new Error(errorMessage));
})
);
}
private responseHasIdToken(callbackContext: CallbackContext): boolean {
return !!callbackContext?.authResult?.id_token;
}
private handleResultErrorFromCallback(
result: unknown,
isRenewProcess: boolean
): void {
let validationResult = ValidationResult.SecureTokenServerError;
if (
result &&
typeof result === 'object' &&
'error' in result &&
(result.error as string) === 'login_required'
) {
validationResult = ValidationResult.LoginRequired;
}
this.authStateService.updateAndPublishAuthState({
isAuthenticated: false,
validationResult,
isRenewProcess,
});
}
private historyCleanUpTurnedOn(config: OpenIdConfiguration): boolean {
const { historyCleanupOff } = config;
return !historyCleanupOff;
}
private resetBrowserHistory(): void {
this.document.defaultView?.history.replaceState(
{},
this.document.title,
this.document.defaultView.location.origin +
this.document.defaultView.location.pathname
);
}
private storeSigningKeys(
jwtKeys: JwtKeys,
config: OpenIdConfiguration
): void {
this.storagePersistenceService.write(JWT_KEYS, jwtKeys, config);
}
private readSigningKeys(config: OpenIdConfiguration): any {
return this.storagePersistenceService.read(JWT_KEYS, config);
}
}

View File

@@ -0,0 +1,142 @@
import { DOCUMENT } from '../../dom';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { mockProvider } from '../../../test/auto-mock';
import { LoggerService } from '../../logging/logger.service';
import { CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { ResetAuthDataService } from '../reset-auth-data.service';
import { ImplicitFlowCallbackHandlerService } from './implicit-flow-callback-handler.service';
describe('ImplicitFlowCallbackHandlerService', () => {
let service: ImplicitFlowCallbackHandlerService;
let flowsDataService: FlowsDataService;
let resetAuthDataService: ResetAuthDataService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ImplicitFlowCallbackHandlerService,
mockProvider(FlowsDataService),
mockProvider(ResetAuthDataService),
mockProvider(LoggerService),
{
provide: DOCUMENT,
useValue: {
location: {
get hash(): string {
return '&anyFakeHash';
},
set hash(_value) {
// ...
},
},
},
},
],
});
});
beforeEach(() => {
service = TestBed.inject(ImplicitFlowCallbackHandlerService);
flowsDataService = TestBed.inject(FlowsDataService);
resetAuthDataService = TestBed.inject(ResetAuthDataService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('implicitFlowCallback', () => {
it('calls "resetAuthorizationData" if silent renew is not running', waitForAsync(() => {
spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(false);
const resetAuthorizationDataSpy = spyOn(
resetAuthDataService,
'resetAuthorizationData'
);
const allconfigs = [
{
configId: 'configId1',
},
];
service
.implicitFlowCallback(allconfigs[0], allconfigs, 'any-hash')
.subscribe(() => {
expect(resetAuthorizationDataSpy).toHaveBeenCalled();
});
}));
it('does NOT calls "resetAuthorizationData" if silent renew is running', waitForAsync(() => {
spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true);
const resetAuthorizationDataSpy = spyOn(
resetAuthDataService,
'resetAuthorizationData'
);
const allconfigs = [
{
configId: 'configId1',
},
];
service
.implicitFlowCallback(allconfigs[0], allconfigs, 'any-hash')
.subscribe(() => {
expect(resetAuthorizationDataSpy).not.toHaveBeenCalled();
});
}));
it('returns callbackContext if all params are good', waitForAsync(() => {
spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true);
const expectedCallbackContext = {
code: '',
refreshToken: '',
state: '',
sessionState: null,
authResult: { anyHash: '' },
isRenewProcess: true,
jwtKeys: null,
validationResult: null,
existingIdToken: null,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
},
];
service
.implicitFlowCallback(allconfigs[0], allconfigs, 'anyHash')
.subscribe((callbackContext) => {
expect(callbackContext).toEqual(expectedCallbackContext);
});
}));
it('uses window location hash if no hash is passed', waitForAsync(() => {
spyOn(flowsDataService, 'isSilentRenewRunning').and.returnValue(true);
const expectedCallbackContext = {
code: '',
refreshToken: '',
state: '',
sessionState: null,
authResult: { anyFakeHash: '' },
isRenewProcess: true,
jwtKeys: null,
validationResult: null,
existingIdToken: null,
} as CallbackContext;
const allconfigs = [
{
configId: 'configId1',
},
];
service
.implicitFlowCallback(allconfigs[0], allconfigs)
.subscribe((callbackContext) => {
expect(callbackContext).toEqual(expectedCallbackContext);
});
}));
});
});

View File

@@ -0,0 +1,61 @@
import { DOCUMENT } from '../../dom';
import { inject, Injectable } from 'injection-js';
import { Observable, of } from 'rxjs';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { AuthResult, CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { ResetAuthDataService } from '../reset-auth-data.service';
@Injectable()
export class ImplicitFlowCallbackHandlerService {
private readonly loggerService = inject(LoggerService);
private readonly resetAuthDataService = inject(ResetAuthDataService);
private readonly flowsDataService = inject(FlowsDataService);
private readonly document = inject(DOCUMENT);
// STEP 1 Code Flow
// STEP 1 Implicit Flow
implicitFlowCallback(
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[],
hash?: string
): Observable<CallbackContext> {
const isRenewProcessData =
this.flowsDataService.isSilentRenewRunning(config);
this.loggerService.logDebug(config, 'BEGIN callback, no auth data');
if (!isRenewProcessData) {
this.resetAuthDataService.resetAuthorizationData(config, allConfigs);
}
hash = hash || this.document.location.hash.substring(1);
const authResult = hash
.split('&')
.reduce((resultData: any, item: string) => {
const parts = item.split('=');
resultData[parts.shift() as string] = parts.join('=');
return resultData;
}, {} as AuthResult);
const callbackContext: CallbackContext = {
code: '',
refreshToken: '',
state: '',
sessionState: null,
authResult,
isRenewProcess: isRenewProcessData,
jwtKeys: null,
validationResult: null,
existingIdToken: null,
};
return of(callbackContext);
}
}

View File

@@ -0,0 +1,82 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { mockProvider } from '../../../test/auto-mock';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { LoggerService } from '../../logging/logger.service';
import { CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { RefreshSessionCallbackHandlerService } from './refresh-session-callback-handler.service';
describe('RefreshSessionCallbackHandlerService', () => {
let service: RefreshSessionCallbackHandlerService;
let flowsDataService: FlowsDataService;
let authStateService: AuthStateService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
RefreshSessionCallbackHandlerService,
mockProvider(AuthStateService),
mockProvider(LoggerService),
mockProvider(FlowsDataService),
],
});
});
beforeEach(() => {
service = TestBed.inject(RefreshSessionCallbackHandlerService);
flowsDataService = TestBed.inject(FlowsDataService);
authStateService = TestBed.inject(AuthStateService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('refreshSessionWithRefreshTokens', () => {
it('returns callbackContext if all params are good', waitForAsync(() => {
spyOn(
flowsDataService,
'getExistingOrCreateAuthStateControl'
).and.returnValue('state-data');
spyOn(authStateService, 'getRefreshToken').and.returnValue(
'henlo-furiend'
);
spyOn(authStateService, 'getIdToken').and.returnValue('henlo-legger');
const expectedCallbackContext = {
code: '',
refreshToken: 'henlo-furiend',
state: 'state-data',
sessionState: null,
authResult: null,
isRenewProcess: true,
jwtKeys: null,
validationResult: null,
existingIdToken: 'henlo-legger',
} as CallbackContext;
service
.refreshSessionWithRefreshTokens({ configId: 'configId1' })
.subscribe((callbackContext) => {
expect(callbackContext).toEqual(expectedCallbackContext);
});
}));
it('throws error if no refresh token is given', waitForAsync(() => {
spyOn(
flowsDataService,
'getExistingOrCreateAuthStateControl'
).and.returnValue('state-data');
spyOn(authStateService, 'getRefreshToken').and.returnValue('');
spyOn(authStateService, 'getIdToken').and.returnValue('henlo-legger');
service
.refreshSessionWithRefreshTokens({ configId: 'configId1' })
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
});
});

View File

@@ -0,0 +1,64 @@
import { inject, Injectable } from 'injection-js';
import { Observable, of, throwError } from 'rxjs';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { TokenValidationService } from '../../validation/token-validation.service';
import { CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
@Injectable()
export class RefreshSessionCallbackHandlerService {
private readonly loggerService = inject(LoggerService);
private readonly authStateService = inject(AuthStateService);
private readonly flowsDataService = inject(FlowsDataService);
// STEP 1 Refresh session
refreshSessionWithRefreshTokens(
config: OpenIdConfiguration
): Observable<CallbackContext> {
const stateData =
this.flowsDataService.getExistingOrCreateAuthStateControl(config);
this.loggerService.logDebug(
config,
'RefreshSession created. Adding myautostate: ' + stateData
);
const refreshToken = this.authStateService.getRefreshToken(config);
const idToken = this.authStateService.getIdToken(config);
if (refreshToken) {
const callbackContext: CallbackContext = {
code: '',
refreshToken,
state: stateData,
sessionState: null,
authResult: null,
isRenewProcess: true,
jwtKeys: null,
validationResult: null,
existingIdToken: idToken,
};
this.loggerService.logDebug(
config,
'found refresh code, obtaining new credentials with refresh code'
);
// Nonce is not used with refresh tokens; but Key cloak may send it anyway
this.flowsDataService.setNonce(
TokenValidationService.refreshTokenNoncePlaceholder,
config
);
return of(callbackContext);
} else {
const errorMessage = 'no refresh token found, please login';
this.loggerService.logError(config, errorMessage);
return throwError(() => new Error(errorMessage));
}
}
}

View File

@@ -0,0 +1,178 @@
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { mockProvider } from '../../../test/auto-mock';
import { createRetriableStream } from '../../../test/create-retriable-stream.helper';
import { DataService } from '../../api/data.service';
import { LoggerService } from '../../logging/logger.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { UrlService } from '../../utils/url/url.service';
import { CallbackContext } from '../callback-context';
import { RefreshTokenCallbackHandlerService } from './refresh-token-callback-handler.service';
describe('RefreshTokenCallbackHandlerService', () => {
let service: RefreshTokenCallbackHandlerService;
let storagePersistenceService: StoragePersistenceService;
let dataService: DataService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
RefreshTokenCallbackHandlerService,
mockProvider(UrlService),
mockProvider(LoggerService),
mockProvider(DataService),
mockProvider(StoragePersistenceService),
],
});
});
beforeEach(() => {
service = TestBed.inject(RefreshTokenCallbackHandlerService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
dataService = TestBed.inject(DataService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('refreshTokensRequestTokens', () => {
const HTTP_ERROR = new HttpErrorResponse({});
const CONNECTION_ERROR = new HttpErrorResponse({
error: new ProgressEvent('error'),
status: 0,
statusText: 'Unknown Error',
url: 'https://identity-server.test/openid-connect/token',
});
it('throws error if no tokenEndpoint is given', waitForAsync(() => {
(service as any)
.refreshTokensRequestTokens({} as CallbackContext)
.subscribe({
error: (err: unknown) => {
expect(err).toBeTruthy();
},
});
}));
it('calls data service if all params are good', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(of({}));
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
service
.refreshTokensRequestTokens({} as CallbackContext, {
configId: 'configId1',
})
.subscribe(() => {
expect(postSpy).toHaveBeenCalledOnceWith(
'tokenEndpoint',
undefined,
{ configId: 'configId1' },
jasmine.any(HttpHeaders)
);
const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders;
expect(httpHeaders.has('Content-Type')).toBeTrue();
expect(httpHeaders.get('Content-Type')).toBe(
'application/x-www-form-urlencoded'
);
});
}));
it('calls data service with correct headers if all params are good', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(of({}));
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
service
.refreshTokensRequestTokens({} as CallbackContext, {
configId: 'configId1',
})
.subscribe(() => {
const httpHeaders = postSpy.calls.mostRecent().args[3] as HttpHeaders;
expect(httpHeaders.has('Content-Type')).toBeTrue();
expect(httpHeaders.get('Content-Type')).toBe(
'application/x-www-form-urlencoded'
);
});
}));
it('returns error in case of http error', waitForAsync(() => {
spyOn(dataService, 'post').and.returnValue(throwError(() => HTTP_ERROR));
const config = { configId: 'configId1', authority: 'authority' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
service
.refreshTokensRequestTokens({} as CallbackContext, config)
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('retries request in case of no connection http error and succeeds', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(
createRetriableStream(
throwError(() => CONNECTION_ERROR),
of({})
)
);
const config = { configId: 'configId1', authority: 'authority' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
service
.refreshTokensRequestTokens({} as CallbackContext, config)
.subscribe({
next: (res) => {
expect(res).toBeTruthy();
expect(postSpy).toHaveBeenCalledTimes(1);
},
error: (err) => {
// fails if there should be a result
expect(err).toBeFalsy();
},
});
}));
it('retries request in case of no connection http error and fails because of http error afterwards', waitForAsync(() => {
const postSpy = spyOn(dataService, 'post').and.returnValue(
createRetriableStream(
throwError(() => CONNECTION_ERROR),
throwError(() => HTTP_ERROR)
)
);
const config = { configId: 'configId1', authority: 'authority' };
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', config)
.and.returnValue({ tokenEndpoint: 'tokenEndpoint' });
service
.refreshTokensRequestTokens({} as CallbackContext, config)
.subscribe({
next: (res) => {
// fails if there should be a result
expect(res).toBeFalsy();
},
error: (err) => {
expect(err).toBeTruthy();
expect(postSpy).toHaveBeenCalledTimes(1);
},
});
}));
});
});

View File

@@ -0,0 +1,100 @@
import { HttpHeaders } from '@ngify/http';
import { inject, Injectable } from 'injection-js';
import { Observable, of, throwError, timer } from 'rxjs';
import { catchError, mergeMap, retryWhen, switchMap } from 'rxjs/operators';
import { DataService } from '../../api/data.service';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { UrlService } from '../../utils/url/url.service';
import { AuthResult, CallbackContext } from '../callback-context';
import { isNetworkError } from './error-helper';
@Injectable()
export class RefreshTokenCallbackHandlerService {
private readonly urlService = inject(UrlService);
private readonly loggerService = inject(LoggerService);
private readonly dataService = inject(DataService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
// STEP 2 Refresh Token
refreshTokensRequestTokens(
callbackContext: CallbackContext,
config: OpenIdConfiguration,
customParamsRefresh?: { [key: string]: string | number | boolean }
): Observable<CallbackContext> {
let headers: HttpHeaders = new HttpHeaders();
headers = headers.set('Content-Type', 'application/x-www-form-urlencoded');
const authWellknownEndpoints = this.storagePersistenceService.read(
'authWellKnownEndPoints',
config
);
const tokenEndpoint = authWellknownEndpoints?.tokenEndpoint;
if (!tokenEndpoint) {
return throwError(() => new Error('Token Endpoint not defined'));
}
const data = this.urlService.createBodyForCodeFlowRefreshTokensRequest(
callbackContext.refreshToken,
config,
customParamsRefresh
);
return this.dataService
.post<AuthResult>(tokenEndpoint, data, config, headers)
.pipe(
switchMap((response) => {
this.loggerService.logDebug(
config,
`token refresh response: ${response}`
);
if (response) {
response.state = callbackContext.state;
}
callbackContext.authResult = response;
return of(callbackContext);
}),
retryWhen((error) => this.handleRefreshRetry(error, config)),
catchError((error) => {
const { authority } = config;
const errorMessage = `OidcService code request ${authority}`;
this.loggerService.logError(config, errorMessage, error);
return throwError(() => new Error(errorMessage));
})
);
}
private handleRefreshRetry(
errors: Observable<unknown>,
config: OpenIdConfiguration
): Observable<unknown> {
return errors.pipe(
mergeMap((error) => {
// retry token refresh if there is no internet connection
if (isNetworkError(error)) {
const { authority, refreshTokenRetryInSeconds } = config;
const errorMessage = `OidcService code request ${authority} - no internet connection`;
this.loggerService.logWarning(config, errorMessage, error);
return timer((refreshTokenRetryInSeconds ?? 0) * 1000);
}
return throwError(() => error);
})
);
}
}

View File

@@ -0,0 +1,146 @@
import { DOCUMENT } from '../../dom';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of } from 'rxjs';
import { mockProvider } from '../../../test/auto-mock';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { LoggerService } from '../../logging/logger.service';
import { StateValidationResult } from '../../validation/state-validation-result';
import { StateValidationService } from '../../validation/state-validation.service';
import { ValidationResult } from '../../validation/validation-result';
import { CallbackContext } from '../callback-context';
import { ResetAuthDataService } from '../reset-auth-data.service';
import { StateValidationCallbackHandlerService } from './state-validation-callback-handler.service';
describe('StateValidationCallbackHandlerService', () => {
let service: StateValidationCallbackHandlerService;
let stateValidationService: StateValidationService;
let loggerService: LoggerService;
let authStateService: AuthStateService;
let resetAuthDataService: ResetAuthDataService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
StateValidationCallbackHandlerService,
mockProvider(LoggerService),
mockProvider(StateValidationService),
mockProvider(AuthStateService),
mockProvider(ResetAuthDataService),
{
provide: DOCUMENT,
useValue: {
location: {
get hash(): string {
return '&anyFakeHash';
},
set hash(_value) {
// ...
},
},
},
},
],
});
});
beforeEach(() => {
service = TestBed.inject(StateValidationCallbackHandlerService);
stateValidationService = TestBed.inject(StateValidationService);
loggerService = TestBed.inject(LoggerService);
authStateService = TestBed.inject(AuthStateService);
resetAuthDataService = TestBed.inject(ResetAuthDataService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('callbackStateValidation', () => {
it('returns callbackContext with validationResult if validationResult is valid', waitForAsync(() => {
spyOn(stateValidationService, 'getValidatedStateResult').and.returnValue(
of({
idToken: 'idTokenJustForTesting',
authResponseIsValid: true,
} as StateValidationResult)
);
const allConfigs = [{ configId: 'configId1' }];
service
.callbackStateValidation(
{} as CallbackContext,
allConfigs[0],
allConfigs
)
.subscribe((newCallbackContext) => {
expect(newCallbackContext).toEqual({
validationResult: {
idToken: 'idTokenJustForTesting',
authResponseIsValid: true,
},
} as CallbackContext);
});
}));
it('logs error in case of an error', waitForAsync(() => {
spyOn(stateValidationService, 'getValidatedStateResult').and.returnValue(
of({
authResponseIsValid: false,
} as StateValidationResult)
);
const loggerSpy = spyOn(loggerService, 'logWarning');
const allConfigs = [{ configId: 'configId1' }];
service
.callbackStateValidation(
{} as CallbackContext,
allConfigs[0],
allConfigs
)
.subscribe({
error: () => {
expect(loggerSpy).toHaveBeenCalledOnceWith(
allConfigs[0],
'authorizedCallback, token(s) validation failed, resetting. Hash: &anyFakeHash'
);
},
});
}));
it('calls resetAuthDataService.resetAuthorizationData and authStateService.updateAndPublishAuthState in case of an error', waitForAsync(() => {
spyOn(stateValidationService, 'getValidatedStateResult').and.returnValue(
of({
authResponseIsValid: false,
state: ValidationResult.LoginRequired,
} as StateValidationResult)
);
const resetAuthorizationDataSpy = spyOn(
resetAuthDataService,
'resetAuthorizationData'
);
const updateAndPublishAuthStateSpy = spyOn(
authStateService,
'updateAndPublishAuthState'
);
const allConfigs = [{ configId: 'configId1' }];
service
.callbackStateValidation(
{ isRenewProcess: true } as CallbackContext,
allConfigs[0],
allConfigs
)
.subscribe({
error: () => {
expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1);
expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({
isAuthenticated: false,
validationResult: ValidationResult.LoginRequired,
isRenewProcess: true,
});
},
});
}));
});
});

View File

@@ -0,0 +1,75 @@
import { DOCUMENT } from '../../dom';
import { inject, Injectable } from 'injection-js';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { StateValidationResult } from '../../validation/state-validation-result';
import { StateValidationService } from '../../validation/state-validation.service';
import { CallbackContext } from '../callback-context';
import { ResetAuthDataService } from '../reset-auth-data.service';
@Injectable()
export class StateValidationCallbackHandlerService {
private readonly loggerService = inject(LoggerService);
private readonly stateValidationService = inject(StateValidationService);
private readonly authStateService = inject(AuthStateService);
private readonly resetAuthDataService = inject(ResetAuthDataService);
private readonly document = inject(DOCUMENT);
// STEP 4 All flows
callbackStateValidation(
callbackContext: CallbackContext,
configuration: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): Observable<CallbackContext> {
return this.stateValidationService
.getValidatedStateResult(callbackContext, configuration)
.pipe(
map((validationResult: StateValidationResult) => {
callbackContext.validationResult = validationResult;
if (validationResult.authResponseIsValid) {
this.authStateService.setAuthorizationData(
validationResult.accessToken,
callbackContext.authResult,
configuration,
allConfigs
);
return callbackContext;
} else {
const errorMessage = `authorizedCallback, token(s) validation failed, resetting. Hash: ${this.document.location.hash}`;
this.loggerService.logWarning(configuration, errorMessage);
this.resetAuthDataService.resetAuthorizationData(
configuration,
allConfigs
);
this.publishUnauthorizedState(
callbackContext.validationResult,
callbackContext.isRenewProcess
);
throw new Error(errorMessage);
}
})
);
}
private publishUnauthorizedState(
stateValidationResult: StateValidationResult,
isRenewProcess: boolean
): void {
this.authStateService.updateAndPublishAuthState({
isAuthenticated: false,
validationResult: stateValidationResult.state,
isRenewProcess,
});
}
}

View File

@@ -0,0 +1,457 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of } from 'rxjs';
import { mockProvider } from '../../../test/auto-mock';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { LoggerService } from '../../logging/logger.service';
import { UserService } from '../../user-data/user.service';
import { StateValidationResult } from '../../validation/state-validation-result';
import { ValidationResult } from '../../validation/validation-result';
import { CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { ResetAuthDataService } from '../reset-auth-data.service';
import { UserCallbackHandlerService } from './user-callback-handler.service';
describe('UserCallbackHandlerService', () => {
let service: UserCallbackHandlerService;
let authStateService: AuthStateService;
let flowsDataService: FlowsDataService;
let userService: UserService;
let resetAuthDataService: ResetAuthDataService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
UserCallbackHandlerService,
mockProvider(LoggerService),
mockProvider(AuthStateService),
mockProvider(FlowsDataService),
mockProvider(UserService),
mockProvider(ResetAuthDataService),
],
});
});
beforeEach(() => {
service = TestBed.inject(UserCallbackHandlerService);
flowsDataService = TestBed.inject(FlowsDataService);
authStateService = TestBed.inject(AuthStateService);
userService = TestBed.inject(UserService);
resetAuthDataService = TestBed.inject(ResetAuthDataService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('callbackUser', () => {
it('calls flowsDataService.setSessionState with correct params if autoUserInfo is false, isRenewProcess is false and refreshToken is null', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded'
);
const callbackContext = {
code: '',
refreshToken: '',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: '',
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: false,
},
];
const spy = spyOn(flowsDataService, 'setSessionState');
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(spy).toHaveBeenCalledOnceWith('mystate', allConfigs[0]);
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('does NOT call flowsDataService.setSessionState if autoUserInfo is false, isRenewProcess is true and refreshToken is null', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded'
);
const callbackContext = {
code: '',
refreshToken: '',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: true,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: false,
},
];
const spy = spyOn(flowsDataService, 'setSessionState');
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(spy).not.toHaveBeenCalled();
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('does NOT call flowsDataService.setSessionState if autoUserInfo is false isRenewProcess is false, refreshToken has value', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded'
);
const callbackContext = {
code: '',
refreshToken: 'somerefreshtoken',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: false,
},
];
const spy = spyOn(flowsDataService, 'setSessionState');
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(spy).not.toHaveBeenCalled();
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('does NOT call flowsDataService.setSessionState if autoUserInfo is false isRenewProcess is false, refreshToken has value, id_token is false', waitForAsync(() => {
const svr = new StateValidationResult('accesstoken', '', true, '');
const callbackContext = {
code: '',
refreshToken: 'somerefreshtoken',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: false,
},
];
const spy = spyOn(flowsDataService, 'setSessionState');
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(spy).not.toHaveBeenCalled();
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('calls authStateService.updateAndPublishAuthState with correct params if autoUserInfo is false', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded'
);
const callbackContext = {
code: '',
refreshToken: 'somerefreshtoken',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: false,
},
];
const updateAndPublishAuthStateSpy = spyOn(
authStateService,
'updateAndPublishAuthState'
);
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({
isAuthenticated: true,
validationResult: ValidationResult.NotSet,
isRenewProcess: false,
});
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('calls userService.getAndPersistUserDataInStore with correct params if autoUserInfo is true', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded'
);
const callbackContext = {
code: '',
refreshToken: 'somerefreshtoken',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: true,
},
];
const getAndPersistUserDataInStoreSpy = spyOn(
userService,
'getAndPersistUserDataInStore'
).and.returnValue(of({ user: 'some_data' }));
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(getAndPersistUserDataInStoreSpy).toHaveBeenCalledOnceWith(
allConfigs[0],
allConfigs,
false,
'idtoken',
'decoded'
);
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('calls authStateService.updateAndPublishAuthState with correct params if autoUserInfo is true', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded',
ValidationResult.MaxOffsetExpired
);
const callbackContext = {
code: '',
refreshToken: 'somerefreshtoken',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: true,
},
];
spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue(
of({ user: 'some_data' })
);
const updateAndPublishAuthStateSpy = spyOn(
authStateService,
'updateAndPublishAuthState'
);
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({
isAuthenticated: true,
validationResult: ValidationResult.MaxOffsetExpired,
isRenewProcess: false,
});
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('calls flowsDataService.setSessionState with correct params if user data is present and NOT refresh token', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded',
ValidationResult.MaxOffsetExpired
);
const callbackContext = {
code: '',
refreshToken: '', // something falsy
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: true,
},
];
spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue(
of({ user: 'some_data' })
);
const setSessionStateSpy = spyOn(flowsDataService, 'setSessionState');
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe((resultCallbackContext) => {
expect(setSessionStateSpy).toHaveBeenCalledOnceWith(
'mystate',
allConfigs[0]
);
expect(resultCallbackContext).toEqual(callbackContext);
});
}));
it('calls authStateService.publishUnauthorizedState with correct params if user info which are coming back are null', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded',
ValidationResult.MaxOffsetExpired
);
const callbackContext = {
code: '',
refreshToken: 'somerefreshtoken',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: true,
},
];
spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue(
of(null)
);
const updateAndPublishAuthStateSpy = spyOn(
authStateService,
'updateAndPublishAuthState'
);
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe({
error: (err) => {
expect(updateAndPublishAuthStateSpy).toHaveBeenCalledOnceWith({
isAuthenticated: false,
validationResult: ValidationResult.MaxOffsetExpired,
isRenewProcess: false,
});
expect(err.message).toEqual(
'Failed to retrieve user info with error: Error: Called for userData but they were null'
);
},
});
}));
it('calls resetAuthDataService.resetAuthorizationData if user info which are coming back are null', waitForAsync(() => {
const svr = new StateValidationResult(
'accesstoken',
'idtoken',
true,
'decoded',
ValidationResult.MaxOffsetExpired
);
const callbackContext = {
code: '',
refreshToken: 'somerefreshtoken',
state: '',
sessionState: null,
authResult: { session_state: 'mystate' },
isRenewProcess: false,
jwtKeys: null,
validationResult: svr,
existingIdToken: null,
} as CallbackContext;
const allConfigs = [
{
configId: 'configId1',
autoUserInfo: true,
},
];
spyOn(userService, 'getAndPersistUserDataInStore').and.returnValue(
of(null)
);
const resetAuthorizationDataSpy = spyOn(
resetAuthDataService,
'resetAuthorizationData'
);
service
.callbackUser(callbackContext, allConfigs[0], allConfigs)
.subscribe({
error: (err) => {
expect(resetAuthorizationDataSpy).toHaveBeenCalledTimes(1);
expect(err.message).toEqual(
'Failed to retrieve user info with error: Error: Called for userData but they were null'
);
},
});
}));
});
});

View File

@@ -0,0 +1,132 @@
import { inject, Injectable } from 'injection-js';
import { Observable, of, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AuthStateService } from '../../auth-state/auth-state.service';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { UserService } from '../../user-data/user.service';
import { StateValidationResult } from '../../validation/state-validation-result';
import { CallbackContext } from '../callback-context';
import { FlowsDataService } from '../flows-data.service';
import { ResetAuthDataService } from '../reset-auth-data.service';
@Injectable()
export class UserCallbackHandlerService {
private readonly loggerService = inject(LoggerService);
private readonly authStateService = inject(AuthStateService);
private readonly flowsDataService = inject(FlowsDataService);
private readonly userService = inject(UserService);
private readonly resetAuthDataService = inject(ResetAuthDataService);
// STEP 5 userData
callbackUser(
callbackContext: CallbackContext,
configuration: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): Observable<CallbackContext> {
const { isRenewProcess, validationResult, authResult, refreshToken } =
callbackContext;
const { autoUserInfo, renewUserInfoAfterTokenRenew } = configuration;
if (!autoUserInfo) {
if (!isRenewProcess || renewUserInfoAfterTokenRenew) {
// userData is set to the id_token decoded, auto get user data set to false
if (validationResult?.decodedIdToken) {
this.userService.setUserDataToStore(
validationResult.decodedIdToken,
configuration,
allConfigs
);
}
}
if (!isRenewProcess && !refreshToken) {
this.flowsDataService.setSessionState(
authResult?.session_state,
configuration
);
}
this.publishAuthState(validationResult, isRenewProcess);
return of(callbackContext);
}
return this.userService
.getAndPersistUserDataInStore(
configuration,
allConfigs,
isRenewProcess,
validationResult?.idToken,
validationResult?.decodedIdToken
)
.pipe(
switchMap((userData) => {
if (!!userData) {
if (!refreshToken) {
this.flowsDataService.setSessionState(
authResult?.session_state,
configuration
);
}
this.publishAuthState(validationResult, isRenewProcess);
return of(callbackContext);
} else {
this.resetAuthDataService.resetAuthorizationData(
configuration,
allConfigs
);
this.publishUnauthenticatedState(validationResult, isRenewProcess);
const errorMessage = `Called for userData but they were ${userData}`;
this.loggerService.logWarning(configuration, errorMessage);
return throwError(() => new Error(errorMessage));
}
}),
catchError((err) => {
const errorMessage = `Failed to retrieve user info with error: ${err}`;
this.loggerService.logWarning(configuration, errorMessage);
return throwError(() => new Error(errorMessage));
})
);
}
private publishAuthState(
stateValidationResult: StateValidationResult | null,
isRenewProcess: boolean
): void {
if (!stateValidationResult) {
return;
}
this.authStateService.updateAndPublishAuthState({
isAuthenticated: true,
validationResult: stateValidationResult.state,
isRenewProcess,
});
}
private publishUnauthenticatedState(
stateValidationResult: StateValidationResult | null,
isRenewProcess: boolean
): void {
if (!stateValidationResult) {
return;
}
this.authStateService.updateAndPublishAuthState({
isAuthenticated: false,
validationResult: stateValidationResult.state,
isRenewProcess,
});
}
}

View File

@@ -0,0 +1,333 @@
import { TestBed } from '@angular/core/testing';
import { mockProvider } from '../../test/auto-mock';
import { LoggerService } from '../logging/logger.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { CryptoService } from '../utils/crypto/crypto.service';
import { FlowsDataService } from './flows-data.service';
import { RandomService } from './random/random.service';
describe('Flows Data Service', () => {
let service: FlowsDataService;
let storagePersistenceService: StoragePersistenceService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
FlowsDataService,
RandomService,
CryptoService,
mockProvider(LoggerService),
mockProvider(StoragePersistenceService),
],
});
});
beforeEach(() => {
service = TestBed.inject(FlowsDataService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('createNonce', () => {
it('createNonce returns nonce and stores it', () => {
const spy = spyOn(storagePersistenceService, 'write');
const result = service.createNonce({ configId: 'configId1' });
expect(result).toBeTruthy();
expect(spy).toHaveBeenCalledOnceWith('authNonce', result, {
configId: 'configId1',
});
});
});
describe('AuthStateControl', () => {
it('getAuthStateControl returns property from store', () => {
const spy = spyOn(storagePersistenceService, 'read');
service.getAuthStateControl({ configId: 'configId1' });
expect(spy).toHaveBeenCalledOnceWith('authStateControl', {
configId: 'configId1',
});
});
it('setAuthStateControl saves property in store', () => {
const spy = spyOn(storagePersistenceService, 'write');
service.setAuthStateControl('ToSave', { configId: 'configId1' });
expect(spy).toHaveBeenCalledOnceWith('authStateControl', 'ToSave', {
configId: 'configId1',
});
});
});
describe('getExistingOrCreateAuthStateControl', () => {
it('if nothing stored it creates a 40 char one and saves the authStateControl', () => {
spyOn(storagePersistenceService, 'read')
.withArgs('authStateControl', { configId: 'configId1' })
.and.returnValue(null);
const setSpy = spyOn(storagePersistenceService, 'write');
const result = service.getExistingOrCreateAuthStateControl({
configId: 'configId1',
});
expect(result).toBeTruthy();
expect(result.length).toBe(41);
expect(setSpy).toHaveBeenCalledOnceWith('authStateControl', result, {
configId: 'configId1',
});
});
it('if stored it returns the value and does NOT Store the value again', () => {
spyOn(storagePersistenceService, 'read')
.withArgs('authStateControl', { configId: 'configId1' })
.and.returnValue('someAuthStateControl');
const setSpy = spyOn(storagePersistenceService, 'write');
const result = service.getExistingOrCreateAuthStateControl({
configId: 'configId1',
});
expect(result).toEqual('someAuthStateControl');
expect(result.length).toBe('someAuthStateControl'.length);
expect(setSpy).not.toHaveBeenCalled();
});
});
describe('setSessionState', () => {
it('setSessionState saves the value in the storage', () => {
const spy = spyOn(storagePersistenceService, 'write');
service.setSessionState('Genesis', { configId: 'configId1' });
expect(spy).toHaveBeenCalledOnceWith('session_state', 'Genesis', {
configId: 'configId1',
});
});
});
describe('resetStorageFlowData', () => {
it('resetStorageFlowData calls correct method on storagePersistenceService', () => {
const spy = spyOn(storagePersistenceService, 'resetStorageFlowData');
service.resetStorageFlowData({ configId: 'configId1' });
expect(spy).toHaveBeenCalled();
});
});
describe('codeVerifier', () => {
it('getCodeVerifier returns value from the store', () => {
const spy = spyOn(storagePersistenceService, 'read')
.withArgs('codeVerifier', { configId: 'configId1' })
.and.returnValue('Genesis');
const result = service.getCodeVerifier({ configId: 'configId1' });
expect(result).toBe('Genesis');
expect(spy).toHaveBeenCalledOnceWith('codeVerifier', {
configId: 'configId1',
});
});
it('createCodeVerifier returns random createCodeVerifier and stores it', () => {
const setSpy = spyOn(storagePersistenceService, 'write');
const result = service.createCodeVerifier({ configId: 'configId1' });
expect(result).toBeTruthy();
expect(result.length).toBe(67);
expect(setSpy).toHaveBeenCalledOnceWith('codeVerifier', result, {
configId: 'configId1',
});
});
});
describe('isCodeFlowInProgress', () => {
it('checks code flow is in progress and returns result', () => {
const config = {
configId: 'configId1',
};
jasmine.clock().uninstall();
jasmine.clock().install();
const baseTime = new Date();
jasmine.clock().mockDate(baseTime);
spyOn(storagePersistenceService, 'read')
.withArgs('storageCodeFlowInProgress', config)
.and.returnValue(true);
const spyWrite = spyOn(storagePersistenceService, 'write');
const isCodeFlowInProgressResult = service.isCodeFlowInProgress(config);
expect(spyWrite).not.toHaveBeenCalled();
expect(isCodeFlowInProgressResult).toBeTrue();
});
it('state object does not exist returns false result', () => {
// arrange
spyOn(storagePersistenceService, 'read')
.withArgs('storageCodeFlowInProgress', { configId: 'configId1' })
.and.returnValue(null);
// act
const isCodeFlowInProgressResult = service.isCodeFlowInProgress({
configId: 'configId1',
});
// assert
expect(isCodeFlowInProgressResult).toBeFalse();
});
});
describe('setCodeFlowInProgress', () => {
it('set setCodeFlowInProgress to `in progress` when called', () => {
jasmine.clock().uninstall();
jasmine.clock().install();
const baseTime = new Date();
jasmine.clock().mockDate(baseTime);
const spy = spyOn(storagePersistenceService, 'write');
service.setCodeFlowInProgress({ configId: 'configId1' });
expect(spy).toHaveBeenCalledOnceWith('storageCodeFlowInProgress', true, {
configId: 'configId1',
});
});
});
describe('resetCodeFlowInProgress', () => {
it('set resetCodeFlowInProgress to false when called', () => {
const spy = spyOn(storagePersistenceService, 'write');
service.resetCodeFlowInProgress({ configId: 'configId1' });
expect(spy).toHaveBeenCalledOnceWith('storageCodeFlowInProgress', false, {
configId: 'configId1',
});
});
});
describe('isSilentRenewRunning', () => {
it('silent renew process timeout exceeded reset state object and returns false result', () => {
const config = {
silentRenewTimeoutInSeconds: 10,
configId: 'configId1',
};
jasmine.clock().uninstall();
jasmine.clock().install();
const baseTime = new Date();
jasmine.clock().mockDate(baseTime);
const storageObject = {
state: 'running',
dateOfLaunchedProcessUtc: baseTime.toISOString(),
};
spyOn(storagePersistenceService, 'read')
.withArgs('storageSilentRenewRunning', config)
.and.returnValue(JSON.stringify(storageObject));
const spyWrite = spyOn(storagePersistenceService, 'write');
jasmine.clock().tick((config.silentRenewTimeoutInSeconds + 1) * 1000);
const isSilentRenewRunningResult = service.isSilentRenewRunning(config);
expect(spyWrite).toHaveBeenCalledOnceWith(
'storageSilentRenewRunning',
'',
config
);
expect(isSilentRenewRunningResult).toBeFalse();
});
it('checks silent renew process and returns result', () => {
const config = {
silentRenewTimeoutInSeconds: 10,
configId: 'configId1',
};
jasmine.clock().uninstall();
jasmine.clock().install();
const baseTime = new Date();
jasmine.clock().mockDate(baseTime);
const storageObject = {
state: 'running',
dateOfLaunchedProcessUtc: baseTime.toISOString(),
};
spyOn(storagePersistenceService, 'read')
.withArgs('storageSilentRenewRunning', config)
.and.returnValue(JSON.stringify(storageObject));
const spyWrite = spyOn(storagePersistenceService, 'write');
const isSilentRenewRunningResult = service.isSilentRenewRunning(config);
expect(spyWrite).not.toHaveBeenCalled();
expect(isSilentRenewRunningResult).toBeTrue();
});
it('state object does not exist returns false result', () => {
spyOn(storagePersistenceService, 'read')
.withArgs('storageSilentRenewRunning', { configId: 'configId1' })
.and.returnValue(null);
const isSilentRenewRunningResult = service.isSilentRenewRunning({
configId: 'configId1',
});
expect(isSilentRenewRunningResult).toBeFalse();
});
});
describe('setSilentRenewRunning', () => {
it('set setSilentRenewRunning to `running` with lauched time when called', () => {
jasmine.clock().uninstall();
jasmine.clock().install();
const baseTime = new Date();
jasmine.clock().mockDate(baseTime);
const storageObject = {
state: 'running',
dateOfLaunchedProcessUtc: baseTime.toISOString(),
};
const spy = spyOn(storagePersistenceService, 'write');
service.setSilentRenewRunning({ configId: 'configId1' });
expect(spy).toHaveBeenCalledOnceWith(
'storageSilentRenewRunning',
JSON.stringify(storageObject),
{ configId: 'configId1' }
);
});
});
describe('resetSilentRenewRunning', () => {
it('set resetSilentRenewRunning to empty string when called', () => {
const spy = spyOn(storagePersistenceService, 'write');
service.resetSilentRenewRunning({ configId: 'configId1' });
expect(spy).toHaveBeenCalledOnceWith('storageSilentRenewRunning', '', {
configId: 'configId1',
});
});
});
});

View File

@@ -0,0 +1,204 @@
import { inject, Injectable } from 'injection-js';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { LoggerService } from '../logging/logger.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { SilentRenewRunning } from './flows.models';
import { RandomService } from './random/random.service';
@Injectable()
export class FlowsDataService {
private readonly loggerService = inject(LoggerService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
private readonly randomService = inject(RandomService);
createNonce(configuration: OpenIdConfiguration): string {
const nonce = this.randomService.createRandom(40, configuration);
this.loggerService.logDebug(configuration, 'Nonce created. nonce:' + nonce);
this.setNonce(nonce, configuration);
return nonce;
}
setNonce(nonce: string, configuration: OpenIdConfiguration): void {
this.storagePersistenceService.write('authNonce', nonce, configuration);
}
getAuthStateControl(configuration: OpenIdConfiguration | null): string {
if (!configuration) {
return '';
}
return this.storagePersistenceService.read(
'authStateControl',
configuration
);
}
setAuthStateControl(
authStateControl: string,
configuration: OpenIdConfiguration | null
): boolean {
if (!configuration) {
return false;
}
return this.storagePersistenceService.write(
'authStateControl',
authStateControl,
configuration
);
}
getExistingOrCreateAuthStateControl(configuration: OpenIdConfiguration): any {
let state = this.storagePersistenceService.read(
'authStateControl',
configuration
);
if (!state) {
state = this.randomService.createRandom(40, configuration);
this.storagePersistenceService.write(
'authStateControl',
state,
configuration
);
}
return state;
}
setSessionState(sessionState: any, configuration: OpenIdConfiguration): void {
this.storagePersistenceService.write(
'session_state',
sessionState,
configuration
);
}
resetStorageFlowData(configuration: OpenIdConfiguration): void {
this.storagePersistenceService.resetStorageFlowData(configuration);
}
getCodeVerifier(configuration: OpenIdConfiguration): any {
return this.storagePersistenceService.read('codeVerifier', configuration);
}
createCodeVerifier(configuration: OpenIdConfiguration): string {
const codeVerifier = this.randomService.createRandom(67, configuration);
this.storagePersistenceService.write(
'codeVerifier',
codeVerifier,
configuration
);
return codeVerifier;
}
isCodeFlowInProgress(configuration: OpenIdConfiguration): boolean {
return !!this.storagePersistenceService.read(
'storageCodeFlowInProgress',
configuration
);
}
setCodeFlowInProgress(configuration: OpenIdConfiguration): void {
this.storagePersistenceService.write(
'storageCodeFlowInProgress',
true,
configuration
);
}
resetCodeFlowInProgress(configuration: OpenIdConfiguration): void {
this.storagePersistenceService.write(
'storageCodeFlowInProgress',
false,
configuration
);
}
isSilentRenewRunning(configuration: OpenIdConfiguration): boolean {
const { configId, silentRenewTimeoutInSeconds } = configuration;
const storageObject = this.getSilentRenewRunningStorageEntry(configuration);
if (!storageObject) {
return false;
}
if (storageObject.state === 'not-running') {
return false;
}
const timeOutInMilliseconds = (silentRenewTimeoutInSeconds ?? 0) * 1000;
const dateOfLaunchedProcessUtc = Date.parse(
storageObject.dateOfLaunchedProcessUtc
);
const currentDateUtc = Date.parse(new Date().toISOString());
const elapsedTimeInMilliseconds = Math.abs(
currentDateUtc - dateOfLaunchedProcessUtc
);
const isProbablyStuck = elapsedTimeInMilliseconds > timeOutInMilliseconds;
if (isProbablyStuck) {
this.loggerService.logDebug(
configuration,
'silent renew process is probably stuck, state will be reset.',
configId
);
this.resetSilentRenewRunning(configuration);
return false;
}
return storageObject.state === 'running';
}
setSilentRenewRunning(configuration: OpenIdConfiguration): void {
const storageObject: SilentRenewRunning = {
state: 'running',
dateOfLaunchedProcessUtc: new Date().toISOString(),
};
this.storagePersistenceService.write(
'storageSilentRenewRunning',
JSON.stringify(storageObject),
configuration
);
}
resetSilentRenewRunning(configuration: OpenIdConfiguration | null): void {
if (!configuration) {
return;
}
this.storagePersistenceService.write(
'storageSilentRenewRunning',
'',
configuration
);
}
private getSilentRenewRunningStorageEntry(
configuration: OpenIdConfiguration
): SilentRenewRunning {
const storageEntry = this.storagePersistenceService.read(
'storageSilentRenewRunning',
configuration
);
if (!storageEntry) {
return {
dateOfLaunchedProcessUtc: '',
state: 'not-running',
};
}
return JSON.parse(storageEntry);
}
}

View File

@@ -0,0 +1,6 @@
export interface SilentRenewRunning {
state: SilentRenewRunningState;
dateOfLaunchedProcessUtc: string;
}
export type SilentRenewRunningState = 'running' | 'not-running';

View File

@@ -0,0 +1,226 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of } from 'rxjs';
import { mockProvider } from '../../test/auto-mock';
import { CallbackContext } from './callback-context';
import { CodeFlowCallbackHandlerService } from './callback-handling/code-flow-callback-handler.service';
import { HistoryJwtKeysCallbackHandlerService } from './callback-handling/history-jwt-keys-callback-handler.service';
import { ImplicitFlowCallbackHandlerService } from './callback-handling/implicit-flow-callback-handler.service';
import { RefreshSessionCallbackHandlerService } from './callback-handling/refresh-session-callback-handler.service';
import { RefreshTokenCallbackHandlerService } from './callback-handling/refresh-token-callback-handler.service';
import { StateValidationCallbackHandlerService } from './callback-handling/state-validation-callback-handler.service';
import { UserCallbackHandlerService } from './callback-handling/user-callback-handler.service';
import { FlowsService } from './flows.service';
describe('Flows Service', () => {
let service: FlowsService;
let codeFlowCallbackHandlerService: CodeFlowCallbackHandlerService;
let implicitFlowCallbackHandlerService: ImplicitFlowCallbackHandlerService;
let historyJwtKeysCallbackHandlerService: HistoryJwtKeysCallbackHandlerService;
let userCallbackHandlerService: UserCallbackHandlerService;
let stateValidationCallbackHandlerService: StateValidationCallbackHandlerService;
let refreshSessionCallbackHandlerService: RefreshSessionCallbackHandlerService;
let refreshTokenCallbackHandlerService: RefreshTokenCallbackHandlerService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
FlowsService,
mockProvider(CodeFlowCallbackHandlerService),
mockProvider(ImplicitFlowCallbackHandlerService),
mockProvider(HistoryJwtKeysCallbackHandlerService),
mockProvider(UserCallbackHandlerService),
mockProvider(StateValidationCallbackHandlerService),
mockProvider(RefreshSessionCallbackHandlerService),
mockProvider(RefreshTokenCallbackHandlerService),
],
});
});
beforeEach(() => {
service = TestBed.inject(FlowsService);
codeFlowCallbackHandlerService = TestBed.inject(
CodeFlowCallbackHandlerService
);
implicitFlowCallbackHandlerService = TestBed.inject(
ImplicitFlowCallbackHandlerService
);
historyJwtKeysCallbackHandlerService = TestBed.inject(
HistoryJwtKeysCallbackHandlerService
);
userCallbackHandlerService = TestBed.inject(UserCallbackHandlerService);
stateValidationCallbackHandlerService = TestBed.inject(
StateValidationCallbackHandlerService
);
refreshSessionCallbackHandlerService = TestBed.inject(
RefreshSessionCallbackHandlerService
);
refreshTokenCallbackHandlerService = TestBed.inject(
RefreshTokenCallbackHandlerService
);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('processCodeFlowCallback', () => {
it('calls all methods correctly', waitForAsync(() => {
const codeFlowCallbackSpy = spyOn(
codeFlowCallbackHandlerService,
'codeFlowCallback'
).and.returnValue(of({} as CallbackContext));
const codeFlowCodeRequestSpy = spyOn(
codeFlowCallbackHandlerService,
'codeFlowCodeRequest'
).and.returnValue(of({} as CallbackContext));
const callbackHistoryAndResetJwtKeysSpy = spyOn(
historyJwtKeysCallbackHandlerService,
'callbackHistoryAndResetJwtKeys'
).and.returnValue(of({} as CallbackContext));
const callbackStateValidationSpy = spyOn(
stateValidationCallbackHandlerService,
'callbackStateValidation'
).and.returnValue(of({} as CallbackContext));
const callbackUserSpy = spyOn(
userCallbackHandlerService,
'callbackUser'
).and.returnValue(of({} as CallbackContext));
const allConfigs = [
{
configId: 'configId1',
},
];
service
.processCodeFlowCallback('some-url1234', allConfigs[0], allConfigs)
.subscribe((value) => {
expect(value).toEqual({} as CallbackContext);
expect(codeFlowCallbackSpy).toHaveBeenCalledOnceWith(
'some-url1234',
allConfigs[0]
);
expect(codeFlowCodeRequestSpy).toHaveBeenCalledTimes(1);
expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalledTimes(1);
expect(callbackStateValidationSpy).toHaveBeenCalledTimes(1);
expect(callbackUserSpy).toHaveBeenCalledTimes(1);
});
}));
});
describe('processSilentRenewCodeFlowCallback', () => {
it('calls all methods correctly', waitForAsync(() => {
const codeFlowCodeRequestSpy = spyOn(
codeFlowCallbackHandlerService,
'codeFlowCodeRequest'
).and.returnValue(of({} as CallbackContext));
const callbackHistoryAndResetJwtKeysSpy = spyOn(
historyJwtKeysCallbackHandlerService,
'callbackHistoryAndResetJwtKeys'
).and.returnValue(of({} as CallbackContext));
const callbackStateValidationSpy = spyOn(
stateValidationCallbackHandlerService,
'callbackStateValidation'
).and.returnValue(of({} as CallbackContext));
const callbackUserSpy = spyOn(
userCallbackHandlerService,
'callbackUser'
).and.returnValue(of({} as CallbackContext));
const allConfigs = [
{
configId: 'configId1',
},
];
service
.processSilentRenewCodeFlowCallback(
{} as CallbackContext,
allConfigs[0],
allConfigs
)
.subscribe((value) => {
expect(value).toEqual({} as CallbackContext);
expect(codeFlowCodeRequestSpy).toHaveBeenCalled();
expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalled();
expect(callbackStateValidationSpy).toHaveBeenCalled();
expect(callbackUserSpy).toHaveBeenCalled();
});
}));
});
describe('processImplicitFlowCallback', () => {
it('calls all methods correctly', waitForAsync(() => {
const implicitFlowCallbackSpy = spyOn(
implicitFlowCallbackHandlerService,
'implicitFlowCallback'
).and.returnValue(of({} as CallbackContext));
const callbackHistoryAndResetJwtKeysSpy = spyOn(
historyJwtKeysCallbackHandlerService,
'callbackHistoryAndResetJwtKeys'
).and.returnValue(of({} as CallbackContext));
const callbackStateValidationSpy = spyOn(
stateValidationCallbackHandlerService,
'callbackStateValidation'
).and.returnValue(of({} as CallbackContext));
const callbackUserSpy = spyOn(
userCallbackHandlerService,
'callbackUser'
).and.returnValue(of({} as CallbackContext));
const allConfigs = [
{
configId: 'configId1',
},
];
service
.processImplicitFlowCallback(allConfigs[0], allConfigs, 'any-hash')
.subscribe((value) => {
expect(value).toEqual({} as CallbackContext);
expect(implicitFlowCallbackSpy).toHaveBeenCalled();
expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalled();
expect(callbackStateValidationSpy).toHaveBeenCalled();
expect(callbackUserSpy).toHaveBeenCalled();
});
}));
});
describe('processRefreshToken', () => {
it('calls all methods correctly', waitForAsync(() => {
const refreshSessionWithRefreshTokensSpy = spyOn(
refreshSessionCallbackHandlerService,
'refreshSessionWithRefreshTokens'
).and.returnValue(of({} as CallbackContext));
const refreshTokensRequestTokensSpy = spyOn(
refreshTokenCallbackHandlerService,
'refreshTokensRequestTokens'
).and.returnValue(of({} as CallbackContext));
const callbackHistoryAndResetJwtKeysSpy = spyOn(
historyJwtKeysCallbackHandlerService,
'callbackHistoryAndResetJwtKeys'
).and.returnValue(of({} as CallbackContext));
const callbackStateValidationSpy = spyOn(
stateValidationCallbackHandlerService,
'callbackStateValidation'
).and.returnValue(of({} as CallbackContext));
const callbackUserSpy = spyOn(
userCallbackHandlerService,
'callbackUser'
).and.returnValue(of({} as CallbackContext));
const allConfigs = [
{
configId: 'configId1',
},
];
service
.processRefreshToken(allConfigs[0], allConfigs)
.subscribe((value) => {
expect(value).toEqual({} as CallbackContext);
expect(refreshSessionWithRefreshTokensSpy).toHaveBeenCalled();
expect(refreshTokensRequestTokensSpy).toHaveBeenCalled();
expect(callbackHistoryAndResetJwtKeysSpy).toHaveBeenCalled();
expect(callbackStateValidationSpy).toHaveBeenCalled();
expect(callbackUserSpy).toHaveBeenCalled();
});
}));
});
});

182
src/flows/flows.service.ts Normal file
View File

@@ -0,0 +1,182 @@
import { inject, Injectable } from 'injection-js';
import { Observable } from 'rxjs';
import { concatMap } from 'rxjs/operators';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { CallbackContext } from './callback-context';
import { CodeFlowCallbackHandlerService } from './callback-handling/code-flow-callback-handler.service';
import { HistoryJwtKeysCallbackHandlerService } from './callback-handling/history-jwt-keys-callback-handler.service';
import { ImplicitFlowCallbackHandlerService } from './callback-handling/implicit-flow-callback-handler.service';
import { RefreshSessionCallbackHandlerService } from './callback-handling/refresh-session-callback-handler.service';
import { RefreshTokenCallbackHandlerService } from './callback-handling/refresh-token-callback-handler.service';
import { StateValidationCallbackHandlerService } from './callback-handling/state-validation-callback-handler.service';
import { UserCallbackHandlerService } from './callback-handling/user-callback-handler.service';
@Injectable()
export class FlowsService {
private readonly codeFlowCallbackHandlerService = inject(
CodeFlowCallbackHandlerService
);
private readonly implicitFlowCallbackHandlerService = inject(
ImplicitFlowCallbackHandlerService
);
private readonly historyJwtKeysCallbackHandlerService = inject(
HistoryJwtKeysCallbackHandlerService
);
private readonly userHandlerService = inject(UserCallbackHandlerService);
private readonly stateValidationCallbackHandlerService = inject(
StateValidationCallbackHandlerService
);
private readonly refreshSessionCallbackHandlerService = inject(
RefreshSessionCallbackHandlerService
);
private readonly refreshTokenCallbackHandlerService = inject(
RefreshTokenCallbackHandlerService
);
processCodeFlowCallback(
urlToCheck: string,
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): Observable<CallbackContext> {
return this.codeFlowCallbackHandlerService
.codeFlowCallback(urlToCheck, config)
.pipe(
concatMap((callbackContext) =>
this.codeFlowCallbackHandlerService.codeFlowCodeRequest(
callbackContext,
config
)
),
concatMap((callbackContext) =>
this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.stateValidationCallbackHandlerService.callbackStateValidation(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.userHandlerService.callbackUser(
callbackContext,
config,
allConfigs
)
)
);
}
processSilentRenewCodeFlowCallback(
firstContext: CallbackContext,
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[]
): Observable<CallbackContext> {
return this.codeFlowCallbackHandlerService
.codeFlowCodeRequest(firstContext, config)
.pipe(
concatMap((callbackContext) =>
this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.stateValidationCallbackHandlerService.callbackStateValidation(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.userHandlerService.callbackUser(
callbackContext,
config,
allConfigs
)
)
);
}
processImplicitFlowCallback(
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[],
hash?: string
): Observable<CallbackContext> {
return this.implicitFlowCallbackHandlerService
.implicitFlowCallback(config, allConfigs, hash)
.pipe(
concatMap((callbackContext) =>
this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.stateValidationCallbackHandlerService.callbackStateValidation(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.userHandlerService.callbackUser(
callbackContext,
config,
allConfigs
)
)
);
}
processRefreshToken(
config: OpenIdConfiguration,
allConfigs: OpenIdConfiguration[],
customParamsRefresh?: { [key: string]: string | number | boolean }
): Observable<CallbackContext> {
return this.refreshSessionCallbackHandlerService
.refreshSessionWithRefreshTokens(config)
.pipe(
concatMap((callbackContext) =>
this.refreshTokenCallbackHandlerService.refreshTokensRequestTokens(
callbackContext,
config,
customParamsRefresh
)
),
concatMap((callbackContext) =>
this.historyJwtKeysCallbackHandlerService.callbackHistoryAndResetJwtKeys(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.stateValidationCallbackHandlerService.callbackStateValidation(
callbackContext,
config,
allConfigs
)
),
concatMap((callbackContext) =>
this.userHandlerService.callbackUser(
callbackContext,
config,
allConfigs
)
)
);
}
}

View File

@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import { mockProvider } from '../../../test/auto-mock';
import { LoggerService } from '../../logging/logger.service';
import { CryptoService } from '../../utils/crypto/crypto.service';
import { RandomService } from './random.service';
describe('RandomService Tests', () => {
let randomService: RandomService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [RandomService, mockProvider(LoggerService), CryptoService],
});
});
beforeEach(() => {
randomService = TestBed.inject(RandomService);
});
it('should create', () => {
expect(randomService).toBeTruthy();
});
it('should be not equal', () => {
const r1 = randomService.createRandom(45, { configId: 'configId1' });
const r2 = randomService.createRandom(45, { configId: 'configId1' });
expect(r1).not.toEqual(r2);
});
it('correct length with high number', () => {
const r1 = randomService.createRandom(79, { configId: 'configId1' });
const result = r1.length;
expect(result).toBe(79);
});
it('correct length with small number', () => {
const r1 = randomService.createRandom(7, { configId: 'configId1' });
const result = r1.length;
expect(result).toBe(7);
});
it('correct length with 0', () => {
const r1 = randomService.createRandom(0, { configId: 'configId1' });
const result = r1.length;
expect(result).toBe(0);
expect(r1).toBe('');
});
for (let index = 1; index < 7; index++) {
it('Giving back 10 or more characters when called with numbers less than 7', () => {
const requiredLengthSmallerThenSeven = index;
const fallbackLength = 10;
const r1 = randomService.createRandom(requiredLengthSmallerThenSeven, {
configId: 'configId1',
});
expect(r1.length).toBeGreaterThanOrEqual(fallbackLength);
});
}
});

View File

@@ -0,0 +1,60 @@
import { inject, Injectable } from 'injection-js';
import { OpenIdConfiguration } from '../../config/openid-configuration';
import { LoggerService } from '../../logging/logger.service';
import { CryptoService } from '../../utils/crypto/crypto.service';
@Injectable()
export class RandomService {
private readonly loggerService = inject(LoggerService);
private readonly cryptoService = inject(CryptoService);
createRandom(
requiredLength: number,
configuration: OpenIdConfiguration
): string {
if (requiredLength <= 0) {
return '';
}
if (requiredLength > 0 && requiredLength < 7) {
this.loggerService.logWarning(
configuration,
`RandomService called with ${requiredLength} but 7 chars is the minimum, returning 10 chars`
);
requiredLength = 10;
}
const length = requiredLength - 6;
const arr = new Uint8Array(Math.floor(length / 2));
const crypto = this.cryptoService.getCrypto();
if (crypto) {
crypto.getRandomValues(arr);
}
return Array.from(arr, this.toHex).join('') + this.randomString(7);
}
private toHex(dec: number): string {
return ('0' + dec.toString(16)).substr(-2);
}
private randomString(length: number): string {
let result = '';
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const values = new Uint32Array(length);
const crypto = this.cryptoService.getCrypto();
if (crypto) {
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
result += characters[values[i] % characters.length];
}
}
return result;
}
}

View File

@@ -0,0 +1,75 @@
import { TestBed } from '@angular/core/testing';
import { mockProvider } from '../../test/auto-mock';
import { AuthStateService } from '../auth-state/auth-state.service';
import { LoggerService } from '../logging/logger.service';
import { UserService } from '../user-data/user.service';
import { FlowsDataService } from './flows-data.service';
import { ResetAuthDataService } from './reset-auth-data.service';
describe('ResetAuthDataService', () => {
let service: ResetAuthDataService;
let userService: UserService;
let flowsDataService: FlowsDataService;
let authStateService: AuthStateService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ResetAuthDataService,
mockProvider(AuthStateService),
mockProvider(FlowsDataService),
mockProvider(UserService),
mockProvider(LoggerService),
],
});
});
beforeEach(() => {
service = TestBed.inject(ResetAuthDataService);
userService = TestBed.inject(UserService);
flowsDataService = TestBed.inject(FlowsDataService);
authStateService = TestBed.inject(AuthStateService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('resetAuthorizationData', () => {
it('calls resetUserDataInStore when autoUserInfo is true', () => {
const resetUserDataInStoreSpy = spyOn(
userService,
'resetUserDataInStore'
);
const allConfigs = [
{
configId: 'configId1',
},
];
service.resetAuthorizationData(allConfigs[0], allConfigs);
expect(resetUserDataInStoreSpy).toHaveBeenCalled();
});
it('calls correct methods', () => {
const resetStorageFlowDataSpy = spyOn(
flowsDataService,
'resetStorageFlowData'
);
const setUnauthorizedAndFireEventSpy = spyOn(
authStateService,
'setUnauthenticatedAndFireEvent'
);
const allConfigs = [
{
configId: 'configId1',
},
];
service.resetAuthorizationData(allConfigs[0], allConfigs);
expect(resetStorageFlowDataSpy).toHaveBeenCalled();
expect(setUnauthorizedAndFireEventSpy).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,38 @@
import { inject, Injectable } from 'injection-js';
import { AuthStateService } from '../auth-state/auth-state.service';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { LoggerService } from '../logging/logger.service';
import { UserService } from '../user-data/user.service';
import { FlowsDataService } from './flows-data.service';
@Injectable()
export class ResetAuthDataService {
private readonly loggerService = inject(LoggerService);
private readonly userService = inject(UserService);
private readonly flowsDataService = inject(FlowsDataService);
private readonly authStateService = inject(AuthStateService);
resetAuthorizationData(
currentConfiguration: OpenIdConfiguration | null,
allConfigs: OpenIdConfiguration[]
): void {
if (!currentConfiguration) {
return;
}
this.userService.resetUserDataInStore(currentConfiguration, allConfigs);
this.flowsDataService.resetStorageFlowData(currentConfiguration);
this.authStateService.setUnauthenticatedAndFireEvent(
currentConfiguration,
allConfigs
);
this.loggerService.logDebug(
currentConfiguration,
'Local Login information cleaned up and event fired'
);
}
}

View File

@@ -0,0 +1,220 @@
import { HttpResponse } from '@angular/common/http';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { isObservable, of, throwError } from 'rxjs';
import { mockProvider } from '../../test/auto-mock';
import { createRetriableStream } from '../../test/create-retriable-stream.helper';
import { DataService } from '../api/data.service';
import { LoggerService } from '../logging/logger.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { SigninKeyDataService } from './signin-key-data.service';
const DUMMY_JWKS = {
keys: [
{
kid: 'random-id',
kty: 'RSA',
alg: 'RS256',
use: 'sig',
n: 'some-value',
e: 'AQAB',
x5c: ['some-value'],
x5t: 'some-value',
'x5t#S256': 'some-value',
},
],
};
describe('Signin Key Data Service', () => {
let service: SigninKeyDataService;
let storagePersistenceService: StoragePersistenceService;
let dataService: DataService;
let loggerService: LoggerService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
SigninKeyDataService,
mockProvider(DataService),
mockProvider(LoggerService),
mockProvider(StoragePersistenceService),
],
});
});
beforeEach(() => {
service = TestBed.inject(SigninKeyDataService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
dataService = TestBed.inject(DataService);
loggerService = TestBed.inject(LoggerService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('getSigningKeys', () => {
it('throws error when no wellKnownEndpoints given', waitForAsync(() => {
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue(null);
const result = service.getSigningKeys({ configId: 'configId1' });
result.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('throws error when no jwksUri given', waitForAsync(() => {
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ jwksUri: null });
const result = service.getSigningKeys({ configId: 'configId1' });
result.subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
it('calls dataservice if jwksurl is given', waitForAsync(() => {
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ jwksUri: 'someUrl' });
const spy = spyOn(dataService, 'get').and.callFake(() => of());
const result = service.getSigningKeys({ configId: 'configId1' });
result.subscribe({
complete: () => {
expect(spy).toHaveBeenCalledOnceWith('someUrl', {
configId: 'configId1',
});
},
});
}));
it('should retry once', waitForAsync(() => {
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ jwksUri: 'someUrl' });
spyOn(dataService, 'get').and.returnValue(
createRetriableStream(
throwError(() => new Error('Error')),
of(DUMMY_JWKS)
)
);
service.getSigningKeys({ configId: 'configId1' }).subscribe({
next: (res) => {
expect(res).toBeTruthy();
expect(res).toEqual(DUMMY_JWKS);
},
});
}));
it('should retry twice', waitForAsync(() => {
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ jwksUri: 'someUrl' });
spyOn(dataService, 'get').and.returnValue(
createRetriableStream(
throwError(() => new Error('Error')),
throwError(() => new Error('Error')),
of(DUMMY_JWKS)
)
);
service.getSigningKeys({ configId: 'configId1' }).subscribe({
next: (res) => {
expect(res).toBeTruthy();
expect(res).toEqual(DUMMY_JWKS);
},
});
}));
it('should fail after three tries', waitForAsync(() => {
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ jwksUri: 'someUrl' });
spyOn(dataService, 'get').and.returnValue(
createRetriableStream(
throwError(() => new Error('Error')),
throwError(() => new Error('Error')),
throwError(() => new Error('Error')),
of(DUMMY_JWKS)
)
);
service.getSigningKeys({ configId: 'configId1' }).subscribe({
error: (err) => {
expect(err).toBeTruthy();
},
});
}));
});
describe('handleErrorGetSigningKeys', () => {
it('keeps observable if error is catched', waitForAsync(() => {
const result = (service as any).handleErrorGetSigningKeys(
new HttpResponse()
);
const hasTypeObservable = isObservable(result);
expect(hasTypeObservable).toBeTrue();
}));
it('logs error if error is response', waitForAsync(() => {
const logSpy = spyOn(loggerService, 'logError');
(service as any)
.handleErrorGetSigningKeys(
new HttpResponse({ status: 400, statusText: 'nono' }),
{ configId: 'configId1' }
)
.subscribe({
error: () => {
expect(logSpy).toHaveBeenCalledOnceWith(
{ configId: 'configId1' },
'400 - nono {}'
);
},
});
}));
it('logs error if error is not a response', waitForAsync(() => {
const logSpy = spyOn(loggerService, 'logError');
(service as any)
.handleErrorGetSigningKeys('Just some Error', { configId: 'configId1' })
.subscribe({
error: () => {
expect(logSpy).toHaveBeenCalledOnceWith(
{ configId: 'configId1' },
'Just some Error'
);
},
});
}));
it('logs error if error with message property is not a response', waitForAsync(() => {
const logSpy = spyOn(loggerService, 'logError');
(service as any)
.handleErrorGetSigningKeys(
{ message: 'Just some Error' },
{ configId: 'configId1' }
)
.subscribe({
error: () => {
expect(logSpy).toHaveBeenCalledOnceWith(
{ configId: 'configId1' },
'Just some Error'
);
},
});
}));
});
});

View File

@@ -0,0 +1,71 @@
import { HttpResponse } from '@ngify/http';
import { inject, Injectable } from 'injection-js';
import { Observable, throwError } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';
import { DataService } from '../api/data.service';
import { OpenIdConfiguration } from '../config/openid-configuration';
import { LoggerService } from '../logging/logger.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { JwtKeys } from '../validation/jwtkeys';
@Injectable()
export class SigninKeyDataService {
private readonly loggerService = inject(LoggerService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
private readonly dataService = inject(DataService);
getSigningKeys(
currentConfiguration: OpenIdConfiguration
): Observable<JwtKeys> {
const authWellKnownEndPoints = this.storagePersistenceService.read(
'authWellKnownEndPoints',
currentConfiguration
);
const jwksUri = authWellKnownEndPoints?.jwksUri;
if (!jwksUri) {
const error = `getSigningKeys: authWellKnownEndpoints.jwksUri is: '${jwksUri}'`;
this.loggerService.logWarning(currentConfiguration, error);
return throwError(() => new Error(error));
}
this.loggerService.logDebug(
currentConfiguration,
'Getting signinkeys from ',
jwksUri
);
return this.dataService.get<JwtKeys>(jwksUri, currentConfiguration).pipe(
retry(2),
catchError((e) => this.handleErrorGetSigningKeys(e, currentConfiguration))
);
}
private handleErrorGetSigningKeys(
errorResponse: HttpResponse<any> | any,
currentConfiguration: OpenIdConfiguration
): Observable<never> {
let errMsg = '';
if (errorResponse instanceof HttpResponse) {
const body = errorResponse.body || {};
const err = JSON.stringify(body);
const { status, statusText } = errorResponse;
errMsg = `${status || ''} - ${statusText || ''} ${err || ''}`;
} else {
const { message } = errorResponse;
errMsg = !!message ? message : `${errorResponse}`;
}
this.loggerService.logError(currentConfiguration, errMsg);
return throwError(() => new Error(errMsg));
}
}