Skip to content

Commit 2a0e9f9

Browse files
authored
Merge pull request #67 from mucsi96/claude/blissful-hypatia-O3iku
Claude/blissful hypatia o3iku
2 parents dcda2cb + a5e1cc4 commit 2a0e9f9

5 files changed

Lines changed: 327 additions & 304 deletions

File tree

client/src/app/app.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { routes } from './app.routes';
2020
import { errorInterceptor } from './utils/error.interceptor';
2121
import { authRetryInterceptor } from './utils/auth-retry.interceptor';
2222
import { timezoneInterceptor } from './utils/timezone.interceptor';
23-
import { TokenRenewalService } from './utils/token-renewal.service';
23+
import { AuthService } from './auth.service';
2424
import { authInterceptor } from 'angular-auth-oidc-client';
2525
import { provideOidcAuth } from './auth.config';
2626
import {
@@ -50,7 +50,7 @@ export function getAppConfig(environment: EnvironmentConfig): ApplicationConfig
5050
provideAngularMaterialTheme(),
5151
{ provide: ENVIRONMENT_CONFIG, useValue: environment },
5252
provideOidcAuth(environment),
53-
provideAppInitializer(() => inject(TokenRenewalService).init()),
53+
provideAppInitializer(() => inject(AuthService).init()),
5454
],
5555
};
5656
}

client/src/app/auth.service.ts

Lines changed: 319 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,313 @@
1-
import { computed, inject, Injectable } from '@angular/core';
1+
import { computed, DestroyRef, inject, Injectable } from '@angular/core';
2+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
23
import { NotificationsService } from '@mucsi96/angular-material-theme';
3-
import { OidcSecurityService } from 'angular-auth-oidc-client';
4+
import {
5+
LoginResponse,
6+
OidcSecurityService,
7+
} from 'angular-auth-oidc-client';
8+
import {
9+
catchError,
10+
defer,
11+
EMPTY,
12+
finalize,
13+
forkJoin,
14+
fromEvent,
15+
merge,
16+
Observable,
17+
of,
18+
shareReplay,
19+
switchMap,
20+
take,
21+
tap,
22+
throttleTime,
23+
} from 'rxjs';
424

