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' ;
23import { 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' } )
843export 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+ }
0 commit comments