@securitydept/token-set-context-client-angular 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,999 @@
1
+ import { HTTP_INTERCEPTORS } from '@angular/common/http';
2
+ import * as i0 from '@angular/core';
3
+ import { signal, inject, DestroyRef, Injectable, InjectionToken, provideEnvironmentInitializer, Component } from '@angular/core';
4
+ import { createTokenSetAuthRegistry } from '@securitydept/token-set-context-client/registry';
5
+ export { ClientInitializationPriority } from '@securitydept/token-set-context-client/registry';
6
+ import { bridgeToAngularSignal, signalToObservable, AUTH_PLANNER_HOST, extractFullRouteRequirements, withRouteRequirements } from '@securitydept/client-angular';
7
+ import { Router } from '@angular/router';
8
+ import { firstValueFrom, from, switchMap, take } from 'rxjs';
9
+
10
+ // ============================================================================
11
+ // 5. TokenSetAuthService — per-client service wrapper
12
+ // ============================================================================
13
+ /**
14
+ * Per-client Angular service that bridges a single OIDC mode client to
15
+ * Angular signals + RxJS.
16
+ *
17
+ * Iteration 110 change: lifecycle teardown is now explicit. Construct with
18
+ * `new TokenSetAuthService(client, autoRestore)` and call `.dispose()` when
19
+ * the service owner tears down. The shared registry core handles this
20
+ * automatically — adopters that wire the service directly (rare) must call
21
+ * `dispose()` themselves, typically bound to a `DestroyRef.onDestroy`.
22
+ */
23
+ class TokenSetAuthService {
24
+ client;
25
+ /** Current auth snapshot as an Angular signal. */
26
+ authState;
27
+ /** Current auth snapshot as an RxJS Observable. */
28
+ authState$;
29
+ /** Promise that resolves when initial state restore completes (or null if skipped). */
30
+ restorePromise;
31
+ cleanup;
32
+ disposed = false;
33
+ constructor(
34
+ /** The underlying SDK client instance. */
35
+ client, autoRestore) {
36
+ this.client = client;
37
+ this.authState = signal(null, ...(ngDevMode ? [{ debugName: "authState" }] : /* istanbul ignore next */ []));
38
+ this.cleanup = bridgeToAngularSignal(client.state, this.authState);
39
+ this.authState$ = signalToObservable(client.state);
40
+ this.restorePromise = autoRestore ? client.restorePersistedState() : null;
41
+ }
42
+ /** Whether the user is currently authenticated. */
43
+ isAuthenticated() {
44
+ return this.authState() !== null;
45
+ }
46
+ /** Current access token, or null. */
47
+ accessToken() {
48
+ return this.authState()?.tokens.accessToken ?? null;
49
+ }
50
+ /**
51
+ * Release all resources owned by this service.
52
+ *
53
+ * - Unsubscribes the SDK state bridge
54
+ * - Calls `client.dispose()`
55
+ *
56
+ * Idempotent: subsequent calls are no-ops. Called by the shared registry
57
+ * at teardown; adopters invoking TokenSetAuthService directly must call
58
+ * this themselves (typically bound to `DestroyRef.onDestroy`).
59
+ */
60
+ dispose() {
61
+ if (this.disposed)
62
+ return;
63
+ this.disposed = true;
64
+ try {
65
+ this.cleanup();
66
+ }
67
+ catch {
68
+ // Swallow — best effort.
69
+ }
70
+ try {
71
+ this.client.dispose();
72
+ }
73
+ catch {
74
+ // Swallow — best effort.
75
+ }
76
+ }
77
+ }
78
+
79
+ class TokenSetAuthRegistry {
80
+ /**
81
+ * Shared framework-neutral registry core. Angular consumers rarely need
82
+ * direct access, but this is exposed so adapter-internal helpers
83
+ * (`CallbackResumeService`, `bearer-interceptor`) can query the core
84
+ * directly when they don't need Angular-specific behaviour.
85
+ */
86
+ core;
87
+ constructor() {
88
+ this.core = createTokenSetAuthRegistry({
89
+ materialize: (client, entry) => new TokenSetAuthService(client, entry.autoRestore ?? true),
90
+ dispose: (service) => service.dispose(),
91
+ accessTokenOf: (service) => service.accessToken(),
92
+ });
93
+ // Try to bind core.dispose() to the current injection context's
94
+ // DestroyRef. Registry is typically constructed via DI (where this
95
+ // always succeeds); pure unit tests instantiate directly and must
96
+ // call `.dispose()` manually.
97
+ try {
98
+ const destroyRef = inject(DestroyRef);
99
+ destroyRef.onDestroy(() => this.core.dispose());
100
+ }
101
+ catch {
102
+ // Not in an injection context — adopter is expected to call
103
+ // registry.dispose() manually (or never, for short-lived tests).
104
+ }
105
+ }
106
+ register(entry) {
107
+ return this.core.register(entry);
108
+ }
109
+ /**
110
+ * Manually dispose the registry. Normally bound to the ambient
111
+ * `DestroyRef` at construction; call this explicitly when instantiating
112
+ * the registry outside of an Angular injection context (e.g. in unit
113
+ * tests).
114
+ */
115
+ dispose() {
116
+ this.core.dispose();
117
+ }
118
+ // --------------------------------------------------------------------------
119
+ // Pass-through API (backwards-compatible with iteration 109 surface)
120
+ // --------------------------------------------------------------------------
121
+ isReady(key) {
122
+ return this.core.isReady(key);
123
+ }
124
+ readinessState(key) {
125
+ return this.core.readinessState(key);
126
+ }
127
+ async whenReady(key) {
128
+ return this.core.whenReady(key);
129
+ }
130
+ /**
131
+ * Preload a lazy client without throwing on rejection. Callers are
132
+ * expected to attach `.catch` handlers for fire-and-forget usage.
133
+ */
134
+ preload(key) {
135
+ return this.core.preload(key);
136
+ }
137
+ /**
138
+ * Schedule preload for every lazy+not-initialized client using
139
+ * `requestIdleCallback` / `setTimeout` fallback.
140
+ */
141
+ idleWarmup() {
142
+ return this.core.idleWarmup();
143
+ }
144
+ reset(key) {
145
+ this.core.reset(key);
146
+ }
147
+ metaFor(clientKey) {
148
+ return this.core.metaFor(clientKey);
149
+ }
150
+ get(key) {
151
+ return this.core.get(key);
152
+ }
153
+ require(key) {
154
+ return this.core.require(key);
155
+ }
156
+ keys() {
157
+ return this.core.keys();
158
+ }
159
+ entries() {
160
+ return this.core.entriesSnapshot();
161
+ }
162
+ // ---- URL / callback / requirement / provider-family discrimination -----
163
+ *clientKeyGenForUrl(url) {
164
+ yield* this.core.clientKeyGenForUrl(url);
165
+ }
166
+ *clientKeyGenForCallback(url) {
167
+ yield* this.core.clientKeyGenForCallback(url);
168
+ }
169
+ *clientKeyGenForRequirement(requirementKind) {
170
+ yield* this.core.clientKeyGenForRequirement(requirementKind);
171
+ }
172
+ *clientKeyGenForProviderFamily(providerFamily) {
173
+ yield* this.core.clientKeyGenForProviderFamily(providerFamily);
174
+ }
175
+ clientKeyListForUrl(url) {
176
+ return this.core.clientKeyListForUrl(url);
177
+ }
178
+ clientKeyListForCallback(url) {
179
+ return this.core.clientKeyListForCallback(url);
180
+ }
181
+ clientKeyListForRequirement(requirementKind) {
182
+ return this.core.clientKeyListForRequirement(requirementKind);
183
+ }
184
+ clientKeyListForProviderFamily(providerFamily) {
185
+ return this.core.clientKeyListForProviderFamily(providerFamily);
186
+ }
187
+ clientKeyForUrl(url, selector) {
188
+ return this.core.clientKeyForUrl(url, selector);
189
+ }
190
+ clientKeyForCallback(url, selector) {
191
+ return this.core.clientKeyForCallback(url, selector);
192
+ }
193
+ clientKeyForRequirement(requirementKind, selector) {
194
+ return this.core.clientKeyForRequirement(requirementKind, selector);
195
+ }
196
+ clientKeyForProviderFamily(providerFamily, selector) {
197
+ return this.core.clientKeyForProviderFamily(providerFamily, selector);
198
+ }
199
+ requireForRequirement(requirementKind, selector) {
200
+ return this.core.requireForRequirement(requirementKind, selector);
201
+ }
202
+ requireForProviderFamily(providerFamily, selector) {
203
+ return this.core.requireForProviderFamily(providerFamily, selector);
204
+ }
205
+ *clientKeyGenForFilter(filter) {
206
+ yield* this.core.clientKeyGenForFilter(filter);
207
+ }
208
+ *clientKeyGenForOptions(options) {
209
+ yield* this.core.clientKeyGenForOptions(options);
210
+ }
211
+ clientKeysForOptions(options) {
212
+ return this.core.clientKeysForOptions(options);
213
+ }
214
+ accessToken(key) {
215
+ return this.core.accessToken(key);
216
+ }
217
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetAuthRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
218
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetAuthRegistry });
219
+ }
220
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetAuthRegistry, decorators: [{
221
+ type: Injectable
222
+ }], ctorParameters: () => [] });
223
+
224
+ /**
225
+ * DI token for {@link BearerInterceptorOptions}. Adopters normally configure
226
+ * options via `provideTokenSetBearerInterceptor({ ... })`; the token is
227
+ * exported for advanced cases where the options need to be supplied or
228
+ * overridden separately (for example, in tests).
229
+ */
230
+ const TOKEN_SET_BEARER_INTERCEPTOR_OPTIONS = new InjectionToken("TOKEN_SET_BEARER_INTERCEPTOR_OPTIONS");
231
+ /**
232
+ * Angular class-based bearer-token interceptor (`HTTP_INTERCEPTORS` style).
233
+ *
234
+ * Selects the correct access token from the multi-client registry by URL
235
+ * pattern. Behaviour for unmatched URLs is governed by
236
+ * {@link BearerInterceptorOptions.strictUrlMatch}.
237
+ *
238
+ * Prefer the functional version via `createTokenSetBearerInterceptor()` +
239
+ * `withInterceptors()` when using `provideHttpClient`. Use this class-based
240
+ * version when you need classic `HTTP_INTERCEPTORS` multi-provider style
241
+ * (e.g. NgModule apps).
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * // NgModule providers:
246
+ * provideTokenSetBearerInterceptor({ strictUrlMatch: true })
247
+ * ```
248
+ */
249
+ class TokenSetBearerInterceptor {
250
+ registry = inject(TokenSetAuthRegistry);
251
+ options = inject(TOKEN_SET_BEARER_INTERCEPTOR_OPTIONS, { optional: true }) ?? {};
252
+ intercept(req, next) {
253
+ const key = this.registry.clientKeyForUrl(req.url);
254
+ // When a client key matches but the client is still initializing
255
+ // (async clientFactory not yet resolved), registry.get() returns
256
+ // undefined. This is an explicit design decision: interceptors do NOT
257
+ // block the HTTP request waiting for client initialization.
258
+ // Responsibility for "ensure client is ready before making requests"
259
+ // belongs to route guards (which use whenReady() to await readiness).
260
+ // A not-yet-ready client correctly produces no token → request proceeds
261
+ // without Authorization header.
262
+ const token = key
263
+ ? (this.registry.get(key)?.accessToken() ?? null)
264
+ : this.options.strictUrlMatch
265
+ ? null
266
+ : this.registry.accessToken();
267
+ if (!token) {
268
+ return next.handle(req);
269
+ }
270
+ const authReq = req.clone({
271
+ setHeaders: { Authorization: `Bearer ${token}` },
272
+ });
273
+ return next.handle(authReq);
274
+ }
275
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetBearerInterceptor, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
276
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetBearerInterceptor });
277
+ }
278
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetBearerInterceptor, decorators: [{
279
+ type: Injectable
280
+ }] });
281
+ /**
282
+ * Create an `HTTP_INTERCEPTORS` multi-provider entry for bearer token injection.
283
+ *
284
+ * Returns a single `Provider` object that registers {@link TokenSetBearerInterceptor}
285
+ * as a class-based HTTP interceptor. Suitable for NgModule `providers` arrays.
286
+ *
287
+ * For functional-interceptor style (standalone apps using `provideHttpClient`),
288
+ * use `createTokenSetBearerInterceptor()` with `withInterceptors()` instead.
289
+ *
290
+ * @example
291
+ * ```ts
292
+ * @NgModule({
293
+ * providers: [
294
+ * provideTokenSetAuth({ clients: [...] }),
295
+ * provideTokenSetBearerInterceptor(), // <— replaces hand-written AuthInterceptor
296
+ * provideHttpClient(withInterceptorsFromDi()),
297
+ * ],
298
+ * })
299
+ * export class AppModule {}
300
+ * ```
301
+ */
302
+ /**
303
+ * Create an `HTTP_INTERCEPTORS` multi-provider entry for bearer token injection.
304
+ *
305
+ * Returns Angular providers that register {@link TokenSetBearerInterceptor}
306
+ * as a class-based HTTP interceptor and (optionally) the
307
+ * {@link BearerInterceptorOptions} that govern its URL-match behaviour.
308
+ * Suitable for NgModule `providers` arrays.
309
+ *
310
+ * For functional-interceptor style (standalone apps using `provideHttpClient`),
311
+ * use `createTokenSetBearerInterceptor()` with `withInterceptors()` instead.
312
+ *
313
+ * @example
314
+ * ```ts
315
+ * @NgModule({
316
+ * providers: [
317
+ * provideTokenSetAuth({ clients: [...] }),
318
+ * // Adopters with multiple backends or any third-party HTTP traffic
319
+ * // MUST opt into strictUrlMatch to prevent token leakage.
320
+ * provideTokenSetBearerInterceptor({ strictUrlMatch: true }),
321
+ * provideHttpClient(withInterceptorsFromDi()),
322
+ * ],
323
+ * })
324
+ * export class AppModule {}
325
+ * ```
326
+ */
327
+ function provideTokenSetBearerInterceptor(options) {
328
+ const providers = [
329
+ {
330
+ provide: HTTP_INTERCEPTORS,
331
+ useClass: TokenSetBearerInterceptor,
332
+ multi: true,
333
+ },
334
+ ];
335
+ if (options) {
336
+ providers.push({
337
+ provide: TOKEN_SET_BEARER_INTERCEPTOR_OPTIONS,
338
+ useValue: options,
339
+ });
340
+ }
341
+ return providers;
342
+ }
343
+ /**
344
+ * Create an Angular `HttpInterceptorFn` that injects bearer authorization
345
+ * headers, selecting the correct token from the multi-client registry
346
+ * based on URL pattern matching.
347
+ *
348
+ * If no URL pattern matches, falls back to the first available token
349
+ * (single-client convenience).
350
+ *
351
+ * ## Async client readiness
352
+ *
353
+ * The interceptor does **not** block or delay the HTTP request waiting for
354
+ * an async client to materialize. If a client is still initializing when the
355
+ * request fires, the request proceeds without an Authorization header.
356
+ *
357
+ * This is intentional: blocking HTTP for client initialization would deadlock
358
+ * apps that make HTTP requests during initialization itself. Route guards
359
+ * (which use `registry.whenReady()`) are the correct place to enforce
360
+ * "client must be ready before user reaches this route".
361
+ *
362
+ * For NgModule-style apps using `HTTP_INTERCEPTORS`, use
363
+ * `provideTokenSetBearerInterceptor()` instead.
364
+ *
365
+ * @example
366
+ * ```ts
367
+ * import { provideHttpClient, withInterceptors } from "@angular/common/http";
368
+ * import { createTokenSetBearerInterceptor } from "@securitydept/token-set-context-client-angular";
369
+ *
370
+ * provideHttpClient(withInterceptors([
371
+ * createTokenSetBearerInterceptor(registry),
372
+ * ]));
373
+ * ```
374
+ */
375
+ /**
376
+ * Create an Angular `HttpInterceptorFn` that injects bearer authorization
377
+ * headers, selecting the correct token from the multi-client registry
378
+ * based on URL pattern matching.
379
+ *
380
+ * If no URL pattern matches, the unmatched-URL behaviour is governed by
381
+ * {@link BearerInterceptorOptions.strictUrlMatch}: the default falls back
382
+ * to `registry.accessToken()` (single-client convenience), while
383
+ * `strictUrlMatch: true` returns no token, ensuring bearer headers are
384
+ * never injected for URLs outside the registered patterns.
385
+ *
386
+ * ## Async client readiness
387
+ *
388
+ * The interceptor does **not** block or delay the HTTP request waiting for
389
+ * an async client to materialize. If a client is still initializing when the
390
+ * request fires, the request proceeds without an Authorization header.
391
+ *
392
+ * This is intentional: blocking HTTP for client initialization would deadlock
393
+ * apps that make HTTP requests during initialization itself. Route guards
394
+ * (which use `registry.whenReady()`) are the correct place to enforce
395
+ * "client must be ready before user reaches this route".
396
+ *
397
+ * For NgModule-style apps using `HTTP_INTERCEPTORS`, use
398
+ * `provideTokenSetBearerInterceptor()` instead.
399
+ *
400
+ * @example
401
+ * ```ts
402
+ * import { provideHttpClient, withInterceptors } from "@angular/common/http";
403
+ * import { createTokenSetBearerInterceptor } from "@securitydept/token-set-context-client-angular";
404
+ *
405
+ * provideHttpClient(withInterceptors([
406
+ * createTokenSetBearerInterceptor(registry, { strictUrlMatch: true }),
407
+ * ]));
408
+ * ```
409
+ */
410
+ function createTokenSetBearerInterceptor(registry, options = {}) {
411
+ return (req, next) => {
412
+ // Try URL-pattern-based client selection first.
413
+ const key = registry.clientKeyForUrl(req.url);
414
+ // Explicit not-yet-ready semantics: if the client exists in metadata
415
+ // (via clientKeyForUrl) but registry.get() returns undefined because the
416
+ // async clientFactory has not resolved yet, the request proceeds without
417
+ // a token. See class-based interceptor above for full rationale.
418
+ const token = key
419
+ ? (registry.get(key)?.accessToken() ?? null)
420
+ : options.strictUrlMatch
421
+ ? null
422
+ : registry.accessToken();
423
+ if (!token) {
424
+ return next(req);
425
+ }
426
+ const authReq = req.clone({
427
+ setHeaders: { Authorization: `Bearer ${token}` },
428
+ });
429
+ return next(authReq);
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Angular-native service for handling OIDC redirect callbacks,
435
+ * with multi-client discrimination support.
436
+ *
437
+ * The post-authentication redirect URI is propagated automatically through
438
+ * the core client's pending state (set via `loginWithRedirect({ postAuthRedirectUri })`
439
+ * and recovered in `handleCallback()`). No manual URL storage is required.
440
+ *
441
+ * @example
442
+ * ```ts
443
+ * @Component({ ... })
444
+ * export class AuthCallbackComponent {
445
+ * private readonly callbackService = inject(CallbackResumeService);
446
+ * private readonly router = inject(Router);
447
+ *
448
+ * async ngOnInit() {
449
+ * const result = await this.callbackService.handleCallback(
450
+ * window.location.href,
451
+ * );
452
+ * this.router.navigateByUrl(result.resumeUrl, { replaceUrl: true });
453
+ * }
454
+ * }
455
+ * ```
456
+ */
457
+ class CallbackResumeService {
458
+ registry = inject(TokenSetAuthRegistry);
459
+ /**
460
+ * Check whether a URL is an OIDC authorization callback for any registered
461
+ * client in this registry.
462
+ *
463
+ * This is a convenience wrapper over `clientKeyForCallback()`. Use it for
464
+ * programmatic early-exit guards (e.g. in a service constructor) before
465
+ * calling `handleCallback()`.
466
+ *
467
+ * @param url - The full URL to check (e.g. `window.location.href`).
468
+ * @returns `true` when the URL matches a registered callback path and
469
+ * contains an `code` or `error` query parameter.
470
+ */
471
+ isCallback(url) {
472
+ return this.registry.clientKeyForCallback(url) !== undefined;
473
+ }
474
+ /**
475
+ * Handle the OIDC callback, auto-detecting which client the callback
476
+ * belongs to via registered callback paths.
477
+ *
478
+ * The `resumeUrl` in the result is taken directly from the core client's
479
+ * pending state (`postAuthRedirectUri` set at `loginWithRedirect()` time),
480
+ * falling back to `"/"` when no redirect URI was recorded.
481
+ *
482
+ * @param callbackUrl - The full current URL (e.g. `window.location.href`).
483
+ * @param explicitClientKey - Optional: force-select a client key instead of
484
+ * auto-detecting from registered callback paths.
485
+ */
486
+ async handleCallback(callbackUrl, explicitClientKey) {
487
+ // Determine which client this callback is for.
488
+ const clientKey = explicitClientKey ?? this.registry.clientKeyForCallback(callbackUrl);
489
+ if (!clientKey) {
490
+ throw new Error(`[CallbackResumeService] Cannot determine which client this callback belongs to. ` +
491
+ `URL: ${callbackUrl}. Register callbackPath in the client entry.`);
492
+ }
493
+ // Use whenReady() — not require() — so that if the client's async
494
+ // clientFactory is still in-flight when the callback page first loads,
495
+ // we wait for it to materialize rather than throwing.
496
+ const service = await this.registry.whenReady(clientKey);
497
+ const result = await service.client.handleCallback(callbackUrl);
498
+ // postAuthRedirectUri is recovered from the core client's pending state,
499
+ // which was stored by authorizeUrl() when loginWithRedirect() was called.
500
+ return {
501
+ clientKey,
502
+ snapshot: result.snapshot,
503
+ resumeUrl: result.postAuthRedirectUri ?? "/",
504
+ };
505
+ }
506
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: CallbackResumeService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
507
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: CallbackResumeService });
508
+ }
509
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: CallbackResumeService, decorators: [{
510
+ type: Injectable
511
+ }] });
512
+
513
+ // ============================================================================
514
+ // 8. Callback helper — OIDC redirect callback with multi-client discrimination
515
+ // ============================================================================
516
+ /**
517
+ * Check whether the current URL is an OIDC authorization callback.
518
+ */
519
+ function isOidcCallback(options) {
520
+ try {
521
+ const url = new URL(options.currentUrl);
522
+ const callbackUrl = new URL(options.callbackPath, url.origin);
523
+ return (url.pathname === callbackUrl.pathname &&
524
+ (url.searchParams.has("code") || url.searchParams.has("error")));
525
+ }
526
+ catch {
527
+ return false;
528
+ }
529
+ }
530
+
531
+ /** InjectionToken for the multi-client auth registry. */
532
+ const TOKEN_SET_AUTH_REGISTRY = new InjectionToken("TOKEN_SET_AUTH_REGISTRY");
533
+
534
+ /**
535
+ * Create Angular providers for the multi-client token-set auth integration.
536
+ *
537
+ * Registers all services into Angular DI so adopters can directly inject
538
+ * `TokenSetAuthRegistry`, `CallbackResumeService`, and look up individual
539
+ * `TokenSetAuthService` instances by key.
540
+ *
541
+ * ## Async client initialization
542
+ *
543
+ * When a client's `clientFactory` returns a `Promise` (e.g. because it
544
+ * needs to fetch a config projection from a backend endpoint), the
545
+ * registry tracks its initialization state automatically. Guards and
546
+ * interceptors should use `registry.whenReady(key)` to await
547
+ * materialization before accessing the service.
548
+ *
549
+ * @example
550
+ * ```ts
551
+ * import { provideTokenSetAuth } from "@securitydept/token-set-context-client-angular";
552
+ * import { resolveConfigProjection, networkConfigSource, createFrontendOidcModeClient }
553
+ * from "@securitydept/token-set-context-client/frontend-oidc-mode";
554
+ *
555
+ * export const appConfig = {
556
+ * providers: [
557
+ * provideTokenSetAuth({
558
+ * clients: [
559
+ * {
560
+ * key: "main",
561
+ * // Async clientFactory: fetches config projection from backend
562
+ * clientFactory: async () => {
563
+ * const resolved = await resolveConfigProjection([
564
+ * networkConfigSource({
565
+ * apiEndpoint: "https://api.example.com/api",
566
+ * redirectUri: `${location.origin}/auth/callback`,
567
+ * }),
568
+ * ]);
569
+ * return createFrontendOidcModeClient(resolved.config, runtime);
570
+ * },
571
+ * urlPatterns: ["/api/"],
572
+ * callbackPath: "/auth/callback",
573
+ * },
574
+ * ],
575
+ * }),
576
+ * ],
577
+ * };
578
+ * ```
579
+ */
580
+ function provideTokenSetAuth(options) {
581
+ return [
582
+ // Registry is a singleton — all clients share one registry.
583
+ {
584
+ provide: TokenSetAuthRegistry,
585
+ useFactory: () => new TokenSetAuthRegistry(),
586
+ },
587
+ // Eagerly register all client entries during environment initialization.
588
+ // provideEnvironmentInitializer runs in injection context, so inject() works.
589
+ // Note: async clientFactories create promises that the registry tracks;
590
+ // the initializer does not block Angular bootstrap on them (guards/interceptors
591
+ // use registry.whenReady() instead).
592
+ provideEnvironmentInitializer(() => {
593
+ const registry = inject(TokenSetAuthRegistry);
594
+ for (const entry of options.clients) {
595
+ registry.register(entry);
596
+ }
597
+ if (options.idleWarmup !== false) {
598
+ // Kick off idle-time preload for any lazy clients. The cancel
599
+ // handle is intentionally discarded — teardown happens through
600
+ // the registry's own DestroyRef binding at dispose time.
601
+ registry.idleWarmup();
602
+ }
603
+ }),
604
+ // Token for direct registry access.
605
+ {
606
+ provide: TOKEN_SET_AUTH_REGISTRY,
607
+ useExisting: TokenSetAuthRegistry,
608
+ },
609
+ // Callback resume service.
610
+ CallbackResumeService,
611
+ ];
612
+ }
613
+
614
+ // ---------------------------------------------------------------------------
615
+ // Guard factory
616
+ // ---------------------------------------------------------------------------
617
+ /**
618
+ * Create an Angular `CanActivateFn` that performs full-route requirement
619
+ * aggregation and single-pass planner evaluation.
620
+ *
621
+ * **Canonical Angular Router auth pattern** (route-metadata driven):
622
+ * 1. Requirements are declared in route `data` using `withRouteRequirements()`.
623
+ * 2. When the guard runs, it walks `route.pathFromRoot` to collect ALL
624
+ * requirements from every ancestor segment in a single pass.
625
+ * 3. Each requirement's `kind` is mapped to one or more registry clients,
626
+ * unless overridden by a `requirementPolicies[id].selector`.
627
+ * 4. The complete candidate set is handed to the `PlannerHost` for a
628
+ * single `evaluate()` call — the planner selects the first unresolved
629
+ * requirement and calls the matching handler.
630
+ *
631
+ * **Handler resolution order** (most specific wins):
632
+ * 1. `requirementPolicies[requirement.id].onUnauthenticated`
633
+ * 2. `requirementHandlers[requirement.kind]`
634
+ * 3. `defaultOnUnauthenticated`
635
+ * 4. Block navigation (`false`) if none of the above is provided.
636
+ *
637
+ * **Client resolution order** for a requirement:
638
+ * 1. `requirementPolicies[requirement.id].selector.clientKey` — single explicit key
639
+ * 2. `requirementPolicies[requirement.id].selector.query` — composite query
640
+ * 3. `registry.clientKeyListForRequirement(requirement.kind)` — default mapping
641
+ *
642
+ * This model enables:
643
+ * - One-pass requirement collection (no per-segment DI scope accumulation)
644
+ * - One-call planner evaluation (chooser sees the full set at once)
645
+ * - One redirect decision (no per-segment guard coordination needed)
646
+ * - Fine-grained per-requirement policy via `requirementPolicies`
647
+ *
648
+ * @example
649
+ * ```ts
650
+ * // routes.ts — declare requirements in route data
651
+ * import { withRouteRequirements } from "@securitydept/client-angular";
652
+ * import { createTokenSetRouteAggregationGuard } from "@securitydept/token-set-context-client-angular";
653
+ *
654
+ * // Simple — kind-level handler
655
+ * const guard = createTokenSetRouteAggregationGuard({
656
+ * requirementHandlers: {
657
+ * frontend_oidc: (_failing, _req) => {
658
+ * inject(AuthService).redirectToLogin("confluence").subscribe();
659
+ * return false;
660
+ * },
661
+ * },
662
+ * });
663
+ *
664
+ * // Fine-grained — per-requirement policy
665
+ * const guard = createTokenSetRouteAggregationGuard({
666
+ * requirementPolicies: {
667
+ * "confluence-oidc": {
668
+ * selector: { clientKey: "confluence" },
669
+ * onUnauthenticated: () => "/auth/confluence",
670
+ * },
671
+ * "admin-oidc": {
672
+ * selector: { query: { providerFamily: "internal-sso" } },
673
+ * onUnauthenticated: (failing) => "/auth/admin",
674
+ * },
675
+ * },
676
+ * });
677
+ *
678
+ * export const routes: Routes = [
679
+ * {
680
+ * path: "",
681
+ * data: withRouteRequirements([{ id: "session", kind: "session" }]),
682
+ * children: [
683
+ * {
684
+ * path: "confluence",
685
+ * data: withRouteRequirements([{ id: "confluence-oidc", kind: "frontend_oidc" }]),
686
+ * canActivate: [guard],
687
+ * },
688
+ * ],
689
+ * },
690
+ * ];
691
+ * ```
692
+ */
693
+ function createTokenSetRouteAggregationGuard(options = {}) {
694
+ return async (route, state) => {
695
+ const registry = inject(TokenSetAuthRegistry);
696
+ const router = inject(Router);
697
+ // ── Resolve planner-host ────────────────────────────────────────────
698
+ const plannerHost = options.plannerHostResolver?.(route, state) ??
699
+ options.plannerHost ??
700
+ inject(AUTH_PLANNER_HOST, { optional: true });
701
+ if (!plannerHost) {
702
+ throw new Error("[createTokenSetRouteAggregationGuard] No PlannerHost found. " +
703
+ "Provide AUTH_PLANNER_HOST via provideAuthPlannerHost() " +
704
+ "in your app/route providers, or pass plannerHost inline.");
705
+ }
706
+ // ── Step 1: Full-route requirement aggregation ───────────────────────
707
+ const allRequirements = extractFullRouteRequirements(route, options.requirementsKey);
708
+ if (allRequirements.length === 0) {
709
+ return true;
710
+ }
711
+ const resolvedRequirements = [];
712
+ for (const req of allRequirements) {
713
+ const policy = options.requirementPolicies?.[req.id];
714
+ let clientKeys;
715
+ if (policy?.selector) {
716
+ // Policy provides an explicit selector — use it instead of kind mapping.
717
+ if (policy.selector.clientKey !== undefined) {
718
+ clientKeys = [policy.selector.clientKey];
719
+ }
720
+ else {
721
+ clientKeys = registry.clientKeysForOptions(policy.selector.query);
722
+ }
723
+ }
724
+ else {
725
+ // Default: resolve by requirement kind.
726
+ clientKeys = registry.clientKeyListForRequirement(req.kind);
727
+ }
728
+ if (clientKeys.length === 0) {
729
+ // No client for this requirement — skip (lets non-token-set requirements
730
+ // coexist in the same route chain without breaking the guard).
731
+ continue;
732
+ }
733
+ const entries = await Promise.all(clientKeys.map(async (key) => ({
734
+ // Use whenReady() — not require() — so that if the client's
735
+ // async clientFactory is still in-flight when the guard first
736
+ // fires, we block here until it materializes rather than
737
+ // crashing or silently bypassing auth.
738
+ service: await registry.whenReady(key),
739
+ clientKey: key,
740
+ meta: registry.metaFor(key),
741
+ })));
742
+ resolvedRequirements.push({ requirement: req, entries });
743
+ }
744
+ if (resolvedRequirements.length === 0) {
745
+ return true;
746
+ }
747
+ // ── Step 3: Build planner candidates ────────────────────────────────
748
+ // De-duplicate by requirementId (last declaration for the same id wins).
749
+ const seenIds = new Map();
750
+ for (const r of resolvedRequirements) {
751
+ seenIds.set(r.requirement.id, r);
752
+ }
753
+ const deduped = [...seenIds.values()];
754
+ const candidates = deduped.map((r) => ({
755
+ requirementId: r.requirement.id,
756
+ requirementKind: r.requirement.kind,
757
+ label: r.requirement.label,
758
+ attributes: r.requirement.attributes,
759
+ checkAuthenticated: () => r.entries.every(({ service }) => service.isAuthenticated()),
760
+ onUnauthenticated: () => {
761
+ const failing = r.entries.filter(({ service }) => !service.isAuthenticated());
762
+ // Handler resolution: policy > kind handler > default
763
+ const handler = options.requirementPolicies?.[r.requirement.id]?.onUnauthenticated ??
764
+ options.requirementHandlers?.[r.requirement.kind] ??
765
+ options.defaultOnUnauthenticated;
766
+ if (!handler)
767
+ return false;
768
+ const result = handler(failing, r.requirement);
769
+ if (typeof result === "string")
770
+ return result;
771
+ if (typeof result === "boolean")
772
+ return result;
773
+ return result.toString();
774
+ },
775
+ _resolved: r,
776
+ }));
777
+ // ── Step 4: Single planner evaluation ───────────────────────────────
778
+ const decide = async () => {
779
+ const result = await plannerHost.evaluate(candidates);
780
+ if (result.allAuthenticated)
781
+ return true;
782
+ if (result.pendingCandidate) {
783
+ const candidate = result.pendingCandidate;
784
+ const failing = candidate._resolved.entries.filter(({ service }) => !service.isAuthenticated());
785
+ // Handler resolution: policy > kind handler > default
786
+ const handler = options.requirementPolicies?.[candidate._resolved.requirement.id]
787
+ ?.onUnauthenticated ??
788
+ options.requirementHandlers?.[candidate._resolved.requirement.kind] ??
789
+ options.defaultOnUnauthenticated;
790
+ if (!handler)
791
+ return false;
792
+ const action = await handler(failing, candidate._resolved.requirement);
793
+ if (typeof action === "string")
794
+ return router.parseUrl(action);
795
+ if (typeof action === "boolean")
796
+ return action;
797
+ return action;
798
+ }
799
+ return false;
800
+ };
801
+ // ── Restore-aware: wait for all pending restores ─────────────────────
802
+ const pendingRestores = deduped
803
+ .flatMap((r) => r.entries)
804
+ .map(({ service }) => service.restorePromise)
805
+ .filter((p) => p !== null);
806
+ if (pendingRestores.length === 0) {
807
+ return decide();
808
+ }
809
+ // await the restore-phase Observable before running planner evaluation
810
+ // so the async guard always returns a Promise, not an Observable.
811
+ return firstValueFrom(from(Promise.all(pendingRestores)).pipe(switchMap(() => from(decide())), take(1)));
812
+ };
813
+ }
814
+
815
+ /**
816
+ * Route data key for choosing a planner-host registered at the route root.
817
+ */
818
+ const TOKEN_SET_ROUTE_PLANNER_HOST_KEY_DATA_KEY = "tokenSetPlannerHostKey";
819
+ /**
820
+ * Build a secured child route declaration.
821
+ *
822
+ * The returned route contains only serializable metadata:
823
+ * requirements, composition strategy, planner-host lookup key, and any extra
824
+ * route data the caller provides.
825
+ */
826
+ function secureRoute(path, securityOptions = {}, routeOptions = {}) {
827
+ const data = withRouteRequirements(securityOptions.requirements ?? [], {
828
+ composition: securityOptions.composition,
829
+ extra: {
830
+ ...(routeOptions.data ?? {}),
831
+ ...(securityOptions.plannerHostKey
832
+ ? {
833
+ [TOKEN_SET_ROUTE_PLANNER_HOST_KEY_DATA_KEY]: securityOptions.plannerHostKey,
834
+ }
835
+ : {}),
836
+ },
837
+ });
838
+ return {
839
+ ...routeOptions,
840
+ path,
841
+ data,
842
+ };
843
+ }
844
+ /**
845
+ * Build a secured route-tree root.
846
+ *
847
+ * This is the canonical Angular adopter entry for token-set route security:
848
+ * root-level runtime policy stays here, child routes use {@link secureRoute}
849
+ * for serializable declarations only.
850
+ */
851
+ function secureRouteRoot(path, securityOptions = {}, routeOptions = {}) {
852
+ const canActivate = createTokenSetRouteAggregationGuard({
853
+ requirementPolicies: securityOptions.requirementPolicies,
854
+ requirementHandlers: securityOptions.requirementHandlers,
855
+ defaultOnUnauthenticated: securityOptions.defaultOnUnauthenticated,
856
+ requirementsKey: securityOptions.requirementsKey,
857
+ plannerHostResolver: (route, state) => resolvePlannerHostForRoute(route, state, securityOptions),
858
+ });
859
+ const canActivateChild = createCanActivateChildAdapter(canActivate);
860
+ const baseRoute = secureRoute(path, securityOptions, routeOptions);
861
+ return {
862
+ ...baseRoute,
863
+ canActivate: [canActivate, ...(routeOptions.canActivate ?? [])],
864
+ canActivateChild: [
865
+ canActivateChild,
866
+ ...(routeOptions.canActivateChild ?? []),
867
+ ],
868
+ };
869
+ }
870
+ /**
871
+ * Alias with a token-set-specific name for callers who prefer explicit imports.
872
+ */
873
+ const secureTokenSetRoute = secureRoute;
874
+ /**
875
+ * Alias with a token-set-specific name for callers who prefer explicit imports.
876
+ */
877
+ const secureTokenSetRouteRoot = secureRouteRoot;
878
+ function createCanActivateChildAdapter(canActivate) {
879
+ return (childRoute, state) => canActivate(childRoute, state);
880
+ }
881
+ function resolvePlannerHostForRoute(route, _state, securityOptions) {
882
+ const plannerHostKey = findNearestPlannerHostKey(route) ?? securityOptions.defaultPlannerHostKey;
883
+ if (plannerHostKey) {
884
+ const resolved = securityOptions.plannerHosts?.[plannerHostKey];
885
+ if (!resolved) {
886
+ throw new Error(`[secureRouteRoot] No planner host registered for key "${plannerHostKey}"`);
887
+ }
888
+ return resolved;
889
+ }
890
+ return undefined;
891
+ }
892
+ function findNearestPlannerHostKey(route) {
893
+ const chain = route.pathFromRoot ?? [route];
894
+ for (let index = chain.length - 1; index >= 0; index -= 1) {
895
+ const segment = chain[index];
896
+ if (!segment)
897
+ continue;
898
+ const raw = segment.data[TOKEN_SET_ROUTE_PLANNER_HOST_KEY_DATA_KEY] ??
899
+ segment.routeConfig?.data?.[TOKEN_SET_ROUTE_PLANNER_HOST_KEY_DATA_KEY];
900
+ if (typeof raw === "string" && raw.length > 0) {
901
+ return raw;
902
+ }
903
+ }
904
+ return undefined;
905
+ }
906
+
907
+ // ============================================================================
908
+ // 9. TokenSetCallbackComponent — drop-in standalone callback route component
909
+ // ============================================================================
910
+ /**
911
+ * Drop-in standalone Angular component for OIDC redirect callback routes.
912
+ *
913
+ * Handles the full callback lifecycle on initialization:
914
+ * 1. Calls `CallbackResumeService.handleCallback()` with the current URL.
915
+ * 2. Navigates to `result.resumeUrl` (the post-auth redirect set at login time).
916
+ * 3. On error: logs and falls back to navigating to `"/"`.
917
+ *
918
+ * Register as a route component — no additional glue needed:
919
+ * ```ts
920
+ * import { TokenSetCallbackComponent } from "@securitydept/token-set-context-client-angular";
921
+ *
922
+ * export const routes: Routes = [
923
+ * { path: "auth/callback", component: TokenSetCallbackComponent },
924
+ * ];
925
+ * ```
926
+ *
927
+ * For custom error handling or UI, subclass this component or build your own
928
+ * using `CallbackResumeService` directly.
929
+ */
930
+ class TokenSetCallbackComponent {
931
+ callbackService = inject(CallbackResumeService);
932
+ router = inject(Router);
933
+ ngOnInit() {
934
+ const currentUrl = window.location.href;
935
+ if (!this.callbackService.isCallback(currentUrl)) {
936
+ // Not a callback URL — navigate home to avoid processing errors.
937
+ this.router.navigateByUrl("/", { replaceUrl: true });
938
+ return;
939
+ }
940
+ this.callbackService
941
+ .handleCallback(currentUrl)
942
+ .then((result) => {
943
+ this.router.navigateByUrl(result.resumeUrl, { replaceUrl: true });
944
+ })
945
+ .catch((error) => {
946
+ console.error("[TokenSetCallbackComponent] Callback handling failed:", error);
947
+ this.router.navigateByUrl("/", { replaceUrl: true });
948
+ });
949
+ }
950
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetCallbackComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
951
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.8", type: TokenSetCallbackComponent, isStandalone: true, selector: "token-set-callback", ngImport: i0, template: "", isInline: true });
952
+ }
953
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: TokenSetCallbackComponent, decorators: [{
954
+ type: Component,
955
+ args: [{
956
+ selector: "token-set-callback",
957
+ standalone: true,
958
+ template: "",
959
+ }]
960
+ }] });
961
+
962
+ // Angular integration family for @securitydept/token-set-context-client
963
+ //
964
+ // Canonical import path:
965
+ // import { ... } from "@securitydept/token-set-context-client-angular"
966
+ //
967
+ // Multi-client-first Angular-native adapter surface:
968
+ // - Keyed client registration via provideTokenSetAuth()
969
+ // - Multi-client lookup via TokenSetAuthRegistry
970
+ // - Per-client TokenSetAuthService with signal + RxJS bridge
971
+ // - Multi-client bearer interceptor (functional + class-based)
972
+ // - provideTokenSetBearerInterceptor() for NgModule HTTP_INTERCEPTORS setup
973
+ // - OIDC callback resume with client key discrimination
974
+ // - CallbackResumeService.isCallback() for programmatic callback detection
975
+ // - TokenSetCallbackComponent standalone component (drop-in callback route)
976
+ // - Requirement/provider-family → client key mapping
977
+ // - secureRouteRoot() / secureRoute() — canonical Angular Router route-security
978
+ // builders: keep non-serializable runtime policy at the root, let child
979
+ // routes declare serializable requirement metadata only, and wire both
980
+ // canActivate + canActivateChild to the full-route aggregation guard.
981
+ // - createTokenSetRouteAggregationGuard() — lower-level advanced guard for
982
+ // direct route-tree evaluation when adopters intentionally bypass the route
983
+ // builder helpers.
984
+ // - Signal/Observable bridge utilities live in @securitydept/client-angular
985
+ //
986
+ // Built by ng-packagr (APF / FESM2022). Decorators are fully supported.
987
+ //
988
+ // Stability: provisional (framework adapter)
989
+
990
+ /*
991
+ * Public API Surface of @securitydept/token-set-context-client-angular
992
+ */
993
+
994
+ /**
995
+ * Generated bundle index. Do not edit.
996
+ */
997
+
998
+ export { CallbackResumeService, TOKEN_SET_AUTH_REGISTRY, TOKEN_SET_BEARER_INTERCEPTOR_OPTIONS, TOKEN_SET_ROUTE_PLANNER_HOST_KEY_DATA_KEY, TokenSetAuthRegistry, TokenSetAuthService, TokenSetBearerInterceptor, TokenSetCallbackComponent, createTokenSetBearerInterceptor, createTokenSetRouteAggregationGuard, isOidcCallback, provideTokenSetAuth, provideTokenSetBearerInterceptor, secureRoute, secureRouteRoot, secureTokenSetRoute, secureTokenSetRouteRoot };
999
+ //# sourceMappingURL=securitydept-token-set-context-client-angular.mjs.map