diff --git a/frontend/app/src/app/app.component.ts b/frontend/app/src/app/app.component.ts index 827a7626..56edb6f9 100644 --- a/frontend/app/src/app/app.component.ts +++ b/frontend/app/src/app/app.component.ts @@ -18,7 +18,7 @@ import {App, URLOpenListenerEvent} from '@capacitor/app'; import {Platform, ToastController} from '@ionic/angular/standalone'; import {SettingsProvider} from './modules/settings/settings.provider'; import {InAppReviewProvider} from './modules/settings/in-app-review/in-app-review.provider'; -import {AuthProvider} from "./modules/auth/auth.provider"; +import {AuthActions, AuthProvider} from "./modules/auth/auth.provider"; import {environment} from '../environments/environment'; import {Capacitor} from '@capacitor/core'; import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.service'; @@ -132,18 +132,21 @@ export class AppComponent implements AfterContentInit { } private async authNotificationsInit() { - this.authProvider - .events$.subscribe(action => this.showMessage(this.authHelper.getAuthMessage(action))); + this.authProvider.events$.subscribe(action => { + const isWarning = action.action === AuthActions.SessionExpired; + + void this.showMessage(this.authHelper.getAuthMessage(action), isWarning ? 'warning' : 'success'); + }); } - private async showMessage(message?: string) { + private async showMessage(message: string | undefined, color: 'success' | 'warning' = 'success') { if (message === undefined) { return; } const toast = await this.toastController.create({ message: message, - duration: 2000, - color: 'success', + duration: color === 'warning' ? 4000 : 2000, + color, }); await toast.present(); } diff --git a/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts b/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts index dedb6f48..73718acb 100644 --- a/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts +++ b/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts @@ -126,10 +126,19 @@ describe('AuthHelperService', () => { ); }); - it('returns undefined for all other actions without translating', () => { - const otherActions = Object.values(AuthActions).filter( - action => action !== AuthActions.SignInSuccess && action !== AuthActions.SignOutSuccess, + it('returns the translated message for SessionExpired', () => { + expect(service.getAuthMessage({action: AuthActions.SessionExpired})).toBe( + `translated:${AUTH_MESSAGE_PREFIX}.session_expired`, ); + }); + + it('returns undefined for all other actions without translating', () => { + const messageActions = new Set([ + AuthActions.SignInSuccess, + AuthActions.SignOutSuccess, + AuthActions.SessionExpired, + ]); + const otherActions = Object.values(AuthActions).filter(action => !messageActions.has(action)); for (const action of otherActions) { expect(service.getAuthMessage({action})).withContext(action).toBeUndefined(); diff --git a/frontend/app/src/app/modules/auth/auth-helper.service.ts b/frontend/app/src/app/modules/auth/auth-helper.service.ts index 546977ea..703a4dbb 100644 --- a/frontend/app/src/app/modules/auth/auth-helper.service.ts +++ b/frontend/app/src/app/modules/auth/auth-helper.service.ts @@ -75,6 +75,11 @@ export class AuthHelperService { `${AUTH_MESSAGE_PREFIX}.logged_out_success`, ); + case AuthActions.SessionExpired: + return this.translateService.instant( + `${AUTH_MESSAGE_PREFIX}.session_expired`, + ); + default: return undefined; } diff --git a/frontend/app/src/app/modules/auth/auth.provider.methods.ts b/frontend/app/src/app/modules/auth/auth.provider.methods.ts index 3ad6d009..617031d8 100644 --- a/frontend/app/src/app/modules/auth/auth.provider.methods.ts +++ b/frontend/app/src/app/modules/auth/auth.provider.methods.ts @@ -41,8 +41,6 @@ export function getClientConfig( const provider = authConfig.default; - console.log(getPlatformScopes(provider.client.scopes)); - return { server_host: provider.client.url, client_id: provider.client.clientId, diff --git a/frontend/app/src/app/modules/auth/auth.provider.spec.ts b/frontend/app/src/app/modules/auth/auth.provider.spec.ts index fdcf94cc..70e9e634 100644 --- a/frontend/app/src/app/modules/auth/auth.provider.spec.ts +++ b/frontend/app/src/app/modules/auth/auth.provider.spec.ts @@ -11,6 +11,8 @@ import {AuthActions, AuthProvider, IAuthAction, INVALID_GRANT, isInvalidGrant} f const TOKEN_RESPONSE_KEY = 'token_response'; const TOKEN_LIFETIME = 3600; +/** Shibboleth OP default access token lifetime. */ +const SHORT_TOKEN_LIFETIME = 600; const NOW_MS = Date.parse('2026-01-01T12:00:00Z'); const NOW = NOW_MS / 1000; @@ -22,19 +24,27 @@ const NOW = NOW_MS / 1000; * refreshToken: null → token without refresh token */ function makeToken( - opts: {accessToken?: string; refreshToken?: string | null; remainingSeconds?: number} = {}, + opts: {accessToken?: string; refreshToken?: string | null; remainingSeconds?: number; lifetime?: number} = {}, ): TokenResponse { - const remaining = opts.remainingSeconds ?? TOKEN_LIFETIME; + const lifetime = opts.lifetime ?? TOKEN_LIFETIME; + const remaining = opts.remainingSeconds ?? lifetime; return new TokenResponse({ access_token: opts.accessToken ?? 'access-token', refresh_token: opts.refreshToken === null ? undefined : (opts.refreshToken ?? 'refresh-token'), token_type: 'bearer', - expires_in: String(TOKEN_LIFETIME), - issued_at: NOW - (TOKEN_LIFETIME - remaining), + expires_in: String(lifetime), + issued_at: NOW - (lifetime - remaining), }); } +/** Lets pending promise callbacks run (the jasmine clock only drives timers). */ +async function flushPromises(): Promise { + for (let index = 0; index < 50; index++) { + await Promise.resolve(); + } +} + /** Typed access to the private members the tests depend on. */ interface AuthProviderInternals { storage: StorageBackend; @@ -167,8 +177,8 @@ describe('AuthProvider', () => { }); it('refreshes the token when the access token expires within the buffer', async () => { - // 5 minutes left, default buffer requires more than 10. - await givenLoadedToken(makeToken({remainingSeconds: 5 * 60})); + // 30 seconds left, the buffer for a 1 hour token is 60 seconds. + await givenLoadedToken(makeToken({remainingSeconds: 30})); tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token'})); const token = await provider.getValidToken(); @@ -177,6 +187,25 @@ describe('AuthProvider', () => { expect(token.accessToken).toBe('new-access-token'); }); + it('does not refresh a fresh short-lived (10 minute) token', async () => { + await givenLoadedToken(makeToken({accessToken: 'current-access-token', lifetime: SHORT_TOKEN_LIFETIME})); + + const token = await provider.getValidToken(); + + expect(tokenRequest).not.toHaveBeenCalled(); + expect(token.accessToken).toBe('current-access-token'); + }); + + it('accepts a freshly refreshed short-lived (10 minute) token', async () => { + await givenLoadedToken(makeToken({remainingSeconds: -60, lifetime: SHORT_TOKEN_LIFETIME})); + tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token', lifetime: SHORT_TOKEN_LIFETIME})); + + const token = await provider.getValidToken(); + + expect(tokenRequest).toHaveBeenCalledTimes(1); + expect(token.accessToken).toBe('new-access-token'); + }); + it('keeps the previous refresh token when the refresh response contains none', async () => { await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: 'old-refresh-token'})); tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token', refreshToken: null})); @@ -338,6 +367,76 @@ describe('AuthProvider', () => { }); }); + describe('expiry timer', () => { + it('logs the user out when the token expires, without any page asking for a token', async () => { + await givenLoadedToken(makeToken({remainingSeconds: 60, refreshToken: null})); + const events = recordEvents(); + + jasmine.clock().tick(59 * 1000); + await flushPromises(); + expect(await isLoggedIn()).toBeTrue(); + + jasmine.clock().tick(2 * 1000); + await flushPromises(); + + expect(await isLoggedIn()).toBeFalse(); + expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY); + expect(events).toEqual([AuthActions.SessionExpired]); + }); + + it('refreshes the token shortly before it expires when a refresh token is available', async () => { + await givenLoadedToken(makeToken({remainingSeconds: 120, lifetime: SHORT_TOKEN_LIFETIME})); + tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token', lifetime: SHORT_TOKEN_LIFETIME})); + + // Due 60 seconds before expiry. + jasmine.clock().tick(61 * 1000); + await flushPromises(); + + expect(tokenRequest).toHaveBeenCalledTimes(1); + expect(internals.tokenSubject.value?.accessToken).toBe('new-access-token'); + expect(await isLoggedIn()).toBeTrue(); + }); + + it('logs the user out when the refresh token is rejected', async () => { + await givenLoadedToken(makeToken({remainingSeconds: 30})); + tokenRequest.and.rejectWith(new AppAuthError(INVALID_GRANT)); + + jasmine.clock().tick(2 * 1000); + await flushPromises(); + + expect(await isLoggedIn()).toBeFalse(); + }); + + it('stays logged in and retries later after a temporary error', async () => { + await givenLoadedToken(makeToken({remainingSeconds: 30})); + tokenRequest.and.rejectWith(new AppAuthError('Network Error')); + + jasmine.clock().tick(2 * 1000); + await flushPromises(); + + expect(await isLoggedIn()).toBeTrue(); + expect(tokenRequest).toHaveBeenCalledTimes(1); + + tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token'})); + jasmine.clock().tick(61 * 1000); + await flushPromises(); + + expect(tokenRequest).toHaveBeenCalledTimes(2); + expect(internals.tokenSubject.value?.accessToken).toBe('new-access-token'); + }); + + it('does not fire after logout', async () => { + await givenLoadedToken(makeToken({remainingSeconds: 60, refreshToken: null})); + await provider.signOut(); + const events = recordEvents(); + + jasmine.clock().tick(2 * 60 * 1000); + await flushPromises(); + + expect(events).not.toContain(AuthActions.SessionExpired); + }); + }); + describe('isInvalidGrant', () => { it('detects invalid_grant as error message', () => { expect(isInvalidGrant(new AppAuthError(INVALID_GRANT))).toBeTrue(); diff --git a/frontend/app/src/app/modules/auth/auth.provider.ts b/frontend/app/src/app/modules/auth/auth.provider.ts index a096af2f..84de231c 100644 --- a/frontend/app/src/app/modules/auth/auth.provider.ts +++ b/frontend/app/src/app/modules/auth/auth.provider.ts @@ -58,7 +58,42 @@ import {storageFactory} from './factories/storage.factory'; const TOKEN_RESPONSE_KEY = 'token_response'; export const INVALID_GRANT: ErrorType = 'invalid_grant'; -const AUTH_EXPIRY_BUFFER = -10 * 60; + +/** + * Refresh at most this many seconds before the access token expires. + */ +const MAX_EARLY_REFRESH_SECONDS = 60; + +/** + * Retry interval after a failed background session check (e.g. offline). + */ +const SESSION_CHECK_RETRY_MS = 60 * 1000; + +/** + * Largest delay setTimeout accepts (about 24.8 days). + */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Returns the AppAuth isValid() buffer (negative seconds) for a token: + * refresh shortly before expiry, but never earlier than after three quarters + * of the token lifetime. A fixed buffer would make short-lived tokens + * (e.g. Shibboleth's default of 10 minutes) count as expired right away. + */ +export function earlyRefreshBuffer( + token: TokenResponse, +): number { + const lifetime = token.expiresIn ?? 0; + + if (lifetime <= 0) { + return 0; + } + + return -Math.min( + MAX_EARLY_REFRESH_SECONDS, + Math.floor(lifetime / 4), + ); +} export interface AuthConfig { server_host: string; @@ -224,6 +259,14 @@ export class AuthProvider { */ private refreshInFlight?: Promise; + /** + * Fires when the current access token expires (or is due for refresh), + * so the UI does not keep showing a session that has already ended. + */ + private expiryTimer?: ReturnType; + + private visibilityListenerInitialized = false; + constructor( private readonly platform: Platform, private readonly configProvider: ConfigProvider, @@ -248,6 +291,13 @@ export class AuthProvider { new BaseTokenRequestHandler( this.requestor, ); + + /* + * Every new (or removed) token reschedules the expiry check. + */ + this.tokenSubject.subscribe(token => { + this.scheduleExpiryCheck(token); + }); } get token$(): @@ -306,6 +356,7 @@ export class AuthProvider { public async init(): Promise { this.setupConfiguration(); this.setupAuthorizationNotifier(); + this.setupVisibilityListener(); await this.loadTokenFromStorage(); } @@ -583,17 +634,26 @@ export class AuthProvider { } } + /** + * Returns a usable access token, refreshing it first if it is expired + * or about to expire. + * @param buffer AppAuth isValid() buffer in (negative) seconds. + * Defaults to earlyRefreshBuffer() of the current token. + */ public async getValidToken( - buffer = AUTH_EXPIRY_BUFFER, + buffer?: number, ): Promise { const currentToken = this.tokenSubject.value; /* - * Access token still valid for longer than the configured - * expiry buffer. + * Access token still valid for longer than the buffer. */ - if (currentToken?.isValid(buffer)) { + if ( + currentToken?.isValid( + buffer ?? earlyRefreshBuffer(currentToken), + ) + ) { return currentToken; } @@ -638,9 +698,13 @@ export class AuthProvider { ); } - if (!refreshedToken.isValid(buffer)) { + /* + * A freshly issued token is usable even if it is short-lived. + * Only reject one that is already expired. + */ + if (!refreshedToken.isValid(0)) { throw new Error( - 'Unable To Obtain Valid Token: Refreshed Token Is Already Too Close To Expiry', + 'Unable To Obtain Valid Token: Refreshed Token Is Already Expired', ); } @@ -699,22 +763,128 @@ export class AuthProvider { private async expireSession( error: unknown, ): Promise { - try { - await this.storage.removeItem( - TOKEN_RESPONSE_KEY, - ); - } catch { - /* - * Still log out in memory if removing the stored token fails. - */ + /* + * Already logged out, e.g. the expiry timer and a request noticed the + * expiry at the same time. Report the expiry only once. + */ + if (!this.tokenSubject.value) { + return; } + /* + * Log out in memory first (synchronously), so parallel callers see + * the session as ended. + */ this.notify({ action: AuthActions.SessionExpired, error, }); + + try { + await this.storage.removeItem( + TOKEN_RESPONSE_KEY, + ); + } catch { + /* + * Still logged out in memory if removing the stored token fails. + */ + } + } + + /** + * Schedules checkSession() for the moment the token expires + * (or is due for refresh when a refresh token is available). + */ + private scheduleExpiryCheck( + token: TokenResponse | undefined, + ): void { + if (this.expiryTimer) { + clearTimeout(this.expiryTimer); + this.expiryTimer = undefined; + } + + if (!token?.expiresIn) { + return; + } + + const buffer = + token.refreshToken + ? earlyRefreshBuffer(token) + : 0; + + const dueAtMs = + (token.issuedAt + token.expiresIn + buffer) * 1000; + + /* + * One extra second so that isValid() is definitely false when + * the timer fires. + */ + const delayMs = + Math.min( + Math.max(0, dueAtMs - Date.now()) + 1000, + MAX_TIMEOUT_MS, + ); + + this.expiryTimer = setTimeout(() => { + this.expiryTimer = undefined; + void this.checkSession(); + }, delayMs); + } + + /** + * Refreshes the token or ends the session if it can no longer be used. + * + * Called by the expiry timer and when the app becomes visible again + * (browsers and mobile OSes pause timers in the background). + */ + private async checkSession(): Promise { + if (!this.tokenSubject.value) { + return; + } + + try { + /* + * Returns immediately if the token is still valid. Otherwise it + * refreshes, or logs out via SessionExpired. + */ + await this.getValidToken(); + } catch { + /* + * Still logged in after a failure means a temporary error + * (e.g. offline): try again later. + */ + if ( + this.tokenSubject.value && + !this.expiryTimer + ) { + this.expiryTimer = setTimeout(() => { + this.expiryTimer = undefined; + void this.checkSession(); + }, SESSION_CHECK_RETRY_MS); + } + } + } + + private setupVisibilityListener(): void { + if ( + this.visibilityListenerInitialized || + typeof document === 'undefined' + ) { + return; + } + + document.addEventListener( + 'visibilitychange', + () => { + if (document.visibilityState === 'visible') { + void this.checkSession(); + } + }, + ); + + this.visibilityListenerInitialized = true; } private setupAuthorizationNotifier(): diff --git a/frontend/app/src/assets/i18n/de.json b/frontend/app/src/assets/i18n/de.json index ad606d17..6caa892e 100644 --- a/frontend/app/src/assets/i18n/de.json +++ b/frontend/app/src/assets/i18n/de.json @@ -63,6 +63,7 @@ "default": { "logged_in_success": "Erfolgreich eingeloggt.", "logged_out_success": "Erfolgreich ausgeloggt.", + "session_expired": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", "log_out_alert": { "header": "Komplett ausloggen", "message": "Du wurdest innerhalb der App ausgeloggt. Soll zudem die Browser Session beendet werden? Wähle im Zweifelsfall \"Ja\"." diff --git a/frontend/app/src/assets/i18n/en.json b/frontend/app/src/assets/i18n/en.json index f2ef9271..955e27c0 100644 --- a/frontend/app/src/assets/i18n/en.json +++ b/frontend/app/src/assets/i18n/en.json @@ -63,6 +63,7 @@ "default": { "logged_in_success": "Successfully logged in.", "logged_out_success": "Successfully logged out.", + "session_expired": "Your session has expired. Please log in again.", "log_out_alert": { "header": "Complete logout", "message": "You are now logged out in your app. Do you want to log out from your identity provider as well? If unsure, please choose \"Yes\"."