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,289 @@
import {
HTTP_INTERCEPTORS,
HttpClient,
provideHttpClient,
withInterceptors,
withInterceptorsFromDi,
} from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { mockProvider } from '../../test/auto-mock';
import { AuthStateService } from '../auth-state/auth-state.service';
import { ConfigurationService } from '../config/config.service';
import { LoggerService } from '../logging/logger.service';
import { AuthInterceptor, authInterceptor } from './auth.interceptor';
import { ClosestMatchingRouteService } from './closest-matching-route.service';
describe(`AuthHttpInterceptor`, () => {
let httpTestingController: HttpTestingController;
let configurationService: ConfigurationService;
let httpClient: HttpClient;
let authStateService: AuthStateService;
let closestMatchingRouteService: ClosestMatchingRouteService;
describe(`with Class Interceptor`, () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
ClosestMatchingRouteService,
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true,
},
mockProvider(AuthStateService),
mockProvider(LoggerService),
mockProvider(ConfigurationService),
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
});
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
configurationService = TestBed.inject(ConfigurationService);
authStateService = TestBed.inject(AuthStateService);
closestMatchingRouteService = TestBed.inject(ClosestMatchingRouteService);
});
afterEach(() => {
httpTestingController.verify();
});
runTests();
});
describe(`with Functional Interceptor`, () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
ClosestMatchingRouteService,
provideHttpClient(withInterceptors([authInterceptor()])),
provideHttpClientTesting(),
mockProvider(AuthStateService),
mockProvider(LoggerService),
mockProvider(ConfigurationService),
],
});
httpClient = TestBed.inject(HttpClient);
httpTestingController = TestBed.inject(HttpTestingController);
configurationService = TestBed.inject(ConfigurationService);
authStateService = TestBed.inject(AuthStateService);
closestMatchingRouteService = TestBed.inject(ClosestMatchingRouteService);
});
afterEach(() => {
httpTestingController.verify();
});
runTests();
});
function runTests(): void {
it('should add an Authorization header when route matches and token is present', waitForAsync(() => {
const actionUrl = `https://jsonplaceholder.typicode.com/`;
spyOn(configurationService, 'getAllConfigurations').and.returnValue([
{
secureRoutes: [actionUrl],
configId: 'configId1',
},
]);
spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken');
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true);
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(true);
httpRequest.flush('something');
httpTestingController.verify();
}));
it('should not add an Authorization header when `secureRoutes` is not given', waitForAsync(() => {
const actionUrl = `https://jsonplaceholder.typicode.com/`;
spyOn(configurationService, 'getAllConfigurations').and.returnValue([
{
configId: 'configId1',
},
]);
spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken');
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true);
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(false);
httpRequest.flush('something');
httpTestingController.verify();
}));
it('should not add an Authorization header when no routes configured', waitForAsync(() => {
const actionUrl = `https://jsonplaceholder.typicode.com/`;
spyOn(configurationService, 'getAllConfigurations').and.returnValue([
{
secureRoutes: [],
configId: 'configId1',
},
]);
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true);
spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken');
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(false);
httpRequest.flush('something');
httpTestingController.verify();
}));
it('should not add an Authorization header when no routes configured', waitForAsync(() => {
const actionUrl = `https://jsonplaceholder.typicode.com/`;
spyOn(configurationService, 'getAllConfigurations').and.returnValue([
{
secureRoutes: [],
configId: 'configId1',
},
]);
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true);
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(false);
httpRequest.flush('something');
httpTestingController.verify();
}));
it('should not add an Authorization header when route is configured but no token is present', waitForAsync(() => {
const actionUrl = `https://jsonplaceholder.typicode.com/`;
spyOn(configurationService, 'getAllConfigurations').and.returnValue([
{
secureRoutes: [actionUrl],
configId: 'configId1',
},
]);
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true);
spyOn(authStateService, 'getAccessToken').and.returnValue('');
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(false);
httpRequest.flush('something');
httpTestingController.verify();
}));
it('should not add an Authorization header when no config is present', waitForAsync(() => {
const actionUrl = `https://jsonplaceholder.typicode.com/`;
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(false);
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(false);
httpRequest.flush('something');
httpTestingController.verify();
}));
it('should not add an Authorization header when no configured route is matching the request', waitForAsync(() => {
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true);
const actionUrl = `https://jsonplaceholder.typicode.com/`;
spyOn(configurationService, 'getAllConfigurations').and.returnValue([
{
secureRoutes: [actionUrl],
configId: 'configId1',
},
]);
spyOn(
closestMatchingRouteService,
'getConfigIdForClosestMatchingRoute'
).and.returnValue({
matchingRoute: null,
matchingConfig: null,
});
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(false);
httpRequest.flush('something');
httpTestingController.verify();
}));
it('should add an Authorization header when multiple routes are configured and token is present', waitForAsync(() => {
const actionUrl = `https://jsonplaceholder.typicode.com/`;
const actionUrl2 = `https://some-other-url.com/`;
spyOn(configurationService, 'getAllConfigurations').and.returnValue([
{ secureRoutes: [actionUrl, actionUrl2], configId: 'configId1' },
]);
spyOn(authStateService, 'getAccessToken').and.returnValue('thisIsAToken');
spyOn(configurationService, 'hasAtLeastOneConfig').and.returnValue(true);
httpClient.get(actionUrl).subscribe((response) => {
expect(response).toBeTruthy();
});
httpClient.get(actionUrl2).subscribe((response) => {
expect(response).toBeTruthy();
});
const httpRequest = httpTestingController.expectOne(actionUrl);
expect(httpRequest.request.headers.has('Authorization')).toEqual(true);
const httpRequest2 = httpTestingController.expectOne(actionUrl2);
expect(httpRequest2.request.headers.has('Authorization')).toEqual(true);
httpRequest.flush('something');
httpRequest2.flush('something');
httpTestingController.verify();
}));
}
});

