mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-10-14 13:32:57 +00:00
WIP
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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\"."
|
||||
|
||||
@@ -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\"."
|
||||
|
||||
Reference in New Issue
Block a user