From 0866a386148d9a6f93579da72b358edf670d6f4c Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Mon, 29 Sep 2025 17:53:50 +0200 Subject: [PATCH 1/9] feat: enhance angular lib with guards and interceptors --- src/angular.ts | 275 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 246 insertions(+), 29 deletions(-) diff --git a/src/angular.ts b/src/angular.ts index 8cfbf536..a7118a90 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -4,19 +4,32 @@ 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 { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from "@angular/common/http"; import { - type Signal, inject, - type EnvironmentProviders, makeEnvironmentProviders, - provideAppInitializer + 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 "./vendor/frontend/tsafe"; export type ParamsOfProvide = { issuerUri: string; @@ -159,6 +172,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 > { @@ -264,37 +296,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: "enforceLoginGuard" + }) 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 httpMethodTest && urlTest; + }; + const interceptor: HttpInterceptorFn = (req, next) => { + const instance = inject(this); + return instance.#createBearerTokenInterceptor({ + conditions: conditions.map(c => ({ + bearerPrefix, + authorizationHeaderName, + 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; + } - return true; - }) satisfies CanActivateFn; + 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: "enforceLoginGuard" + }) 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; } From 01c1c0c45fb6adb90e85d3232958e0286f08ba2a Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Mon, 29 Sep 2025 17:54:25 +0200 Subject: [PATCH 2/9] chore: temp usage of dist folder for referencing lib --- examples/angular-kitchensink/package.json | 2 +- examples/angular/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/angular-kitchensink/package.json b/examples/angular-kitchensink/package.json index 220a604d..ab9c2af2 100644 --- a/examples/angular-kitchensink/package.json +++ b/examples/angular-kitchensink/package.json @@ -22,7 +22,7 @@ }, "private": true, "dependencies": { - "oidc-spa": "latest", + "oidc-spa": "file:../../dist", "zod": "^4.1.11", "@angular/common": "^20.3.0", "@angular/compiler": "^20.3.0", 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", From b247d4c3347d1ae91f7d5e3bdf323d3f7da0bcc2 Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Mon, 29 Sep 2025 17:58:09 +0200 Subject: [PATCH 3/9] feat: adapt angular examples with new apis --- .../angular-kitchensink/src/app/app.config.ts | 43 +++--- examples/angular-kitchensink/src/app/app.html | 135 ++++++++++-------- .../angular-kitchensink/src/app/app.routes.ts | 24 +--- .../src/app/guards/admin.guard.ts | 18 +++ .../app/interceptors/bearer.interceptor.ts | 6 + .../src/app/services/todo.service.ts | 27 +--- examples/angular/src/app/app.config.ts | 6 +- examples/angular/src/app/app.html | 99 +++++++------ .../app/interceptors/bearer.interceptor.ts | 6 + .../angular/src/app/services/todo.service.ts | 27 +--- 10 files changed, 196 insertions(+), 195 deletions(-) create mode 100644 examples/angular-kitchensink/src/app/guards/admin.guard.ts create mode 100644 examples/angular-kitchensink/src/app/interceptors/bearer.interceptor.ts create mode 100644 examples/angular/src/app/interceptors/bearer.interceptor.ts diff --git a/examples/angular-kitchensink/src/app/app.config.ts b/examples/angular-kitchensink/src/app/app.config.ts index de34b840..58709651 100644 --- a/examples/angular-kitchensink/src/app/app.config.ts +++ b/examples/angular-kitchensink/src/app/app.config.ts @@ -1,41 +1,44 @@ +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { ApplicationConfig, - inject, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; -import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http'; import { provideRouter } from '@angular/router'; +import { environment } from '../environments/environment'; import { routes } from './app.routes'; -import { todoApiInterceptor } from './services/todo.service'; +import { BearerInterceptor } from './interceptors/bearer.interceptor'; import { Oidc } from './services/oidc.service'; -import { firstValueFrom } from 'rxjs'; -import { environment } from '../environments/environment'; type RemoteOidcConfig = { issuerUri: string; clientId: string; }; +const provideOidc = (useMockOidc: boolean) => + 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, + }; + }); + 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), ], }; 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.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/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/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); From 445ad1ab6de5799fc93604944ee7d5bd107196c2 Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Mon, 29 Sep 2025 18:15:32 +0200 Subject: [PATCH 4/9] fix: wrong callerName usage --- src/angular.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/angular.ts b/src/angular.ts index a7118a90..d56713bb 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -314,7 +314,7 @@ export abstract class AbstractOidcService< return from(this.prInitialized).pipe( switchMap(() => { const oidc: Oidc = this.#getOidc({ - callerName: "enforceLoginGuard" + callerName: "createBearerTokenInterceptor" }) as Oidc; return from( Promise.all( @@ -453,7 +453,7 @@ export abstract class AbstractOidcService< await this.prInitialized; const oidc: Oidc = this.#getOidc({ - callerName: "enforceLoginGuard" + callerName: "createAuthGuard" }) as Oidc; return isAccessAllowed({ route, state, oidc }); From 1b1b0eabf44988b1bc0eecac776e2318f7d5291a Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Mon, 29 Sep 2025 18:32:11 +0200 Subject: [PATCH 5/9] fix: wrong conditions position --- src/angular.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/angular.ts b/src/angular.ts index d56713bb..883e1eb4 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -410,9 +410,9 @@ export abstract class AbstractOidcService< const interceptor: HttpInterceptorFn = (req, next) => { const instance = inject(this); return instance.#createBearerTokenInterceptor({ - conditions: conditions.map(c => ({ - bearerPrefix, - authorizationHeaderName, + bearerPrefix, + authorizationHeaderName, + conditions: conditions.map(c => ({ shouldAddToken: async req => findMatchingCondition(req, c) })), req, From eff81eb4457bc4d838ef6c782e8efc7b1413617d Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Mon, 29 Sep 2025 18:35:46 +0200 Subject: [PATCH 6/9] fix: use relative path for fetch --- examples/angular-kitchensink/src/app/app.config.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/examples/angular-kitchensink/src/app/app.config.ts b/examples/angular-kitchensink/src/app/app.config.ts index 58709651..93fb1617 100644 --- a/examples/angular-kitchensink/src/app/app.config.ts +++ b/examples/angular-kitchensink/src/app/app.config.ts @@ -22,9 +22,7 @@ const provideOidc = (useMockOidc: boolean) => }) : 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() - ); + const config: RemoteOidcConfig = await fetch('/oidc-config.json').then((res) => res.json()); return { issuerUri: config.issuerUri, From c80d8110eaf4826f429c61bfe2de9e9a0d73476e Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Thu, 2 Oct 2025 12:10:57 +0200 Subject: [PATCH 7/9] fix: rebase conflicts --- src/angular.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/src/angular.ts b/src/angular.ts index 883e1eb4..0c8b2020 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -1,9 +1,3 @@ -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 { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from "@angular/common/http"; import { inject, @@ -29,7 +23,7 @@ import type { ReadonlyBehaviorSubject } from "./tools/ReadonlyBehaviorSubject"; import type { ValueOrAsyncGetter } from "./tools/ValueOrAsyncGetter"; import { createObjectThatThrowsIfAccessed } from "./tools/createObjectThatThrowsIfAccessed"; import { getBaseHref } from "./tools/getBaseHref"; -import { assert, is, type Equals } from "./vendor/frontend/tsafe"; +import { assert, is, type Equals } from "./tools/tsafe/assert"; export type ParamsOfProvide = { issuerUri: string; From 9f8bf1ce0d0ca51ae76946739bf59c211d7589f6 Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Thu, 2 Oct 2025 12:12:33 +0200 Subject: [PATCH 8/9] feat: add SSR support --- examples/angular-kitchensink/angular.json | 7 +- examples/angular-kitchensink/package.json | 12 +- .../src/app/app.config.server.ts | 10 ++ .../angular-kitchensink/src/app/app.config.ts | 25 +--- .../src/app/app.routes.server.ts | 11 ++ examples/angular-kitchensink/src/app/oidc.ts | 23 +++ .../angular-kitchensink/src/main.server.ts | 7 + examples/angular-kitchensink/src/server.ts | 66 +++++++++ .../angular-kitchensink/tsconfig.app.json | 2 +- src/angular.ts | 137 ++++++++++-------- 10 files changed, 210 insertions(+), 90 deletions(-) create mode 100644 examples/angular-kitchensink/src/app/app.config.server.ts create mode 100644 examples/angular-kitchensink/src/app/app.routes.server.ts create mode 100644 examples/angular-kitchensink/src/app/oidc.ts create mode 100644 examples/angular-kitchensink/src/main.server.ts create mode 100644 examples/angular-kitchensink/src/server.ts 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 ab9c2af2..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, @@ -23,21 +24,26 @@ "private": true, "dependencies": { "oidc-spa": "file:../../dist", - "zod": "^4.1.11", "@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 93fb1617..305b6c6a 100644 --- a/examples/angular-kitchensink/src/app/app.config.ts +++ b/examples/angular-kitchensink/src/app/app.config.ts @@ -4,32 +4,12 @@ import { provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; +import { provideClientHydration, withEventReplay } from '@angular/platform-browser'; import { provideRouter } from '@angular/router'; import { environment } from '../environments/environment'; import { routes } from './app.routes'; import { BearerInterceptor } from './interceptors/bearer.interceptor'; -import { Oidc } from './services/oidc.service'; - -type RemoteOidcConfig = { - issuerUri: string; - clientId: string; -}; - -const provideOidc = (useMockOidc: boolean) => - 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, - }; - }); +import { provideOidc } from './oidc'; export const appConfig: ApplicationConfig = { providers: [ @@ -38,5 +18,6 @@ export const appConfig: ApplicationConfig = { provideHttpClient(withInterceptors([BearerInterceptor])), provideRouter(routes), provideOidc(environment.useMockOidc), + provideClientHydration(withEventReplay()), ], }; 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/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/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/src/angular.ts b/src/angular.ts index 0c8b2020..35b1e907 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -1,7 +1,9 @@ +import { isPlatformBrowser } from "@angular/common"; import { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from "@angular/common/http"; import { inject, makeEnvironmentProviders, + PLATFORM_ID, provideAppInitializer, type EnvironmentProviders, type Signal @@ -209,40 +211,44 @@ export abstract class AbstractOidcService< return makeEnvironmentProviders([ this, provideAppInitializer(async () => { - const instance = inject(this); - - instance.#initialize({ - prOidcOrInitializationError: (async () => { - const [{ createOidc }, { autoLogoutWarningDurationSeconds, ...params }] = - await Promise.all([ - import("./core"), - typeof paramsOrGetParams === "function" - ? paramsOrGetParams() - : paramsOrGetParams - ]); - - if (autoLogoutWarningDurationSeconds !== undefined) { - instance.#autoLogoutWarningDurationSeconds = - autoLogoutWarningDurationSeconds; - } + // Detect platform + const platformId = inject(PLATFORM_ID); + + if (isPlatformBrowser(platformId)) { + const instance = inject(this); + instance.#initialize({ + prOidcOrInitializationError: (async () => { + const [{ createOidc }, { autoLogoutWarningDurationSeconds, ...params }] = + await Promise.all([ + import("./core"), + typeof paramsOrGetParams === "function" + ? paramsOrGetParams() + : paramsOrGetParams + ]); + + if (autoLogoutWarningDurationSeconds !== undefined) { + instance.#autoLogoutWarningDurationSeconds = + autoLogoutWarningDurationSeconds; + } - try { - return createOidc({ - homeUrl: getBaseHref(), - autoLogin: instance.autoLogin, - decodedIdTokenSchema: instance.decodedIdTokenSchema, - ...params - }); - } catch (initializationError) { - assert(initializationError instanceof Error); - assert(is(initializationError)); - return initializationError; - } - })() - }); + try { + return createOidc({ + homeUrl: getBaseHref(), + autoLogin: instance.autoLogin, + decodedIdTokenSchema: instance.decodedIdTokenSchema, + ...params + }); + } catch (initializationError) { + assert(initializationError instanceof Error); + assert(is(initializationError)); + return initializationError; + } + })() + }); - if (instance.providerAwaitsInitialization) { - await instance.prInitialized; + if (instance.providerAwaitsInitialization) { + await instance.prInitialized; + } } }) ]); @@ -254,38 +260,43 @@ export abstract class AbstractOidcService< return makeEnvironmentProviders([ this, provideAppInitializer(async () => { - const instance = inject(this); - - instance.#initialize({ - prOidcOrInitializationError: (async () => { - const { createMockOidc } = await import("./mock"); - - return createMockOidc, boolean>({ - homeUrl: getBaseHref(), - autoLogin: instance.autoLogin, - isUserInitiallyLoggedIn: instance.autoLogin - ? true - : params.isUserInitiallyLoggedIn, - mockedParams: { - issuerUri: params.mockIssuerUri, - clientId: params.mockClientId - }, - mockedTokens: { - accessToken: params.mockAccessToken, - decodedIdToken: await (() => { - if (instance.mockDecodedIdToken === undefined) { - return undefined; - } - if (typeof instance.mockDecodedIdToken === "function") { - return instance.mockDecodedIdToken(); - } - })() - } - }); - })() - }); + // Detect platform + const platformId = inject(PLATFORM_ID); + + if (isPlatformBrowser(platformId)) { + const instance = inject(this); + + instance.#initialize({ + prOidcOrInitializationError: (async () => { + const { createMockOidc } = await import("./mock"); + + return createMockOidc, boolean>({ + homeUrl: getBaseHref(), + autoLogin: instance.autoLogin, + isUserInitiallyLoggedIn: instance.autoLogin + ? true + : params.isUserInitiallyLoggedIn, + mockedParams: { + issuerUri: params.mockIssuerUri, + clientId: params.mockClientId + }, + mockedTokens: { + accessToken: params.mockAccessToken, + decodedIdToken: await (() => { + if (instance.mockDecodedIdToken === undefined) { + return undefined; + } + if (typeof instance.mockDecodedIdToken === "function") { + return instance.mockDecodedIdToken(); + } + })() + } + }); + })() + }); - await instance.prInitialized; + await instance.prInitialized; + } }) ]); } From 77404c95da4a12ff286ea1a65398a93132cffb65 Mon Sep 17 00:00:00 2001 From: Luca Peruzzo Date: Thu, 2 Oct 2025 14:30:30 +0200 Subject: [PATCH 9/9] fix: add early return on provide function --- src/angular.ts | 130 ++++++++++++++++++++++++------------------------- 1 file changed, 64 insertions(+), 66 deletions(-) diff --git a/src/angular.ts b/src/angular.ts index 35b1e907..450ccd43 100644 --- a/src/angular.ts +++ b/src/angular.ts @@ -214,41 +214,40 @@ export abstract class AbstractOidcService< // Detect platform const platformId = inject(PLATFORM_ID); - if (isPlatformBrowser(platformId)) { - const instance = inject(this); - instance.#initialize({ - prOidcOrInitializationError: (async () => { - const [{ createOidc }, { autoLogoutWarningDurationSeconds, ...params }] = - await Promise.all([ - import("./core"), - typeof paramsOrGetParams === "function" - ? paramsOrGetParams() - : paramsOrGetParams - ]); - - if (autoLogoutWarningDurationSeconds !== undefined) { - instance.#autoLogoutWarningDurationSeconds = - autoLogoutWarningDurationSeconds; - } + if (!isPlatformBrowser(platformId)) return; + const instance = inject(this); + instance.#initialize({ + prOidcOrInitializationError: (async () => { + const [{ createOidc }, { autoLogoutWarningDurationSeconds, ...params }] = + await Promise.all([ + import("./core"), + typeof paramsOrGetParams === "function" + ? paramsOrGetParams() + : paramsOrGetParams + ]); + + if (autoLogoutWarningDurationSeconds !== undefined) { + instance.#autoLogoutWarningDurationSeconds = + autoLogoutWarningDurationSeconds; + } - try { - return createOidc({ - homeUrl: getBaseHref(), - autoLogin: instance.autoLogin, - decodedIdTokenSchema: instance.decodedIdTokenSchema, - ...params - }); - } catch (initializationError) { - assert(initializationError instanceof Error); - assert(is(initializationError)); - return initializationError; - } - })() - }); + try { + return createOidc({ + homeUrl: getBaseHref(), + autoLogin: instance.autoLogin, + decodedIdTokenSchema: instance.decodedIdTokenSchema, + ...params + }); + } catch (initializationError) { + assert(initializationError instanceof Error); + assert(is(initializationError)); + return initializationError; + } + })() + }); - if (instance.providerAwaitsInitialization) { - await instance.prInitialized; - } + if (instance.providerAwaitsInitialization) { + await instance.prInitialized; } }) ]); @@ -263,40 +262,39 @@ export abstract class AbstractOidcService< // Detect platform const platformId = inject(PLATFORM_ID); - if (isPlatformBrowser(platformId)) { - const instance = inject(this); - - instance.#initialize({ - prOidcOrInitializationError: (async () => { - const { createMockOidc } = await import("./mock"); - - return createMockOidc, boolean>({ - homeUrl: getBaseHref(), - autoLogin: instance.autoLogin, - isUserInitiallyLoggedIn: instance.autoLogin - ? true - : params.isUserInitiallyLoggedIn, - mockedParams: { - issuerUri: params.mockIssuerUri, - clientId: params.mockClientId - }, - mockedTokens: { - accessToken: params.mockAccessToken, - decodedIdToken: await (() => { - if (instance.mockDecodedIdToken === undefined) { - return undefined; - } - if (typeof instance.mockDecodedIdToken === "function") { - return instance.mockDecodedIdToken(); - } - })() - } - }); - })() - }); + if (!isPlatformBrowser(platformId)) return; + const instance = inject(this); + + instance.#initialize({ + prOidcOrInitializationError: (async () => { + const { createMockOidc } = await import("./mock"); + + return createMockOidc, boolean>({ + homeUrl: getBaseHref(), + autoLogin: instance.autoLogin, + isUserInitiallyLoggedIn: instance.autoLogin + ? true + : params.isUserInitiallyLoggedIn, + mockedParams: { + issuerUri: params.mockIssuerUri, + clientId: params.mockClientId + }, + mockedTokens: { + accessToken: params.mockAccessToken, + decodedIdToken: await (() => { + if (instance.mockDecodedIdToken === undefined) { + return undefined; + } + if (typeof instance.mockDecodedIdToken === "function") { + return instance.mockDecodedIdToken(); + } + })() + } + }); + })() + }); - await instance.prInitialized; - } + await instance.prInitialized; }) ]); }