This commit is contained in:
Jovan Krunić
2026-10-12 17:20:22 +02:00
parent d1ef5731c7
commit f88e792188
8 changed files with 318 additions and 32 deletions
+9 -6
View File
@@ -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();
}
@@ -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();
@@ -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;
}
@@ -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,
@@ -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<void> {
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();
@@ -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<void>;
/**
* 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<typeof setTimeout>;
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<void> {
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<TokenResponse> {
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<void> {
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<void> {
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():
+1
View File
@@ -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\"."
+1
View File
@@ -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\"."