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,237 @@
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 { AuthWellKnownDataService } from './auth-well-known-data.service';
import { AuthWellKnownEndpoints } from './auth-well-known-endpoints';
const DUMMY_WELL_KNOWN_DOCUMENT = {
issuer: 'https://identity-server.test/realms/main',
authorization_endpoint:
'https://identity-server.test/realms/main/protocol/openid-connect/auth',
token_endpoint:
'https://identity-server.test/realms/main/protocol/openid-connect/token',
userinfo_endpoint:
'https://identity-server.test/realms/main/protocol/openid-connect/userinfo',
end_session_endpoint:
'https://identity-server.test/realms/main/master/protocol/openid-connect/logout',
jwks_uri:
'https://identity-server.test/realms/main/protocol/openid-connect/certs',
check_session_iframe:
'https://identity-server.test/realms/main/protocol/openid-connect/login-status-iframe.html',
introspection_endpoint:
'https://identity-server.test/realms/main/protocol/openid-connect/token/introspect',
};
describe('AuthWellKnownDataService', () => {
let service: AuthWellKnownDataService;
let dataService: DataService;
let loggerService: LoggerService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AuthWellKnownDataService,
mockProvider(DataService),
mockProvider(LoggerService),
],
});
});
beforeEach(() => {
service = TestBed.inject(AuthWellKnownDataService);
loggerService = TestBed.inject(LoggerService);
dataService = TestBed.inject(DataService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('getWellKnownDocument', () => {
it('should add suffix if it does not exist on current URL', waitForAsync(() => {
const dataServiceSpy = spyOn(dataService, 'get').and.returnValue(
of(null)
);
const urlWithoutSuffix = 'myUrl';
const urlWithSuffix = `${urlWithoutSuffix}/.well-known/openid-configuration`;
(service as any)
.getWellKnownDocument(urlWithoutSuffix, { configId: 'configId1' })
.subscribe(() => {
expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, {
configId: 'configId1',
});
});
}));
it('should not add suffix if it does exist on current url', waitForAsync(() => {
const dataServiceSpy = spyOn(dataService, 'get').and.returnValue(
of(null)
);
const urlWithSuffix = `myUrl/.well-known/openid-configuration`;
(service as any)
.getWellKnownDocument(urlWithSuffix, { configId: 'configId1' })
.subscribe(() => {
expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, {
configId: 'configId1',
});
});
}));
it('should not add suffix if it does exist in the middle of current url', waitForAsync(() => {
const dataServiceSpy = spyOn(dataService, 'get').and.returnValue(
of(null)
);
const urlWithSuffix = `myUrl/.well-known/openid-configuration/and/some/more/stuff`;
(service as any)
.getWellKnownDocument(urlWithSuffix, { configId: 'configId1' })
.subscribe(() => {
expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, {
configId: 'configId1',
});
});
}));
it('should use the custom suffix provided in the config', waitForAsync(() => {
const dataServiceSpy = spyOn(dataService, 'get').and.returnValue(
of(null)
);
const urlWithoutSuffix = `myUrl`;
const urlWithSuffix = `${urlWithoutSuffix}/.well-known/test-openid-configuration`;
(service as any)
.getWellKnownDocument(urlWithoutSuffix, {
configId: 'configId1',
authWellknownUrlSuffix: '/.well-known/test-openid-configuration',
})
.subscribe(() => {
expect(dataServiceSpy).toHaveBeenCalledOnceWith(urlWithSuffix, {
configId: 'configId1',
authWellknownUrlSuffix: '/.well-known/test-openid-configuration',
});
});
}));
it('should retry once', waitForAsync(() => {
spyOn(dataService, 'get').and.returnValue(
createRetriableStream(
throwError(() => new Error('one')),
of(DUMMY_WELL_KNOWN_DOCUMENT)
)
);
(service as any)
.getWellKnownDocument('anyurl', { configId: 'configId1' })
.subscribe({
next: (res: unknown) => {
expect(res).toBeTruthy();
expect(res).toEqual(DUMMY_WELL_KNOWN_DOCUMENT);
},
});
}));
it('should retry twice', waitForAsync(() => {
spyOn(dataService, 'get').and.returnValue(
createRetriableStream(
throwError(() => new Error('one')),
throwError(() => new Error('two')),
of(DUMMY_WELL_KNOWN_DOCUMENT)
)
);
(service as any)
.getWellKnownDocument('anyurl', { configId: 'configId1' })
.subscribe({
next: (res: any) => {
expect(res).toBeTruthy();
expect(res).toEqual(DUMMY_WELL_KNOWN_DOCUMENT);
},
});
}));
it('should fail after three tries', waitForAsync(() => {
spyOn(dataService, 'get').and.returnValue(
createRetriableStream(
throwError(() => new Error('one')),
throwError(() => new Error('two')),
throwError(() => new Error('three')),
of(DUMMY_WELL_KNOWN_DOCUMENT)
)
);
(service as any).getWellKnownDocument('anyurl', 'configId').subscribe({
error: (err: unknown) => {
expect(err).toBeTruthy();
},
});
}));
});
describe('getWellKnownEndPointsForConfig', () => {
it('calling internal getWellKnownDocument and maps', waitForAsync(() => {
spyOn(dataService, 'get').and.returnValue(of({ jwks_uri: 'jwks_uri' }));
const spy = spyOn(
service as any,
'getWellKnownDocument'
).and.callThrough();
service
.getWellKnownEndPointsForConfig({
configId: 'configId1',
authWellknownEndpointUrl: 'any-url',
})
.subscribe((result) => {
expect(spy).toHaveBeenCalled();
expect((result as any).jwks_uri).toBeUndefined();
expect(result.jwksUri).toBe('jwks_uri');
});
}));
it('throws error and logs if no authwellknownUrl is given', waitForAsync(() => {
const loggerSpy = spyOn(loggerService, 'logError');
const config = {
configId: 'configId1',
authWellknownEndpointUrl: undefined,
};
service.getWellKnownEndPointsForConfig(config).subscribe({
error: (error) => {
expect(loggerSpy).toHaveBeenCalledOnceWith(
config,
'no authWellknownEndpoint given!'
);
expect(error.message).toEqual('no authWellknownEndpoint given!');
},
});
}));
it('should merge the mapped endpoints with the provided endpoints', waitForAsync(() => {
spyOn(dataService, 'get').and.returnValue(of(DUMMY_WELL_KNOWN_DOCUMENT));
const expected: AuthWellKnownEndpoints = {
endSessionEndpoint: 'config-endSessionEndpoint',
revocationEndpoint: 'config-revocationEndpoint',
jwksUri: DUMMY_WELL_KNOWN_DOCUMENT.jwks_uri,
};
service
.getWellKnownEndPointsForConfig({
configId: 'configId1',
authWellknownEndpointUrl: 'any-url',
authWellknownEndpoints: {
endSessionEndpoint: 'config-endSessionEndpoint',
revocationEndpoint: 'config-revocationEndpoint',
},
})
.subscribe((result) => {
expect(result).toEqual(jasmine.objectContaining(expected));
});
}));
});
});

View File

