dhomie-app 0.0.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.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # MicroApp
2
+
3
+ This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.0.
4
+
5
+ ## Code scaffolding
6
+
7
+ Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
8
+
9
+ ```bash
10
+ ng generate component component-name
11
+ ```
12
+
13
+ For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
14
+
15
+ ```bash
16
+ ng generate --help
17
+ ```
18
+
19
+ ## Building
20
+
21
+ To build the library, run:
22
+
23
+ ```bash
24
+ ng build micro-app
25
+ ```
26
+
27
+ This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
28
+
29
+ ### Publishing the Library
30
+
31
+ Once the project is built, you can publish your library by following these steps:
32
+
33
+ 1. Navigate to the `dist` directory:
34
+ ```bash
35
+ cd dist/micro-app
36
+ ```
37
+
38
+ 2. Run the `npm publish` command to publish your library to the npm registry:
39
+ ```bash
40
+ npm publish
41
+ ```
42
+
43
+ ## Running unit tests
44
+
45
+ To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
46
+
47
+ ```bash
48
+ ng test
49
+ ```
50
+
51
+ ## Running end-to-end tests
52
+
53
+ For end-to-end (e2e) testing, run:
54
+
55
+ ```bash
56
+ ng e2e
57
+ ```
58
+
59
+ Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
60
+
61
+ ## Additional Resources
62
+
63
+ For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
@@ -0,0 +1,608 @@
1
+ import * as i0 from '@angular/core';
2
+ import { Injectable, inject, Component, DestroyRef, provideEnvironmentInitializer } from '@angular/core';
3
+ import { Router, RouterOutlet } from '@angular/router';
4
+ import { App } from '@capacitor/app';
5
+ import { Browser } from '@capacitor/browser';
6
+ import { Capacitor } from '@capacitor/core';
7
+ import { OAuthService, OAuthStorage, provideOAuthClient } from 'angular-oauth2-oidc';
8
+ const env = {
9
+ production: true,
10
+ oidc: {
11
+ issuer: 'https://auth.dhomie.com',
12
+ clientId: 'dhomie-micro-client',
13
+ scope: 'openid profile email offline_access',
14
+ redirectUri: 'https://app.dhomie.com/micro/callback',
15
+ mobileRedirectUri: 'com.thehood.app://micro/callback',
16
+ responseType: 'code',
17
+ showDebugInformation: false,
18
+ useSilentRefresh: false,
19
+ },
20
+ microApiBaseUrl: 'https://api.dhomie.com/v1',
21
+ };
22
+ import { Preferences } from '@capacitor/preferences';
23
+ import { IonContent, IonSpinner, IonButton, IonButtons, IonHeader, IonItem, IonLabel, IonList, IonTitle, IonToolbar } from '@ionic/angular/standalone';
24
+ import { HttpContextToken, HttpErrorResponse, provideHttpClient, withInterceptors } from '@angular/common/http';
25
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
26
+ import { filter, catchError, throwError, from, switchMap } from 'rxjs';
27
+
28
+ /**
29
+ * Key prefix used for all entries written to @capacitor/preferences.
30
+ * Exported so StorageHydrationService can filter and strip it.
31
+ */
32
+ const MICRO_OAUTH_PREF_PREFIX = 'micro_oauth_';
33
+ /**
34
+ * OAuthStorage implementation for Capacitor apps.
35
+ *
36
+ * Two-layer design (required because angular-oauth2-oidc's OAuthStorage interface is synchronous):
37
+ *
38
+ * Layer 1 — RAM Map (sync): satisfies getItem/setItem/removeItem immediately.
39
+ * Layer 2 — @capacitor/preferences (async): persists tokens across app restarts.
40
+ * Writes are fire-and-forget; reads only happen during explicit hydration.
41
+ *
42
+ * The RAM Map is populated at startup by StorageHydrationService.hydrate().
43
+ * Call hydrate() and await it BEFORE any token check (MicroAuthGuard does this).
44
+ */
45
+ class CapacitorOAuthStorage {
46
+ constructor() {
47
+ this.ram = new Map();
48
+ }
49
+ getItem(key) {
50
+ return this.ram.get(key) ?? null;
51
+ }
52
+ setItem(key, data) {
53
+ this.ram.set(key, data);
54
+ Preferences.set({ key: MICRO_OAUTH_PREF_PREFIX + key, value: data }).catch(() => { });
55
+ }
56
+ removeItem(key) {
57
+ this.ram.delete(key);
58
+ Preferences.remove({ key: MICRO_OAUTH_PREF_PREFIX + key }).catch(() => { });
59
+ }
60
+ /**
61
+ * Writes a key→value pair directly into the RAM cache without touching Preferences.
62
+ * Called exclusively by StorageHydrationService during the hydration phase to avoid
63
+ * a redundant async write-back of data that was just read from Preferences.
64
+ */
65
+ hydrateEntry(key, value) {
66
+ this.ram.set(key, value);
67
+ }
68
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: CapacitorOAuthStorage, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
69
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: CapacitorOAuthStorage }); }
70
+ }
71
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: CapacitorOAuthStorage, decorators: [{
72
+ type: Injectable
73
+ }] });
74
+
75
+ /**
76
+ * Bridges the async @capacitor/preferences layer and the sync RAM cache in CapacitorOAuthStorage.
77
+ *
78
+ * Call hydrate() once before the first token check. All subsequent calls return the same
79
+ * memoized Promise so concurrent callers (e.g. parallel route guards) await a single read.
80
+ *
81
+ * Lifecycle:
82
+ * App start → MicroAuthGuard fires → hydrate() → RAM populated → hasValidAccessToken() checked
83
+ *
84
+ * hydrate() is NOT called by MicroCallbackComponent (the callback route has no guard).
85
+ * After tryLoginCodeFlow() the library writes tokens via setItem(), which populates RAM
86
+ * directly and schedules the async Preferences write in the background.
87
+ */
88
+ class StorageHydrationService {
89
+ constructor() {
90
+ this.storage = inject(CapacitorOAuthStorage);
91
+ this.hydrated = null;
92
+ }
93
+ hydrate() {
94
+ this.hydrated ??= this.doHydrate();
95
+ return this.hydrated;
96
+ }
97
+ async doHydrate() {
98
+ const { keys } = await Preferences.keys();
99
+ await Promise.all(keys
100
+ .filter((k) => k.startsWith(MICRO_OAUTH_PREF_PREFIX))
101
+ .map(async (prefixedKey) => {
102
+ const { value } = await Preferences.get({ key: prefixedKey });
103
+ if (value !== null) {
104
+ this.storage.hydrateEntry(prefixedKey.slice(MICRO_OAUTH_PREF_PREFIX.length), value);
105
+ }
106
+ }));
107
+ }
108
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: StorageHydrationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
109
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: StorageHydrationService }); }
110
+ }
111
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: StorageHydrationService, decorators: [{
112
+ type: Injectable
113
+ }] });
114
+
115
+ /**
116
+ * Route guard for all guarded micro-app routes (everything except /micro/callback).
117
+ *
118
+ * Sequence (mirrors the frozen auth sequence):
119
+ * 1. Hydrate RAM cache from @capacitor/preferences — memoized, instant on repeat calls.
120
+ * 2. hasValidAccessToken() → true: allow navigation immediately.
121
+ * 3. loadDiscoveryDocument() — skipped when already loaded.
122
+ * 4a. Native: set mobileRedirectUri, createLoginUrl() (generates + stores PKCE verifier),
123
+ * register appUrlOpen listener, Browser.open(), return false.
124
+ * 4b. Web: set web redirectUri, initCodeFlow() (redirects window.location.href),
125
+ * return false.
126
+ *
127
+ * The guard is a plain CanActivateFn — no class, no provideMicroApp() entry needed.
128
+ * inject() works because Angular runs guards inside the child environment injector.
129
+ */
130
+ const microAuthGuard = async () => {
131
+ const oauth = inject(OAuthService);
132
+ const hydration = inject(StorageHydrationService);
133
+ const router = inject(Router);
134
+ // Step 1: populate RAM from persisted storage.
135
+ await hydration.hydrate();
136
+ // Step 2: still-valid token → allow.
137
+ if (oauth.hasValidAccessToken()) {
138
+ return true;
139
+ }
140
+ // Step 3: fetch OIDC discovery document.
141
+ if (!oauth.discoveryDocumentLoaded) {
142
+ await oauth.loadDiscoveryDocument();
143
+ }
144
+ // Step 4: platform-split login.
145
+ if (Capacitor.isNativePlatform()) {
146
+ // Mutate redirectUri so tryLoginCodeFlow() (in MicroCallbackComponent) sends the
147
+ // correct redirect_uri in the token exchange request — must match what we use here.
148
+ oauth.redirectUri = env.oidc.mobileRedirectUri;
149
+ // createLoginUrl() generates a code_verifier, stores it in OAuthStorage (RAM cache),
150
+ // computes the code_challenge, and returns the full authorization URL.
151
+ const loginUrl = await oauth.createLoginUrl();
152
+ // Register the listener BEFORE opening the browser — the auth flow can complete
153
+ // faster than the next event loop tick on some devices.
154
+ const handle = await App.addListener('appUrlOpen', async (event) => {
155
+ await handle.remove();
156
+ await Browser.close();
157
+ // Parse query params from the custom-scheme callback URL.
158
+ // e.g. com.thehood.app://micro/callback?code=abc&state=xyz
159
+ const callbackUrl = new URL(event.url);
160
+ const queryParams = {};
161
+ callbackUrl.searchParams.forEach((value, key) => {
162
+ queryParams[key] = value;
163
+ });
164
+ // Navigate to the callback route with the code + state as query params.
165
+ // Angular Router updates window.location so tryLoginCodeFlow() can read them.
166
+ await router.navigate(['/micro/callback'], { queryParams });
167
+ });
168
+ await Browser.open({ url: loginUrl });
169
+ return false;
170
+ }
171
+ else {
172
+ // Web: restore web redirect URI in case a previous native-path call mutated it.
173
+ oauth.redirectUri = env.oidc.redirectUri;
174
+ oauth.initCodeFlow();
175
+ return false;
176
+ }
177
+ };
178
+
179
+ /**
180
+ * Handles the OAuth 2.0 Authorization Code + PKCE callback.
181
+ *
182
+ * This route has NO guard by design — the guard would redirect to login, creating
183
+ * a redirect loop. The component completes the token exchange itself.
184
+ *
185
+ * Flow:
186
+ * Web: IdP redirects browser to /micro/callback?code=xxx&state=yyy
187
+ * Angular Router activates this component; window.location contains the params.
188
+ * Native: MicroAuthGuard's appUrlOpen handler calls router.navigate(['/micro/callback'],
189
+ * { queryParams: { code, state } }), then this component activates.
190
+ * window.location reflects the Angular Router URL so tryLoginCodeFlow() can
191
+ * read the same params transparently.
192
+ *
193
+ * On success: navigate to /micro/home (replaceUrl so callback is not in history).
194
+ * On failure: navigate to /micro/home anyway — MicroAuthGuard will restart the login.
195
+ * Failure reasons include cold-start after OS kill (code_verifier lost from RAM),
196
+ * user denying access at the IdP, or a network error during token exchange.
197
+ */
198
+ class MicroCallbackComponent {
199
+ constructor() {
200
+ this.oauth = inject(OAuthService);
201
+ this.router = inject(Router);
202
+ }
203
+ async ngOnInit() {
204
+ try {
205
+ // Exchanges the authorization code for tokens.
206
+ // Reads code + state from window.location (set by Angular Router).
207
+ // Verifies the state nonce and PKCE code_verifier stored in OAuthStorage (RAM cache).
208
+ // On success, tokens are written to OAuthStorage → RAM + async @capacitor/preferences.
209
+ await this.oauth.tryLoginCodeFlow();
210
+ }
211
+ catch {
212
+ // Token exchange failed (cold-start, user denied, network error, etc.).
213
+ // Navigate to home — MicroAuthGuard will detect no valid token and restart login.
214
+ }
215
+ await this.router.navigate(['/micro/home'], { replaceUrl: true });
216
+ }
217
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroCallbackComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
218
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.17", type: MicroCallbackComponent, isStandalone: true, selector: "micro-callback", ngImport: i0, template: `
219
+ <ion-content>
220
+ <div class="callback-loader">
221
+ <ion-spinner name="crescent" />
222
+ </div>
223
+ </ion-content>
224
+ `, isInline: true, styles: [".callback-loader{display:flex;align-items:center;justify-content:center;height:100%}\n"], dependencies: [{ kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }] }); }
225
+ }
226
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroCallbackComponent, decorators: [{
227
+ type: Component,
228
+ args: [{ selector: 'micro-callback', standalone: true, imports: [IonContent, IonSpinner], template: `
229
+ <ion-content>
230
+ <div class="callback-loader">
231
+ <ion-spinner name="crescent" />
232
+ </div>
233
+ </ion-content>
234
+ `, styles: [".callback-loader{display:flex;align-items:center;justify-content:center;height:100%}\n"] }]
235
+ }] });
236
+
237
+ class MicroHomeComponent {
238
+ constructor() {
239
+ this.oauth = inject(OAuthService);
240
+ this.router = inject(Router);
241
+ }
242
+ get claims() {
243
+ return this.oauth.getIdentityClaims() ?? null;
244
+ }
245
+ async logout() {
246
+ // logOut(true): clears all tokens from OAuthStorage (CapacitorOAuthStorage.removeItem()
247
+ // flushes RAM and fires async @capacitor/preferences removal for each token key)
248
+ // without redirecting to the IdP's end_session_endpoint.
249
+ this.oauth.logOut(true);
250
+ // replaceUrl prevents the user navigating back to the logged-in state via back button.
251
+ // MicroAuthGuard fires on /micro/home → no valid token → restarts login.
252
+ await this.router.navigate(['/micro/home'], { replaceUrl: true });
253
+ }
254
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroHomeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
255
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.17", type: MicroHomeComponent, isStandalone: true, selector: "micro-home", ngImport: i0, template: `
256
+ <ion-header [translucent]="true">
257
+ <ion-toolbar>
258
+ <ion-title>DHomie</ion-title>
259
+ <ion-buttons slot="end">
260
+ <ion-button (click)="logout()">Sign out</ion-button>
261
+ </ion-buttons>
262
+ </ion-toolbar>
263
+ </ion-header>
264
+
265
+ <ion-content [fullscreen]="true">
266
+ <ion-header collapse="condense">
267
+ <ion-toolbar>
268
+ <ion-title size="large">DHomie</ion-title>
269
+ </ion-toolbar>
270
+ </ion-header>
271
+
272
+ @if (claims) {
273
+ <ion-list>
274
+ <ion-item>
275
+ <ion-label>
276
+ <h2>{{ claims['name'] ?? claims['sub'] }}</h2>
277
+ <p>{{ claims['email'] }}</p>
278
+ </ion-label>
279
+ </ion-item>
280
+ </ion-list>
281
+ }
282
+ </ion-content>
283
+ `, isInline: true, dependencies: [{ kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonItem, selector: "ion-item", inputs: ["button", "color", "detail", "detailIcon", "disabled", "download", "href", "lines", "mode", "rel", "routerAnimation", "routerDirection", "target", "type"] }, { kind: "component", type: IonLabel, selector: "ion-label", inputs: ["color", "mode", "position"] }, { kind: "component", type: IonList, selector: "ion-list", inputs: ["inset", "lines", "mode"] }, { kind: "component", type: IonTitle, selector: "ion-title", inputs: ["color", "size"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }] }); }
284
+ }
285
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroHomeComponent, decorators: [{
286
+ type: Component,
287
+ args: [{
288
+ selector: 'micro-home',
289
+ standalone: true,
290
+ imports: [
291
+ IonButton,
292
+ IonButtons,
293
+ IonContent,
294
+ IonHeader,
295
+ IonItem,
296
+ IonLabel,
297
+ IonList,
298
+ IonTitle,
299
+ IonToolbar,
300
+ ],
301
+ template: `
302
+ <ion-header [translucent]="true">
303
+ <ion-toolbar>
304
+ <ion-title>DHomie</ion-title>
305
+ <ion-buttons slot="end">
306
+ <ion-button (click)="logout()">Sign out</ion-button>
307
+ </ion-buttons>
308
+ </ion-toolbar>
309
+ </ion-header>
310
+
311
+ <ion-content [fullscreen]="true">
312
+ <ion-header collapse="condense">
313
+ <ion-toolbar>
314
+ <ion-title size="large">DHomie</ion-title>
315
+ </ion-toolbar>
316
+ </ion-header>
317
+
318
+ @if (claims) {
319
+ <ion-list>
320
+ <ion-item>
321
+ <ion-label>
322
+ <h2>{{ claims['name'] ?? claims['sub'] }}</h2>
323
+ <p>{{ claims['email'] }}</p>
324
+ </ion-label>
325
+ </ion-item>
326
+ </ion-list>
327
+ }
328
+ </ion-content>
329
+ `,
330
+ }]
331
+ }] });
332
+
333
+ class MicroShellComponent {
334
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroShellComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
335
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.17", type: MicroShellComponent, isStandalone: true, selector: "micro-shell", ngImport: i0, template: '<router-outlet />', isInline: true, dependencies: [{ kind: "directive", type: RouterOutlet, selector: "router-outlet", inputs: ["name", "routerOutletData"], outputs: ["activate", "deactivate", "attach", "detach"], exportAs: ["outlet"] }] }); }
336
+ }
337
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroShellComponent, decorators: [{
338
+ type: Component,
339
+ args: [{
340
+ selector: 'micro-shell',
341
+ standalone: true,
342
+ imports: [RouterOutlet],
343
+ template: '<router-outlet />',
344
+ }]
345
+ }] });
346
+
347
+ /** Milliseconds before access token expiry at which a proactive refresh is triggered. */
348
+ const REFRESH_AHEAD_MS = 60_000;
349
+ /**
350
+ * Schedules proactive access-token refresh so sessions never expire mid-use.
351
+ *
352
+ * Why this exists:
353
+ * MicroInterceptor already handles reactive 401 recovery (post-expiry), but that causes
354
+ * a failed request, a round-trip to the token endpoint, and a retry — visible latency
355
+ * for the user. This service keeps the access token fresh by refreshing it REFRESH_AHEAD_MS
356
+ * before it expires, so the interceptor path is never hit during normal operation.
357
+ *
358
+ * Lifecycle:
359
+ * Constructed eagerly via provideEnvironmentInitializer() so the timer starts as soon as
360
+ * the child injector is created — even when the user returns to /micro with an existing
361
+ * hydrated session (no token_received event fires in that case).
362
+ *
363
+ * Destroyed with the child EnvironmentInjector (user navigates away from /micro).
364
+ * ngOnDestroy cancels any pending timer; takeUntilDestroyed unsubscribes the event stream.
365
+ *
366
+ * On refresh failure:
367
+ * Clears tokens via logOut() and navigates to /micro/home.
368
+ * MicroAuthGuard fires → no valid token → restarts the login flow.
369
+ */
370
+ class MicroRefreshSchedulerService {
371
+ constructor() {
372
+ this.oauth = inject(OAuthService);
373
+ this.router = inject(Router);
374
+ this.destroyRef = inject(DestroyRef);
375
+ this.timerId = null;
376
+ // Reschedule every time new tokens arrive (initial login or any successful refresh).
377
+ this.oauth.events
378
+ .pipe(filter((e) => e.type === 'token_received' || e.type === 'token_refreshed'), takeUntilDestroyed(this.destroyRef))
379
+ .subscribe(() => this.scheduleRefresh());
380
+ // Handle the warm-start case: user returns to /micro with tokens already in RAM
381
+ // (populated by StorageHydrationService). No token event fires in this case, so
382
+ // check immediately after construction.
383
+ if (this.oauth.hasValidAccessToken()) {
384
+ this.scheduleRefresh();
385
+ }
386
+ }
387
+ ngOnDestroy() {
388
+ this.clearTimer();
389
+ }
390
+ scheduleRefresh() {
391
+ this.clearTimer();
392
+ const expiresAt = this.oauth.getAccessTokenExpiration();
393
+ const delay = expiresAt - Date.now() - REFRESH_AHEAD_MS;
394
+ if (delay <= 0) {
395
+ // Token expires within the ahead-window (or already expired) — refresh immediately.
396
+ this.doRefresh();
397
+ }
398
+ else {
399
+ this.timerId = setTimeout(() => this.doRefresh(), delay);
400
+ }
401
+ }
402
+ doRefresh() {
403
+ this.clearTimer();
404
+ this.oauth.refreshToken().catch(() => {
405
+ // Refresh failed — the refresh token is likely expired or revoked.
406
+ // logOut(true): clears all tokens from OAuthStorage (RAM + async Preferences.remove())
407
+ // without redirecting to the IdP's end_session_endpoint.
408
+ this.oauth.logOut(true);
409
+ this.router.navigate(['/micro/home']);
410
+ });
411
+ }
412
+ clearTimer() {
413
+ if (this.timerId !== null) {
414
+ clearTimeout(this.timerId);
415
+ this.timerId = null;
416
+ }
417
+ }
418
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroRefreshSchedulerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
419
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroRefreshSchedulerService }); }
420
+ }
421
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroRefreshSchedulerService, decorators: [{
422
+ type: Injectable
423
+ }], ctorParameters: () => [] });
424
+
425
+ /**
426
+ * Serialises concurrent refresh-token grants into a single in-flight Promise.
427
+ *
428
+ * Problem: Two or more simultaneous HTTP requests can all receive a 401 response at the
429
+ * same time (e.g. after an access token silently expires). Without a lock, each interceptor
430
+ * invocation would race to call oauth.refreshToken(), resulting in multiple /token requests
431
+ * and potential "refresh token already used" errors from the IdP.
432
+ *
433
+ * Solution: The first caller triggers refreshToken() and caches the Promise.
434
+ * Every subsequent caller receives the same Promise and waits on it.
435
+ * The lock is cleared once the grant resolves or rejects, so the next request after
436
+ * a successful refresh starts with a clean lock.
437
+ */
438
+ class MicroTokenRefreshService {
439
+ constructor() {
440
+ this.oauth = inject(OAuthService);
441
+ this.refreshLock = null;
442
+ }
443
+ refresh() {
444
+ this.refreshLock ??= this.oauth
445
+ .refreshToken()
446
+ .then(() => {
447
+ this.refreshLock = null;
448
+ })
449
+ .catch((err) => {
450
+ this.refreshLock = null;
451
+ throw err;
452
+ });
453
+ return this.refreshLock;
454
+ }
455
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroTokenRefreshService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
456
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroTokenRefreshService }); }
457
+ }
458
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.17", ngImport: i0, type: MicroTokenRefreshService, decorators: [{
459
+ type: Injectable
460
+ }] });
461
+
462
+ /**
463
+ * Marks an outgoing request as a micro-app API call.
464
+ *
465
+ * Usage (inside micro components):
466
+ * httpClient.get('/endpoint', { context: new HttpContext().set(IS_MICRO_API, true) })
467
+ *
468
+ * Parent interceptors MUST check this token and call next(req) immediately when true,
469
+ * so they never interfere with micro-app HTTP calls.
470
+ */
471
+ const IS_MICRO_API = new HttpContextToken(() => false);
472
+
473
+ /**
474
+ * Clones the request with the micro-API base URL prepended (if not already absolute)
475
+ * and the current Bearer token attached. No header is added when no token is present
476
+ * (e.g. before first login — the 401 path will then trigger a refresh attempt).
477
+ */
478
+ function withBaseUrlAndToken(req, oauth) {
479
+ const token = oauth.getAccessToken();
480
+ return req.clone({
481
+ url: req.url.startsWith('http') ? req.url : env.microApiBaseUrl + req.url,
482
+ setHeaders: token ? { Authorization: `Bearer ${token}` } : {},
483
+ });
484
+ }
485
+ /**
486
+ * Functional HTTP interceptor scoped to the micro-app child injector.
487
+ *
488
+ * Routing logic:
489
+ * IS_MICRO_API = false → pass through unchanged (covers all angular-oauth2-oidc
490
+ * /authorize, /token, /.well-known/* requests)
491
+ * IS_MICRO_API = true → prepend base URL + attach Bearer, handle 401 with refresh lock
492
+ *
493
+ * 401 handling:
494
+ * 1. Delegate to MicroTokenRefreshService.refresh() — concurrent callers share one Promise.
495
+ * 2. On success retry the original request once with the new token.
496
+ * 3. On refresh failure propagate the original 401 error so the caller (guard / component)
497
+ * can trigger a new login flow.
498
+ */
499
+ const microInterceptor = (req, next) => {
500
+ if (!req.context.get(IS_MICRO_API)) {
501
+ return next(req);
502
+ }
503
+ const oauth = inject(OAuthService);
504
+ const refreshService = inject(MicroTokenRefreshService);
505
+ return next(withBaseUrlAndToken(req, oauth)).pipe(catchError((err) => {
506
+ if (!(err instanceof HttpErrorResponse) || err.status !== 401) {
507
+ return throwError(() => err);
508
+ }
509
+ return from(refreshService.refresh()).pipe(switchMap(() => next(withBaseUrlAndToken(req, oauth))),
510
+ // Refresh failed: surface the original 401 so Phase-5 guard can restart login.
511
+ catchError(() => throwError(() => err)));
512
+ }));
513
+ };
514
+
515
+ /**
516
+ * Builds the AuthConfig from the build-time env file.
517
+ *
518
+ * redirectUri is the web callback URL. MicroAuthGuard overrides it for native
519
+ * by setting oauth.redirectUri = env.oidc.mobileRedirectUri before calling createLoginUrl().
520
+ *
521
+ * useSilentRefresh: false — iframe-based refresh does not work in Capacitor WKWebView.
522
+ * clearHashAfterLogin: false — Ionic uses path-based routing; no hash to clear.
523
+ * strictDiscoveryDocumentValidation: false — custom OIDC servers may not expose every
524
+ * RFC-mandated endpoint; disabling strict validation avoids startup failures.
525
+ */
526
+ function buildAuthConfig() {
527
+ return {
528
+ issuer: env.oidc.issuer,
529
+ clientId: env.oidc.clientId,
530
+ responseType: env.oidc.responseType,
531
+ scope: env.oidc.scope,
532
+ redirectUri: env.oidc.redirectUri,
533
+ useSilentRefresh: false,
534
+ clearHashAfterLogin: false,
535
+ showDebugInformation: env.oidc.showDebugInformation,
536
+ strictDiscoveryDocumentValidation: false,
537
+ };
538
+ }
539
+ /**
540
+ * All providers for the micro-app feature. Registered on the shell route so Angular
541
+ * creates a child EnvironmentInjector — every provider here is scoped to /micro and
542
+ * invisible to the parent application.
543
+ *
544
+ * Provider order matters for tokens that appear multiple times:
545
+ * provideOAuthClient() registers a default OAuthStorage (localStorage).
546
+ * Our CapacitorOAuthStorage entries come AFTER and therefore win.
547
+ */
548
+ function provideMicroApp() {
549
+ return [
550
+ // Child-scoped HttpClient with the micro interceptor.
551
+ // Requests made with this client never reach parent interceptors and vice-versa.
552
+ provideHttpClient(withInterceptors([microInterceptor])),
553
+ // angular-oauth2-oidc: provides OAuthService, token validation, event bus, etc.
554
+ // No resourceServer config — token attachment is handled manually in microInterceptor.
555
+ provideOAuthClient(),
556
+ // Run as soon as the child environment injector is created (first /micro navigation).
557
+ // Two jobs in one callback to avoid a second initializer entry:
558
+ // 1. Configure OAuthService with OIDC settings from the build-time env file.
559
+ // 2. Eagerly construct MicroRefreshSchedulerService so its constructor subscribes
560
+ // to oauth.events immediately — before any token_received event could fire.
561
+ provideEnvironmentInitializer(() => {
562
+ inject(OAuthService).configure(buildAuthConfig());
563
+ inject(MicroRefreshSchedulerService);
564
+ }),
565
+ // Storage layer — must come AFTER provideOAuthClient() to override its localStorage binding.
566
+ CapacitorOAuthStorage,
567
+ { provide: OAuthStorage, useExisting: CapacitorOAuthStorage },
568
+ StorageHydrationService,
569
+ // Reactive 401 refresh lock — shared across all concurrent interceptor invocations.
570
+ MicroTokenRefreshService,
571
+ // Proactive token refresh — schedules refresh REFRESH_AHEAD_MS before expiry.
572
+ MicroRefreshSchedulerService,
573
+ ];
574
+ }
575
+
576
+ const MICRO_ROUTES = [
577
+ {
578
+ path: '',
579
+ component: MicroShellComponent,
580
+ // providers creates a child EnvironmentInjector — all micro providers are scoped here
581
+ // and invisible to the parent application.
582
+ providers: provideMicroApp(),
583
+ children: [
584
+ {
585
+ // No guard: this route completes the OAuth callback.
586
+ // Adding a guard here would cause a redirect loop.
587
+ path: 'callback',
588
+ component: MicroCallbackComponent,
589
+ },
590
+ {
591
+ path: 'home',
592
+ component: MicroHomeComponent,
593
+ canActivate: [microAuthGuard],
594
+ },
595
+ {
596
+ path: '',
597
+ redirectTo: 'home',
598
+ pathMatch: 'full',
599
+ },
600
+ ],
601
+ },
602
+ ];
603
+
604
+ /**
605
+ * Generated bundle index. Do not edit.
606
+ */
607
+
608
+ export { IS_MICRO_API, MICRO_ROUTES, provideMicroApp };
package/index.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { Routes } from '@angular/router';
2
+ import { Provider, EnvironmentProviders } from '@angular/core';
3
+ import { HttpContextToken } from '@angular/common/http';
4
+
5
+ declare const MICRO_ROUTES: Routes;
6
+
7
+ /**
8
+ * All providers for the micro-app feature. Registered on the shell route so Angular
9
+ * creates a child EnvironmentInjector — every provider here is scoped to /micro and
10
+ * invisible to the parent application.
11
+ *
12
+ * Provider order matters for tokens that appear multiple times:
13
+ * provideOAuthClient() registers a default OAuthStorage (localStorage).
14
+ * Our CapacitorOAuthStorage entries come AFTER and therefore win.
15
+ */
16
+ declare function provideMicroApp(): Array<Provider | EnvironmentProviders>;
17
+
18
+ /**
19
+ * Marks an outgoing request as a micro-app API call.
20
+ *
21
+ * Usage (inside micro components):
22
+ * httpClient.get('/endpoint', { context: new HttpContext().set(IS_MICRO_API, true) })
23
+ *
24
+ * Parent interceptors MUST check this token and call next(req) immediately when true,
25
+ * so they never interfere with micro-app HTTP calls.
26
+ */
27
+ declare const IS_MICRO_API: HttpContextToken<boolean>;
28
+
29
+ export { IS_MICRO_API, MICRO_ROUTES, provideMicroApp };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "dhomie-app",
3
+ "version": "0.0.1",
4
+ "peerDependencies": {
5
+ "@angular/common": "^20.3.0",
6
+ "@angular/core": "^20.3.0",
7
+ "@angular/router": "^20.3.0",
8
+ "@capacitor/app": "^7.0.0",
9
+ "@capacitor/browser": "^7.0.0",
10
+ "@capacitor/core": "^7.0.0",
11
+ "@capacitor/preferences": "^7.0.0",
12
+ "@ionic/angular": "^8.0.0",
13
+ "angular-oauth2-oidc": "^20.0.0"
14
+ },
15
+ "dependencies": {
16
+ "tslib": "^2.3.0"
17
+ },
18
+ "sideEffects": false,
19
+ "module": "fesm2022/dhomie-app.mjs",
20
+ "typings": "index.d.ts",
21
+ "exports": {
22
+ "./package.json": {
23
+ "default": "./package.json"
24
+ },
25
+ ".": {
26
+ "types": "./index.d.ts",
27
+ "default": "./fesm2022/dhomie-app.mjs"
28
+ }
29
+ }
30
+ }