View File

@@ -0,0 +1,121 @@
import {
HttpEvent,
HttpHandler,
HttpHandlerFn,
HttpInterceptor,
HttpInterceptorFn,
HttpRequest,
} from '@ngify/http';
import { inject, Injectable } from 'injection-js';
import { Observable } from 'rxjs';
import { AuthStateService } from '../auth-state/auth-state.service';
import { ConfigurationService } from '../config/config.service';
import { LoggerService } from '../logging/logger.service';
import { flattenArray } from '../utils/collections/collections.helper';
import { ClosestMatchingRouteService } from './closest-matching-route.service';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private readonly authStateService = inject(AuthStateService);
private readonly configurationService = inject(ConfigurationService);
private readonly loggerService = inject(LoggerService);
private readonly closestMatchingRouteService = inject(
ClosestMatchingRouteService
);
intercept(
req: HttpRequest<any>,
next: HttpHandler
): Observable<HttpEvent<any>> {
return interceptRequest(req, next.handle, {
configurationService: this.configurationService,
authStateService: this.authStateService,
closestMatchingRouteService: this.closestMatchingRouteService,
loggerService: this.loggerService,
});
}
}
export function authInterceptor(): HttpInterceptorFn {
return (req, next) => {
return interceptRequest(req, next, {
configurationService: inject(ConfigurationService),
authStateService: inject(AuthStateService),
closestMatchingRouteService: inject(ClosestMatchingRouteService),
loggerService: inject(LoggerService),
});
};
}
function interceptRequest(
req: HttpRequest<any>,
next: HttpHandlerFn,
deps: {
authStateService: AuthStateService;
configurationService: ConfigurationService;
loggerService: LoggerService;
closestMatchingRouteService: ClosestMatchingRouteService;
}
): Observable<HttpEvent<unknown>> {
if (!deps.configurationService.hasAtLeastOneConfig()) {
return next(req);
}
const allConfigurations = deps.configurationService.getAllConfigurations();
const allRoutesConfigured = allConfigurations.map(
(x) => x.secureRoutes || []
);
const allRoutesConfiguredFlat = flattenArray(allRoutesConfigured);
if (allRoutesConfiguredFlat.length === 0) {
deps.loggerService.logDebug(
allConfigurations[0],
`No routes to check configured`
);
return next(req);
}
const { matchingConfig, matchingRoute } =
deps.closestMatchingRouteService.getConfigIdForClosestMatchingRoute(
req.url,
allConfigurations
);
if (!matchingConfig) {
deps.loggerService.logDebug(
allConfigurations[0],
`Did not find any configured route for route ${req.url}`
);
return next(req);
}
deps.loggerService.logDebug(
matchingConfig,
`'${req.url}' matches configured route '${matchingRoute}'`
);
const token = deps.authStateService.getAccessToken(matchingConfig);
if (!token) {
deps.loggerService.logDebug(
matchingConfig,
`Wanted to add token to ${req.url} but found no token: '${token}'`
);
return next(req);
}
deps.loggerService.logDebug(
matchingConfig,
`'${req.url}' matches configured route '${matchingRoute}', adding token`
);
req = req.clone({
headers: req.headers.set('Authorization', 'Bearer ' + token),
});
return next(req);
}

View File