@@ -0,0 +1,68 @@
import { inject, Injectable } from 'injection-js';
import { Observable, throwError } from 'rxjs';
import { map, retry } from 'rxjs/operators';
import { DataService } from '../../api/data.service';
import { LoggerService } from '../../logging/logger.service';
import { OpenIdConfiguration } from '../openid-configuration';
import { AuthWellKnownEndpoints } from './auth-well-known-endpoints';
const WELL_KNOWN_SUFFIX = `/.well-known/openid-configuration`;
@Injectable()
export class AuthWellKnownDataService {
private readonly loggerService = inject(LoggerService);
private readonly http = inject(DataService);
getWellKnownEndPointsForConfig(
config: OpenIdConfiguration
): Observable<AuthWellKnownEndpoints> {
const { authWellknownEndpointUrl, authWellknownEndpoints = {} } = config;
if (!authWellknownEndpointUrl) {
const errorMessage = 'no authWellknownEndpoint given!';
this.loggerService.logError(config, errorMessage);
return throwError(() => new Error(errorMessage));
}
return this.getWellKnownDocument(authWellknownEndpointUrl, config).pipe(
map(
(wellKnownEndpoints) =>
({
issuer: wellKnownEndpoints.issuer,
jwksUri: wellKnownEndpoints.jwks_uri,
authorizationEndpoint: wellKnownEndpoints.authorization_endpoint,
tokenEndpoint: wellKnownEndpoints.token_endpoint,
userInfoEndpoint: wellKnownEndpoints.userinfo_endpoint,
endSessionEndpoint: wellKnownEndpoints.end_session_endpoint,
checkSessionIframe: wellKnownEndpoints.check_session_iframe,
revocationEndpoint: wellKnownEndpoints.revocation_endpoint,
introspectionEndpoint: wellKnownEndpoints.introspection_endpoint,
parEndpoint:
wellKnownEndpoints.pushed_authorization_request_endpoint,
} as AuthWellKnownEndpoints)
),
map((mappedWellKnownEndpoints) => ({
...mappedWellKnownEndpoints,
...authWellknownEndpoints,
}))
);
}
private getWellKnownDocument(
wellKnownEndpoint: string,
config: OpenIdConfiguration
): Observable<any> {
let url = wellKnownEndpoint;
const wellKnownSuffix = config.authWellknownUrlSuffix || WELL_KNOWN_SUFFIX;
if (!wellKnownEndpoint.includes(wellKnownSuffix)) {
url = `${wellKnownEndpoint}${wellKnownSuffix}`;
}
return this.http.get(url, config).pipe(retry(2));
}
}

View File

@@ -0,0 +1,12 @@
export interface AuthWellKnownEndpoints {
issuer?: string;
jwksUri?: string;
authorizationEndpoint?: string;
tokenEndpoint?: string;
userInfoEndpoint?: string;
endSessionEndpoint?: string;
checkSessionIframe?: string;
revocationEndpoint?: string;
introspectionEndpoint?: string;
parEndpoint?: string;
}

View File

