Skip to content

Commit fe12372

Browse files
authored
Merge pull request #52 from mucsi96/claude/mobile-token-rejection-auth-woc2E
Add mobile token renewal and PWA support
2 parents 0f1d1cc + 9ae6e47 commit fe12372

11 files changed

Lines changed: 147 additions & 3 deletions

client/public/favicon.png

125 Bytes
Loading
562 Bytes
Loading

client/public/icons/icon-192.png

606 Bytes
Loading

client/public/icons/icon-512.png

2.73 KB
Loading
606 Bytes
Loading
2.73 KB
Loading

client/public/manifest.webmanifest

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "Skeleton App",
3+
"short_name": "Skeleton",
4+
"description": "Reference application demonstrating authentication, AI integration and deployment patterns.",
5+
"start_url": "/",
6+
"scope": "/",
7+
"display": "standalone",
8+
"orientation": "portrait",
9+
"background_color": "#121212",
10+
"theme_color": "#121212",
11+
"icons": [
12+
{
13+
"src": "icons/icon-192.png",
14+
"sizes": "192x192",
15+
"type": "image/png",
16+
"purpose": "any"
17+
},
18+
{
19+
"src": "icons/icon-512.png",
20+
"sizes": "512x512",
21+
"type": "image/png",
22+
"purpose": "any"
23+
},
24+
{
25+
"src": "icons/icon-maskable-192.png",
26+
"sizes": "192x192",
27+
"type": "image/png",
28+
"purpose": "maskable"
29+
},
30+
{
31+
"src": "icons/icon-maskable-512.png",
32+
"sizes": "512x512",
33+
"type": "image/png",
34+
"purpose": "maskable"
35+
}
36+
]
37+
}

client/src/app/app.config.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
1+
import {
2+
ApplicationConfig,
3+
inject,
4+
provideAppInitializer,
5+
provideZoneChangeDetection,
6+
} from '@angular/core';
27
import { provideRouter } from '@angular/router';
38

49
import {
@@ -13,7 +18,9 @@ import { provideAnimationsAsync } from '@angular/platform-browser/animations/asy
1318
import { provideAngularMaterialTheme } from '@mucsi96/angular-material-theme';
1419
import { routes } from './app.routes';
1520
import { errorInterceptor } from './utils/error.interceptor';
21+
import { authRetryInterceptor } from './utils/auth-retry.interceptor';
1622
import { timezoneInterceptor } from './utils/timezone.interceptor';
23+
import { TokenRenewalService } from './utils/token-renewal.service';
1724
import { authInterceptor } from 'angular-auth-oidc-client';
1825
import { provideOidcAuth } from './auth.config';
1926
import {
@@ -31,13 +38,19 @@ export function getAppConfig(environment: EnvironmentConfig): ApplicationConfig
3138
provideZoneChangeDetection({ eventCoalescing: true }),
3239
provideRouter(routes),
3340
provideHttpClient(
34-
withInterceptors([authInterceptor(), timezoneInterceptor, errorInterceptor])
41+
withInterceptors([
42+
errorInterceptor,
43+
authRetryInterceptor,
44+
authInterceptor(),
45+
timezoneInterceptor,
46+
])
3547
),
3648
{ provide: MAT_RIPPLE_GLOBAL_OPTIONS, useValue: globalRippleConfig },
3749
provideAnimationsAsync(),
3850
provideAngularMaterialTheme(),
3951
{ provide: ENVIRONMENT_CONFIG, useValue: environment },
4052
provideOidcAuth(environment),
53+
provideAppInitializer(() => inject(TokenRenewalService).init()),
4154
],
4255
};
4356
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
2+
import { inject } from '@angular/core';
3+
import { OidcSecurityService } from 'angular-auth-oidc-client';
4+
import { catchError, switchMap, throwError } from 'rxjs';
5+
6+
const isApiRequest = (url: string): boolean => /\/api(\/|$)/.test(url);
7+
8+
/**
9+
* Recovers from an expired access token without forcing a full re-login.
10+
*
11+
* If a request to the API comes back 401 (typically because the token expired
12+
* while the app was backgrounded on mobile), silently refresh the session and
13+
* retry the request once. Only if that retry also fails does the error
14+
* propagate to the error interceptor.
15+
*
16+
* Sits outside the bearer-token interceptor so the retried request is re-issued
17+
* with the freshly refreshed token, and inside the error interceptor so a
18+
* successful retry never surfaces a spurious error notification.
19+
*/
20+
export const authRetryInterceptor: HttpInterceptorFn = (req, next) => {
21+
const oidcSecurityService = inject(OidcSecurityService);
22+
23+
return next(req).pipe(
24+
catchError((error: unknown) => {
25+
const is401 = error instanceof HttpErrorResponse && error.status === 401;
26+
27+
if (!is401 || !isApiRequest(req.url)) {
28+
return throwError(() => error);
29+
}
30+
31+
return oidcSecurityService
32+
.forceRefreshSession()
33+
.pipe(switchMap(() => next(req)));
34+
})
35+
);
36+
};
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { DestroyRef, inject, Injectable } from '@angular/core';
2+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
3+
import { OidcSecurityService } from 'angular-auth-oidc-client';
4+
import { fromEvent, merge, throttleTime } from 'rxjs';
5+
6+
/**
7+
* Keeps the access token fresh on mobile.
8+
*
9+
* `angular-auth-oidc-client` schedules silent renewal on a JS timer, but iOS
10+
* freezes timers (and the whole page) while the app is backgrounded - which on
11+
* a phone is most of the time. The scheduled renewal therefore never fires and
12+
* the user returns to an expired token and a forced re-login.
13+
*
14+
* This service renews proactively whenever the app comes back to the
15+
* foreground (the moment iOS un-freezes the page and the user is about to make
16+
* a request anyway), so the hourly access-token expiry stops being noticeable.
17+
*/
18+
@Injectable({ providedIn: 'root' })
19+
export class TokenRenewalService {
20+
private readonly oidcSecurityService = inject(OidcSecurityService);
21+
private readonly destroyRef = inject(DestroyRef);
22+
23+
init(): void {
24+
const visible$ = fromEvent(document, 'visibilitychange');
25+
const focus$ = fromEvent(window, 'focus');
26+
const online$ = fromEvent(window, 'online');
27+
28+
merge(visible$, focus$, online$)
29+
.pipe(
30+
throttleTime(30_000, undefined, { leading: true, trailing: false }),
31+
takeUntilDestroyed(this.destroyRef)
32+
)
33+
.subscribe(() => this.renewIfForeground());
34+
}
35+
36+
private renewIfForeground(): void {
37+
if (document.visibilityState !== 'visible') {
38+
return;
39+
}
40+
41+
this.oidcSecurityService
42+
.isAuthenticated()
43+
.pipe(takeUntilDestroyed(this.destroyRef))
44+
.subscribe((isAuthenticated) => {
45+
if (isAuthenticated) {
46+
this.oidcSecurityService
47+
.forceRefreshSession()
48+
.pipe(takeUntilDestroyed(this.destroyRef))
49+
.subscribe();
50+
}
51+
});
52+
}
53+
}

0 commit comments

Comments
 (0)