5-
@Injectable({
6-
providedIn: 'root',
7-
})
25+
/**
26+
* Single owner of the OIDC session.
27+
*
28+
* Holds everything auth-related so call sites stay thin:
29+
* - signal-shaped state for components (isAuthenticated, userData)
30+
* - cold-start + visibilitychange/focus/online proactive refresh, because
31+
* iOS freezes JS timers when the PWA is backgrounded and the library's
32+
* timer-based silent renewal therefore never fires
33+
* - single-flight `refresh()` - cold-start, foreground transition, the 401
34+
* interceptor and the route guard all share one in-flight
35+
* forceRefreshSession; running them in parallel makes
36+
* angular-auth-oidc-client emit "authCallback incorrect nonce" and reset
37+
* the session
38+
* - `ensureAuthenticated()` - the route guard's decision, which blocks on
39+
* any in-flight refresh so a route never activates with a token that's
40+
* about to be replaced
41+
*/
42+
@Injectable({ providedIn: 'root' })
843
export class AuthService {
944
private readonly notifications = inject(NotificationsService);
10-
private readonly oidcSecurityService = inject(OidcSecurityService);
45+
private readonly oidc = inject(OidcSecurityService);
46+
private readonly destroyRef = inject(DestroyRef);
47+
48+
private inFlightRefresh$: Observable<LoginResponse> | null = null;
1149

1250
readonly isAuthenticated = computed(
13-
() => this.oidcSecurityService.authenticated().isAuthenticated
51+
() => this.oidc.authenticated().isAuthenticated
1452
);
53+
readonly userData = this.oidc.userData;
54+
55+
init(): void {
56+
this.runColdStart();
1557

16-
readonly userData = this.oidcSecurityService.userData;
58+
merge(
59+
fromEvent(document, 'visibilitychange'),
60+
fromEvent(window, 'focus'),
61+
fromEvent(window, 'online')
62+
)
63+
.pipe(
64+
throttleTime(30_000, undefined, { leading: true, trailing: false }),
65+
takeUntilDestroyed(this.destroyRef)
66+
)
67+
.subscribe(() => this.runForegroundRefresh());
68+
}
1769

1870
login(): void {
19-
console.info('[auth] Full re-authentication started (redirect to authority)');
20-
this.oidcSecurityService.authorize();
71+
console.info(
72+
'[auth] Full re-authentication started (redirect to authority)'
73+
);
74+
this.oidc.authorize();
2175
}
2276

2377
logout(): void {
2478
console.info('[auth] Logout started');
25-
this.oidcSecurityService
26-
.logoff()
27-
.subscribe({
28-
error: (err) => this.showError(err),
79+
this.oidc.logoff().subscribe({
80+
error: (err) => this.showError(err),
81+
});
82+
}
83+
84+
/**
85+
* Waits for any in-flight refresh to settle before deciding whether the
86+
* user is authenticated, so a route never renders with a token that is
87+
* about to be replaced. Falls back to silent renewal (when a refresh
88+
* token is present) or a full authority redirect.
89+
*/
90+
ensureAuthenticated(): Observable<boolean> {
91+
return defer(() => this.inFlightRefresh$ ?? of(null)).pipe(
92+
switchMap(() =>
93+
forkJoin({
94+
isAuthenticated: this.oidc.isAuthenticated(),
95+
refreshToken: this.oidc.getRefreshToken(),
96+
})
97+
),
98+
take(1),
99+
switchMap(({ isAuthenticated, refreshToken }) => {
100+
if (isAuthenticated) {
101+
console.info(
102+
'[auth] Auth guard passed - already authenticated, no renewal needed'
103+
);
104+
return of(true);
105+
}
106+
107+
if (!refreshToken) {
108+
console.info(
109+
'[auth] Full re-authentication started - not authenticated and no refresh token in storage'
110+
);
111+
this.oidc.authorize();
112+
return of(false);
113+
}
114+
115+
console.info(
116+
'[auth] Not authenticated but refresh token present - attempting silent renewal before full re-authentication',
117+
JSON.stringify({ refreshTokenLength: refreshToken.length })
118+
);
119+
return this.refresh('guard-silent-renew').pipe(
120+
switchMap((result) => {
121+
if (result?.isAuthenticated) {
122+
console.info(
123+
'[auth] Silent renewal recovered the session - skipping full re-authentication'
124+
);
125+
return of(true);
126+
}
127+
console.warn(
128+
'[auth] Full re-authentication started - silent renewal did not authenticate'
129+
);
130+
this.oidc.authorize();
131+
return of(false);
132+
}),
133+
catchError((error: unknown) => {
134+
console.warn(
135+
'[auth] Full re-authentication started - silent renewal failed',
136+
JSON.stringify({
137+
error: error instanceof Error ? error.message : String(error),
138+
})
139+
);
140+
this.oidc.authorize();
141+
return of(false);
142+
})
143+
);
144+
})
145+
);
146+
}
147+
148+
/**
149+
* Single-flight forceRefreshSession. Concurrent callers join the same
150+
* in-flight Observable; a fresh refresh starts on the next call once the
151+
* previous one has settled.
152+
*/
153+
refresh(reason: string): Observable<LoginResponse> {
154+
if (this.inFlightRefresh$) {
155+
console.info(
156+
'[auth] Refresh requested while another is in flight - joining',
157+
JSON.stringify({ reason })
158+
);
159+
return this.inFlightRefresh$;
160+
}
161+
162+
console.info('[auth] Refresh starting', JSON.stringify({ reason }));
163+
this.inFlightRefresh$ = this.oidc.forceRefreshSession().pipe(
164+
finalize(() => {
165+
this.inFlightRefresh$ = null;
166+
}),
167+
shareReplay({ bufferSize: 1, refCount: false })
168+
);
169+
return this.inFlightRefresh$;
170+
}
171+
172+
private runColdStart(): void {
173+
const url = new URL(window.location.href);
174+
const returnedFromAuthority =
175+
url.searchParams.has('code') || url.searchParams.has('error');
176+
177+
forkJoin({
178+
isAuthenticated: this.oidc.isAuthenticated(),
179+
refreshToken: this.oidc.getRefreshToken(),
180+
accessToken: this.oidc.getAccessToken(),
181+
})
182+
.pipe(takeUntilDestroyed(this.destroyRef))
183+
.subscribe(({ isAuthenticated, refreshToken, accessToken }) => {
184+
const hasRefreshToken = !!refreshToken;
185+
const hasAccessToken = !!accessToken;
186+
187+
console.info(
188+
`[auth] Cold start - ${
189+
hasRefreshToken
190+
? 'refresh token present in storage'
191+
: 'no refresh token in storage'
192+
}`,
193+
JSON.stringify({
194+
isAuthenticated,
195+
hasRefreshToken,
196+
hasAccessToken,
197+
refreshTokenLength: refreshToken?.length ?? 0,
198+
returnedFromAuthority,
199+
displayMode: window.matchMedia?.('(display-mode: standalone)')
200+
.matches
201+
? 'standalone'
202+
: 'browser',
203+
storage: snapshotStorageKeys(),
204+
})
205+
);
206+
207+
if (returnedFromAuthority) {
208+
console.info(
209+
'[auth] Cold start - skipping proactive refresh, OIDC library is completing the authority redirect'
210+
);
211+
return;
212+
}
213+
214+
if (!hasRefreshToken) {
215+
// ensureAuthenticated handles the no-refresh-token path (full re-auth)
216+
return;
217+
}
218+
219+
console.info(
220+
'[auth] Cold start - proactively refreshing access token using stored refresh token'
221+
);
222+
this.refresh('cold-start')
223+
.pipe(
224+
tap((result) =>
225+
console.info(
226+
'[auth] Cold start proactive token refresh completed',
227+
JSON.stringify({
228+
isAuthenticated: result?.isAuthenticated ?? false,
229+
hasAccessToken: !!result?.accessToken,
230+
})
231+
)
232+
),
233+
catchError((error: unknown) => {
234+
console.error(
235+
'[auth] Cold start proactive token refresh failed',
236+
JSON.stringify({
237+
error: error instanceof Error ? error.message : String(error),
238+
})
239+
);
240+
return EMPTY;
241+
}),
242+
takeUntilDestroyed(this.destroyRef)
243+
)
244+
.subscribe();
245+
});
246+
}
247+
248+
private runForegroundRefresh(): void {
249+
if (document.visibilityState !== 'visible') {
250+
return;
251+
}
252+
253+
forkJoin({
254+
isAuthenticated: this.oidc.isAuthenticated(),
255+
refreshToken: this.oidc.getRefreshToken(),
256+
accessToken: this.oidc.getAccessToken(),
257+
})
258+
.pipe(takeUntilDestroyed(this.destroyRef))
259+
.subscribe(({ isAuthenticated, refreshToken, accessToken }) => {
260+
const hasRefreshToken = !!refreshToken;
261+
const hasAccessToken = !!accessToken;
262+
263+
console.info(
264+
`[auth] App returned to foreground - refresh token ${
265+
hasRefreshToken ? 'present in storage' : 'not in storage'
266+
}`,
267+
JSON.stringify({
268+
isAuthenticated,
269+
hasRefreshToken,
270+
hasAccessToken,
271+
refreshTokenLength: refreshToken?.length ?? 0,
272+
// If iOS evicted the storage the whole OIDC entry disappears, not
273+
// just the refresh token - the surviving key names reveal which.
274+
storage: snapshotStorageKeys(),
275+
})
276+
);
277+
278+
if (!hasRefreshToken) {
279+
console.warn(
280+
'[auth] App returned to foreground with no refresh token in storage - silent renewal impossible, full re-authentication will be required'
281+
);
282+
return;
283+
}
284+
285+
console.info(
286+
'[auth] Proactively refreshing access token using stored refresh token'
287+
);
288+
this.refresh('foreground')
289+
.pipe(
290+
tap((result) =>
291+
console.info(
292+
'[auth] Proactive foreground token refresh completed',
293+
JSON.stringify({
294+
isAuthenticated: result?.isAuthenticated ?? false,
295+
hasAccessToken: !!result?.accessToken,
296+
})
297+
)
298+
),
299+
catchError((error: unknown) => {
300+
console.error(
301+
'[auth] Proactive foreground token refresh failed',
302+
JSON.stringify({
303+
error: error instanceof Error ? error.message : String(error),
304+
})
305+
);
306+
return EMPTY;
307+
}),
308+
takeUntilDestroyed(this.destroyRef)
309+
)
310+
.subscribe();
29311
});
30312
}
31313

@@ -34,3 +316,26 @@ export class AuthService {
34316
this.notifications.error('An error occurred. ' + message);
35317
}
36318
}
319+
320+
/**
321+
* Snapshots which keys currently live in local/session storage (names only,
322+
* never values). When iOS reclaims storage for a backgrounded PWA the OIDC
323+
* entry vanishes entirely, so a shrinking/empty key list across foreground
324+
* events is the fingerprint of eviction rather than a normal token expiry.
325+
*/
326+
function snapshotStorageKeys(): {
327+
localStorageKeys: string[];
328+
sessionStorageKeys: string[];
329+
} {
330+
const keysOf = (store: Storage): string[] => {
331+
try {
332+
return Object.keys(store);
333+
} catch {
334+
return ['<unavailable>'];
335+
}
336+
};
337+
return {
338+
localStorageKeys: keysOf(localStorage),
339+
sessionStorageKeys: keysOf(sessionStorage),
340+
};
341+
}

client/src/app/utils/auth-retry.interceptor.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
22
import { inject } from '@angular/core';
3-
import { OidcSecurityService } from 'angular-auth-oidc-client';
43
import { catchError, switchMap, tap, throwError } from 'rxjs';
4+
import { AuthService } from '../auth.service';
55

66
const isApiRequest = (url: string): boolean => /\/api(\/|$)/.test(url);
77

@@ -18,7 +18,7 @@ const isApiRequest = (url: string): boolean => /\/api(\/|$)/.test(url);
1818
* successful retry never surfaces a spurious error notification.
1919
*/
2020
export const authRetryInterceptor: HttpInterceptorFn = (req, next) => {
21-
const oidcSecurityService = inject(OidcSecurityService);
21+
const auth = inject(AuthService);
2222

2323
return next(req).pipe(
2424
catchError((error: unknown) => {
@@ -33,7 +33,7 @@ export const authRetryInterceptor: HttpInterceptorFn = (req, next) => {
3333
JSON.stringify({ url: req.url })
3434
);
3535

36-
return oidcSecurityService.forceRefreshSession().pipe(
36+
return auth.refresh('http-401').pipe(
3737
tap((result) =>
3838
console.info(
3939
'[auth] Token refreshed after 401 - retrying original request',

0 commit comments

Comments
 (0)