@@ -0,0 +1,110 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { mockProvider } from '../../../test/auto-mock';
import { EventTypes } from '../../public-events/event-types';
import { PublicEventsService } from '../../public-events/public-events.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { AuthWellKnownDataService } from './auth-well-known-data.service';
import { AuthWellKnownService } from './auth-well-known.service';
describe('AuthWellKnownService', () => {
let service: AuthWellKnownService;
let dataService: AuthWellKnownDataService;
let storagePersistenceService: StoragePersistenceService;
let publicEventsService: PublicEventsService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
AuthWellKnownService,
PublicEventsService,
mockProvider(AuthWellKnownDataService),
mockProvider(StoragePersistenceService),
],
});
});
beforeEach(() => {
service = TestBed.inject(AuthWellKnownService);
dataService = TestBed.inject(AuthWellKnownDataService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
publicEventsService = TestBed.inject(PublicEventsService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('getAuthWellKnownEndPoints', () => {
it('getAuthWellKnownEndPoints throws an error if not config provided', waitForAsync(() => {
service.queryAndStoreAuthWellKnownEndPoints(null).subscribe({
error: (error) => {
expect(error).toEqual(
new Error(
'Please provide a configuration before setting up the module'
)
);
},
});
}));
it('getAuthWellKnownEndPoints calls always dataservice', waitForAsync(() => {
const dataServiceSpy = spyOn(
dataService,
'getWellKnownEndPointsForConfig'
).and.returnValue(of({ issuer: 'anything' }));
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue({ issuer: 'anything' });
service
.queryAndStoreAuthWellKnownEndPoints({ configId: 'configId1' })
.subscribe((result) => {
expect(storagePersistenceService.read).not.toHaveBeenCalled();
expect(dataServiceSpy).toHaveBeenCalled();
expect(result).toEqual({ issuer: 'anything' });
});
}));
it('getAuthWellKnownEndPoints stored the result if http call is made', waitForAsync(() => {
const dataServiceSpy = spyOn(
dataService,
'getWellKnownEndPointsForConfig'
).and.returnValue(of({ issuer: 'anything' }));
spyOn(storagePersistenceService, 'read')
.withArgs('authWellKnownEndPoints', { configId: 'configId1' })
.and.returnValue(null);
const storeSpy = spyOn(service, 'storeWellKnownEndpoints');
service
.queryAndStoreAuthWellKnownEndPoints({ configId: 'configId1' })
.subscribe((result) => {
expect(dataServiceSpy).toHaveBeenCalled();
expect(storeSpy).toHaveBeenCalled();
expect(result).toEqual({ issuer: 'anything' });
});
}));
it('throws `ConfigLoadingFailed` event when error happens from http', waitForAsync(() => {
spyOn(dataService, 'getWellKnownEndPointsForConfig').and.returnValue(
throwError(() => new Error('error'))
);
const publicEventsServiceSpy = spyOn(publicEventsService, 'fireEvent');
service
.queryAndStoreAuthWellKnownEndPoints({ configId: 'configId1' })
.subscribe({
error: (err) => {
expect(err).toBeTruthy();
expect(publicEventsServiceSpy).toHaveBeenCalledTimes(1);
expect(publicEventsServiceSpy).toHaveBeenCalledOnceWith(
EventTypes.ConfigLoadingFailed,
null
);
},
});
}));
});
});

View File

@@ -0,0 +1,58 @@
import { inject, Injectable } from 'injection-js';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { EventTypes } from '../../public-events/event-types';
import { PublicEventsService } from '../../public-events/public-events.service';
import { StoragePersistenceService } from '../../storage/storage-persistence.service';
import { OpenIdConfiguration } from '../openid-configuration';
import { AuthWellKnownDataService } from './auth-well-known-data.service';
import { AuthWellKnownEndpoints } from './auth-well-known-endpoints';
@Injectable()
export class AuthWellKnownService {
private readonly dataService = inject(AuthWellKnownDataService);
private readonly publicEventsService = inject(PublicEventsService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
storeWellKnownEndpoints(
config: OpenIdConfiguration,
mappedWellKnownEndpoints: AuthWellKnownEndpoints
): void {
this.storagePersistenceService.write(
'authWellKnownEndPoints',
mappedWellKnownEndpoints,
config
);
}
queryAndStoreAuthWellKnownEndPoints(
config: OpenIdConfiguration | null
): Observable<AuthWellKnownEndpoints> {
if (!config) {
return throwError(
() =>
new Error(
'Please provide a configuration before setting up the module'
)
);
}
return this.dataService.getWellKnownEndPointsForConfig(config).pipe(
tap((mappedWellKnownEndpoints) =>
this.storeWellKnownEndpoints(config, mappedWellKnownEndpoints)
),
catchError((error) => {
this.publicEventsService.fireEvent(
EventTypes.ConfigLoadingFailed,
null
);
return throwError(() => new Error(error));
})
);
}
}

View File

@@ -0,0 +1,290 @@
import { TestBed, waitForAsync } from '@angular/core/testing';
import { of } from 'rxjs';
import { mockAbstractProvider, mockProvider } from '../../test/auto-mock';
import { LoggerService } from '../logging/logger.service';
import { EventTypes } from '../public-events/event-types';
import { PublicEventsService } from '../public-events/public-events.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { PlatformProvider } from '../utils/platform-provider/platform.provider';
import { AuthWellKnownService } from './auth-well-known/auth-well-known.service';
import { ConfigurationService } from './config.service';
import { StsConfigLoader, StsConfigStaticLoader } from './loader/config-loader';
import { OpenIdConfiguration } from './openid-configuration';
import { ConfigValidationService } from './validation/config-validation.service';
describe('Configuration Service', () => {
let configService: ConfigurationService;
let publicEventsService: PublicEventsService;
let authWellKnownService: AuthWellKnownService;
let storagePersistenceService: StoragePersistenceService;
let configValidationService: ConfigValidationService;
let platformProvider: PlatformProvider;
let stsConfigLoader: StsConfigLoader;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ConfigurationService,
mockProvider(LoggerService),
PublicEventsService,
mockProvider(StoragePersistenceService),
ConfigValidationService,
mockProvider(PlatformProvider),
mockProvider(AuthWellKnownService),
mockAbstractProvider(StsConfigLoader, StsConfigStaticLoader),
],
});
});
beforeEach(() => {
configService = TestBed.inject(ConfigurationService);
publicEventsService = TestBed.inject(PublicEventsService);
authWellKnownService = TestBed.inject(AuthWellKnownService);
storagePersistenceService = TestBed.inject(StoragePersistenceService);
stsConfigLoader = TestBed.inject(StsConfigLoader);
platformProvider = TestBed.inject(PlatformProvider);
configValidationService = TestBed.inject(ConfigValidationService);
});
it('should create', () => {
expect(configService).toBeTruthy();
});
describe('hasManyConfigs', () => {
it('returns true if many configs are stored', () => {
(configService as any).configsInternal = {
configId1: { configId: 'configId1' },
configId2: { configId: 'configId2' },
};
const result = configService.hasManyConfigs();
expect(result).toBe(true);
});
it('returns false if only one config is stored', () => {
(configService as any).configsInternal = {
configId1: { configId: 'configId1' },
};
const result = configService.hasManyConfigs();
expect(result).toBe(false);
});
});
describe('getAllConfigurations', () => {
it('returns all configs as array', () => {
(configService as any).configsInternal = {
configId1: { configId: 'configId1' },
configId2: { configId: 'configId2' },
};
const result = configService.getAllConfigurations();
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(2);
});
});
describe('getOpenIDConfiguration', () => {
it(`if config is already saved 'loadConfigs' is not called`, waitForAsync(() => {
(configService as any).configsInternal = {
configId1: { configId: 'configId1' },
configId2: { configId: 'configId2' },
};
const spy = spyOn(configService as any, 'loadConfigs');
configService.getOpenIDConfiguration('configId1').subscribe((config) => {
expect(config).toBeTruthy();
expect(spy).not.toHaveBeenCalled();
});
}));
it(`if config is NOT already saved 'loadConfigs' is called`, waitForAsync(() => {
const configs = [{ configId: 'configId1' }, { configId: 'configId2' }];
const spy = spyOn(configService as any, 'loadConfigs').and.returnValue(
of(configs)
);
spyOn(configValidationService, 'validateConfig').and.returnValue(true);
configService.getOpenIDConfiguration('configId1').subscribe((config) => {
expect(config).toBeTruthy();
expect(spy).toHaveBeenCalled();
});
}));
it(`returns null if config is not valid`, waitForAsync(() => {
const configs = [{ configId: 'configId1' }];
spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs));
spyOn(configValidationService, 'validateConfig').and.returnValue(false);
const consoleSpy = spyOn(console, 'warn');
configService.getOpenIDConfiguration('configId1').subscribe((config) => {
expect(config).toBeNull();
expect(consoleSpy).toHaveBeenCalledOnceWith(`[angular-auth-oidc-client] No configuration found for config id 'configId1'.`)
});
}));
it(`returns null if configs are stored but not existing ID is passed`, waitForAsync(() => {
(configService as any).configsInternal = {
configId1: { configId: 'configId1' },
configId2: { configId: 'configId2' },
};
configService
.getOpenIDConfiguration('notExisting')
.subscribe((config) => {
expect(config).toBeNull();
});
}));
it(`sets authWellKnownEndPoints on config if authWellKnownEndPoints is stored`, waitForAsync(() => {
const configs = [{ configId: 'configId1' }];
spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs));
spyOn(configValidationService, 'validateConfig').and.returnValue(true);
const consoleSpy = spyOn(console, 'warn');
spyOn(storagePersistenceService, 'read').and.returnValue({
issuer: 'auth-well-known',
});
configService.getOpenIDConfiguration('configId1').subscribe((config) => {
expect(config?.authWellknownEndpoints).toEqual({
issuer: 'auth-well-known',
});
expect(consoleSpy).not.toHaveBeenCalled()
});
}));
it(`fires ConfigLoaded if authWellKnownEndPoints is stored`, waitForAsync(() => {
const configs = [{ configId: 'configId1' }];
spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs));
spyOn(configValidationService, 'validateConfig').and.returnValue(true);
spyOn(storagePersistenceService, 'read').and.returnValue({
issuer: 'auth-well-known',
});
const spy = spyOn(publicEventsService, 'fireEvent');
configService.getOpenIDConfiguration('configId1').subscribe(() => {
expect(spy).toHaveBeenCalledOnceWith(
EventTypes.ConfigLoaded,
jasmine.anything()
);
});
}));
it(`stores, uses and fires event when authwellknownendpoints are passed`, waitForAsync(() => {
const configs = [
{
configId: 'configId1',
authWellknownEndpoints: { issuer: 'auth-well-known' },
},
];
spyOn(configService as any, 'loadConfigs').and.returnValue(of(configs));
spyOn(configValidationService, 'validateConfig').and.returnValue(true);
spyOn(storagePersistenceService, 'read').and.returnValue(null);
const fireEventSpy = spyOn(publicEventsService, 'fireEvent');
const storeWellKnownEndpointsSpy = spyOn(
authWellKnownService,
'storeWellKnownEndpoints'
);
configService.getOpenIDConfiguration('configId1').subscribe((config) => {
expect(config).toBeTruthy();
expect(fireEventSpy).toHaveBeenCalledOnceWith(
EventTypes.ConfigLoaded,
jasmine.anything()
);
expect(storeWellKnownEndpointsSpy).toHaveBeenCalledOnceWith(
config as OpenIdConfiguration,
{
issuer: 'auth-well-known',
}
);
});
}));
});
describe('getOpenIDConfigurations', () => {
it(`returns correct result`, waitForAsync(() => {
spyOn(stsConfigLoader, 'loadConfigs').and.returnValue(
of([
{ configId: 'configId1' } as OpenIdConfiguration,
{ configId: 'configId2' } as OpenIdConfiguration,
])
);
spyOn(configValidationService, 'validateConfig').and.returnValue(true);
configService.getOpenIDConfigurations('configId1').subscribe((result) => {
expect(result.allConfigs.length).toEqual(2);
expect(result.currentConfig).toBeTruthy();
});
}));
it(`created configId when configId is not set`, waitForAsync(() => {
spyOn(stsConfigLoader, 'loadConfigs').and.returnValue(
of([
{ clientId: 'clientId1' } as OpenIdConfiguration,
{ clientId: 'clientId2' } as OpenIdConfiguration,
])
);
spyOn(configValidationService, 'validateConfig').and.returnValue(true);
configService.getOpenIDConfigurations().subscribe((result) => {
expect(result.allConfigs.length).toEqual(2);
const allConfigIds = result.allConfigs.map((x) => x.configId);
expect(allConfigIds).toEqual(['0-clientId1', '1-clientId2']);
expect(result.currentConfig).toBeTruthy();
expect(result.currentConfig?.configId).toBeTruthy();
});
}));
it(`returns empty array if config is not valid`, waitForAsync(() => {
spyOn(stsConfigLoader, 'loadConfigs').and.returnValue(
of([
{ configId: 'configId1' } as OpenIdConfiguration,
{ configId: 'configId2' } as OpenIdConfiguration,
])
);
spyOn(configValidationService, 'validateConfigs').and.returnValue(false);
configService
.getOpenIDConfigurations()
.subscribe(({ allConfigs, currentConfig }) => {
expect(allConfigs).toEqual([]);
expect(currentConfig).toBeNull();
});
}));
});
describe('setSpecialCases', () => {
it(`should set special cases when current platform is browser`, () => {
spyOn(platformProvider, 'isBrowser').and.returnValue(false);
const config = { configId: 'configId1' } as OpenIdConfiguration;
(configService as any).setSpecialCases(config);
expect(config).toEqual({
configId: 'configId1',
startCheckSession: false,
silentRenew: false,
useRefreshToken: false,
usePushedAuthorisationRequests: false,
});
});
});
});