@@ -0,0 +1,180 @@
import { TestBed } from '@angular/core/testing';
import { mockProvider } from '../../test/auto-mock';
import { LoggerService } from '../logging/logger.service';
import { ClosestMatchingRouteService } from './closest-matching-route.service';
describe('ClosestMatchingRouteService', () => {
let service: ClosestMatchingRouteService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ClosestMatchingRouteService, mockProvider(LoggerService)],
});
});
beforeEach(() => {
service = TestBed.inject(ClosestMatchingRouteService);
});
it('should create', () => {
expect(service).toBeTruthy();
});
describe('getConfigForClosestMatchingRoute', () => {
it('gets best match for configured routes', () => {
const allConfigs = [
{
configId: 'configId1',
secureRoutes: [
'https://my-secure-url.com/',
'https://my-second-secure-url.com/',
],
},
{
configId: 'configId2',
secureRoutes: [
'https://my-third-secure-url.com/',
'https://my-fourth-second-secure-url.com/',
],
},
];
const { matchingConfig } = service.getConfigIdForClosestMatchingRoute(
'https://my-secure-url.com/',
allConfigs
);
expect(matchingConfig).toEqual(allConfigs[0]);
});
it('gets best match for configured routes - same route prefix', () => {
const allConfigs = [
{
configId: 'configId1',
secureRoutes: [
'https://my-secure-url.com/',
'https://my-secure-url.com/test',
],
},
{
configId: 'configId2',
secureRoutes: [
'https://my-third-secure-url.com/',
'https://my-fourth-second-secure-url.com/',
],
},
];
const { matchingConfig } = service.getConfigIdForClosestMatchingRoute(
'https://my-secure-url.com/',
allConfigs
);
expect(matchingConfig).toEqual(allConfigs[0]);
});
it('gets best match for configured routes - main route', () => {
const allConfigs = [
{
configId: 'configId1',
secureRoutes: [
'https://first-route.com/',
'https://second-route.com/test',
],
},
{
configId: 'configId2',
secureRoutes: [
'https://third-route.com/test2',
'https://fourth-route.com/test3',
],
},
];
const { matchingConfig } = service.getConfigIdForClosestMatchingRoute(
'https://first-route.com/',
allConfigs
);
expect(matchingConfig).toEqual(allConfigs[0]);
});
it('gets best match for configured routes - request route with params', () => {
const allConfigs = [
{
configId: 'configId1',
secureRoutes: [
'https://first-route.com/',
'https://second-route.com/test',
],
},
{
configId: 'configId2',
secureRoutes: [
'https://third-route.com/test2',
'https://fourth-route.com/test3',
],
},
];
const { matchingConfig } = service.getConfigIdForClosestMatchingRoute(
'https://first-route.com/anyparam',
allConfigs
);
expect(matchingConfig).toEqual(allConfigs[0]);
});
it('gets best match for configured routes - configured route with params', () => {
const allConfigs = [
{
configId: 'configId1',
secureRoutes: [
'https://first-route.com/',
'https://second-route.com/test',
],
},
{
configId: 'configId2',
secureRoutes: [
'https://third-route.com/test2',
'https://fourth-route.com/test3',
],
},
];
const { matchingConfig } = service.getConfigIdForClosestMatchingRoute(
'https://third-route.com/',
allConfigs
);
expect(matchingConfig).toBeNull();
});
it('gets best match for configured routes - no config Id', () => {
const allConfigs = [
{
configId: 'configId1',
secureRoutes: [
'https://my-secure-url.com/',
'https://my-secure-url.com/test',
],
},
{
configId: 'configId2',
secureRoutes: [
'https://my-secure-url.com/test2',
'https://my-secure-url.com/test2/test',
],
},
];
const { matchingConfig } = service.getConfigIdForClosestMatchingRoute(
'blabla',
allConfigs
);
expect(matchingConfig).toBeNull();
});
});
});

View File

@@ -0,0 +1,33 @@
import { Injectable } from 'injection-js';
import { OpenIdConfiguration } from '../config/openid-configuration';
@Injectable()
export class ClosestMatchingRouteService {
getConfigIdForClosestMatchingRoute(
route: string,
configurations: OpenIdConfiguration[]
): ClosestMatchingRouteResult {
for (const config of configurations) {
const { secureRoutes } = config;
for (const configuredRoute of secureRoutes ?? []) {
if (route.startsWith(configuredRoute)) {
return {
matchingRoute: configuredRoute,
matchingConfig: config,
};
}
}
}
return {
matchingRoute: null,
matchingConfig: null,
};
}
}
export interface ClosestMatchingRouteResult {
matchingRoute: string | null;
matchingConfig: OpenIdConfiguration | null;
}