diff --git a/examples/angular-kitchensink/angular.json b/examples/angular-kitchensink/angular.json index 1f16e71d..959ea734 100644 --- a/examples/angular-kitchensink/angular.json +++ b/examples/angular-kitchensink/angular.json @@ -21,7 +21,12 @@ "input": "public" } ], - "styles": ["src/styles.css"] + "styles": ["src/styles.css"], + "server": "src/main.server.ts", + "outputMode": "server", + "ssr": { + "entry": "src/server.ts" + } }, "configurations": { "production": { diff --git a/examples/angular-kitchensink/package.json b/examples/angular-kitchensink/package.json index 220a604d..f5cc6f70 100644 --- a/examples/angular-kitchensink/package.json +++ b/examples/angular-kitchensink/package.json @@ -6,7 +6,8 @@ "start": "ng serve", "build": "ng build", "watch": "ng build --watch --configuration development", - "test": "ng test" + "test": "ng test", + "serve:ssr:angular": "node dist/angular/server/server.mjs" }, "prettier": { "printWidth": 100, @@ -22,22 +23,27 @@ }, "private": true, "dependencies": { - "oidc-spa": "latest", - "zod": "^4.1.11", + "oidc-spa": "file:../../dist", "@angular/common": "^20.3.0", "@angular/compiler": "^20.3.0", "@angular/core": "^20.3.0", "@angular/forms": "^20.3.0", "@angular/platform-browser": "^20.3.0", + "@angular/platform-server": "^20.3.0", "@angular/router": "^20.3.0", + "@angular/ssr": "^20.3.3", + "express": "^5.1.0", "rxjs": "~7.8.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zod": "^4.1.11" }, "devDependencies": { "@angular/build": "^20.3.2", "@angular/cli": "^20.3.2", "@angular/compiler-cli": "^20.3.0", + "@types/express": "^5.0.1", "@types/jasmine": "~5.1.0", + "@types/node": "^20.17.19", "jasmine-core": "~5.9.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", diff --git a/examples/angular-kitchensink/src/app/app.config.server.ts b/examples/angular-kitchensink/src/app/app.config.server.ts new file mode 100644 index 00000000..35926e4a --- /dev/null +++ b/examples/angular-kitchensink/src/app/app.config.server.ts @@ -0,0 +1,10 @@ +import { ApplicationConfig, mergeApplicationConfig } from '@angular/core'; +import { provideServerRendering, withRoutes } from '@angular/ssr'; +import { appConfig } from './app.config'; +import { serverRoutes } from './app.routes.server'; + +const serverConfig: ApplicationConfig = { + providers: [provideServerRendering(withRoutes(serverRoutes))], +}; + +export const config = mergeApplicationConfig(appConfig, serverConfig); diff --git a/examples/angular-kitchensink/src/app/app.config.ts b/examples/angular-kitchensink/src/app/app.config.ts index de34b840..305b6c6a 100644 --- a/examples/angular-kitchensink/src/app/app.config.ts +++ b/examples/angular-kitchensink/src/app/app.config.ts @@ -1,41 +1,23 @@ +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, - inject, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; -import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; -import { routes } from './app.routes'; -import { todoApiInterceptor } from './services/todo.service'; -import { Oidc } from './services/oidc.service'; -import { firstValueFrom } from 'rxjs'; import { environment } from '../environments/environment'; - -type RemoteOidcConfig = { - issuerUri: string; - clientId: string; -}; +import { routes } from './app.routes'; +import { BearerInterceptor } from './interceptors/bearer.interceptor'; +import { provideOidc } from './oidc'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), - provideHttpClient(withInterceptors([todoApiInterceptor])), + provideHttpClient(withInterceptors([BearerInterceptor])), provideRouter(routes), - environment.useMockOidc - ? Oidc.provideMock({ - isUserInitiallyLoggedIn: true, - }) - : Oidc.provide(async () => { - const http = inject(HttpClient); - const config = await firstValueFrom(http.get('./oidc-config.json')); - - return { - issuerUri: config.issuerUri, - clientId: config.clientId, - debugLogs: true, - }; - }), + provideOidc(environment.useMockOidc), + provideClientHydration(withEventReplay()), ], }; diff --git a/examples/angular-kitchensink/src/app/app.html b/examples/angular-kitchensink/src/app/app.html index 4518e6a1..3da87146 100644 --- a/examples/angular-kitchensink/src/app/app.html +++ b/examples/angular-kitchensink/src/app/app.html @@ -1,66 +1,77 @@ -
-
- OIDC-SPA + Angular (Kitchen Sink) -   -   -   -   - - Home -     - My protected page +
+
+ OIDC-SPA + Angular (Kitchen Sink) +         + + Home +     + My protected page - @defer (when oidc.prInitialized | async) { -     - Admin only - } @placeholder { -     - Admin only - } - -
+ [attr.aria-disabled]="canShowAdminLink ? null : true" + >Admin only + } @placeholder {     + Admin only + } + +
- @defer (when oidc.prInitialized | async) { - @if (oidc.isUserLoggedIn) { -
- Hello {{ oidc.$decodedIdToken().name }} -     - -
- } @else { -
- - +
+ } @else { +
+ + -
- } - } @placeholder { - Initializing OIDC... - } - + " + > + Register + + + } } @placeholder { + Initializing OIDC... + }