View File

@@ -0,0 +1,208 @@
import {inject, Injectable, isDevMode} from 'injection-js';
import { forkJoin, Observable, of } from 'rxjs';
import { concatMap, map } from 'rxjs/operators';
import { LoggerService } from '../logging/logger.service';
import { EventTypes } from '../public-events/event-types';
import { PublicEventsService } from '../public-events/public-events.service';
import { StoragePersistenceService } from '../storage/storage-persistence.service';
import { PlatformProvider } from '../utils/platform-provider/platform.provider';
import { AuthWellKnownService } from './auth-well-known/auth-well-known.service';
import { DEFAULT_CONFIG } from './default-config';
import { StsConfigLoader } from './loader/config-loader';
import { OpenIdConfiguration } from './openid-configuration';
import { ConfigValidationService } from './validation/config-validation.service';
@Injectable()
export class ConfigurationService {
private readonly loggerService = inject(LoggerService);
private readonly publicEventsService = inject(PublicEventsService);
private readonly storagePersistenceService = inject(
StoragePersistenceService
);
private readonly platformProvider = inject(PlatformProvider);
private readonly authWellKnownService = inject(AuthWellKnownService);
private readonly loader = inject(StsConfigLoader);
private readonly configValidationService = inject(ConfigValidationService);
private configsInternal: Record<string, OpenIdConfiguration> = {};
hasManyConfigs(): boolean {
return Object.keys(this.configsInternal).length > 1;
}
getAllConfigurations(): OpenIdConfiguration[] {
return Object.values(this.configsInternal);
}
getOpenIDConfiguration(
configId?: string
): Observable<OpenIdConfiguration | null> {
if (this.configsAlreadySaved()) {
return of(this.getConfig(configId));
}
return this.getOpenIDConfigurations(configId).pipe(
map((result) => result.currentConfig)
);
}
getOpenIDConfigurations(configId?: string): Observable<{
allConfigs: OpenIdConfiguration[];
currentConfig: OpenIdConfiguration | null;
}> {
return this.loadConfigs().pipe(
concatMap((allConfigs) => this.prepareAndSaveConfigs(allConfigs)),
map((allPreparedConfigs) => ({
allConfigs: allPreparedConfigs,
currentConfig: this.getConfig(configId),
}))
);
}
hasAtLeastOneConfig(): boolean {
return Object.keys(this.configsInternal).length > 0;
}
private saveConfig(readyConfig: OpenIdConfiguration): void {
const { configId } = readyConfig;
this.configsInternal[configId as string] = readyConfig;
}
private loadConfigs(): Observable<OpenIdConfiguration[]> {
return this.loader.loadConfigs();
}
private configsAlreadySaved(): boolean {
return this.hasAtLeastOneConfig();
}
private getConfig(configId?: string): OpenIdConfiguration | null {
if (Boolean(configId)) {
const config = this.configsInternal[configId!];
if(!config && isDevMode()) {
console.warn(`[angular-auth-oidc-client] No configuration found for config id '${configId}'.`);
}
return config || null;
}
const [, value] = Object.entries(this.configsInternal)[0] || [[null, null]];
return value || null;
}
private prepareAndSaveConfigs(
passedConfigs: OpenIdConfiguration[]
): Observable<OpenIdConfiguration[]> {
if (!this.configValidationService.validateConfigs(passedConfigs)) {
return of([]);
}
this.createUniqueIds(passedConfigs);
const allHandleConfigs$ = passedConfigs.map((x) => this.handleConfig(x));
const as = forkJoin(allHandleConfigs$).pipe(
map((config) => config.filter((conf) => Boolean(conf))),
map((c) => c as OpenIdConfiguration[])
);
return as;
}
private createUniqueIds(passedConfigs: OpenIdConfiguration[]): void {
passedConfigs.forEach((config, index) => {
if (!config.configId) {
config.configId = `${index}-${config.clientId}`;
}
});
}
private handleConfig(
passedConfig: OpenIdConfiguration
): Observable<OpenIdConfiguration | null> {
if (!this.configValidationService.validateConfig(passedConfig)) {
this.loggerService.logError(
passedConfig,
'Validation of config rejected with errors. Config is NOT set.'
);
return of(null);
}
if (!passedConfig.authWellknownEndpointUrl) {
passedConfig.authWellknownEndpointUrl = passedConfig.authority;
}
const usedConfig = this.prepareConfig(passedConfig);
this.saveConfig(usedConfig);
const configWithAuthWellKnown =
this.enhanceConfigWithWellKnownEndpoint(usedConfig);
this.publicEventsService.fireEvent<OpenIdConfiguration>(
EventTypes.ConfigLoaded,
configWithAuthWellKnown
);
return of(usedConfig);
}
private enhanceConfigWithWellKnownEndpoint(
configuration: OpenIdConfiguration
): OpenIdConfiguration {
const alreadyExistingAuthWellKnownEndpoints =
this.storagePersistenceService.read(
'authWellKnownEndPoints',
configuration
);
if (!!alreadyExistingAuthWellKnownEndpoints) {
configuration.authWellknownEndpoints =
alreadyExistingAuthWellKnownEndpoints;
return configuration;
}
const passedAuthWellKnownEndpoints = configuration.authWellknownEndpoints;
if (!!passedAuthWellKnownEndpoints) {
this.authWellKnownService.storeWellKnownEndpoints(
configuration,
passedAuthWellKnownEndpoints
);
configuration.authWellknownEndpoints = passedAuthWellKnownEndpoints;
return configuration;
}
return configuration;
}
private prepareConfig(
configuration: OpenIdConfiguration
): OpenIdConfiguration {
const openIdConfigurationInternal = { ...DEFAULT_CONFIG, ...configuration };
this.setSpecialCases(openIdConfigurationInternal);
return openIdConfigurationInternal;
}
private setSpecialCases(currentConfig: OpenIdConfiguration): void {
if (!this.platformProvider.isBrowser()) {
currentConfig.startCheckSession = false;
currentConfig.silentRenew = false;
currentConfig.useRefreshToken = false;
currentConfig.usePushedAuthorisationRequests = false;
}
}
}

View File

@@ -0,0 +1,43 @@
import { LogLevel } from '../logging/log-level';
import { OpenIdConfiguration } from './openid-configuration';
export const DEFAULT_CONFIG: OpenIdConfiguration = {
authority: 'https://please_set',
authWellknownEndpointUrl: '',
authWellknownEndpoints: undefined,
redirectUrl: 'https://please_set',
checkRedirectUrlWhenCheckingIfIsCallback: true,
clientId: 'please_set',
responseType: 'code',
scope: 'openid email profile',
hdParam: '',
postLogoutRedirectUri: 'https://please_set',
startCheckSession: false,
silentRenew: false,
silentRenewUrl: 'https://please_set',
silentRenewTimeoutInSeconds: 20,
renewTimeBeforeTokenExpiresInSeconds: 0,
useRefreshToken: false,
usePushedAuthorisationRequests: false,
ignoreNonceAfterRefresh: false,
postLoginRoute: '/',
forbiddenRoute: '/forbidden',
unauthorizedRoute: '/unauthorized',
autoUserInfo: true,
autoCleanStateAfterAuthentication: true,
triggerAuthorizationResultEvent: false,
logLevel: LogLevel.Warn,
issValidationOff: false,
historyCleanupOff: false,
maxIdTokenIatOffsetAllowedInSeconds: 120,
disableIatOffsetValidation: false,
customParamsAuthRequest: {},
customParamsRefreshTokenRequest: {},
customParamsEndSessionRequest: {},
customParamsCodeRequest: {},
disableRefreshIdTokenAuthTimeValidation: false,
triggerRefreshWhenIdTokenExpired: true,
tokenRefreshInSeconds: 4,
refreshTokenRetryInSeconds: 3,
ngswBypass: false,
};

View File

@@ -0,0 +1,86 @@
import { waitForAsync } from '@angular/core/testing';
import { of } from 'rxjs';
import { OpenIdConfiguration } from '../openid-configuration';
import { StsConfigHttpLoader, StsConfigStaticLoader } from './config-loader';
describe('ConfigLoader', () => {
describe('StsConfigStaticLoader', () => {
describe('loadConfigs', () => {
it('returns an array if an array is passed', waitForAsync(() => {
const toPass = [
{ configId: 'configId1' } as OpenIdConfiguration,
{ configId: 'configId2' } as OpenIdConfiguration,
];
const loader = new StsConfigStaticLoader(toPass);
const result$ = loader.loadConfigs();
result$.subscribe((result) => {
expect(Array.isArray(result)).toBeTrue();
});
}));
it('returns an array if only one config is passed', waitForAsync(() => {
const loader = new StsConfigStaticLoader({
configId: 'configId1',
} as OpenIdConfiguration);
const result$ = loader.loadConfigs();
result$.subscribe((result) => {
expect(Array.isArray(result)).toBeTrue();
});
}));
});
});
describe('StsConfigHttpLoader', () => {
describe('loadConfigs', () => {
it('returns an array if an array of observables is passed', waitForAsync(() => {
const toPass = [
of({ configId: 'configId1' } as OpenIdConfiguration),
of({ configId: 'configId2' } as OpenIdConfiguration),
];
const loader = new StsConfigHttpLoader(toPass);
const result$ = loader.loadConfigs();
result$.subscribe((result) => {
expect(Array.isArray(result)).toBeTrue();
expect(result[0].configId).toBe('configId1');
expect(result[1].configId).toBe('configId2');
});
}));
it('returns an array if an observable with a config array is passed', waitForAsync(() => {
const toPass = of([
{ configId: 'configId1' } as OpenIdConfiguration,
{ configId: 'configId2' } as OpenIdConfiguration,
]);
const loader = new StsConfigHttpLoader(toPass);
const result$ = loader.loadConfigs();
result$.subscribe((result) => {
expect(Array.isArray(result)).toBeTrue();
expect(result[0].configId).toBe('configId1');
expect(result[1].configId).toBe('configId2');
});
}));
it('returns an array if only one config is passed', waitForAsync(() => {
const loader = new StsConfigHttpLoader(
of({ configId: 'configId1' } as OpenIdConfiguration)
);
const result$ = loader.loadConfigs();
result$.subscribe((result) => {
expect(Array.isArray(result)).toBeTrue();
expect(result[0].configId).toBe('configId1');
});
}));
});
});
});

View File

@@ -0,0 +1,53 @@
import { Provider } from 'injection-js';
import { forkJoin, Observable, of } from 'rxjs';
import { map } from 'rxjs/operators';
import { OpenIdConfiguration } from '../openid-configuration';
export class OpenIdConfigLoader {
loader?: Provider;
}
export abstract class StsConfigLoader {
abstract loadConfigs(): Observable<OpenIdConfiguration[]>;
}
export class StsConfigStaticLoader implements StsConfigLoader {
constructor(
private readonly passedConfigs: OpenIdConfiguration | OpenIdConfiguration[]
) {}
loadConfigs(): Observable<OpenIdConfiguration[]> {
if (Array.isArray(this.passedConfigs)) {
return of(this.passedConfigs);
}
return of([this.passedConfigs]);
}
}
export class StsConfigHttpLoader implements StsConfigLoader {
constructor(
private readonly configs$:
| Observable<OpenIdConfiguration>
| Observable<OpenIdConfiguration>[]
| Observable<OpenIdConfiguration[]>
) {}
loadConfigs(): Observable<OpenIdConfiguration[]> {
if (Array.isArray(this.configs$)) {
return forkJoin(this.configs$);
}
const singleConfigOrArray = this.configs$ as Observable<unknown>;
return singleConfigOrArray.pipe(
map((value: unknown) => {
if (Array.isArray(value)) {
return value as OpenIdConfiguration[];
}
return [value] as OpenIdConfiguration[];
})
);
}
}

View File