@@ -69,7 +80,8 @@ @if (oidc.$secondsLeftBeforeAutoLogout() ) { -
-
-

Are you still there?

-

You will be logged out in {{ oidc.$secondsLeftBeforeAutoLogout() }}

-
+ }" +> +
+

Are you still there?

+

You will be logged out in {{ oidc.$secondsLeftBeforeAutoLogout() }}

+
} diff --git a/examples/angular-kitchensink/src/app/app.routes.server.ts b/examples/angular-kitchensink/src/app/app.routes.server.ts new file mode 100644 index 00000000..cb0e1b86 --- /dev/null +++ b/examples/angular-kitchensink/src/app/app.routes.server.ts @@ -0,0 +1,11 @@ +import { RenderMode, ServerRoute } from '@angular/ssr'; + +export const serverRoutes: ServerRoute[] = [ + { path: '', renderMode: RenderMode.Prerender }, + { path: 'protected', renderMode: RenderMode.Client }, + { path: 'admin-only', renderMode: RenderMode.Client }, + { + path: '**', + renderMode: RenderMode.Client, + }, +]; diff --git a/examples/angular-kitchensink/src/app/app.routes.ts b/examples/angular-kitchensink/src/app/app.routes.ts index f4872bfa..2d8afab3 100644 --- a/examples/angular-kitchensink/src/app/app.routes.ts +++ b/examples/angular-kitchensink/src/app/app.routes.ts @@ -1,10 +1,9 @@ -import { inject } from '@angular/core'; -import { Router, Routes, RedirectCommand } from '@angular/router'; -import { Public } from './pages/public'; +import { Routes } from '@angular/router'; +import { AdminGuard } from './guards/admin.guard'; import { Oidc } from './services/oidc.service'; export const routes: Routes = [ - { path: '', component: Public }, + { path: '', loadComponent: () => import('./pages/public').then((c) => c.Public) }, { path: 'protected', loadComponent: () => import('./pages/protected').then((c) => c.Protected), @@ -13,22 +12,7 @@ export const routes: Routes = [ { path: 'admin-only', loadComponent: () => import('./pages/admin-only').then((c) => c.AdminOnly), - canActivate: [ - async (route) => { - const oidc = inject(Oidc); - const router = inject(Router); - - await Oidc.enforceLoginGuard(route); - - if ((oidc.$decodedIdToken().realm_access?.roles ?? []).includes('admin')) { - return true; - } - - alert('Only Admins can access this page'); - - return new RedirectCommand(router.parseUrl('/')); - }, - ], + canActivate: [AdminGuard], }, { path: '**', redirectTo: '' }, ]; diff --git a/examples/angular-kitchensink/src/app/guards/admin.guard.ts b/examples/angular-kitchensink/src/app/guards/admin.guard.ts new file mode 100644 index 00000000..7cd6944c --- /dev/null +++ b/examples/angular-kitchensink/src/app/guards/admin.guard.ts @@ -0,0 +1,18 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, RedirectCommand, Router } from '@angular/router'; +import { DecodedIdToken, Oidc } from '../services/oidc.service'; + +export const AdminGuard: CanActivateFn = (route, state) => { + const router = inject(Router); + return Oidc.createAuthGuard(({ oidc }) => { + if ( + oidc.isUserLoggedIn && + (oidc.getDecodedIdToken().realm_access?.roles ?? []).includes('admin') + ) { + return true; + } + alert('Only Admins can access this page'); + + return new RedirectCommand(router.parseUrl('/')); + })(route, state); +}; diff --git a/examples/angular-kitchensink/src/app/interceptors/bearer.interceptor.ts b/examples/angular-kitchensink/src/app/interceptors/bearer.interceptor.ts new file mode 100644 index 00000000..68bb5fa0 --- /dev/null +++ b/examples/angular-kitchensink/src/app/interceptors/bearer.interceptor.ts @@ -0,0 +1,6 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { Oidc } from '../services/oidc.service'; + +export const BearerInterceptor: HttpInterceptorFn = Oidc.createBasicBearerTokenInterceptor({ + conditions: [{ urlPattern: /^(https:\/\/jsonplaceholder\.typicode\.com)(\/.*)?$/i }], +}); diff --git a/examples/angular-kitchensink/src/app/oidc.ts b/examples/angular-kitchensink/src/app/oidc.ts new file mode 100644 index 00000000..71ad672d --- /dev/null +++ b/examples/angular-kitchensink/src/app/oidc.ts @@ -0,0 +1,23 @@ +import { EnvironmentProviders } from '@angular/core'; +import { Oidc } from './services/oidc.service'; + +type RemoteOidcConfig = { + issuerUri: string; + clientId: string; +}; + +export const provideOidc = (useMockOidc: boolean): EnvironmentProviders => + useMockOidc + ? Oidc.provideMock({ + isUserInitiallyLoggedIn: true, + }) + : Oidc.provide(async () => { + // should be runned outside angular to prevent http interceptor request piping + const config: RemoteOidcConfig = await fetch('/oidc-config.json').then((res) => res.json()); + + return { + issuerUri: config.issuerUri, + clientId: config.clientId, + debugLogs: true, + }; + }); diff --git a/examples/angular-kitchensink/src/app/services/todo.service.ts b/examples/angular-kitchensink/src/app/services/todo.service.ts index 1af9e87f..00040e40 100644 --- a/examples/angular-kitchensink/src/app/services/todo.service.ts +++ b/examples/angular-kitchensink/src/app/services/todo.service.ts @@ -1,7 +1,6 @@ -import { HttpClient, HttpInterceptorFn } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { type Observable, from, switchMap } from 'rxjs'; -import { Oidc } from '../services/oidc.service'; +import { type Observable } from 'rxjs'; export interface Todo { userId: number; @@ -12,28 +11,6 @@ export interface Todo { const TODO_API_URL = 'https://jsonplaceholder.typicode.com/todos'; -export const todoApiInterceptor: HttpInterceptorFn = (req, next) => { - const oidc = inject(Oidc); - - if (!req.url.startsWith(TODO_API_URL)) { - return next(req); - } - - return from(oidc.getAccessToken()).pipe( - switchMap(({ isUserLoggedIn, accessToken }) => { - if (!isUserLoggedIn) { - throw new Error("Assertion Error: Call to the TODO API while the user isn't logged in."); - } - - return next( - req.clone({ - setHeaders: { Authorization: `Bearer ${accessToken}` }, - }) - ); - }) - ); -}; - @Injectable({ providedIn: 'root' }) export class TodoService { private readonly http = inject(HttpClient); diff --git a/examples/angular-kitchensink/src/main.server.ts b/examples/angular-kitchensink/src/main.server.ts new file mode 100644 index 00000000..9b1aba74 --- /dev/null +++ b/examples/angular-kitchensink/src/main.server.ts @@ -0,0 +1,7 @@ +import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser'; +import { App } from './app/app'; +import { config } from './app/app.config.server'; + +const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context); + +export default bootstrap; diff --git a/examples/angular-kitchensink/src/server.ts b/examples/angular-kitchensink/src/server.ts new file mode 100644 index 00000000..09ee8da9 --- /dev/null +++ b/examples/angular-kitchensink/src/server.ts @@ -0,0 +1,66 @@ +import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node'; +import express from 'express'; +import { join } from 'node:path'; + +const browserDistFolder = join(import.meta.dirname, '../browser'); + +const app = express(); +const angularApp = new AngularNodeAppEngine(); + +/** + * Example Express Rest API endpoints can be defined here. + * Uncomment and define endpoints as necessary. + * + * Example: + * ```ts + * app.get('/api/{*splat}', (req, res) => { + * // Handle API request + * }); + * ``` + */ + +/** + * Serve static files from /browser + */ +app.use( + express.static(browserDistFolder, { + maxAge: '1y', + index: false, + redirect: false, + }) +); + +/** + * Handle all other requests by rendering the Angular application. + */ +app.use((req, res, next) => { + angularApp + .handle(req) + .then((response) => (response ? writeResponseToNodeResponse(response, res) : next())) + .catch(next); +}); + +/** + * Start the server if this module is the main entry point. + * The server listens on the port defined by the `PORT` environment variable, or defaults to 4000. + */ +if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000; + app.listen(port, (error) => { + if (error) { + throw error; + } + + console.log(`Node Express server listening on http://localhost:${port}`); + }); +} + +/** + * Request handler used by the Angular CLI (for dev-server and during build) or Firebase Cloud Functions. + */ +export const reqHandler = createNodeRequestHandler(app); diff --git a/examples/angular-kitchensink/tsconfig.app.json b/examples/angular-kitchensink/tsconfig.app.json index a0dcc37c..908478be 100644 --- a/examples/angular-kitchensink/tsconfig.app.json +++ b/examples/angular-kitchensink/tsconfig.app.json @@ -4,7 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [] + "types": ["node"] }, "include": ["src/**/*.ts"], "exclude": ["src/**/*.spec.ts"] diff --git a/examples/angular/package.json b/examples/angular/package.json index f66a3ba7..17e68cab 100644 --- a/examples/angular/package.json +++ b/examples/angular/package.json @@ -22,7 +22,7 @@ }, "private": true, "dependencies": { - "oidc-spa": "latest", + "oidc-spa": "file:../../dist", "@angular/common": "^20.3.0", "@angular/compiler": "^20.3.0", "@angular/core": "^20.3.0", diff --git a/examples/angular/src/app/app.config.ts b/examples/angular/src/app/app.config.ts index b7723921..b67ba08a 100644 --- a/examples/angular/src/app/app.config.ts +++ b/examples/angular/src/app/app.config.ts @@ -1,19 +1,19 @@ +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; -import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; -import { todoApiInterceptor } from './services/todo.service'; +import { BearerInterceptor } from './interceptors/bearer.interceptor'; import { Oidc } from './services/oidc.service'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), - provideHttpClient(withInterceptors([todoApiInterceptor])), + provideHttpClient(withInterceptors([BearerInterceptor])), provideRouter(routes), Oidc.provide({ issuerUri: 'https://cloud-iam.oidc-spa.dev/realms/oidc-spa', diff --git a/examples/angular/src/app/app.html b/examples/angular/src/app/app.html index e986b842..ca54fb54 100644 --- a/examples/angular/src/app/app.html +++ b/examples/angular/src/app/app.html @@ -1,40 +1,55 @@ -
- OIDC-SPA + Angular -
- Home -       - My protected page -
- @if (oidc.isUserLoggedIn) { -
- Hello {{ oidc.$decodedIdToken().name }} -     - -
- } @else { -
- - +
+ } @else { +
+ + -
- } + " + > + Register + + + }

@@ -43,7 +58,8 @@ @if (oidc.$secondsLeftBeforeAutoLogout() ) { -
-
-

Are you still there?

-

You will be logged out in {{ oidc.$secondsLeftBeforeAutoLogout() }}

-
+ }" +> +
+

Are you still there?

+

You will be logged out in {{ oidc.$secondsLeftBeforeAutoLogout() }}

+
-} \ No newline at end of file +} diff --git a/examples/angular/src/app/interceptors/bearer.interceptor.ts b/examples/angular/src/app/interceptors/bearer.interceptor.ts new file mode 100644 index 00000000..68bb5fa0 --- /dev/null +++ b/examples/angular/src/app/interceptors/bearer.interceptor.ts @@ -0,0 +1,6 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { Oidc } from '../services/oidc.service'; + +export const BearerInterceptor: HttpInterceptorFn = Oidc.createBasicBearerTokenInterceptor({ + conditions: [{ urlPattern: /^(https:\/\/jsonplaceholder\.typicode\.com)(\/.*)?$/i }], +}); diff --git a/examples/angular/src/app/services/todo.service.ts b/examples/angular/src/app/services/todo.service.ts index 1af9e87f..00040e40 100644 --- a/examples/angular/src/app/services/todo.service.ts +++ b/examples/angular/src/app/services/todo.service.ts @@ -1,7 +1,6 @@ -import { HttpClient, HttpInterceptorFn } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; -import { type Observable, from, switchMap } from 'rxjs'; -import { Oidc } from '../services/oidc.service'; +import { type Observable } from 'rxjs'; export interface Todo { userId: number; @@ -12,28 +11,6 @@ export interface Todo { const TODO_API_URL = 'https://jsonplaceholder.typicode.com/todos'; -export const todoApiInterceptor: HttpInterceptorFn = (req, next) => { - const oidc = inject(Oidc); - - if (!req.url.startsWith(TODO_API_URL)) { - return next(req); - } - - return from(oidc.getAccessToken()).pipe( - switchMap(({ isUserLoggedIn, accessToken }) => { - if (!isUserLoggedIn) { - throw new Error("Assertion Error: Call to the TODO API while the user isn't logged in."); - } - - return next( - req.clone({ - setHeaders: { Authorization: `Bearer ${accessToken}` }, - }) - ); - }) - ); -}; - @Injectable({ providedIn: 'root' }) export class TodoService { private readonly http = inject(HttpClient); diff --git a/src/angular.ts b/src/angular.ts index 8cfbf536..450ccd43 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -1,22 +1,31 @@ -import { BehaviorSubject } from "rxjs"; -import type { Oidc, OidcInitializationError, ParamsOfCreateOidc } from "./core"; -import type { OidcMetadata } from "./core/OidcMetadata"; -import { Deferred } from "./tools/Deferred"; -import { assert, type Equals, is } from "./tools/tsafe/assert"; -import { createObjectThatThrowsIfAccessed } from "./tools/createObjectThatThrowsIfAccessed"; +import { isPlatformBrowser } from "@angular/common"; +import { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from "@angular/common/http"; import { - type Signal, inject, - type EnvironmentProviders, makeEnvironmentProviders, - provideAppInitializer + PLATFORM_ID, + provideAppInitializer, + type EnvironmentProviders, + type Signal } from "@angular/core"; import { toSignal } from "@angular/core/rxjs-interop"; +import { + ActivatedRouteSnapshot, + GuardResult, + Router, + RouterStateSnapshot, + type CanActivateFn +} from "@angular/router"; +import { BehaviorSubject, from, mergeMap, switchMap } from "rxjs"; +import type { Oidc, OidcInitializationError, ParamsOfCreateOidc } from "./core"; +import type { OidcMetadata } from "./core/OidcMetadata"; +import type { ConcreteClass } from "./tools/ConcreteClass"; +import { Deferred } from "./tools/Deferred"; import type { ReadonlyBehaviorSubject } from "./tools/ReadonlyBehaviorSubject"; -import { Router, type CanActivateFn } from "@angular/router"; import type { ValueOrAsyncGetter } from "./tools/ValueOrAsyncGetter"; +import { createObjectThatThrowsIfAccessed } from "./tools/createObjectThatThrowsIfAccessed"; import { getBaseHref } from "./tools/getBaseHref"; -import type { ConcreteClass } from "./tools/ConcreteClass"; +import { assert, is, type Equals } from "./tools/tsafe/assert"; export type ParamsOfProvide = { issuerUri: string; @@ -159,6 +168,25 @@ export type ParamsOfProvideMock = { isUserInitiallyLoggedIn?: boolean; }; +type BearerTokenCondition< + T_DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_base +> = { + /** + * A function that dynamically determines whether the Bearer token should be included + * in the `Authorization` header for a given request. + * + * This function is asynchronous and receives the following arguments: + * - `req`: The `HttpRequest` object representing the current outgoing HTTP request. + * - `next`: The `HttpHandlerFn` for forwarding the request to the next handler in the chain. + * - `oidc`: The `Oidc` instance representing the authentication context. + */ + shouldAddToken: ( + req: HttpRequest, + next: HttpHandlerFn, + oidc: Oidc + ) => Promise; +}; + export abstract class AbstractOidcService< T_DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_base > { @@ -183,8 +211,11 @@ export abstract class AbstractOidcService< return makeEnvironmentProviders([ this, provideAppInitializer(async () => { - const instance = inject(this); + // Detect platform + const platformId = inject(PLATFORM_ID); + if (!isPlatformBrowser(platformId)) return; + const instance = inject(this); instance.#initialize({ prOidcOrInitializationError: (async () => { const [{ createOidc }, { autoLogoutWarningDurationSeconds, ...params }] = @@ -228,6 +259,10 @@ export abstract class AbstractOidcService< return makeEnvironmentProviders([ this, provideAppInitializer(async () => { + // Detect platform + const platformId = inject(PLATFORM_ID); + + if (!isPlatformBrowser(platformId)) return; const instance = inject(this); instance.#initialize({ @@ -264,37 +299,222 @@ export abstract class AbstractOidcService< ]); } - static get enforceLoginGuard() { - const canActivateFn = (async route => { - const instance = inject(this); - const router = inject(Router); - - await instance.prInitialized; - - const oidc = instance.#getOidc({ callerName: "enforceLoginGuard" }); + #createBearerTokenInterceptor< + T_DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_base + >({ + bearerPrefix, + authorizationHeaderName, + conditions, + req, + next + }: { + bearerPrefix?: string; + authorizationHeaderName?: string; + conditions: BearerTokenCondition[]; + req: HttpRequest; + next: HttpHandlerFn; + }): ReturnType { + return from(this.prInitialized).pipe( + switchMap(() => { + const oidc: Oidc = this.#getOidc({ + callerName: "createBearerTokenInterceptor" + }) as Oidc; + return from( + Promise.all( + conditions.map( + async condition => await condition.shouldAddToken(req, next, oidc) + ) + ) + ); + }), + mergeMap(evaluatedConditions => { + const matchingConditionIndex = evaluatedConditions.findIndex(Boolean); + const matchingCondition = conditions[matchingConditionIndex]; - if (!oidc.isUserLoggedIn) { - const redirectUrl = router.serializeUrl( - router.createUrlTree( - route.url.map(u => u.path), - { - queryParams: route.queryParams, - fragment: route.fragment ?? undefined + if (!matchingCondition) { + return next(req); + } + return from(this.getAccessToken()).pipe( + switchMap(({ isUserLoggedIn, accessToken }) => { + if (!isUserLoggedIn) { + throw new Error( + `Assertion Error: Call to ${req.url} while the user isn't logged in.` + ); } - ) + const clonedRequest = req.clone({ + setHeaders: { + [authorizationHeaderName ?? "Authorization"]: `${ + bearerPrefix ?? "Bearer" + } ${accessToken}` + } + }); + return next(clonedRequest); + }) ); + }) + ); + } - const doesCurrentHrefRequiresAuth = - location.href.replace(/\/$/, "") === redirectUrl.replace(/\/$/, ""); + static createAdvancedBearerTokenInterceptor< + T_DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_base + >({ + bearerPrefix, + authorizationHeaderName, + conditions + }: { + bearerPrefix?: string; + authorizationHeaderName?: string; + conditions?: BearerTokenCondition[]; + }): HttpInterceptorFn { + const bearerConditions = conditions ?? []; + const interceptor: HttpInterceptorFn = (req, next) => { + const instance = inject(this); - await oidc.login({ - doesCurrentHrefRequiresAuth, - redirectUrl - }); + return instance.#createBearerTokenInterceptor({ + conditions: bearerConditions, + bearerPrefix, + next, + req, + authorizationHeaderName + }); + }; + return interceptor; + } + + static createBasicBearerTokenInterceptor({ + bearerPrefix, + authorizationHeaderName, + conditions + }: { + bearerPrefix?: string; + authorizationHeaderName?: string; + conditions: { + urlPattern: RegExp; + httpMethods?: ("GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH")[]; + }[]; + }) { + const findMatchingCondition = ( + { method, url }: HttpRequest, + { + urlPattern, + httpMethods = [] + }: { + urlPattern: RegExp; + httpMethods?: ("GET" | "POST" | "PUT" | "DELETE" | "OPTIONS" | "HEAD" | "PATCH")[]; } + ): boolean => { + const httpMethodTest = + httpMethods.length === 0 || httpMethods.join().indexOf(method.toUpperCase()) > -1; + + const urlTest = urlPattern.test(url); - return true; - }) satisfies CanActivateFn; + return httpMethodTest && urlTest; + }; + const interceptor: HttpInterceptorFn = (req, next) => { + const instance = inject(this); + return instance.#createBearerTokenInterceptor({ + bearerPrefix, + authorizationHeaderName, + conditions: conditions.map(c => ({ + shouldAddToken: async req => findMatchingCondition(req, c) + })), + req, + next + }); + }; + + return interceptor; + } + + static get bearerTokenInterceptor() { + const interceptor: HttpInterceptorFn = (req, next) => { + const instance = inject(this); + return instance.#createBearerTokenInterceptor({ + conditions: [{ shouldAddToken: async () => true }], + req, + next + }); + }; + return interceptor; + } + + async #createAuthGuard< + T_DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_base + >( + isAccessAllowed: ({ + route, + state, + oidc + }: { + route: ActivatedRouteSnapshot; + state: RouterStateSnapshot; + oidc: Oidc; + }) => Promise | GuardResult, + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot + ) { + await this.prInitialized; + + const oidc: Oidc = this.#getOidc({ + callerName: "createAuthGuard" + }) as Oidc; + + return isAccessAllowed({ route, state, oidc }); + } + + static createAuthGuard< + T_DecodedIdToken extends Record = Oidc.Tokens.DecodedIdToken_base + >( + isAccessAllowed: ({ + route, + state, + oidc + }: { + route: ActivatedRouteSnapshot; + state: RouterStateSnapshot; + oidc: Oidc; + }) => Promise | GuardResult + ): CanActivateFn { + const canActivateFn: CanActivateFn = (route, state) => { + const instance = inject(this); + return instance.#createAuthGuard(isAccessAllowed, route, state); + }; + return canActivateFn; + } + + static get enforceLoginGuard(): CanActivateFn { + const canActivateFn: CanActivateFn = (route, state) => { + const router = inject(Router); + const instance = inject(this); + return instance.#createAuthGuard( + async ({ route, oidc }) => { + if (!oidc.isUserLoggedIn) { + const redirectUrl = router.serializeUrl( + router.createUrlTree( + route.url.map(u => u.path), + { + queryParams: route.queryParams, + fragment: route.fragment ?? undefined + } + ) + ); + + const doesCurrentHrefRequiresAuth = + location.href.replace(/\/$/, "") === redirectUrl.replace(/\/$/, ""); + + await oidc.login({ + doesCurrentHrefRequiresAuth, + redirectUrl + }); + return false; + } + + return true; + }, + route, + state + ); + }; return canActivateFn; }