@@ -0,0 +1,211 @@
import { LogLevel } from '../logging/log-level';
import { AuthWellKnownEndpoints } from './auth-well-known/auth-well-known-endpoints';
export interface OpenIdConfiguration {
/**
* To identify a configuration the `configId` parameter was introduced.
* If you do not explicitly set this value, the library will generate
* and assign the value for you. If set, the configured value is used.
* The value is optional.
*/
configId?: string;
/**
* The url to the Security Token Service (STS). The authority issues tokens.
* This field is required.
*/
authority?: string;
/** Override the default Security Token Service wellknown endpoint postfix. */
authWellknownEndpointUrl?: string;
authWellknownEndpoints?: AuthWellKnownEndpoints;
/**
* Override the default Security Token Service wellknown endpoint postfix.
*
* @default /.well-known/openid-configuration
*/
authWellknownUrlSuffix?: string;
/** The redirect URL defined on the Security Token Service. */
redirectUrl?: string;
/**
* Whether to check if current URL matches the redirect URI when determining
* if current URL is in fact the redirect URI.
* Default: true
*/
checkRedirectUrlWhenCheckingIfIsCallback?: boolean;
/**
* The Client MUST validate that the aud (audience) Claim contains its `client_id` value
* registered at the Issuer identified by the iss (issuer) Claim as an audience.
* The ID Token MUST be rejected if the ID Token does not list the Client as a valid audience,
* or if it contains additional audiences not trusted by the Client.
*/
clientId?: string;
/**
* `code`, `id_token token` or `id_token`.
* Name of the flow which can be configured.
* You must use the `id_token token` flow, if you want to access an API
* or get user data from the server. The `access_token` is required for this,
* and only returned with this flow.
*/
responseType?: string;
/**
* List of scopes which are requested from the server from this client.
* This must match the Security Token Service configuration for the client you use.
* The `openid` scope is required. The `offline_access` scope can be requested when using refresh tokens
* but this is optional and some Security Token Service do not support this or recommend not requesting this even when using
* refresh tokens in the browser.
*/
scope?: string;
/**
* Optional hd parameter for Google Auth with particular G Suite domain,
* see https://developers.google.com/identity/protocols/OpenIDConnect#hd-param
*/
hdParam?: string;
/** URL to redirect to after a server logout if using the end session API. */
postLogoutRedirectUri?: string;
/** Starts the OpenID session management for this client. */
startCheckSession?: boolean;
/** Renews the client tokens, once the id_token expires. Can use iframes or refresh tokens. */
silentRenew?: boolean;
/** An optional URL to handle silent renew callbacks */
silentRenewUrl?: string;
/**
* Sets the maximum waiting time for silent renew process. If this time is exceeded, the silent renew state will
* be reset. Default = 20.
* */
silentRenewTimeoutInSeconds?: number;
/**
* Makes it possible to add an offset to the silent renew check in seconds.
* By entering a value, you can renew the tokens before the tokens expire.
*/
renewTimeBeforeTokenExpiresInSeconds?: number;
/**
* Allows for a custom domain to be used with Auth0.
* With this flag set the 'authority' does not have to end with
* 'auth0.com' to trigger the auth0 special handling of logouts.
*/
useCustomAuth0Domain?: boolean;
/**
* When set to true, refresh tokens are used to renew the user session.
* When set to false, standard silent renew is used.
* Default value is false.
*/
useRefreshToken?: boolean;
/**
* Activates Pushed Authorisation Requests for login and popup login.
* Not compatible with iframe renew.
*/
usePushedAuthorisationRequests?: boolean;
/**
* A token obtained by using a refresh token normally doesn't contain a nonce value.
* The library checks it is not there. However, some OIDC endpoint implementations do send one.
* Setting `ignoreNonceAfterRefresh` to `true` disables the check if a nonce is present.
* Please note that the nonce value, if present, will not be verified. Default is `false`.
*/
ignoreNonceAfterRefresh?: boolean;
/**
* The default Angular route which is used after a successful login, if not using the
* `triggerAuthorizationResultEvent`
*/
postLoginRoute?: string;
/** Route to redirect to if the server returns a 403 error. This has to be an Angular route. HTTP 403. */
forbiddenRoute?: string;
/** Route to redirect to if the server returns a 401 error. This has to be an Angular route. HTTP 401. */
unauthorizedRoute?: string;
/** When set to true, the library automatically gets user info after authentication */
autoUserInfo?: boolean;
/** When set to true, the library automatically gets user info after token renew */
renewUserInfoAfterTokenRenew?: boolean;
/** Used for custom state logic handling. The state is not automatically reset when set to false */
autoCleanStateAfterAuthentication?: boolean;
/**
* This can be set to true which emits an event instead of an Angular route change.
* Instead of forcing the application consuming this library to automatically redirect to one of the 3
* hard-configured routes (start, unauthorized, forbidden), this modification will add an extra
* configuration option to override such behavior and trigger an event that will allow to subscribe to
* it and let the application perform other actions. This would be useful to allow the application to
* save an initial return URL so that the user is redirected to it after a successful login on the Security Token Service
* (i.e. saving the return URL previously on sessionStorage and then retrieving it during the triggering of the event).
*/
triggerAuthorizationResultEvent?: boolean;
/** 0, 1, 2 can be used to set the log level displayed in the console. */
logLevel?: LogLevel;
/** Make it possible to turn off the iss validation per configuration. **You should not turn this off!** */
issValidationOff?: boolean;
/**
* If this is active, the history is not cleaned up on an authorize callback.
* This can be used when the application needs to preserve the history.
*/
historyCleanupOff?: boolean;
/**
* Amount of offset allowed between the server creating the token and the client app receiving the id_token.
* The diff in time between the server time and client time is also important in validating this value.
* All times are in UTC.
*/
maxIdTokenIatOffsetAllowedInSeconds?: number;
/**
* This allows the application to disable the iat offset validation check.
* The iat Claim can be used to reject tokens that were issued too far away from the current time,
* limiting the amount of time that nonces need to be stored to prevent attacks.
* The acceptable range is client specific.
*/
disableIatOffsetValidation?: boolean;
/** Extra parameters to add to the authorization URL request */
customParamsAuthRequest?: { [key: string]: string | number | boolean };
/** Extra parameters to add to the refresh token request body */
customParamsRefreshTokenRequest?: {
[key: string]: string | number | boolean;
};
/** Extra parameters to add to the authorization EndSession request */
customParamsEndSessionRequest?: { [key: string]: string | number | boolean };
/** Extra parameters to add to the token URL request */
customParamsCodeRequest?: { [key: string]: string | number | boolean };
// Azure B2C have implemented this incorrectly. Add support for to disable this until fixed.
/** Disables the auth_time validation for id_tokens in a refresh due to Azure's incorrect implementation. */
disableRefreshIdTokenAuthTimeValidation?: boolean;
/**
* Enables the id_token validation, default value is `true`.
* You can disable this validation if you like to ignore the expired value in the renew process or not check this in the expiry check. Only the access token is used to trigger a renew.
* If no id_token is returned in using refresh tokens, set this to `false`.
*/
triggerRefreshWhenIdTokenExpired?: boolean;
/** Controls the periodic check time interval in sections.
* Default value is 3.
*/
tokenRefreshInSeconds?: number;
/**
* Array of secure URLs on which the token should be sent if the interceptor is added to the `HTTP_INTERCEPTORS`.
*/
secureRoutes?: string[];
/**
* Controls the periodic retry time interval for retrieving new tokens in seconds.
* `silentRenewTimeoutInSeconds` and `tokenRefreshInSeconds` are upper bounds for this value.
* Default value is 3
*/
refreshTokenRetryInSeconds?: number;
/** Adds the ngsw-bypass param to all requests */
ngswBypass?: boolean;
/** Allow refresh token reuse (refresh without rotation), default value is false.
* The refresh token rotation is optional (rfc6749) but is more safe and hence encouraged.
*/
allowUnsafeReuseRefreshToken?: boolean;
/** Disable validation for id_token
* This is not recommended! You should always validate the id_token if returned.
*/
disableIdTokenValidation?: boolean;
/** Disables PKCE support.
* Authorize request will be sent without code challenge.
*/
disablePkce?: boolean;
/**
* Disable cleaning up the popup when receiving invalid messages
*/
disableCleaningPopupOnInvalidMessage?: boolean
}

View File

@@ -0,0 +1,192 @@
import { TestBed } from '@angular/core/testing';
import { mockProvider } from '../../../test/auto-mock';
import { LogLevel } from '../../logging/log-level';
import { LoggerService } from '../../logging/logger.service';
import { OpenIdConfiguration } from '../openid-configuration';
import { ConfigValidationService } from './config-validation.service';
import { allMultipleConfigRules } from './rules';
describe('Config Validation Service', () => {
let configValidationService: ConfigValidationService;
let loggerService: LoggerService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ConfigValidationService, mockProvider(LoggerService)],
});
});
const VALID_CONFIG = {
authority: 'https://offeringsolutions-sts.azurewebsites.net',
redirectUrl: window.location.origin,
postLogoutRedirectUri: window.location.origin,
clientId: 'angularClient',
scope: 'openid profile email',
responseType: 'code',
silentRenew: true,
silentRenewUrl: `${window.location.origin}/silent-renew.html`,
renewTimeBeforeTokenExpiresInSeconds: 10,
logLevel: LogLevel.Debug,
};
beforeEach(() => {
configValidationService = TestBed.inject(ConfigValidationService);
loggerService = TestBed.inject(LoggerService);
});
it('should create', () => {
expect(configValidationService).toBeTruthy();
});
it('should return false for empty config', () => {
const config = {};
const result = configValidationService.validateConfig(config);
expect(result).toBeFalse();
});
it('should return true for valid config', () => {
const result = configValidationService.validateConfig(VALID_CONFIG);
expect(result).toBeTrue();
});
it('calls `logWarning` if one rule has warning level', () => {
const loggerWarningSpy = spyOn(loggerService, 'logWarning');
const messageTypeSpy = spyOn(
configValidationService as any,
'getAllMessagesOfType'
);
messageTypeSpy
.withArgs('warning', jasmine.any(Array))
.and.returnValue(['A warning message']);
messageTypeSpy.withArgs('error', jasmine.any(Array)).and.callThrough();
configValidationService.validateConfig(VALID_CONFIG);
expect(loggerWarningSpy).toHaveBeenCalled();
});
describe('ensure-clientId.rule', () => {
it('return false when no clientId is set', () => {
const config = { ...VALID_CONFIG, clientId: '' } as OpenIdConfiguration;
const result = configValidationService.validateConfig(config);
expect(result).toBeFalse();
});
});
describe('ensure-authority-server.rule', () => {
it('return false when no security token service is set', () => {
const config = {
...VALID_CONFIG,
authority: '',
} as OpenIdConfiguration;
const result = configValidationService.validateConfig(config);
expect(result).toBeFalse();
});
});
describe('ensure-redirect-url.rule', () => {
it('return false for no redirect Url', () => {
const config = { ...VALID_CONFIG, redirectUrl: '' };
const result = configValidationService.validateConfig(config);
expect(result).toBeFalse();
});
});
describe('ensureSilentRenewUrlWhenNoRefreshTokenUsed', () => {
it('return false when silent renew is used with no useRefreshToken and no silentrenewUrl', () => {
const config = {
...VALID_CONFIG,
silentRenew: true,
useRefreshToken: false,
silentRenewUrl: '',
} as OpenIdConfiguration;
const result = configValidationService.validateConfig(config);
expect(result).toBeFalse();
});
});
describe('use-offline-scope-with-silent-renew.rule', () => {
it('return true but warning when silent renew is used with useRefreshToken but no offline_access scope is given', () => {
const config = {
...VALID_CONFIG,
silentRenew: true,
useRefreshToken: true,
scopes: 'scope1 scope2 but_no_offline_access',
};
const loggerSpy = spyOn(loggerService, 'logError');
const loggerWarningSpy = spyOn(loggerService, 'logWarning');
const result = configValidationService.validateConfig(config);
expect(result).toBeTrue();
expect(loggerSpy).not.toHaveBeenCalled();
expect(loggerWarningSpy).toHaveBeenCalled();
});
});
describe('ensure-no-duplicated-configs.rule', () => {
it('should print out correct error when mutiple configs with same properties are passed', () => {
const config1 = {
...VALID_CONFIG,
silentRenew: true,
useRefreshToken: true,
scopes: 'scope1 scope2 but_no_offline_access',
};
const config2 = {
...VALID_CONFIG,
silentRenew: true,
useRefreshToken: true,
scopes: 'scope1 scope2 but_no_offline_access',
};
const loggerErrorSpy = spyOn(loggerService, 'logError');
const loggerWarningSpy = spyOn(loggerService, 'logWarning');
const result = configValidationService.validateConfigs([
config1,
config2,
]);
expect(result).toBeTrue();
expect(loggerErrorSpy).not.toHaveBeenCalled();
expect(loggerWarningSpy.calls.argsFor(0)).toEqual([
config1,
'You added multiple configs with the same authority, clientId and scope',
]);
expect(loggerWarningSpy.calls.argsFor(1)).toEqual([
config2,
'You added multiple configs with the same authority, clientId and scope',
]);
});
it('should return false and a better error message when config is not passed as object with config property', () => {
const loggerWarningSpy = spyOn(loggerService, 'logWarning');
const result = configValidationService.validateConfigs([]);
expect(result).toBeFalse();
expect(loggerWarningSpy).not.toHaveBeenCalled();
});
});
describe('validateConfigs', () => {
it('calls internal method with empty array if something falsy is passed', () => {
const spy = spyOn(
configValidationService as any,
'validateConfigsInternal'
).and.callThrough();
const result = configValidationService.validateConfigs([]);
expect(result).toBeFalse();
expect(spy).toHaveBeenCalledOnceWith([], allMultipleConfigRules);
});
});
});

View File

@@ -0,0 +1,98 @@
import { inject, Injectable } from 'injection-js';
import { LoggerService } from '../../logging/logger.service';
import { OpenIdConfiguration } from '../openid-configuration';
import { Level, RuleValidationResult } from './rule';
import { allMultipleConfigRules, allRules } from './rules';
@Injectable()
export class ConfigValidationService {
private readonly loggerService = inject(LoggerService);
validateConfigs(passedConfigs: OpenIdConfiguration[]): boolean {
return this.validateConfigsInternal(
passedConfigs ?? [],
allMultipleConfigRules
);
}
validateConfig(passedConfig: OpenIdConfiguration): boolean {
return this.validateConfigInternal(passedConfig, allRules);
}
private validateConfigsInternal(
passedConfigs: OpenIdConfiguration[],
allRulesToUse: ((
passedConfig: OpenIdConfiguration[]
) => RuleValidationResult)[]
): boolean {
if (passedConfigs.length === 0) {
return false;
}
const allValidationResults = allRulesToUse.map((rule) =>
rule(passedConfigs)
);
let overallErrorCount = 0;
passedConfigs.forEach((passedConfig) => {
const errorCount = this.processValidationResultsAndGetErrorCount(
allValidationResults,
passedConfig
);
overallErrorCount += errorCount;
});
return overallErrorCount === 0;
}
private validateConfigInternal(
passedConfig: OpenIdConfiguration,
allRulesToUse: ((
passedConfig: OpenIdConfiguration
) => RuleValidationResult)[]
): boolean {
const allValidationResults = allRulesToUse.map((rule) =>
rule(passedConfig)
);
const errorCount = this.processValidationResultsAndGetErrorCount(
allValidationResults,
passedConfig
);
return errorCount === 0;
}
private processValidationResultsAndGetErrorCount(
allValidationResults: RuleValidationResult[],
config: OpenIdConfiguration
): number {
const allMessages = allValidationResults.filter(
(x) => x.messages.length > 0
);
const allErrorMessages = this.getAllMessagesOfType('error', allMessages);
const allWarnings = this.getAllMessagesOfType('warning', allMessages);
allErrorMessages.forEach((message) =>
this.loggerService.logError(config, message)
);
allWarnings.forEach((message) =>
this.loggerService.logWarning(config, message)
);
return allErrorMessages.length;
}
private getAllMessagesOfType(
type: Level,
results: RuleValidationResult[]
): string[] {
const allMessages = results
.filter((x) => x.level === type)
.map((result) => result.messages);
return allMessages.reduce((acc, val) => acc.concat(val), []);
}
}

View File

@@ -0,0 +1,19 @@
import { OpenIdConfiguration } from '../openid-configuration';
export interface Rule {
validate(passedConfig: OpenIdConfiguration): RuleValidationResult;
}
export interface RuleValidationResult {
result: boolean;
messages: string[];
level: Level;
}
export const POSITIVE_VALIDATION_RESULT: RuleValidationResult = {
result: true,
messages: [],
level: 'none',
};
export type Level = 'warning' | 'error' | 'none';

View File

@@ -0,0 +1,16 @@
import { OpenIdConfiguration } from '../../openid-configuration';
import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule';
export const ensureAuthority = (
passedConfig: OpenIdConfiguration
): RuleValidationResult => {
if (!passedConfig.authority) {
return {
result: false,
messages: ['The authority URL MUST be provided in the configuration! '],
level: 'error',
};
}
return POSITIVE_VALIDATION_RESULT;
};

View File

@@ -0,0 +1,16 @@
import { OpenIdConfiguration } from '../../openid-configuration';
import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule';
export const ensureClientId = (
passedConfig: OpenIdConfiguration
): RuleValidationResult => {
if (!passedConfig.clientId) {
return {
result: false,
messages: ['The clientId is required and missing from your config!'],
level: 'error',
};
}
return POSITIVE_VALIDATION_RESULT;
};

View File

@@ -0,0 +1,47 @@
import { OpenIdConfiguration } from '../../openid-configuration';
import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule';
const createIdentifierToCheck = (passedConfig: OpenIdConfiguration): string => {
if (!passedConfig) {
return '';
}
const { authority, clientId, scope } = passedConfig;
return `${authority}${clientId}${scope}`;
};
const arrayHasDuplicates = (array: string[]): boolean =>
new Set(array).size !== array.length;
export const ensureNoDuplicatedConfigsRule = (
passedConfigs: OpenIdConfiguration[]
): RuleValidationResult => {
const allIdentifiers = passedConfigs.map((x) => createIdentifierToCheck(x));
const someAreNotSet = allIdentifiers.some((x) => x === '');
if (someAreNotSet) {
return {
result: false,
messages: [
`Please make sure you add an object with a 'config' property: ....({ config }) instead of ...(config)`,
],
level: 'error',
};
}
const hasDuplicates = arrayHasDuplicates(allIdentifiers);
if (hasDuplicates) {
return {
result: false,
messages: [
'You added multiple configs with the same authority, clientId and scope',
],
level: 'warning',
};
}
return POSITIVE_VALIDATION_RESULT;
};

View File

@@ -0,0 +1,16 @@
import { OpenIdConfiguration } from '../../openid-configuration';
import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule';
export const ensureRedirectRule = (
passedConfig: OpenIdConfiguration
): RuleValidationResult => {
if (!passedConfig.redirectUrl) {
return {
result: false,
messages: ['The redirectUrl is required and missing from your config'],
level: 'error',
};
}
return POSITIVE_VALIDATION_RESULT;
};

View File

@@ -0,0 +1,22 @@
import { OpenIdConfiguration } from '../../openid-configuration';
import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule';
export const ensureSilentRenewUrlWhenNoRefreshTokenUsed = (
passedConfig: OpenIdConfiguration
): RuleValidationResult => {
const usesSilentRenew = passedConfig.silentRenew;
const usesRefreshToken = passedConfig.useRefreshToken;
const hasSilentRenewUrl = passedConfig.silentRenewUrl;
if (usesSilentRenew && !usesRefreshToken && !hasSilentRenewUrl) {
return {
result: false,
messages: [
'Please provide a silent renew URL if using renew and not refresh tokens',
],
level: 'error',
};
}
return POSITIVE_VALIDATION_RESULT;
};

View File

@@ -0,0 +1,16 @@
import { ensureAuthority } from './ensure-authority.rule';
import { ensureClientId } from './ensure-clientId.rule';
import { ensureNoDuplicatedConfigsRule } from './ensure-no-duplicated-configs.rule';
import { ensureRedirectRule } from './ensure-redirect-url.rule';
import { ensureSilentRenewUrlWhenNoRefreshTokenUsed } from './ensure-silentRenewUrl-with-no-refreshtokens.rule';
import { useOfflineScopeWithSilentRenew } from './use-offline-scope-with-silent-renew.rule';
export const allRules = [
ensureAuthority,
useOfflineScopeWithSilentRenew,
ensureRedirectRule,
ensureClientId,
ensureSilentRenewUrlWhenNoRefreshTokenUsed,
];
export const allMultipleConfigRules = [ensureNoDuplicatedConfigsRule];

View File

@@ -0,0 +1,23 @@
import { OpenIdConfiguration } from '../../openid-configuration';
import { POSITIVE_VALIDATION_RESULT, RuleValidationResult } from '../rule';
export const useOfflineScopeWithSilentRenew = (
passedConfig: OpenIdConfiguration
): RuleValidationResult => {
const hasRefreshToken = passedConfig.useRefreshToken;
const hasSilentRenew = passedConfig.silentRenew;
const scope = passedConfig.scope || '';
const hasOfflineScope = scope.split(' ').includes('offline_access');
if (hasRefreshToken && hasSilentRenew && !hasOfflineScope) {
return {
result: false,
messages: [
'When using silent renew and refresh tokens please set the `offline_access` scope',
],
level: 'warning',
};
}
return POSITIVE_VALIDATION_RESULT;
};