diff --git a/backend/backend/config/f-u/backendrc.js b/backend/backend/config/f-u/backendrc.js
index 8138cdc2..c6b2b61b 100644
--- a/backend/backend/config/f-u/backendrc.js
+++ b/backend/backend/config/f-u/backendrc.js
@@ -13,23 +13,24 @@ const config = {
default: {
client: {
clientId: '1cac3f99-33fa-4234-8438-979f07e0cdab',
- scopes: '',
- url: 'https://cas.rz.uni-frankfurt.de/cas/oauth2.0',
+ scopes: 'openid profile email offline_access',
+ url: 'https://idp.ub.uni-frankfurt.de/idp/profile/oidc',
},
endpoints: {
- authorization: 'https://cas.rz.uni-frankfurt.de/cas/oauth2.0/authorize',
- endSession: 'https://cas.rz.uni-frankfurt.de/cas/logout',
+ authorization: 'https://idp.ub.uni-frankfurt.de/idp/profile/oidc/authorize',
+ endSession: 'https://idp.ub.uni-frankfurt.de/idp/profile/oidc/end-session',
mapping: {
- id: '$.id',
- email: '$.attributes.mailPrimaryAddress',
- familyName: '$.attributes.sn',
- givenName: '$.attributes.givenName',
- name: '$.attributes.givenName',
- role: '$.attributes.eduPersonPrimaryAffiliation',
- studentId: '$.attributes.employeeNumber',
+ id: '$.preferred_username',
+ email: '$.mailPrimaryAddress',
+ familyName: '$.family_name',
+ givenName: '$.given_name',
+ name: '$.name',
+ role: '$.eduPersonPrimaryAffiliation',
+ studentId: '$.employeeNumber',
},
- token: 'https://cas.rz.uni-frankfurt.de/cas/oauth2.0/accessToken',
- userinfo: 'https://cas.rz.uni-frankfurt.de/cas/oauth2.0/profile',
+ token: 'https://idp.ub.uni-frankfurt.de/idp/profile/oidc/token',
+ userinfo: 'https://idp.ub.uni-frankfurt.de/idp/profile/oidc/userinfo',
+ revoke: 'https://idp.ub.uni-frankfurt.de/idp/profile/oauth2/revocation',
},
},
},
diff --git a/frontend/app/__mocks__/@capacitor/core.ts b/frontend/app/__mocks__/@capacitor/core.ts
new file mode 100644
index 00000000..fe88d238
--- /dev/null
+++ b/frontend/app/__mocks__/@capacitor/core.ts
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2026 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+
+/*
+ * tsconfig.spec.json maps every `@capacitor/*` import to this folder, so this
+ * file replaces `@capacitor/core` in all tests – including the imports made by
+ * real Capacitor plugins (registerPlugin, WebPlugin, CapacitorHttp, …).
+ *
+ * Therefore everything is re-exported from the real package (imported via a
+ * relative path, which bypasses the path mapping) and only `Capacitor` is
+ * replaced by a controllable mock. The local export shadows the one from
+ * `export *`.
+ */
+export * from '../../node_modules/@capacitor/core';
+
+type Platform = 'web' | 'ios' | 'android';
+
+export class CapacitorMock {
+ private platform: Platform = 'web';
+
+ getPlatform(): Platform {
+ return this.platform;
+ }
+
+ isNativePlatform(): boolean {
+ return this.platform !== 'web';
+ }
+
+ isPluginAvailable(_name: string): boolean {
+ return this.isNativePlatform();
+ }
+
+ convertFileSrc(filePath: string): string {
+ return filePath;
+ }
+
+ /**
+ * Test helper: simulate running on the given platform.
+ */
+ setPlatform(platform: Platform): void {
+ this.platform = platform;
+ }
+
+ /**
+ * Test helper: back to the default (web), call this in afterEach.
+ */
+ reset(): void {
+ this.platform = 'web';
+ }
+}
+
+export const Capacitor = new CapacitorMock();
diff --git a/frontend/app/package.json b/frontend/app/package.json
index 659eebf4..fb2800e2 100644
--- a/frontend/app/package.json
+++ b/frontend/app/package.json
@@ -81,7 +81,7 @@
"@maplibre/ngx-maplibre-gl": "22.1.0",
"@ngx-translate/core": "15.0.0",
"@ngx-translate/http-loader": "8.0.0",
- "@openid/appauth": "1.3.2",
+ "@openid/appauth": "1.4.0",
"@openstapps/api": "workspace:*",
"@openstapps/collection-utils": "workspace:*",
"@openstapps/core": "workspace:*",
@@ -93,7 +93,6 @@
"deepmerge": "4.3.1",
"form-data": "4.0.6",
"geojson": "0.5.0",
- "ionic-appauth": "2.1.0",
"ionicons": "8.1.0",
"jsonpath-plus": "10.3.0",
"maplibre-gl": "6.4.1",
diff --git a/frontend/app/src/app/_helpers/data/sample-configuration.ts b/frontend/app/src/app/_helpers/data/sample-configuration.ts
index ee2bf03e..779c2360 100644
--- a/frontend/app/src/app/_helpers/data/sample-configuration.ts
+++ b/frontend/app/src/app/_helpers/data/sample-configuration.ts
@@ -73,7 +73,6 @@ export const sampleAggregations: SCBackendAggregationConfiguration[] = [
export const sampleAuthConfiguration: {
default: SCAuthorizationProvider;
- paia: SCAuthorizationProvider;
} = {
default: {
client: {clientId: '', scopes: '', url: ''},
@@ -84,15 +83,6 @@ export const sampleAuthConfiguration: {
userinfo: '',
},
},
- paia: {
- client: {clientId: '', scopes: '', url: ''},
- endpoints: {
- authorization: '',
- mapping: {id: '', name: ''},
- token: '',
- userinfo: '',
- },
- },
};
export const sampleDefaultPolygon: Polygon = {
diff --git a/frontend/app/src/app/app.component.ts b/frontend/app/src/app/app.component.ts
index a5ecf0bb..495d483a 100644
--- a/frontend/app/src/app/app.component.ts
+++ b/frontend/app/src/app/app.component.ts
@@ -18,16 +18,18 @@ 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 {AuthHelperService} from './modules/auth/auth-helper.service';
+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';
import {Keyboard, KeyboardResize} from '@capacitor/keyboard';
import {AppVersionService} from './modules/about/app-version.service';
import {SplashScreen} from '@capacitor/splash-screen';
+import {AuthHelperService} from "./modules/auth/auth-helper.service";
/**
- * TODO
+ * Root component of the application responsible for global initializations,
+ * deep linking, splash screen management, and global event bindings.
*/
@Component({
selector: 'app-root',
@@ -45,6 +47,8 @@ export class AppComponent implements AfterContentInit {
private readonly zone = inject(NgZone);
+ private readonly authProvider = inject(AuthProvider);
+
private readonly authHelper = inject(AuthHelperService);
private readonly toastController = inject(ToastController);
@@ -129,19 +133,21 @@ export class AppComponent implements AfterContentInit {
}
private async authNotificationsInit() {
- this.authHelper
- .getProvider()
- .events$.subscribe(action => this.showMessage(this.authHelper.getAuthMessage('default', 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/app.module.ts b/frontend/app/src/app/app.module.ts
index 894e72f4..9f974b7c 100644
--- a/frontend/app/src/app/app.module.ts
+++ b/frontend/app/src/app/app.module.ts
@@ -57,7 +57,6 @@ import {AssessmentsModule} from './modules/assessments/assessments.module';
import {ServiceHandlerInterceptor} from './_helpers/service-handler.interceptor';
import {RoutingStackService} from './util/routing-stack.service';
import {SCLanguageCode, SCSettingValue} from '@openstapps/core';
-import {DefaultAuthService} from './modules/auth/default-auth.service';
import {NavigationModule} from './modules/menu/navigation/navigation.module';
import {browserFactory, SimpleBrowser} from './util/browser.factory';
import {getDateFnsLocale} from './translation/dfns-locale';
@@ -67,6 +66,7 @@ import {Capacitor} from '@capacitor/core';
import {SplashScreen} from '@capacitor/splash-screen';
import * as maplibregl from 'maplibre-gl';
import {Protocol} from 'pmtiles';
+import {AuthProvider} from "./modules/auth/auth.provider";
registerLocaleData(localeDe);
@@ -82,7 +82,7 @@ export function initializerFactory(
configProvider: ConfigProvider,
translateService: TranslateService,
_routingStackService: RoutingStackService,
- defaultAuthService: DefaultAuthService,
+ authProvider: AuthProvider,
dateFnsConfigurationService: DateFnsConfigurationService,
) {
return async () => {
@@ -111,7 +111,7 @@ export function initializerFactory(
setDefaultOptions({locale: dateFnsLocale});
dateFnsConfigurationService.setLocale(dateFnsLocale);
- await defaultAuthService.init();
+ await authProvider.init();
} catch (error) {
logger.warn(error);
}
@@ -201,7 +201,7 @@ export function createTranslateLoader(http: HttpClient) {
ConfigProvider,
TranslateService,
RoutingStackService,
- DefaultAuthService,
+ AuthProvider,
DateFnsConfigurationService,
],
useFactory: initializerFactory,
diff --git a/frontend/app/src/app/modules/assessments/assessments.provider.ts b/frontend/app/src/app/modules/assessments/assessments.provider.ts
index 6c419e75..e6528267 100644
--- a/frontend/app/src/app/modules/assessments/assessments.provider.ts
+++ b/frontend/app/src/app/modules/assessments/assessments.provider.ts
@@ -15,9 +15,9 @@
import {Injectable, inject} from '@angular/core';
import {ConfigProvider} from '../config/config.provider';
import {SCAssessment, SCUuid} from '@openstapps/core';
-import {DefaultAuthService} from '../auth/default-auth.service';
import {HttpClient} from '@angular/common/http';
import {uniqBy, keyBy} from '@openstapps/collection-utils';
+import {AuthProvider} from "../auth/auth.provider";
/**
*
@@ -53,7 +53,7 @@ export function toAssessmentMap(data: SCAssessment[]): Record(`${url}/${this.assessmentPath}`, {
headers: {
- Authorization: `Bearer ${accessToken ?? (await this.defaultAuth.getValidToken()).accessToken}`,
+ Authorization: `Bearer ${accessToken ?? (await this.authProvider.getValidToken()).accessToken}`,
},
})
.toPromise()
diff --git a/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts
new file mode 100644
index 00000000..3ec0534b
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2026 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import {TestBed} from '@angular/core/testing';
+import {Router} from '@angular/router';
+import {NavController} from '@ionic/angular/standalone';
+import {Subject} from 'rxjs';
+import {AuthActions, AuthProvider, IAuthAction} from '../../auth.provider';
+import {AuthHelperService} from '../../auth-helper.service';
+import {AuthCallbackPageComponent} from './auth-callback-page.component';
+
+/** Lets pending promise callbacks run. */
+async function flushPromises(): Promise {
+ for (let index = 0; index < 10; index++) {
+ await Promise.resolve();
+ }
+}
+
+/** Creates the page like the router does when the IdP redirects back. */
+function openCallbackPage(): AuthCallbackPageComponent {
+ return TestBed.runInInjectionContext(() => new AuthCallbackPageComponent());
+}
+
+describe('AuthCallbackPageComponent', () => {
+ let events: Subject;
+ let authProvider: {events$: Subject; authorizationCallback: jasmine.Spy};
+ let authHelper: jasmine.SpyObj>;
+ let navController: jasmine.SpyObj>;
+
+ beforeEach(() => {
+ events = new Subject();
+ authProvider = {events$: events, authorizationCallback: jasmine.createSpy('authorizationCallback')};
+
+ authHelper = jasmine.createSpyObj('AuthHelperService', ['getOriginPath', 'deleteOriginPath']);
+ authHelper.deleteOriginPath.and.resolveTo();
+
+ navController = jasmine.createSpyObj('NavController', ['navigateRoot']);
+ navController.navigateRoot.and.resolveTo(true);
+
+ TestBed.configureTestingModule({
+ providers: [
+ {provide: AuthProvider, useValue: authProvider},
+ {provide: AuthHelperService, useValue: authHelper},
+ {provide: NavController, useValue: navController},
+ {provide: Router, useValue: {}},
+ ],
+ });
+ });
+
+ it('hands the callback URL to AuthProvider', () => {
+ openCallbackPage();
+
+ expect(authProvider.authorizationCallback).toHaveBeenCalledOnceWith(window.location.href);
+ });
+
+ it('goes back to the page the login was started from', async () => {
+ authHelper.getOriginPath.and.resolveTo('/assessments');
+ openCallbackPage();
+
+ events.next({action: AuthActions.SignInSuccess});
+ await flushPromises();
+
+ expect(navController.navigateRoot).toHaveBeenCalledOnceWith('/assessments');
+ expect(authHelper.deleteOriginPath).toHaveBeenCalled();
+ });
+
+ it('goes to the profile after login when no origin page is known', async () => {
+ openCallbackPage();
+
+ events.next({action: AuthActions.SignInSuccess});
+ await flushPromises();
+
+ expect(navController.navigateRoot).toHaveBeenCalledOnceWith('profile');
+ });
+
+ it('goes to the profile when the login failed', async () => {
+ openCallbackPage();
+
+ events.next({action: AuthActions.SignInFailed, error: new Error('access_denied')});
+ await flushPromises();
+
+ expect(navController.navigateRoot).toHaveBeenCalledOnceWith('profile');
+ expect(authHelper.getOriginPath).not.toHaveBeenCalled();
+ });
+
+ it('ignores other auth events', async () => {
+ openCallbackPage();
+
+ events.next({action: AuthActions.RefreshSuccess});
+ events.next({action: AuthActions.LoadUserInfoSuccess});
+ await flushPromises();
+
+ expect(navController.navigateRoot).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts
index d3a76cd7..d3d7a18b 100644
--- a/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts
+++ b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts
@@ -13,13 +13,16 @@
* this program. If not, see .
*/
import {Component, inject} from '@angular/core';
-import {NavController} from '@ionic/angular/standalone';
import {Router} from '@angular/router';
-import {AuthActions, IAuthAction} from 'ionic-appauth';
-import {AuthHelperService} from '../../auth-helper.service';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
-import {Observable} from 'rxjs';
-import {DefaultAuthService} from '../../default-auth.service';
+import {NavController} from '@ionic/angular/standalone';
+
+import {
+ AuthActions,
+ AuthProvider,
+ IAuthAction,
+} from '../../auth.provider';
+import {AuthHelperService} from '../../auth-helper.service';
@Component({
templateUrl: 'auth-callback-page.component.html',
@@ -33,23 +36,42 @@ export class AuthCallbackPageComponent {
private authHelper = inject(AuthHelperService);
- private auth = inject(DefaultAuthService);
+ private authProvider = inject(AuthProvider);
constructor() {
- const events: Observable = this.auth.events$;
+ this.authProvider.events$
+ .pipe(takeUntilDestroyed())
+ .subscribe(action => {
+ void this.postCallback(action);
+ });
- events.pipe(takeUntilDestroyed()).subscribe((action: IAuthAction) => this.postCallback(action));
- this.auth.authorizationCallback(window.location.origin + this.router.url);
+ this.authProvider.authorizationCallback(
+ window.location.href,
+ );
}
- async postCallback(action: IAuthAction) {
- if (action.action === AuthActions.SignInSuccess) {
- const originPath = await this.authHelper.getOriginPath();
- this.navCtrl.navigateRoot(originPath ?? 'profile');
- this.authHelper.deleteOriginPath();
- }
- if (action.action === AuthActions.SignInFailed) {
- this.navCtrl.navigateRoot('profile');
+ private async postCallback(
+ action: IAuthAction,
+ ): Promise {
+ switch (action.action) {
+ case AuthActions.SignInSuccess: {
+ const originPath =
+ await this.authHelper.getOriginPath();
+
+ await this.authHelper.deleteOriginPath();
+
+ await this.navCtrl.navigateRoot(
+ originPath ?? 'profile',
+ );
+
+ break;
+ }
+
+ case AuthActions.SignInFailed: {
+ await this.navCtrl.navigateRoot('profile');
+
+ break;
+ }
}
}
}
diff --git a/frontend/app/src/app/modules/auth/auth-guard.service.ts b/frontend/app/src/app/modules/auth/auth-guard.service.ts
index a700f532..ead61617 100644
--- a/frontend/app/src/app/modules/auth/auth-guard.service.ts
+++ b/frontend/app/src/app/modules/auth/auth-guard.service.ts
@@ -16,13 +16,13 @@
import {Injectable, inject} from '@angular/core';
import {CanActivate, NavigationExtras, Router, RouterStateSnapshot} from '@angular/router';
import {ActivatedProtectedRouteSnapshot} from './protected.routes';
-import {AuthHelperService} from './auth-helper.service';
+import {AuthProvider} from "./auth.provider";
@Injectable({
providedIn: 'root',
})
export class AuthGuardService implements CanActivate {
- private authHelper = inject(AuthHelperService);
+ private authProvider = inject(AuthProvider);
private router = inject(Router);
@@ -32,7 +32,7 @@ export class AuthGuardService implements CanActivate {
}
try {
- await this.authHelper.getProvider().getValidToken();
+ await this.authProvider.getValidToken();
} catch {
const originNavigation = this.router.currentNavigation();
let extras: NavigationExtras = {};
@@ -41,7 +41,7 @@ export class AuthGuardService implements CanActivate {
extras = {queryParams: {origin_path: url}};
}
this.router.navigate(['profile'], extras);
- await this.authHelper.getProvider().signIn();
+ await this.authProvider.signIn();
return false;
}
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 69a98d1a..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
@@ -1,118 +1,274 @@
-/*
- * Copyright (C) 2023 StApps
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, version 3.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
import {TestBed} from '@angular/core/testing';
-import {AuthHelperService} from './auth-helper.service';
+import {AlertController} from '@ionic/angular/standalone';
+import {TranslateService} from '@ngx-translate/core';
+
+import {SimpleBrowser} from '../../util/browser.factory';
import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider';
-import {DefaultAuthService} from './default-auth.service';
-import {Browser} from 'ionic-appauth';
-import {Requestor, StorageBackend} from '@openid/appauth';
-import {TranslateService} from '@ngx-translate/core';
-import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
-import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
-import {SimpleBrowser} from '../../util/browser.factory';
-import {LoggerTestingModule} from 'ngx-logger/testing';
+import {AuthActions, AuthProvider} from './auth.provider';
+import {AuthHelperService} from './auth-helper.service';
+
+const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path';
+const AUTH_MESSAGE_PREFIX = 'auth.messages.default';
+const END_SESSION_ENDPOINT = 'https://idp.example.org/logout';
+
+const USER_MAPPING = {
+ id: '$.sub',
+ givenName: '$.given_name',
+ familyName: '$.family_name',
+ email: '$.attributes.mail',
+ role: '$.role',
+};
+
+interface TestAlertButton {
+ text: string;
+ role?: string;
+ cssClass?: string;
+ handler?: () => Promise | void;
+}
describe('AuthHelperService', () => {
- let authHelperService: AuthHelperService;
- const storageProviderSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put', 'search']);
- const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
- const defaultAuthServiceMock = jasmine.createSpyObj('DefaultAuthService', ['init', 'setupConfiguration']);
- const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['constructor']);
- const simpleBrowserMock = jasmine.createSpyObj('SimpleBrowser', ['open']);
- const configProvider = jasmine.createSpyObj('ConfigProvider', {
- getAnyValue: {
- default: {
- endpoints: {
- mapping: {
- id: '$.id',
- email: '$.attributes.mailPrimaryAddress',
- givenName: '$.attributes.givenName',
- familyName: '$.attributes.sn',
- name: '$.attributes.sn',
- role: '$.attributes.eduPersonPrimaryAffiliation',
- studentId: '$.attributes.employeeNumber',
- },
- },
- },
- },
- });
+ let service: AuthHelperService;
- beforeEach(() => {
+ let translateService: jasmine.SpyObj<{instant(key: string): string}>;
+ let configProvider: jasmine.SpyObj<{getAnyValue(key: string): unknown}>;
+ let storageProvider: jasmine.SpyObj<{
+ get(key: string): Promise;
+ put(key: string, value: unknown): Promise;
+ delete(key: string): Promise;
+ }>;
+ let authProvider: jasmine.SpyObj>;
+ let browser: jasmine.SpyObj<{open(url: string): Promise}>;
+ let alertController: jasmine.SpyObj<{create(options: unknown): Promise}>;
+ let alert: jasmine.SpyObj<{present(): Promise}>;
+
+ function createService(authConfig: unknown = {default: {endpoints: {mapping: USER_MAPPING}}}) {
+ configProvider.getAnyValue.and.returnValue(authConfig);
+
+ // AuthHelperService uses inject(), so it has to be created inside an injection context.
+ TestBed.resetTestingModule();
TestBed.configureTestingModule({
- imports: [LoggerTestingModule],
providers: [
- StAppsWebHttpClient,
- {
- provide: TranslateService,
- useValue: translateServiceSpy,
- },
- {
- provide: StorageProvider,
- useValue: storageProviderSpy,
- },
- {
- provider: DefaultAuthService,
- useValue: defaultAuthServiceMock,
- },
- {
- provide: ConfigProvider,
- useValue: configProvider,
- },
- Browser,
- StorageBackend,
- Requestor,
- {
- provider: AuthHelperService,
- useValue: authHelperServiceMock,
- },
- {
- provide: SimpleBrowser,
- useValue: simpleBrowserMock,
- },
- provideHttpClient(withInterceptorsFromDi()),
+ AuthHelperService,
+ {provide: TranslateService, useValue: translateService},
+ {provide: ConfigProvider, useValue: configProvider},
+ {provide: StorageProvider, useValue: storageProvider},
+ {provide: AuthProvider, useValue: authProvider},
+ {provide: SimpleBrowser, useValue: browser},
+ {provide: AlertController, useValue: alertController},
],
});
- authHelperService = TestBed.inject(AuthHelperService);
+
+ return TestBed.inject(AuthHelperService);
+ }
+
+ /** Options passed to the most recent AlertController.create() call. */
+ function lastAlertOptions(): {header: string; message: string; buttons: TestAlertButton[]} {
+ return alertController.create.calls.mostRecent().args[0] as any;
+ }
+
+ function alertButton(role: string): TestAlertButton {
+ const button = lastAlertOptions().buttons.find(b => b.role === role);
+ if (!button) {
+ throw new Error(`No alert button with role "${role}"`);
+ }
+ return button;
+ }
+
+ beforeEach(() => {
+ translateService = jasmine.createSpyObj('TranslateService', ['instant']);
+ translateService.instant.and.callFake((key: string) => `translated:${key}`);
+
+ configProvider = jasmine.createSpyObj('ConfigProvider', ['getAnyValue']);
+
+ storageProvider = jasmine.createSpyObj('StorageProvider', ['get', 'put', 'delete']);
+ storageProvider.put.and.resolveTo();
+ storageProvider.delete.and.resolveTo();
+
+ authProvider = jasmine.createSpyObj('AuthProvider', ['getEndSessionEndpoint']);
+ authProvider.getEndSessionEndpoint.and.resolveTo(END_SESSION_ENDPOINT);
+
+ browser = jasmine.createSpyObj('SimpleBrowser', ['open']);
+ browser.open.and.resolveTo();
+
+ alert = jasmine.createSpyObj('HTMLIonAlertElement', ['present']);
+ alert.present.and.resolveTo();
+
+ alertController = jasmine.createSpyObj('AlertController', ['create']);
+ alertController.create.and.resolveTo(alert);
+
+ service = createService();
+ });
+
+ describe('userConfigurationMap', () => {
+ it('reads the user mapping from the default auth provider', () => {
+ expect(configProvider.getAnyValue).toHaveBeenCalledWith('auth');
+ expect(service.userConfigurationMap).toEqual(USER_MAPPING as any);
+ });
+
+ it('falls back to an empty mapping when no default auth provider is configured', () => {
+ service = createService({});
+
+ expect(service.userConfigurationMap).toEqual({} as any);
+ });
+ });
+
+ describe('getAuthMessage', () => {
+ it('returns the translated message for SignInSuccess', () => {
+ expect(service.getAuthMessage({action: AuthActions.SignInSuccess})).toBe(
+ `translated:${AUTH_MESSAGE_PREFIX}.logged_in_success`,
+ );
+ });
+
+ it('returns the translated message for SignOutSuccess', () => {
+ expect(service.getAuthMessage({action: AuthActions.SignOutSuccess})).toBe(
+ `translated:${AUTH_MESSAGE_PREFIX}.logged_out_success`,
+ );
+ });
+
+ 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();
+ }
+ expect(translateService.instant).not.toHaveBeenCalled();
+ });
});
describe('getUserFromUserInfo', () => {
- it('should provide user configuration from userInfo', async () => {
- const userConfiguration = authHelperService.getUserFromUserInfo({
- attributes: {
- eduPersonPrimaryAffiliation: 'student',
- employeeNumber: '123456',
- givenName: 'Erika',
- mailPrimaryAddress: 'emuster@anyschool.de',
- oauthClientId: '123-abc-123',
- sn: 'Musterfrau',
- uid: 'emuster',
- },
- id: 'emuster',
- client_id: '123-abc-123',
+ it('maps user info fields via the configured JSONPath mapping', () => {
+ const user = service.getUserFromUserInfo({
+ sub: 'user-123',
+ given_name: 'Erika',
+ family_name: 'Mustermann',
+ attributes: {mail: 'erika@example.org'},
+ role: 'employee',
});
- expect(userConfiguration).toEqual({
- id: 'emuster',
- givenName: 'Erika',
- familyName: 'Musterfrau',
- name: 'Erika Musterfrau',
- email: 'emuster@anyschool.de',
+ expect(user).toEqual(
+ jasmine.objectContaining({
+ id: 'user-123',
+ givenName: 'Erika',
+ familyName: 'Mustermann',
+ email: 'erika@example.org',
+ role: 'employee',
+ }),
+ );
+ });
+
+ it('builds the name from given name and family name', () => {
+ const user = service.getUserFromUserInfo({
+ sub: 'user-123',
+ given_name: 'Erika',
+ family_name: 'Mustermann',
+ role: 'student',
+ });
+
+ expect(user.name).toBe('Erika Mustermann');
+ });
+
+ it('keeps the default name when the family name is missing', () => {
+ const user = service.getUserFromUserInfo({
+ sub: 'user-123',
+ given_name: 'Erika',
+ role: 'student',
+ });
+
+ expect(user.name).toBe('');
+ });
+
+ it('returns the default user when no mapping is configured', () => {
+ service = createService({});
+
+ expect(service.getUserFromUserInfo({sub: 'user-123'})).toEqual({
+ id: '',
+ name: '',
role: 'student',
- studentId: '123456',
});
});
});
+
+ describe('origin path', () => {
+ it('stores the origin path', async () => {
+ await service.setOriginPath('/profile');
+
+ expect(storageProvider.put).toHaveBeenCalledOnceWith(AUTH_ORIGIN_PATH, '/profile');
+ });
+
+ it('returns the stored origin path', async () => {
+ storageProvider.get.and.resolveTo('/profile');
+
+ expect(await service.getOriginPath()).toBe('/profile');
+ expect(storageProvider.get).toHaveBeenCalledOnceWith(AUTH_ORIGIN_PATH);
+ });
+
+ it('returns undefined when reading the origin path fails', async () => {
+ storageProvider.get.and.rejectWith(new Error('Value not found'));
+
+ expect(await service.getOriginPath()).toBeUndefined();
+ });
+
+ it('deletes the origin path', async () => {
+ await service.deleteOriginPath();
+
+ expect(storageProvider.delete).toHaveBeenCalledOnceWith(AUTH_ORIGIN_PATH);
+ });
+ });
+
+ describe('endBrowserSession', () => {
+ it('does nothing when the provider has no end-session endpoint', async () => {
+ authProvider.getEndSessionEndpoint.and.resolveTo(undefined);
+
+ await service.endBrowserSession();
+
+ expect(alertController.create).not.toHaveBeenCalled();
+ expect(browser.open).not.toHaveBeenCalled();
+ });
+
+ it('presents a translated confirmation alert', async () => {
+ await service.endBrowserSession();
+
+ const options = lastAlertOptions();
+ expect(options.header).toBe(`translated:${AUTH_MESSAGE_PREFIX}.log_out_alert.header`);
+ expect(options.message).toBe(`translated:${AUTH_MESSAGE_PREFIX}.log_out_alert.message`);
+ expect(options.buttons.map(b => b.role)).toEqual(['cancel', 'confirm']);
+ expect(alert.present).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not open the end-session endpoint before confirmation', async () => {
+ await service.endBrowserSession();
+
+ expect(browser.open).not.toHaveBeenCalled();
+ });
+
+ it('opens the end-session endpoint when the user confirms', async () => {
+ await service.endBrowserSession();
+
+ await alertButton('confirm').handler?.();
+
+ expect(browser.open).toHaveBeenCalledOnceWith(END_SESSION_ENDPOINT);
+ });
+
+ it('does not open the end-session endpoint when the user cancels', async () => {
+ await service.endBrowserSession();
+
+ const cancelButton = alertButton('cancel');
+ await cancelButton.handler?.();
+
+ expect(cancelButton.handler).toBeUndefined();
+ expect(browser.open).not.toHaveBeenCalled();
+ });
+ });
});
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 86dfdce6..703a4dbb 100644
--- a/frontend/app/src/app/modules/auth/auth-helper.service.ts
+++ b/frontend/app/src/app/modules/auth/auth-helper.service.ts
@@ -14,35 +14,38 @@
*/
import {Injectable, inject} from '@angular/core';
-import {AuthActions, IAuthAction} from 'ionic-appauth';
+import {AlertController} from '@ionic/angular/standalone';
import {TranslateService} from '@ngx-translate/core';
-import {JSONPath} from 'jsonpath-plus';
import {
SCAuthorizationProvider,
- SCAuthorizationProviderType,
SCUserConfiguration,
SCUserConfigurationMap,
} from '@openstapps/core';
+import {JSONPath} from 'jsonpath-plus';
+
+import {SimpleBrowser} from '../../util/browser.factory';
import {ConfigProvider} from '../config/config.provider';
import {StorageProvider} from '../storage/storage.provider';
-import {DefaultAuthService} from './default-auth.service';
-import {SimpleBrowser} from '../../util/browser.factory';
-import {AlertController} from '@ionic/angular/standalone';
+import {
+ AuthActions, AuthProvider,
+ IAuthAction,
+} from './auth.provider';
const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path';
+const AUTH_MESSAGE_PREFIX = 'auth.messages.default';
@Injectable({
providedIn: 'root',
})
export class AuthHelperService {
+ private authProvider = inject(AuthProvider);
+
private translateService = inject(TranslateService);
private configProvider = inject(ConfigProvider);
private storageProvider = inject(StorageProvider);
- private defaultAuth = inject(DefaultAuthService);
-
private browser = inject(SimpleBrowser);
private alertController = inject(AlertController);
@@ -58,93 +61,144 @@ export class AuthHelperService {
).default?.endpoints.mapping ?? {};
}
- public getAuthMessage(provider: SCAuthorizationProviderType, action: IAuthAction) {
- let message: string | undefined;
+ public getAuthMessage(
+ action: IAuthAction,
+ ): string | undefined {
switch (action.action) {
- case AuthActions.SignInSuccess: {
- message = this.translateService.instant(`auth.messages.${provider}.logged_in_success`);
- break;
- }
- case AuthActions.SignOutSuccess: {
- message = this.translateService.instant(`auth.messages.${provider}.logged_out_success`);
- break;
- }
+ case AuthActions.SignInSuccess:
+ return this.translateService.instant(
+ `${AUTH_MESSAGE_PREFIX}.logged_in_success`,
+ );
+
+ case AuthActions.SignOutSuccess:
+ return this.translateService.instant(
+ `${AUTH_MESSAGE_PREFIX}.logged_out_success`,
+ );
+
+ case AuthActions.SessionExpired:
+ return this.translateService.instant(
+ `${AUTH_MESSAGE_PREFIX}.session_expired`,
+ );
+
+ default:
+ return undefined;
}
- return message;
}
- getUserFromUserInfo(userInfo: object) {
+ public getUserFromUserInfo(
+ userInfo: object,
+ ): SCUserConfiguration {
const user: SCUserConfiguration = {
id: '',
name: '',
role: 'student',
};
+
for (const key in this.userConfigurationMap) {
- user[key as keyof SCUserConfiguration] = JSONPath({
- path: this.userConfigurationMap[key as keyof SCUserConfiguration] as string,
+ const userKey =
+ key as keyof SCUserConfiguration;
+
+ user[userKey] = JSONPath({
+ path:
+ this.userConfigurationMap[
+ userKey
+ ] as string,
json: userInfo,
})[0];
}
- if (user.givenName && user.givenName.length > 0 && user.familyName && user.familyName.length > 0) {
- user.name = `${user.givenName} ${user.familyName}`;
+
+ if (
+ user.givenName &&
+ user.familyName
+ ) {
+ user.name =
+ `${user.givenName} ${user.familyName}`;
}
return user;
}
- async deleteOriginPath() {
- return this.storageProvider.delete(AUTH_ORIGIN_PATH);
+ public async deleteOriginPath():
+ Promise {
+ await this.storageProvider.delete(
+ AUTH_ORIGIN_PATH,
+ );
}
- async setOriginPath(path: string) {
- return this.storageProvider.put(AUTH_ORIGIN_PATH, path);
+ public async setOriginPath(
+ path: string,
+ ): Promise {
+ await this.storageProvider.put(
+ AUTH_ORIGIN_PATH,
+ path,
+ );
}
- async getOriginPath() {
- let originPath: string;
+ public async getOriginPath():
+ Promise {
try {
- originPath = await this.storageProvider.get(AUTH_ORIGIN_PATH);
+ return await this.storageProvider.get(
+ AUTH_ORIGIN_PATH,
+ );
} catch {
+ return undefined;
+ }
+ }
+
+ public async endBrowserSession():
+ Promise {
+ const endSessionEndpoint =
+ await this.authProvider
+ .getEndSessionEndpoint();
+
+ if (!endSessionEndpoint) {
return;
}
- return originPath;
- }
- /**
- * Provides appropriate auth service instance based on type (string) parameter
- */
- getProvider(): DefaultAuthService {
- return this.defaultAuth;
- }
+ const alert =
+ await this.alertController.create({
+ header:
+ this.translateService.instant(
+ `${AUTH_MESSAGE_PREFIX}.log_out_alert.header`,
+ ),
- /**
- * Ends browser session by opening endSessionEndpoint URL of the provider
- * @param providerType Type of the provider (e.g. 'default' or 'paia')
- */
- async endBrowserSession(providerType: SCAuthorizationProviderType) {
- const endSessionEndpoint = (await this.getProvider().configuration).endSessionEndpoint;
+ message:
+ this.translateService.instant(
+ `${AUTH_MESSAGE_PREFIX}.log_out_alert.message`,
+ ),
- if (endSessionEndpoint) {
- const alert: HTMLIonAlertElement = await this.alertController.create({
- header: this.translateService.instant(`auth.messages.${providerType}.log_out_alert.header`),
- message: this.translateService.instant(`auth.messages.${providerType}.log_out_alert.message`),
buttons: [
{
- text: this.translateService.instant('no'),
+ text:
+ this.translateService.instant(
+ 'no',
+ ),
+
+ role: 'cancel',
cssClass: 'default',
},
{
- text: this.translateService.instant('yes'),
+ text:
+ this.translateService.instant(
+ 'yes',
+ ),
+
role: 'confirm',
cssClass: 'preferred',
- handler: () => {
- this.browser.open(new URL(endSessionEndpoint).href);
+
+ handler: async () => {
+ /*
+ * Only now, after explicit confirmation,
+ * open the IdP end-session endpoint.
+ */
+ await this.browser.open(
+ endSessionEndpoint,
+ );
},
},
],
});
- await alert.present();
- }
+ await alert.present();
}
}
diff --git a/frontend/app/src/app/modules/auth/auth-paths.ts b/frontend/app/src/app/modules/auth/auth-paths.ts
index 3471c846..9cda3b4b 100644
--- a/frontend/app/src/app/modules/auth/auth-paths.ts
+++ b/frontend/app/src/app/modules/auth/auth-paths.ts
@@ -12,15 +12,5 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see .
*/
-import {SCAuthorizationProviderType} from '@openstapps/core';
-export const authPaths: {
- [key in SCAuthorizationProviderType]: {redirect_path: string};
-} = {
- default: {
- redirect_path: 'auth/callback',
- },
- paia: {
- redirect_path: 'auth/paia/callback',
- },
-};
+export const AUTH_REDIRECT_PATH = 'auth/callback';
diff --git a/frontend/app/src/app/modules/auth/auth-routing.module.ts b/frontend/app/src/app/modules/auth/auth-routing.module.ts
index 3f52edb0..beb30d22 100644
--- a/frontend/app/src/app/modules/auth/auth-routing.module.ts
+++ b/frontend/app/src/app/modules/auth/auth-routing.module.ts
@@ -15,12 +15,12 @@
import {RouterModule, Routes} from '@angular/router';
import {NgModule} from '@angular/core';
-import {authPaths} from './auth-paths';
+import {AUTH_REDIRECT_PATH} from './auth-paths';
import {AuthCallbackPageComponent} from './auth-callback/page/auth-callback-page.component';
const authRoutes: Routes = [
{
- path: authPaths.default.redirect_path,
+ path: AUTH_REDIRECT_PATH,
component: AuthCallbackPageComponent,
},
];
diff --git a/frontend/app/src/app/modules/auth/auth.module.ts b/frontend/app/src/app/modules/auth/auth.module.ts
index 0fd9b82a..319cd962 100644
--- a/frontend/app/src/app/modules/auth/auth.module.ts
+++ b/frontend/app/src/app/modules/auth/auth.module.ts
@@ -3,14 +3,12 @@ import {CommonModule} from '@angular/common';
import {Platform} from '@ionic/angular/standalone';
import {Requestor, StorageBackend} from '@openid/appauth';
import {storageFactory} from './factories';
-import {Browser} from 'ionic-appauth';
-import {CapacitorBrowser} from 'ionic-appauth/lib/capacitor';
-import {httpFactory} from './factories/http.factory';
+import {requestorFactory} from './factories/requestor.factory';
import {HttpClient} from '@angular/common/http';
import {AuthRoutingModule} from './auth-routing.module';
import {TranslateModule} from '@ngx-translate/core';
import {AuthCallbackPageComponent} from './auth-callback/page/auth-callback-page.component';
-import {DefaultAuthService} from './default-auth.service';
+import {AuthProvider} from "./auth.provider";
@NgModule({
declarations: [AuthCallbackPageComponent],
@@ -23,14 +21,10 @@ import {DefaultAuthService} from './default-auth.service';
},
{
provide: Requestor,
- useFactory: httpFactory,
+ useFactory: requestorFactory,
deps: [Platform, HttpClient],
},
- {
- provide: Browser,
- useClass: CapacitorBrowser,
- },
- DefaultAuthService,
+ AuthProvider,
],
})
export class AuthModule {}
diff --git a/frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts b/frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts
new file mode 100644
index 00000000..be675d59
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2026 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+
+import {Capacitor} from '@capacitor/core';
+import {SCAuthorizationProvider} from '@openstapps/core';
+
+import type {CapacitorMock} from '../../../../__mocks__/@capacitor/core';
+import {getClientConfig, getPlatformScopes} from './auth.provider.methods';
+
+const capacitor = Capacitor as unknown as CapacitorMock;
+
+const authConfig = {
+ default: {
+ client: {
+ clientId: 'client-id',
+ scopes: 'openid profile email offline_access',
+ url: 'https://idp.example.com',
+ },
+ endpoints: {
+ authorization: 'https://idp.example.com/authorize',
+ token: 'https://idp.example.com/token',
+ userinfo: 'https://idp.example.com/userinfo',
+ mapping: {},
+ },
+ } as unknown as SCAuthorizationProvider,
+};
+
+describe('auth.provider.methods', () => {
+ afterEach(() => {
+ capacitor.reset();
+ });
+
+ describe('getClientConfig', () => {
+ it('requests offline_access on iOS', () => {
+ capacitor.setPlatform('ios');
+
+ expect(getClientConfig(authConfig).scopes).toBe('openid profile email offline_access');
+ });
+
+ it('requests offline_access on Android', () => {
+ capacitor.setPlatform('android');
+
+ expect(getClientConfig(authConfig).scopes).toBe('openid profile email offline_access');
+ });
+
+ it('does not request offline_access in the browser', () => {
+ capacitor.setPlatform('web');
+
+ expect(getClientConfig(authConfig).scopes).toBe('openid profile email');
+ });
+ });
+
+ describe('getPlatformScopes', () => {
+ it('leaves scopes without offline_access unchanged in the browser', () => {
+ expect(getPlatformScopes('openid profile')).toBe('openid profile');
+ });
+
+ it('normalizes extra whitespace', () => {
+ expect(getPlatformScopes(' openid offline_access profile ')).toBe('openid profile');
+ });
+
+ it('still accepts an explicit platform flag', () => {
+ expect(getPlatformScopes('openid offline_access', true)).toBe('openid offline_access');
+ });
+ });
+});
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 5fc4dec0..bbea53d1 100644
--- a/frontend/app/src/app/modules/auth/auth.provider.methods.ts
+++ b/frontend/app/src/app/modules/auth/auth.provider.methods.ts
@@ -13,69 +13,90 @@
* this program. If not, see .
*/
-import {AuthorizationServiceConfigurationJson} from '@openid/appauth';
-import {IAuthConfig} from 'ionic-appauth';
-import {SCAuthorizationProvider, SCAuthorizationProviderType} from '@openstapps/core';
import {Capacitor} from '@capacitor/core';
-import {authPaths} from './auth-paths';
+import {SCAuthorizationProvider} from '@openstapps/core';
+import {
+ AuthorizationServiceConfiguration,
+} from '@openid/appauth';
+
import {environment} from '../../../environments/environment';
+import {AUTH_REDIRECT_PATH} from './auth-paths';
+import type {AuthConfig} from './auth.provider';
+
+export const OFFLINE_ACCESS_SCOPE = 'offline_access';
/**
- * Get configuration of an OAuth2 client
+ * Returns the configuration of the OIDC client.
*/
export function getClientConfig(
- providerType: SCAuthorizationProviderType,
authConfig: {
- default?: SCAuthorizationProvider;
- paia?: SCAuthorizationProvider;
+ default: SCAuthorizationProvider;
},
-): IAuthConfig {
- const providerConfig = authConfig[providerType] as SCAuthorizationProvider;
+): AuthConfig {
+ const provider =
+ authConfig.default;
+
return {
- end_session_redirect_url: '',
+ server_host: provider.client.url,
+ client_id: provider.client.clientId,
+ scopes: getPlatformScopes(provider.client.scopes),
+ redirect_url: getRedirectUrl(),
pkce: true,
- scopes: providerConfig.client.scopes,
- server_host: providerConfig.client.url,
- client_id: providerConfig.client.clientId,
- redirect_url: getRedirectUrl(authPaths[providerType].redirect_path),
};
}
/**
- * Get configuration about endpoints of an OAuth2 server
+ * Returns the scopes to request on the current platform.
+ *
+ * `offline_access` (long-lived refresh tokens) is only requested in the
+ * native apps, where tokens live in the Keychain / Keystore. In the browser,
+ * tokens live in web storage and are reachable by any XSS, so the session is
+ * limited to the access token lifetime instead.
*/
-export function getEndpointsConfig(
- providerType: SCAuthorizationProviderType,
+export function getPlatformScopes(
+ scopes: string,
+ isNative: boolean = Capacitor.isNativePlatform(),
+): string {
+ const scopeList = scopes
+ .split(/\s+/)
+ .filter(scope => scope.length > 0);
+
+ return (
+ isNative
+ ? scopeList
+ : scopeList.filter(scope => scope !== OFFLINE_ACCESS_SCOPE)
+ ).join(' ');
+}
+
+/**
+ * Returns the AppAuth OAuth/OIDC service configuration.
+ */
+export function getServiceConfiguration(
authConfig: {
- default?: SCAuthorizationProvider;
- paia?: SCAuthorizationProvider;
+ default: SCAuthorizationProvider;
},
-): AuthorizationServiceConfigurationJson {
- const providerConfig = authConfig[providerType] as SCAuthorizationProvider;
- return {
- authorization_endpoint: providerConfig.endpoints.authorization,
- end_session_endpoint: providerConfig.endpoints.endSession,
- revocation_endpoint: providerConfig.endpoints.revoke ?? '',
- token_endpoint: providerConfig.endpoints.token,
- userinfo_endpoint: providerConfig.endpoints.userinfo,
- };
+): AuthorizationServiceConfiguration {
+ const endpoints =
+ authConfig.default.endpoints;
+
+ return new AuthorizationServiceConfiguration({
+ authorization_endpoint: endpoints.authorization,
+ token_endpoint: endpoints.token,
+ revocation_endpoint: endpoints.revoke || '',
+ });
}
/**
- * Return a URL of the app, depending on the platform where it is running
+ * Returns the OIDC redirect URL depending on the current platform.
*/
-function getRedirectUrl(routePath: string): string {
- let appHost: string;
- let appSchema: string;
- if (environment.production) {
- appSchema = Capacitor.isNativePlatform() ? environment.custom_url_scheme : 'https';
- appHost = environment.app_host;
- } else {
- appSchema = Capacitor.isNativePlatform()
- ? environment.custom_url_scheme
- : window.location.protocol.split(':')[0];
-
- appHost = Capacitor.isNativePlatform() ? environment.app_host : window.location.host;
+function getRedirectUrl(): string {
+ if (Capacitor.isNativePlatform()) {
+ return `${environment.custom_url_scheme}://${environment.app_host}/${AUTH_REDIRECT_PATH}`;
}
- return `${appSchema}://${appHost}/${routePath}`;
+
+ if (environment.production) {
+ return `https://${environment.app_host}/${AUTH_REDIRECT_PATH}`;
+ }
+
+ return `${window.location.origin}/${AUTH_REDIRECT_PATH}`;
}
diff --git a/frontend/app/src/app/modules/auth/auth.provider.spec.ts b/frontend/app/src/app/modules/auth/auth.provider.spec.ts
new file mode 100644
index 00000000..bfea20cf
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/auth.provider.spec.ts
@@ -0,0 +1,584 @@
+import {
+ AppAuthError,
+ AuthorizationServiceConfiguration,
+ RevokeTokenRequest,
+ StorageBackend,
+ TokenRequestHandler,
+ TokenResponse,
+} from '@openid/appauth';
+import {BehaviorSubject, firstValueFrom, skip} from 'rxjs';
+
+import {AuthActions, AuthProvider, IAuthAction, INVALID_GRANT, isInvalidGrant} from './auth.provider';
+
+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;
+
+/**
+ * Creates a token with a real lifetime instead of spying on isValid().
+ *
+ * remainingSeconds > 0 → still valid for that long
+ * remainingSeconds < 0 → expired that many seconds ago
+ * refreshToken: null → token without refresh token
+ */
+function makeToken(
+ opts: {accessToken?: string; refreshToken?: string | null; remainingSeconds?: number; lifetime?: number} = {},
+): TokenResponse {
+ 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(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;
+ tokenHandler: TokenRequestHandler;
+ tokenSubject: BehaviorSubject;
+ notify(action: IAuthAction): void;
+}
+
+describe('AuthProvider', () => {
+ let provider: AuthProvider;
+ let internals: AuthProviderInternals;
+
+ let storageGetItem: jasmine.Spy;
+ let storageSetItem: jasmine.Spy;
+ let storageRemoveItem: jasmine.Spy;
+ let tokenRequest: jasmine.Spy;
+ let revokeRequest: jasmine.Spy;
+
+ /** Seeds storage with the given token and loads it through the public API. */
+ async function givenLoadedToken(token: TokenResponse | null): Promise {
+ storageGetItem.and.resolveTo(token ? JSON.stringify(token.toJson()) : null);
+ await provider.loadTokenFromStorage();
+ }
+
+ /** Records every auth event emitted after this call (skips the replayed last one). */
+ function recordEvents(): AuthActions[] {
+ const events: AuthActions[] = [];
+ provider.events$.pipe(skip(1)).subscribe(event => events.push(event.action));
+ return events;
+ }
+
+ const isAuthenticated = () => firstValueFrom(provider.isAuthenticated$);
+ const isLoggedIn = () => firstValueFrom(provider.isLoggedIn$);
+
+ beforeEach(() => {
+ jasmine.clock().install();
+ jasmine.clock().mockDate(new Date(NOW_MS));
+
+ const platform = {is: () => false} as any;
+ const configProvider = {} as any;
+
+ provider = new AuthProvider(platform, configProvider);
+ internals = provider as unknown as AuthProviderInternals;
+
+ (provider as any).authConfigValue = {
+ server_host: 'https://idp.example.org',
+ client_id: 'test-client',
+ redirect_url: 'https://app.example.org/callback',
+ scopes: 'openid profile',
+ pkce: true,
+ };
+
+ (provider as any).localConfiguration = new AuthorizationServiceConfiguration({
+ authorization_endpoint: 'https://idp.example.org/authorize',
+ token_endpoint: 'https://idp.example.org/token',
+ revocation_endpoint: 'https://idp.example.org/revoke',
+ });
+
+ // Never touch real storage or the network.
+ storageGetItem = spyOn(internals.storage, 'getItem').and.resolveTo(null);
+ storageSetItem = spyOn(internals.storage, 'setItem').and.resolveTo();
+ storageRemoveItem = spyOn(internals.storage, 'removeItem').and.resolveTo();
+ tokenRequest = spyOn(internals.tokenHandler, 'performTokenRequest').and.rejectWith(
+ new Error('Unexpected token request'),
+ );
+ revokeRequest = spyOn(internals.tokenHandler, 'performRevokeTokenRequest').and.resolveTo(true);
+ });
+
+ afterEach(() => {
+ jasmine.clock().uninstall();
+ });
+
+ describe('isAuthenticated$', () => {
+ it('should provide false through isAuthenticated$ when there is no token response', async () => {
+ // Start authenticated so the test cannot pass on the initial value alone.
+ await givenLoadedToken(makeToken());
+ expect(await isAuthenticated()).toBeTrue();
+
+ await givenLoadedToken(null);
+
+ expect(internals.tokenSubject.value).toBeUndefined();
+ expect(await isAuthenticated()).toBeFalse();
+ });
+
+ it('should provide true through isAuthenticated$ when access token is valid', async () => {
+ await givenLoadedToken(makeToken());
+
+ expect(await isAuthenticated()).toBeTrue();
+ });
+
+ it('should provide false through isAuthenticated$ when access token is invalid', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -TOKEN_LIFETIME}));
+
+ expect(await isAuthenticated()).toBeFalse();
+ // Still logged in: a refresh token is available.
+ expect(await isLoggedIn()).toBeTrue();
+ });
+
+ it('should provide true through isAuthenticated$ after an expired token was refreshed', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ expect(await isAuthenticated()).toBeFalse();
+
+ tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token'}));
+ await provider.getValidToken();
+
+ expect(await isAuthenticated()).toBeTrue();
+ });
+ });
+
+ describe('getValidToken', () => {
+ it('returns the current token without refreshing when it is valid', async () => {
+ await givenLoadedToken(makeToken({accessToken: 'current-access-token'}));
+
+ const token = await provider.getValidToken();
+
+ expect(tokenRequest).not.toHaveBeenCalled();
+ expect(token.accessToken).toBe('current-access-token');
+ });
+
+ it('refreshes the token when the access token is expired', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ tokenRequest.and.resolveTo(
+ makeToken({accessToken: 'new-access-token', refreshToken: 'new-refresh-token'}),
+ );
+
+ const token = await provider.getValidToken();
+
+ expect(tokenRequest).toHaveBeenCalledTimes(1);
+ expect(token.accessToken).toBe('new-access-token');
+ expect(token.refreshToken).toBe('new-refresh-token');
+ expect(storageSetItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY, jasmine.any(String));
+ });
+
+ it('refreshes the token when the access token expires within the buffer', async () => {
+ // 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();
+
+ expect(tokenRequest).toHaveBeenCalledTimes(1);
+ 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}));
+
+ const token = await provider.getValidToken();
+
+ expect(token.accessToken).toBe('new-access-token');
+ expect(token.refreshToken).toBe('old-refresh-token');
+ });
+
+ it('rejects without a request when the token is expired and has no refresh token', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: null}));
+
+ await expectAsync(provider.getValidToken()).toBeRejectedWithError(/No Refresh Token Available/);
+ expect(tokenRequest).not.toHaveBeenCalled();
+ });
+
+ it('sends only one refresh request for parallel callers', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token'}));
+
+ const tokens = await Promise.all([
+ provider.getValidToken(),
+ provider.getValidToken(),
+ provider.getValidToken(),
+ ]);
+
+ expect(tokenRequest).toHaveBeenCalledTimes(1);
+ expect(tokens.map(token => token.accessToken)).toEqual([
+ 'new-access-token',
+ 'new-access-token',
+ 'new-access-token',
+ ]);
+ });
+
+ it('refreshes again after a previous refresh has finished', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ tokenRequest.and.resolveTo(makeToken({accessToken: 'first', remainingSeconds: -60}));
+
+ // The first refresh returns an already expired token, so the call fails ...
+ await expectAsync(provider.getValidToken()).toBeRejected();
+
+ // ... and the next call must start a new request instead of reusing the old one.
+ tokenRequest.and.resolveTo(makeToken({accessToken: 'second'}));
+ expect((await provider.getValidToken()).accessToken).toBe('second');
+ expect(tokenRequest).toHaveBeenCalledTimes(2);
+ });
+
+ describe('without a refresh token (browser)', () => {
+ it('uses the token within the buffer instead of failing early', async () => {
+ await givenLoadedToken(makeToken({accessToken: 'current-access-token', remainingSeconds: 60, refreshToken: null}));
+
+ const token = await provider.getValidToken();
+
+ expect(token.accessToken).toBe('current-access-token');
+ expect(tokenRequest).not.toHaveBeenCalled();
+ });
+
+ it('logs the user out when the token expires while the app is open', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: 60, refreshToken: null}));
+ expect(await isLoggedIn()).toBeTrue();
+ const events = recordEvents();
+
+ jasmine.clock().tick(2 * 60 * 1000);
+
+ await expectAsync(provider.getValidToken()).toBeRejectedWithError(/No Refresh Token Available/);
+ expect(await isLoggedIn()).toBeFalse();
+ expect(internals.tokenSubject.value).toBeUndefined();
+ expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
+ expect(events).toContain(AuthActions.SessionExpired);
+ expect(tokenRequest).not.toHaveBeenCalled();
+ });
+
+ it('does not restore an expired token from storage', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: null}));
+
+ expect(await isLoggedIn()).toBeFalse();
+ expect(internals.tokenSubject.value).toBeUndefined();
+ expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
+ });
+ });
+
+ describe('when refresh fails with invalid_grant from the native requestor', () => {
+ // CapacitorRequestor rejects non-2xx responses itself and keeps the OAuth body in extras.
+ const refreshError = new AppAuthError('HTTP 400', {error: INVALID_GRANT});
+
+ it('logs the user out', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ const events = recordEvents();
+ tokenRequest.and.rejectWith(refreshError);
+
+ await expectAsync(provider.getValidToken()).toBeRejectedWith(refreshError);
+
+ expect(await isLoggedIn()).toBeFalse();
+ expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
+ expect(events).toEqual([AuthActions.RefreshFailed, AuthActions.SessionExpired]);
+ });
+
+ it('logs the user out only once for parallel callers', async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ const events = recordEvents();
+ tokenRequest.and.rejectWith(refreshError);
+
+ const results = await Promise.allSettled([provider.getValidToken(), provider.getValidToken()]);
+
+ expect(results.map(result => result.status)).toEqual(['rejected', 'rejected']);
+ expect(tokenRequest).toHaveBeenCalledTimes(1);
+ expect(events.filter(event => event === AuthActions.SessionExpired).length).toBe(1);
+ });
+ });
+
+ describe('when refresh fails with invalid_grant', () => {
+ const refreshError = new AppAuthError(INVALID_GRANT);
+
+ beforeEach(async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: 'invalid-refresh-token'}));
+ expect(await isLoggedIn()).toBeTrue();
+
+ tokenRequest.and.rejectWith(refreshError);
+
+ await expectAsync(provider.getValidToken()).toBeRejectedWith(refreshError);
+ });
+
+ it('changes isLoggedIn to false', async () => {
+ expect(await isLoggedIn()).toBeFalse();
+ });
+
+ it('removes the token from storage', () => {
+ expect(storageRemoveItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY);
+ });
+
+ it('clears the current token', () => {
+ expect(internals.tokenSubject.value).toBeUndefined();
+ });
+ });
+
+ describe('when refresh fails with a temporary error', () => {
+ // An AppAuthError that is not invalid_grant, e.g. a network failure.
+ const refreshError = new AppAuthError('Network Error');
+
+ beforeEach(async () => {
+ await givenLoadedToken(makeToken({remainingSeconds: -60}));
+ tokenRequest.and.rejectWith(refreshError);
+
+ await expectAsync(provider.getValidToken()).toBeRejectedWith(refreshError);
+ });
+
+ it('keeps the user logged in', async () => {
+ expect(await isLoggedIn()).toBeTrue();
+ });
+
+ it('does not remove the token from storage', () => {
+ expect(storageRemoveItem).not.toHaveBeenCalled();
+ });
+
+ it('keeps the current token for a later retry', () => {
+ expect(internals.tokenSubject.value?.refreshToken).toBe('refresh-token');
+ });
+ });
+ });
+
+ 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('failed sign-in', () => {
+ it('keeps an existing session', async () => {
+ await givenLoadedToken(makeToken({accessToken: 'still-valid'}));
+
+ internals.notify({action: AuthActions.SignInFailed, error: new Error('state mismatch')});
+
+ expect(await isLoggedIn()).toBeTrue();
+ expect(internals.tokenSubject.value?.accessToken).toBe('still-valid');
+ expect(storageRemoveItem).not.toHaveBeenCalled();
+ });
+
+ it('stays logged out without a session', async () => {
+ await givenLoadedToken(null);
+
+ internals.notify({action: AuthActions.SignInFailed, error: new Error('access_denied')});
+
+ expect(await isLoggedIn()).toBeFalse();
+ });
+ });
+
+ describe('signOut', () => {
+ /** The [token, token_type_hint, client_id] of each revoke request, in order. */
+ const revokedTokens = () =>
+ revokeRequest.calls.allArgs().map(([, request]) => {
+ const json = (request as RevokeTokenRequest).toJson();
+ return [json.token, json.token_type_hint, json.client_id];
+ });
+
+ it('revokes the refresh token, then the access token', async () => {
+ await givenLoadedToken(makeToken({accessToken: 'access', refreshToken: 'refresh'}));
+
+ await provider.signOut();
+
+ expect(revokedTokens()).toEqual([
+ ['refresh', 'refresh_token', 'test-client'],
+ ['access', 'access_token', 'test-client'],
+ ]);
+ expect(await isLoggedIn()).toBeFalse();
+ expect(storageRemoveItem).toHaveBeenCalledWith(TOKEN_RESPONSE_KEY);
+ });
+
+ it('revokes only the access token when there is no refresh token (browser)', async () => {
+ await givenLoadedToken(makeToken({accessToken: 'access', refreshToken: null}));
+
+ await provider.signOut();
+
+ expect(revokedTokens()).toEqual([['access', 'access_token', 'test-client']]);
+ });
+
+ it('logs out locally before the IdP answers', async () => {
+ await givenLoadedToken(makeToken());
+ let loggedInWhileRevoking: boolean | undefined;
+ revokeRequest.and.callFake(async () => {
+ loggedInWhileRevoking = await isLoggedIn();
+ return true;
+ });
+
+ await provider.signOut();
+
+ expect(loggedInWhileRevoking).toBeFalse();
+ });
+
+ it('still logs out and tries the access token when revoking the refresh token fails', async () => {
+ await givenLoadedToken(makeToken());
+ const events = recordEvents();
+ revokeRequest.and.returnValues(Promise.reject(new AppAuthError('Network Error')), Promise.resolve(true));
+
+ await provider.signOut();
+
+ expect(revokeRequest).toHaveBeenCalledTimes(2);
+ expect(await isLoggedIn()).toBeFalse();
+ expect(events).toEqual([AuthActions.SignOutSuccess, AuthActions.RevokeTokensFailed]);
+ });
+
+ it('does not wait for an IdP that does not answer', async () => {
+ await givenLoadedToken(makeToken());
+ revokeRequest.and.returnValue(new Promise(() => {}));
+ let done = false;
+
+ const signOut = provider.signOut().then(() => (done = true));
+ await flushPromises();
+ expect(done).toBeFalse();
+ expect(await isLoggedIn()).toBeFalse();
+
+ jasmine.clock().tick(5000);
+ await signOut;
+
+ expect(done).toBeTrue();
+ });
+
+ it('revokes nothing when no revocation endpoint is configured', async () => {
+ (provider as any).localConfiguration = new AuthorizationServiceConfiguration({
+ authorization_endpoint: 'https://idp.example.org/authorize',
+ token_endpoint: 'https://idp.example.org/token',
+ revocation_endpoint: '',
+ });
+ await givenLoadedToken(makeToken());
+
+ await provider.signOut();
+
+ expect(revokeRequest).not.toHaveBeenCalled();
+ expect(await isLoggedIn()).toBeFalse();
+ });
+
+ it('revokes nothing when asked not to', async () => {
+ await givenLoadedToken(makeToken());
+
+ await provider.signOut(undefined, false);
+
+ expect(revokeRequest).not.toHaveBeenCalled();
+ expect(await isLoggedIn()).toBeFalse();
+ });
+
+ it('revokes nothing when nobody is logged in', async () => {
+ await givenLoadedToken(null);
+
+ await provider.signOut();
+
+ expect(revokeRequest).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('isInvalidGrant', () => {
+ it('detects invalid_grant as error message', () => {
+ expect(isInvalidGrant(new AppAuthError(INVALID_GRANT))).toBeTrue();
+ });
+
+ it('detects invalid_grant in the response body', () => {
+ expect(isInvalidGrant(new AppAuthError('HTTP 400', {error: INVALID_GRANT}))).toBeTrue();
+ });
+
+ it('ignores other OAuth errors', () => {
+ expect(isInvalidGrant(new AppAuthError('HTTP 400', {error: 'invalid_request'}))).toBeFalse();
+ });
+
+ it('ignores network and non-AppAuth errors', () => {
+ expect(isInvalidGrant(new AppAuthError('HTTP 503'))).toBeFalse();
+ expect(isInvalidGrant(new Error(INVALID_GRANT))).toBeFalse();
+ expect(isInvalidGrant(undefined)).toBeFalse();
+ });
+ });
+});
diff --git a/frontend/app/src/app/modules/auth/auth.provider.ts b/frontend/app/src/app/modules/auth/auth.provider.ts
new file mode 100644
index 00000000..89bbb54a
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/auth.provider.ts
@@ -0,0 +1,1411 @@
+/*
+ * Copyright (C) 2023 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+
+import {Injectable} from '@angular/core';
+import {Browser} from '@capacitor/browser';
+import {Platform} from '@ionic/angular/standalone';
+import {
+ AuthorizationError,
+ AuthorizationNotifier,
+ AuthorizationRequest,
+ AuthorizationRequestJson,
+ AuthorizationResponse,
+ AuthorizationServiceConfiguration,
+ AppAuthError,
+ BaseTokenRequestHandler,
+ DefaultCrypto,
+ ErrorType,
+ GRANT_TYPE_AUTHORIZATION_CODE,
+ GRANT_TYPE_REFRESH_TOKEN,
+ Requestor,
+ RevokeTokenRequest,
+ RevokeTokenRequestJson,
+ StorageBackend,
+ StringMap,
+ TokenRequest,
+ TokenRequestHandler,
+ TokenRequestJson,
+ TokenResponse,
+} from '@openid/appauth';
+import {SCAuthorizationProvider, SCAuthorizationProviderEndpoints} from '@openstapps/core';
+import {BehaviorSubject, Observable} from 'rxjs';
+
+import {ConfigProvider} from '../config/config.provider';
+import {CapacitorAuthorizationRequestHandler} from './capacitor-authorization-request-handler';
+import {
+ getClientConfig,
+ getServiceConfiguration,
+} from './auth.provider.methods';
+import {requestorFactory} from './factories/requestor.factory';
+import {storageFactory} from './factories/storage.factory';
+
+const TOKEN_RESPONSE_KEY = 'token_response';
+export const INVALID_GRANT: ErrorType = 'invalid_grant';
+
+/**
+ * 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;
+
+/**
+ * Logout never waits longer than this for the IdP to revoke the tokens.
+ */
+const REVOKE_TIMEOUT_MS = 5000;
+
+/**
+ * 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;
+ client_id: string;
+ redirect_url: string;
+ end_session_redirect_url?: string;
+ scopes: string;
+ pkce: boolean;
+}
+
+export enum AuthActions {
+ Init = 'Init',
+
+ SignInSuccess = 'SignInSuccess',
+ SignInFailed = 'SignInFailed',
+
+ SignOutSuccess = 'SignOutSuccess',
+ SignOutFailed = 'SignOutFailed',
+
+ RefreshSuccess = 'RefreshSuccess',
+ RefreshFailed = 'RefreshFailed',
+
+ LoadTokenFromStorageSuccess =
+ 'LoadTokenFromStorageSuccess',
+
+ LoadTokenFromStorageFailed =
+ 'LoadTokenFromStorageFailed',
+
+ RevokeTokensSuccess =
+ 'RevokeTokensSuccess',
+
+ RevokeTokensFailed =
+ 'RevokeTokensFailed',
+
+ LoadUserInfoSuccess =
+ 'LoadUserInfoSuccess',
+
+ LoadUserInfoFailed =
+ 'LoadUserInfoFailed',
+
+ /**
+ * The session can no longer be renewed: the access token expired without a
+ * refresh token (browser), or the IdP rejected the refresh token with
+ * invalid_grant (e.g. refresh token chain lifetime reached).
+ * The user is logged out locally.
+ */
+ SessionExpired =
+ 'SessionExpired',
+}
+
+export interface IAuthAction {
+ action: AuthActions;
+ tokenResponse?: TokenResponse;
+ user?: unknown;
+ error?: unknown;
+}
+
+/**
+ * Returns true if the token endpoint rejected the grant (RFC 6749, 5.2).
+ *
+ * AppAuth's BaseTokenRequestHandler only produces
+ * AppAuthError('invalid_grant') when the requestor resolves with the error
+ * body. Our requestors reject non-2xx responses themselves, so the OAuth error
+ * may also be found in the error's extras (the parsed response body).
+ */
+export function isInvalidGrant(
+ error: unknown,
+): boolean {
+ if (!(error instanceof AppAuthError)) {
+ return false;
+ }
+
+ if (error.message === INVALID_GRANT) {
+ return true;
+ }
+
+ const extras =
+ error.extras as {error?: unknown} | undefined;
+
+ return extras?.error === INVALID_GRANT;
+}
+
+@Injectable({
+ providedIn: 'root',
+})
+export class AuthProvider {
+ private authConfigValue?: AuthConfig;
+
+ private localConfiguration?:
+ AuthorizationServiceConfiguration;
+
+ /**
+ * Endpoints from the backend configuration. AppAuth's
+ * AuthorizationServiceConfiguration has no userinfo / end-session.
+ */
+ private endpoints?: SCAuthorizationProviderEndpoints;
+
+ private readonly authSubject =
+ new BehaviorSubject({
+ action: AuthActions.Init,
+ });
+
+ private readonly tokenSubject =
+ new BehaviorSubject(
+ undefined,
+ );
+
+ private readonly userSubject =
+ new BehaviorSubject(
+ undefined,
+ );
+
+ private readonly authenticatedSubject =
+ new BehaviorSubject(false);
+
+ private readonly loggedInSubject =
+ new BehaviorSubject(false);
+
+ private readonly initCompleteSubject =
+ new BehaviorSubject(false);
+
+ private readonly storage: StorageBackend;
+ private readonly requestor: Requestor;
+
+ private readonly requestHandler:
+ CapacitorAuthorizationRequestHandler;
+
+ private readonly tokenHandler:
+ TokenRequestHandler;
+
+ private notifierInitialized = false;
+
+ /**
+ * Refresh request currently in progress. Parallel callers share it, so the
+ * same refresh token is never sent twice (with refresh token rotation the
+ * second request would fail with invalid_grant and log the user out).
+ */
+ 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,
+ ) {
+ this.storage =
+ storageFactory(platform);
+
+ this.requestor =
+ requestorFactory(platform);
+
+ /*
+ * Same handler on all platforms. On the web, the login is a page
+ * redirect and there is no in-app browser to close.
+ */
+ this.requestHandler =
+ platform.is('capacitor')
+ ? new CapacitorAuthorizationRequestHandler(
+ this.storage,
+ )
+ : new CapacitorAuthorizationRequestHandler(
+ this.storage,
+ async url => {
+ window.location.assign(url);
+ },
+ async () => {
+ // Nothing to close on the web.
+ },
+ );
+
+ this.tokenHandler =
+ new BaseTokenRequestHandler(
+ this.requestor,
+ );
+
+ /*
+ * Every new (or removed) token reschedules the expiry check.
+ */
+ this.tokenSubject.subscribe(token => {
+ this.scheduleExpiryCheck(token);
+ });
+ }
+
+ get token$():
+ Observable {
+ return this.tokenSubject.asObservable();
+ }
+
+ get isAuthenticated$():
+ Observable {
+ return this.authenticatedSubject.asObservable();
+ }
+
+ get isLoggedIn$():
+ Observable {
+ return this.loggedInSubject.asObservable();
+ }
+
+ get initComplete$():
+ Observable {
+ return this.initCompleteSubject.asObservable();
+ }
+
+ get user$():
+ Observable {
+ return this.userSubject.asObservable();
+ }
+
+ get events$():
+ Observable {
+ return this.authSubject.asObservable();
+ }
+
+ get authConfig(): AuthConfig {
+ if (!this.authConfigValue) {
+ throw new Error(
+ 'AuthConfig Not Defined',
+ );
+ }
+
+ return this.authConfigValue;
+ }
+
+ get configuration():
+ Promise {
+ if (!this.localConfiguration) {
+ throw new Error(
+ 'Local Configuration Not Defined',
+ );
+ }
+
+ return Promise.resolve(
+ this.localConfiguration,
+ );
+ }
+
+ public async init(): Promise {
+ this.setupConfiguration();
+ this.setupAuthorizationNotifier();
+ this.setupVisibilityListener();
+
+ await this.loadTokenFromStorage();
+ }
+
+ private setupConfiguration(): void {
+ const config =
+ this.configProvider.getAnyValue(
+ 'auth',
+ ) as {
+ default: SCAuthorizationProvider;
+ };
+
+ this.authConfigValue =
+ getClientConfig(config);
+
+ this.localConfiguration =
+ getServiceConfiguration(config);
+
+ this.endpoints =
+ config.default.endpoints;
+ }
+
+ public async signIn(
+ authExtras?: StringMap,
+ state?: string,
+ ): Promise {
+ try {
+ const requestJson:
+ AuthorizationRequestJson = {
+ response_type:
+ AuthorizationRequest
+ .RESPONSE_TYPE_CODE,
+
+ client_id:
+ this.authConfig.client_id,
+
+ redirect_uri:
+ this.authConfig.redirect_url,
+
+ scope:
+ this.authConfig.scopes,
+
+ extras:
+ authExtras,
+
+ state,
+ };
+
+ const request =
+ new AuthorizationRequest(
+ requestJson,
+ new DefaultCrypto(),
+ this.authConfig.pkce,
+ );
+
+ await this.requestHandler
+ .performAuthorizationRequest(
+ await this.configuration,
+ request,
+ );
+ } catch (error) {
+ this.notify({
+ action:
+ AuthActions.SignInFailed,
+
+ error,
+ });
+ }
+ }
+
+ public authorizationCallback(
+ callbackUrl: string,
+ ): void {
+ void this.handleAuthorizationCallback(
+ callbackUrl,
+ ).catch(error => {
+ this.notify({
+ action:
+ AuthActions.SignInFailed,
+
+ error,
+ });
+ });
+ }
+
+ public async refreshToken():
+ Promise {
+ try {
+ await this.refreshSession();
+ } catch {
+ /*
+ * Already handled by refreshSession(): the session is expired on
+ * invalid_grant and kept on temporary errors.
+ */
+ }
+ }
+
+ public async loadUserInfo():
+ Promise {
+ try {
+ const token =
+ await this.getValidToken(0);
+
+ const endpoint =
+ this.endpoints?.userinfo;
+
+ if (!endpoint) {
+ throw new Error(
+ 'OIDC provider does not expose a userinfo endpoint.',
+ );
+ }
+
+ const user =
+ await this.requestor.xhr<
+ Record
+ >({
+ url:
+ endpoint,
+
+ method:
+ 'GET',
+
+ dataType:
+ 'json',
+
+ headers: {
+ Authorization:
+ `Bearer ${token.accessToken}`,
+ },
+ });
+
+ this.notify({
+ action:
+ AuthActions.LoadUserInfoSuccess,
+
+ user,
+ });
+ } catch (error) {
+ this.notify({
+ action:
+ AuthActions.LoadUserInfoFailed,
+
+ error,
+ });
+ }
+ }
+
+ public async getEndSessionEndpoint():
+ Promise {
+ return this.endpoints
+ ?.endSession;
+ }
+
+ /**
+ * Logs the user out of the app.
+ *
+ * With revokeTokens (default), the refresh and access tokens are also
+ * revoked at the IdP (RFC 7009), so a copied token cannot be used anymore.
+ * This is best effort: the local logout always happens first and never
+ * waits longer than REVOKE_TIMEOUT_MS for the IdP.
+ * @param _state Unused.
+ * @param revokeTokens Revoke the tokens at the IdP.
+ */
+ public async signOut(
+ _state?: string,
+ revokeTokens = true,
+ ): Promise {
+ const token =
+ this.tokenSubject.value;
+
+ try {
+ await this.storage.removeItem(
+ TOKEN_RESPONSE_KEY,
+ );
+
+ /*
+ * Local logout only.
+ *
+ * The optional logout from the identity provider is
+ * deliberately handled by AuthHelperService after the
+ * confirmation dialog.
+ */
+ this.notify({
+ action:
+ AuthActions.SignOutSuccess,
+ });
+ } catch (error) {
+ this.notify({
+ action:
+ AuthActions.SignOutFailed,
+
+ error,
+ });
+ }
+
+ if (revokeTokens && token) {
+ try {
+ await this.withTimeout(
+ this.revokeTokenResponse(token),
+ REVOKE_TIMEOUT_MS,
+ );
+ } catch (error) {
+ /*
+ * Offline, IdP unreachable or revocation not allowed: the user is
+ * logged out locally anyway.
+ */
+ this.notify({
+ action:
+ AuthActions.RevokeTokensFailed,
+
+ error,
+ });
+ }
+ }
+ }
+
+ public async revokeTokens():
+ Promise {
+ try {
+ await this.requestTokenRevoke();
+
+ this.notify({
+ action:
+ AuthActions.RevokeTokensSuccess,
+ });
+ } catch (error) {
+ await this.storage.removeItem(
+ TOKEN_RESPONSE_KEY,
+ );
+
+ this.notify({
+ action:
+ AuthActions.RevokeTokensFailed,
+
+ error,
+ });
+ }
+ }
+
+ public endSessionCallback(): void {
+ void this.internalEndSessionCallback()
+ .catch(error => {
+ this.notify({
+ action:
+ AuthActions.SignOutFailed,
+
+ error,
+ });
+ });
+ }
+
+ public async loadTokenFromStorage():
+ Promise {
+ try {
+ const stored =
+ await this.storage.getItem(
+ TOKEN_RESPONSE_KEY,
+ );
+
+ if (!stored) {
+ throw new Error(
+ 'No Token In Storage',
+ );
+ }
+
+ const token =
+ new TokenResponse(
+ JSON.parse(stored),
+ );
+
+ /*
+ * Expired and not renewable (browser, no refresh token):
+ * this session is over, do not show the user as logged in.
+ */
+ if (
+ !token.isValid(0) &&
+ !token.refreshToken
+ ) {
+ await this.storage.removeItem(
+ TOKEN_RESPONSE_KEY,
+ );
+
+ throw new Error(
+ 'Stored Token Expired',
+ );
+ }
+
+ this.notify({
+ action:
+ AuthActions
+ .LoadTokenFromStorageSuccess,
+
+ tokenResponse:
+ token,
+ });
+ } catch (error) {
+ this.notify({
+ action:
+ AuthActions
+ .LoadTokenFromStorageFailed,
+
+ error,
+ });
+ }
+ }
+
+ /**
+ * 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?: number,
+ ): Promise {
+ const currentToken =
+ this.tokenSubject.value;
+
+ /*
+ * Access token still valid for longer than the buffer.
+ */
+ if (
+ currentToken?.isValid(
+ buffer ?? earlyRefreshBuffer(currentToken),
+ )
+ ) {
+ return currentToken;
+ }
+
+ if (!currentToken?.refreshToken) {
+ /*
+ * Without a refresh token (browser) nothing can be renewed early,
+ * so the buffer is pointless: use the token until it really expires.
+ */
+ if (currentToken?.isValid(0)) {
+ return currentToken;
+ }
+
+ /*
+ * Expired and not renewable: the session is over.
+ */
+ if (currentToken) {
+ await this.expireSession(
+ new Error('Access Token Expired Without Refresh Token'),
+ );
+ }
+
+ throw new Error(
+ 'Unable To Obtain Valid Token: No Refresh Token Available',
+ );
+ }
+
+ /*
+ * Access token is expired or will expire soon: refresh.
+ *
+ * refreshSession() stores the newly returned token and updates
+ * tokenSubject via RefreshSuccess. On invalid_grant it has already
+ * logged the user out.
+ */
+ await this.refreshSession();
+
+ const refreshedToken =
+ this.tokenSubject.value;
+
+ if (!refreshedToken) {
+ throw new Error(
+ 'Unable To Obtain Valid Token: Refresh Returned No Token',
+ );
+ }
+
+ /*
+ * 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 Expired',
+ );
+ }
+
+ return refreshedToken;
+ }
+
+ /**
+ * Refreshes the token once, shared by all parallel callers.
+ * Rejects with the original error after it has been handled.
+ */
+ private refreshSession(): Promise {
+ this.refreshInFlight ??= (async () => {
+ try {
+ await this.requestTokenRefresh();
+ } catch (error) {
+ await this.handleRefreshFailure(
+ error,
+ );
+
+ throw error;
+ }
+ })().finally(() => {
+ this.refreshInFlight = undefined;
+ });
+
+ return this.refreshInFlight;
+ }
+
+ private async handleRefreshFailure(
+ error: unknown,
+ ): Promise {
+ this.notify({
+ action:
+ AuthActions.RefreshFailed,
+
+ error,
+ });
+
+ /*
+ * invalid_grant means that the refresh token is no longer usable,
+ * for example because it expired, was revoked or the refresh-token
+ * chain lifetime has been reached. Log the user out.
+ *
+ * Network and other temporary errors must not log the user out.
+ */
+ if (isInvalidGrant(error)) {
+ await this.expireSession(
+ error,
+ );
+ }
+ }
+
+ /**
+ * Ends the local session because it cannot be renewed anymore.
+ */
+ private async expireSession(
+ error: unknown,
+ ): Promise {
+ /*
+ * 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():
+ void {
+ if (this.notifierInitialized) {
+ return;
+ }
+
+ const notifier =
+ new AuthorizationNotifier();
+
+ this.requestHandler
+ .setAuthorizationNotifier(
+ notifier,
+ );
+
+ notifier.setAuthorizationListener(
+ (
+ request,
+ response,
+ error,
+ ) => {
+ void this
+ .onAuthorizationNotification(
+ request,
+ response,
+ error,
+ )
+ .catch(
+ notificationError => {
+ this.notify({
+ action:
+ AuthActions
+ .SignInFailed,
+
+ error:
+ notificationError,
+ });
+ },
+ );
+ },
+ );
+
+ this.notifierInitialized = true;
+ }
+
+ private async onAuthorizationNotification(
+ request: AuthorizationRequest,
+ response: AuthorizationResponse | null,
+ error: AuthorizationError | null,
+ ): Promise {
+ if (error) {
+ throw new Error(
+ error.errorDescription ||
+ error.error ||
+ 'Authorization failed.',
+ );
+ }
+
+ if (!response) {
+ throw new Error(
+ 'Authorization response is missing.',
+ );
+ }
+
+ const codeVerifier =
+ this.authConfig.pkce
+ ? request.internal?.[
+ 'code_verifier'
+ ]
+ : undefined;
+
+ await this.requestAccessToken(
+ response.code,
+ codeVerifier,
+ );
+ }
+
+ private async requestAccessToken(
+ code: string,
+ codeVerifier?: string,
+ ): Promise {
+ const requestJson:
+ TokenRequestJson = {
+ grant_type:
+ GRANT_TYPE_AUTHORIZATION_CODE,
+
+ code,
+
+ refresh_token:
+ undefined,
+
+ redirect_uri:
+ this.authConfig.redirect_url,
+
+ client_id:
+ this.authConfig.client_id,
+
+ extras:
+ codeVerifier
+ ? {
+ code_verifier:
+ codeVerifier,
+ }
+ : undefined,
+ };
+
+ const token =
+ await this.tokenHandler
+ .performTokenRequest(
+ await this.configuration,
+
+ new TokenRequest(
+ requestJson,
+ ),
+ );
+
+ await this.storeToken(
+ token,
+ );
+
+ this.notify({
+ action:
+ AuthActions.SignInSuccess,
+
+ tokenResponse:
+ token,
+ });
+ }
+
+ private async requestTokenRefresh():
+ Promise {
+ const currentToken =
+ this.tokenSubject.value;
+
+ if (!currentToken) {
+ throw new Error(
+ 'No Token Defined!',
+ );
+ }
+
+ if (!currentToken.refreshToken) {
+ throw new Error(
+ 'No Refresh Token Defined!',
+ );
+ }
+
+ const requestJson:
+ TokenRequestJson = {
+ grant_type:
+ GRANT_TYPE_REFRESH_TOKEN,
+
+ code:
+ undefined,
+
+ refresh_token:
+ currentToken.refreshToken,
+
+ redirect_uri:
+ this.authConfig.redirect_url,
+
+ client_id:
+ this.authConfig.client_id,
+ };
+
+ const response =
+ await this.tokenHandler
+ .performTokenRequest(
+ await this.configuration,
+
+ new TokenRequest(
+ requestJson,
+ ),
+ );
+
+ if (!response.accessToken) {
+ throw new Error(
+ 'No Access Token Defined In Refresh Response',
+ );
+ }
+
+ const token =
+ new TokenResponse({
+ ...response.toJson(),
+
+ refresh_token:
+ response.refreshToken ??
+ currentToken.refreshToken,
+
+ id_token:
+ response.idToken ??
+ currentToken.idToken,
+ });
+
+ await this.storeToken(
+ token,
+ );
+
+ this.notify({
+ action:
+ AuthActions.RefreshSuccess,
+
+ tokenResponse:
+ token,
+ });
+ }
+
+ private async requestTokenRevoke():
+ Promise {
+ const token =
+ this.tokenSubject.value;
+
+ if (!token) {
+ throw new Error(
+ 'No Token Defined!',
+ );
+ }
+
+ await this.revokeTokenResponse(
+ token,
+ );
+
+ await this.storage.removeItem(
+ TOKEN_RESPONSE_KEY,
+ );
+ }
+
+ /**
+ * Revokes the refresh token (if any) and the access token at the IdP.
+ *
+ * The refresh token goes first: it is the long-lived one. Both are tried
+ * even if the first request fails; the first error is rethrown.
+ * Does nothing if the backend configures no revocation endpoint.
+ */
+ private async revokeTokenResponse(
+ token: TokenResponse,
+ ): Promise {
+ const configuration =
+ await this.configuration;
+
+ if (!configuration.revocationEndpoint) {
+ return;
+ }
+
+ const requests: RevokeTokenRequestJson[] = [];
+
+ if (token.refreshToken) {
+ requests.push({
+ token:
+ token.refreshToken,
+
+ token_type_hint:
+ 'refresh_token',
+
+ client_id:
+ this.authConfig.client_id,
+ });
+ }
+
+ requests.push({
+ token:
+ token.accessToken,
+
+ token_type_hint:
+ 'access_token',
+
+ client_id:
+ this.authConfig.client_id,
+ });
+
+ let firstError: unknown;
+
+ for (const request of requests) {
+ try {
+ await this.tokenHandler
+ .performRevokeTokenRequest(
+ configuration,
+
+ new RevokeTokenRequest(
+ request,
+ ),
+ );
+ } catch (error) {
+ firstError ??= error;
+ }
+ }
+
+ if (firstError !== undefined) {
+ throw firstError;
+ }
+ }
+
+ /**
+ * Rejects if the promise does not settle within the given time.
+ */
+ private withTimeout(
+ promise: Promise,
+ timeoutMs: number,
+ ): Promise {
+ let timer:
+ ReturnType | undefined;
+
+ const timeout =
+ new Promise((_resolve, reject) => {
+ timer = setTimeout(() => {
+ reject(
+ new Error('Token Revocation Timed Out'),
+ );
+ }, timeoutMs);
+ });
+
+ return Promise.race([
+ promise,
+ timeout,
+ ]).finally(() => {
+ clearTimeout(timer);
+ });
+ }
+
+ private async handleAuthorizationCallback(
+ callbackUrl: string,
+ ): Promise {
+ await this.requestHandler
+ .completeAuthorizationRequestFromUrl(
+ callbackUrl,
+ );
+ }
+
+ private async internalEndSessionCallback():
+ Promise {
+ await this.closeBrowser();
+
+ await this.storage.removeItem(
+ TOKEN_RESPONSE_KEY,
+ );
+
+ this.notify({
+ action:
+ AuthActions.SignOutSuccess,
+ });
+ }
+
+ private async closeBrowser():
+ Promise {
+ if (
+ !this.platform.is('capacitor')
+ ) {
+ return;
+ }
+
+ try {
+ await Browser.close();
+ } catch {
+ // Browser may already be closed.
+ }
+ }
+
+ private async storeToken(
+ token: TokenResponse,
+ ): Promise {
+ await this.storage.setItem(
+ TOKEN_RESPONSE_KEY,
+
+ JSON.stringify(
+ token.toJson(),
+ ),
+ );
+ }
+
+ private notify(
+ action: IAuthAction,
+ ): void {
+ switch (action.action) {
+ case AuthActions.SignOutSuccess:
+ case AuthActions.SignOutFailed:
+ case AuthActions.SessionExpired: {
+ this.tokenSubject.next(
+ undefined,
+ );
+
+ this.userSubject.next(
+ undefined,
+ );
+
+ this.authenticatedSubject.next(
+ false,
+ );
+
+ this.loggedInSubject.next(
+ false,
+ );
+
+ break;
+ }
+
+ case AuthActions
+ .LoadTokenFromStorageFailed: {
+ this.tokenSubject.next(
+ undefined,
+ );
+
+ this.userSubject.next(
+ undefined,
+ );
+
+ this.authenticatedSubject.next(
+ false,
+ );
+
+ this.loggedInSubject.next(
+ false,
+ );
+
+ this.initCompleteSubject.next(
+ true,
+ );
+
+ break;
+ }
+
+ case AuthActions.SignInSuccess:
+ case AuthActions.RefreshSuccess: {
+ if (action.tokenResponse) {
+ this.tokenSubject.next(
+ action.tokenResponse,
+ );
+
+ this.authenticatedSubject.next(
+ true,
+ );
+
+ this.loggedInSubject.next(
+ true,
+ );
+ }
+
+ break;
+ }
+
+ case AuthActions
+ .LoadTokenFromStorageSuccess: {
+ if (action.tokenResponse) {
+ this.tokenSubject.next(
+ action.tokenResponse,
+ );
+
+ this.authenticatedSubject.next(
+ action.tokenResponse.isValid(
+ 0,
+ ),
+ );
+
+ this.loggedInSubject.next(
+ true,
+ );
+ }
+
+ this.initCompleteSubject.next(
+ true,
+ );
+
+ break;
+ }
+
+ case AuthActions
+ .RevokeTokensSuccess: {
+ this.tokenSubject.next(
+ undefined,
+ );
+
+ this.userSubject.next(
+ undefined,
+ );
+
+ this.authenticatedSubject.next(
+ false,
+ );
+
+ this.loggedInSubject.next(
+ false,
+ );
+
+ break;
+ }
+
+ case AuthActions
+ .LoadUserInfoSuccess: {
+ this.userSubject.next(
+ action.user,
+ );
+
+ break;
+ }
+
+ case AuthActions
+ .LoadUserInfoFailed: {
+ this.userSubject.next(
+ undefined,
+ );
+
+ break;
+ }
+
+ default:
+ break;
+ }
+
+ this.authSubject.next(
+ action,
+ );
+ }
+}
diff --git a/frontend/app/src/app/modules/auth/auth.service.ts b/frontend/app/src/app/modules/auth/auth.service.ts
deleted file mode 100644
index 1b38118f..00000000
--- a/frontend/app/src/app/modules/auth/auth.service.ts
+++ /dev/null
@@ -1,526 +0,0 @@
-/*
- * Copyright (C) 2023 StApps
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, version 3.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-// Temporary use of direct file until the version with bug fix is released
-// https://github.com/wi3land/ionic-appauth/blob/3716f4fc6b5491b0b75e049be0a47a5af8c4da6f/src/auth-service.ts
-// Bug: https://github.com/wi3land/ionic-appauth/issues/154
-import {
- AuthorizationError,
- AuthorizationNotifier,
- AuthorizationRequest,
- AuthorizationRequestHandler,
- AuthorizationRequestJson,
- AuthorizationResponse,
- AuthorizationServiceConfiguration,
- BaseTokenRequestHandler,
- DefaultCrypto,
- GRANT_TYPE_AUTHORIZATION_CODE,
- GRANT_TYPE_REFRESH_TOKEN,
- JQueryRequestor,
- LocalStorageBackend,
- Requestor,
- RevokeTokenRequest,
- RevokeTokenRequestJson,
- StorageBackend,
- StringMap,
- TokenRequest,
- TokenRequestHandler,
- TokenRequestJson,
- TokenResponse,
-} from '@openid/appauth';
-import {
- ActionHistoryObserver,
- AuthActionBuilder,
- AuthActions,
- AuthObserver,
- AUTHORIZATION_RESPONSE_KEY,
- AuthSubject,
- BaseAuthObserver,
- Browser,
- DefaultBrowser,
- EndSessionHandler,
- EndSessionRequest,
- EndSessionRequestJson,
- IAuthAction,
- IAuthConfig,
- IAuthService,
- IonicAuthorizationRequestHandler,
- IonicEndSessionHandler,
- IonicUserInfoHandler,
- SessionObserver,
- UserInfoHandler,
-} from 'ionic-appauth';
-import {BehaviorSubject, Observable} from 'rxjs';
-
-const TOKEN_RESPONSE_KEY = 'token_response';
-const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 mins in seconds
-
-export abstract class AuthService implements IAuthService {
- private _configuration?: AuthorizationServiceConfiguration;
-
- private _authConfig?: IAuthConfig;
-
- private _authSubject: AuthSubject = new AuthSubject();
-
- private _actionHistory: ActionHistoryObserver = new ActionHistoryObserver();
-
- private _session: SessionObserver = new SessionObserver();
-
- private _authSubjectV2 = new BehaviorSubject(AuthActionBuilder.Init());
-
- private _tokenSubject = new BehaviorSubject(undefined);
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private _userSubject = new BehaviorSubject(undefined);
-
- private _authenticatedSubject = new BehaviorSubject(false);
-
- private _loggedInSubject = new BehaviorSubject(false);
-
- private _initComplete = new BehaviorSubject(false);
-
- protected tokenHandler: TokenRequestHandler;
-
- protected userInfoHandler: UserInfoHandler;
-
- protected requestHandler: AuthorizationRequestHandler;
-
- protected endSessionHandler: EndSessionHandler;
-
- constructor(
- protected browser: Browser = new DefaultBrowser(),
- protected storage: StorageBackend = new LocalStorageBackend(),
- protected requestor: Requestor = new JQueryRequestor(),
- ) {
- this.tokenHandler = new BaseTokenRequestHandler(requestor);
- this.userInfoHandler = new IonicUserInfoHandler(requestor);
- this.requestHandler = new IonicAuthorizationRequestHandler(browser, storage);
- this.endSessionHandler = new IonicEndSessionHandler(browser);
- }
-
- /**
- * @deprecated independant observers have been replaced by Rxjs
- * this will be removed in a future release
- * please use $ suffixed observers in future
- */
- get history(): IAuthAction[] {
- return [...this._actionHistory.history];
- }
-
- /**
- * @deprecated independant observers have been replaced by Rxjs
- * this will be removed in a future release
- * please use $ suffixed observers in future
- */
- get session() {
- return this._session.session;
- }
-
- get token$(): Observable {
- return this._tokenSubject.asObservable();
- }
-
- get isAuthenticated$(): Observable {
- return this._authenticatedSubject.asObservable();
- }
-
- /**
- * Similar to isAuthenticated$, but will also return true if the token is expired
- */
- get isLoggedIn$(): Observable {
- return this._loggedInSubject.asObservable();
- }
-
- get initComplete$(): Observable {
- return this._initComplete.asObservable();
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- get user$(): Observable {
- return this._userSubject.asObservable();
- }
-
- get events$(): Observable {
- return this._authSubjectV2.asObservable();
- }
-
- get authConfig(): IAuthConfig {
- if (!this._authConfig) throw new Error('AuthConfig Not Defined');
-
- return this._authConfig;
- }
-
- set authConfig(value: IAuthConfig) {
- this._authConfig = value;
- }
-
- get configuration(): Promise {
- if (!this._configuration) {
- return AuthorizationServiceConfiguration.fetchFromIssuer(
- this.authConfig.server_host,
- this.requestor,
- ).catch(() => {
- throw new Error('Unable To Obtain Server Configuration');
- });
- }
-
- if (this._configuration == undefined) {
- throw new Error('Unable To Obtain Server Configuration');
- } else {
- return Promise.resolve(this._configuration);
- }
- }
-
- public async init() {
- this.setupAuthorizationNotifier();
- this.loadTokenFromStorage();
- this.addActionObserver(this._actionHistory);
- this.addActionObserver(this._session);
- }
-
- protected notifyActionListers(action: IAuthAction) {
- /* eslint-disable unicorn/no-useless-undefined */
- switch (action.action) {
- case AuthActions.SignInFailed:
- case AuthActions.SignOutSuccess:
- case AuthActions.SignOutFailed: {
- this._tokenSubject.next(undefined);
- this._userSubject.next(undefined);
- this._authenticatedSubject.next(false);
- this._loggedInSubject.next(false);
- break;
- }
- case AuthActions.LoadTokenFromStorageFailed: {
- this._tokenSubject.next(undefined);
- this._userSubject.next(undefined);
- this._authenticatedSubject.next(false);
- this._loggedInSubject.next(false);
- this._initComplete.next(true);
- break;
- }
- case AuthActions.SignInSuccess:
- case AuthActions.RefreshSuccess: {
- this._tokenSubject.next(action.tokenResponse);
- this._authenticatedSubject.next(true);
- this._loggedInSubject.next(true);
- break;
- }
- case AuthActions.LoadTokenFromStorageSuccess: {
- this._tokenSubject.next(action.tokenResponse);
- this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0));
- this._loggedInSubject.next(true);
- this._initComplete.next(true);
- break;
- }
- case AuthActions.RevokeTokensSuccess: {
- this._tokenSubject.next(undefined);
- break;
- }
- case AuthActions.LoadUserInfoSuccess: {
- this._userSubject.next(action.user);
- break;
- }
- case AuthActions.LoadUserInfoFailed: {
- this._userSubject.next(undefined);
- break;
- }
- }
-
- this._authSubjectV2.next(action);
- this._authSubject.notify(action);
- }
-
- protected setupAuthorizationNotifier() {
- const notifier = new AuthorizationNotifier();
- this.requestHandler.setAuthorizationNotifier(notifier);
- notifier.setAuthorizationListener((request, response, error) =>
- this.onAuthorizationNotification(request, response, error),
- );
- }
-
- protected onAuthorizationNotification(
- request: AuthorizationRequest,
- response: AuthorizationResponse | null,
- error: AuthorizationError | null,
- ) {
- const codeVerifier: string | undefined =
- request.internal != undefined && this.authConfig.pkce ? request.internal.code_verifier : undefined;
-
- if (response != undefined) {
- this.requestAccessToken(response.code, codeVerifier);
- } else if (error == undefined) {
- throw new Error('Unknown Error With Authentication');
- } else {
- throw new Error(error.errorDescription);
- }
- }
-
- protected async internalAuthorizationCallback(url: string) {
- this.browser.closeWindow();
- await this.storage.setItem(AUTHORIZATION_RESPONSE_KEY, url);
- return this.requestHandler.completeAuthorizationRequestIfPossible();
- }
-
- protected async internalEndSessionCallback() {
- this.browser.closeWindow();
- this._actionHistory.clear();
- this.notifyActionListers(AuthActionBuilder.SignOutSuccess());
- }
-
- protected async performEndSessionRequest(state?: string): Promise {
- if (this._tokenSubject.value == undefined) {
- //if user has no token they should not be logged in in the first place
- this.endSessionCallback();
- } else {
- const requestJson: EndSessionRequestJson = {
- postLogoutRedirectURI: this.authConfig.end_session_redirect_url,
- idTokenHint: this._tokenSubject.value.idToken || '',
- state: state || undefined,
- };
-
- const request: EndSessionRequest = new EndSessionRequest(requestJson);
- const returnedUrl: string | undefined = await this.endSessionHandler.performEndSessionRequest(
- await this.configuration,
- request,
- );
-
- //callback may come from showWindow or via another method
- if (returnedUrl != undefined) {
- this.endSessionCallback();
- }
- }
- }
-
- protected async performAuthorizationRequest(authExtras?: StringMap, state?: string): Promise {
- const requestJson: AuthorizationRequestJson = {
- response_type: AuthorizationRequest.RESPONSE_TYPE_CODE,
- client_id: this.authConfig.client_id,
- redirect_uri: this.authConfig.redirect_url,
- scope: this.authConfig.scopes,
- extras: authExtras,
- state: state || undefined,
- };
-
- const request = new AuthorizationRequest(requestJson, new DefaultCrypto(), this.authConfig.pkce);
-
- if (this.authConfig.pkce) await request.setupCodeVerifier();
-
- return this.requestHandler.performAuthorizationRequest(await this.configuration, request);
- }
-
- protected async requestAccessToken(code: string, codeVerifier?: string): Promise {
- const requestJSON: TokenRequestJson = {
- grant_type: GRANT_TYPE_AUTHORIZATION_CODE,
- code: code,
- refresh_token: undefined,
- redirect_uri: this.authConfig.redirect_url,
- client_id: this.authConfig.client_id,
- extras: codeVerifier
- ? {
- code_verifier: codeVerifier,
- client_secret: this.authConfig.client_secret as string,
- }
- : {
- client_secret: this.authConfig.client_secret as string,
- },
- };
-
- const token: TokenResponse = await this.tokenHandler.performTokenRequest(
- await this.configuration,
- new TokenRequest(requestJSON),
- );
- await this.storage.setItem(TOKEN_RESPONSE_KEY, JSON.stringify(token.toJson()));
- this.notifyActionListers(AuthActionBuilder.SignInSuccess(token));
- }
-
- protected async requestTokenRefresh() {
- if (!this._tokenSubject.value) {
- throw new Error('No Token Defined!');
- }
-
- const requestJSON: TokenRequestJson = {
- grant_type: GRANT_TYPE_REFRESH_TOKEN,
- refresh_token: this._tokenSubject.value?.refreshToken,
- redirect_uri: this.authConfig.redirect_url,
- client_id: this.authConfig.client_id,
- };
-
- const token: TokenResponse = await this.tokenHandler.performTokenRequest(
- await this.configuration,
- new TokenRequest(requestJSON),
- );
- if (!token.accessToken) {
- throw new Error('No Access Token Defined In Refresh Response');
- }
- await this.storage.setItem(TOKEN_RESPONSE_KEY, JSON.stringify(token.toJson()));
- this.notifyActionListers(AuthActionBuilder.RefreshSuccess(token));
- }
-
- protected async internalLoadTokenFromStorage() {
- let token: TokenResponse | undefined;
- const tokenResponseString: string | null = await this.storage.getItem(TOKEN_RESPONSE_KEY);
-
- if (tokenResponseString != undefined) {
- token = new TokenResponse(JSON.parse(tokenResponseString));
-
- if (token) {
- return this.notifyActionListers(AuthActionBuilder.LoadTokenFromStorageSuccess(token));
- }
- }
-
- throw new Error('No Token In Storage');
- }
-
- protected async requestTokenRevoke() {
- const revokeRefreshJson: RevokeTokenRequestJson = {
- token: (this._tokenSubject.value as TokenResponse).refreshToken as string,
- token_type_hint: 'refresh_token',
- client_id: this.authConfig.client_id,
- };
-
- const revokeAccessJson: RevokeTokenRequestJson = {
- token: (this._tokenSubject.value as TokenResponse).accessToken,
- token_type_hint: 'access_token',
- client_id: this.authConfig.client_id,
- };
-
- await this.tokenHandler.performRevokeTokenRequest(
- await this.configuration,
- new RevokeTokenRequest(revokeRefreshJson),
- );
- await this.tokenHandler.performRevokeTokenRequest(
- await this.configuration,
- new RevokeTokenRequest(revokeAccessJson),
- );
- await this.storage.removeItem(TOKEN_RESPONSE_KEY);
- this.notifyActionListers(AuthActionBuilder.RevokeTokensSuccess());
- }
-
- protected async internalRequestUserInfo() {
- if (this._tokenSubject.value) {
- const userInfo = await this.userInfoHandler.performUserInfoRequest(
- await this.configuration,
- this._tokenSubject.value,
- );
- this.notifyActionListers(AuthActionBuilder.LoadUserInfoSuccess(userInfo));
- } else {
- throw new Error('No Token Available');
- }
- }
-
- public async loadTokenFromStorage() {
- await this.internalLoadTokenFromStorage().catch(error => {
- this.notifyActionListers(AuthActionBuilder.LoadTokenFromStorageFailed(error));
- });
- }
-
- public async signIn(authExtras?: StringMap, state?: string) {
- await this.performAuthorizationRequest(authExtras, state).catch(error => {
- this.notifyActionListers(AuthActionBuilder.SignInFailed(error));
- });
- }
-
- public async signOut(state?: string, revokeTokens?: boolean) {
- if (revokeTokens) {
- await this.revokeTokens();
- }
-
- await this.storage.removeItem(TOKEN_RESPONSE_KEY);
-
- if ((await this.configuration).endSessionEndpoint) {
- await this.performEndSessionRequest(state).catch(error => {
- this.notifyActionListers(AuthActionBuilder.SignOutFailed(error));
- });
- }
- }
-
- public async revokeTokens() {
- await this.requestTokenRevoke().catch(error => {
- this.storage.removeItem(TOKEN_RESPONSE_KEY);
- this.notifyActionListers(AuthActionBuilder.RevokeTokensFailed(error));
- });
- }
-
- public async refreshToken() {
- await this.requestTokenRefresh().catch(error => {
- this.notifyActionListers(AuthActionBuilder.RefreshFailed(error));
- });
- }
-
- public async loadUserInfo() {
- await this.internalRequestUserInfo().catch(error => {
- this.notifyActionListers(AuthActionBuilder.LoadUserInfoFailed(error));
- });
- }
-
- public authorizationCallback(callbackUrl: string): void {
- this.internalAuthorizationCallback(callbackUrl).catch(error => {
- this.notifyActionListers(AuthActionBuilder.SignInFailed(error));
- });
- }
-
- public endSessionCallback(): void {
- this.internalEndSessionCallback().catch(error => {
- this.notifyActionListers(AuthActionBuilder.SignOutFailed(error));
- });
- }
-
- public async getValidToken(buffer: number = AUTH_EXPIRY_BUFFER): Promise {
- if (this._tokenSubject.value) {
- if (this._tokenSubject.value.isValid(buffer)) {
- return this._tokenSubject.value;
- } else {
- await this.refreshToken();
- if (this._tokenSubject.value) {
- return this._tokenSubject.value;
- }
- }
- }
-
- throw new Error('Unable To Obtain Valid Token');
- }
-
- /**
- * @deprecated independant observers have been replaced by Rxjs
- * this will be removed in a future release
- * please use $ suffixed observers in future
- */
- public addActionListener(function_: (action: IAuthAction) => void): AuthObserver {
- const observer: AuthObserver = AuthObserver.Create(function_);
- this.addActionObserver(observer);
- return observer;
- }
-
- /**
- * @deprecated independant observers have been replaced by Rxjs
- * this will be removed in a future release
- * please use $ suffixed observers in future
- */
- public addActionObserver(observer: BaseAuthObserver): void {
- if (this._actionHistory.lastAction) {
- observer.update(this._actionHistory.lastAction);
- }
-
- this._authSubject.attach(observer);
- }
-
- /**
- * @deprecated independant observers have been replaced by Rxjs
- * this will be removed in a future release
- * please use $ suffixed observers in future
- */
- public removeActionObserver(observer: BaseAuthObserver): void {
- this._authSubject.detach(observer);
- }
-}
diff --git a/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts
new file mode 100644
index 00000000..6e4a771d
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts
@@ -0,0 +1,328 @@
+/*
+ * Copyright (C) 2026 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import {
+ AuthorizationError,
+ AuthorizationNotifier,
+ AuthorizationRequest,
+ AuthorizationResponse,
+ AuthorizationServiceConfiguration,
+ LocalStorageBackend,
+ StorageBackend,
+} from '@openid/appauth';
+import {CapacitorAuthorizationRequestHandler} from './capacitor-authorization-request-handler';
+
+const PENDING_REQUEST_KEY = 'appauth_capacitor_authorization_request';
+
+/** Redirect URI of the native app (custom URL scheme). */
+const NATIVE_REDIRECT_URI = 'de.example.app://app.example.org/auth/callback';
+
+/** Redirect URI of the web app (callback page). */
+const WEB_REDIRECT_URI = 'https://app.example.org/auth/callback';
+
+const configuration = new AuthorizationServiceConfiguration({
+ authorization_endpoint: 'https://idp.example.org/authorize',
+ token_endpoint: 'https://idp.example.org/token',
+ revocation_endpoint: '',
+});
+
+type AuthorizationListener = jasmine.Spy<
+ (
+ request: AuthorizationRequest,
+ response: AuthorizationResponse | null,
+ error: AuthorizationError | null,
+ ) => void
+>;
+
+/**
+ * In-memory StorageBackend, so the tests can look at what the handler stores
+ * (stands in for the secure storage of the native app).
+ */
+class MemoryStorage extends StorageBackend {
+ readonly items = new Map();
+
+ async getItem(name: string): Promise {
+ return this.items.get(name) ?? null; // eslint-disable-line unicorn/no-null
+ }
+
+ async removeItem(name: string): Promise {
+ this.items.delete(name);
+ }
+
+ async clear(): Promise {
+ this.items.clear();
+ }
+
+ async setItem(name: string, value: string): Promise {
+ this.items.set(name, value);
+ }
+}
+
+/**
+ * Connects a spy that receives the result of each completed login.
+ */
+function listenForResults(handler: CapacitorAuthorizationRequestHandler): AuthorizationListener {
+ const notifier = new AuthorizationNotifier();
+ const listener: AuthorizationListener = jasmine.createSpy('authorizationListener');
+ notifier.setAuthorizationListener(listener);
+ handler.setAuthorizationNotifier(notifier);
+ return listener;
+}
+
+/**
+ * Starts a login like AuthProvider.signIn() does and returns its state.
+ */
+async function startLogin(
+ handler: CapacitorAuthorizationRequestHandler,
+ redirectUri: string,
+): Promise {
+ const request = new AuthorizationRequest({
+ response_type: AuthorizationRequest.RESPONSE_TYPE_CODE,
+ client_id: 'test-client',
+ redirect_uri: redirectUri,
+ scope: 'openid',
+ });
+
+ await handler.performAuthorizationRequest(configuration, request);
+
+ return request.state;
+}
+
+/**
+ * The callback URL as the IdP would send it back.
+ */
+function callbackUrl(redirectUri: string, parameters: Record): string {
+ return `${redirectUri}?${new URLSearchParams(parameters).toString()}`;
+}
+
+/**
+ * The AppAuth keys in localStorage (pending logins on the web).
+ */
+function appAuthKeys(): string[] {
+ return Object.keys(localStorage).filter(key => key.includes('appauth'));
+}
+
+describe('CapacitorAuthorizationRequestHandler', () => {
+ describe('in the native app', () => {
+ let storage: MemoryStorage;
+ let handler: CapacitorAuthorizationRequestHandler;
+ let listener: AuthorizationListener;
+ let windowOpen: jasmine.Spy;
+ let browserClose: jasmine.Spy;
+
+ const login = () => startLogin(handler, NATIVE_REDIRECT_URI);
+ const callback = (parameters: Record) => callbackUrl(NATIVE_REDIRECT_URI, parameters);
+
+ beforeEach(() => {
+ storage = new MemoryStorage();
+ // Same setup as AuthProvider in the native app: Capacitor browser (the defaults).
+ handler = new CapacitorAuthorizationRequestHandler(storage);
+ listener = listenForResults(handler);
+
+ // In tests, @capacitor/browser uses its web implementation,
+ // which opens the page with window.open() and closes that window again.
+ browserClose = jasmine.createSpy('close');
+ windowOpen = spyOn(window, 'open').and.returnValue({close: browserClose} as unknown as Window);
+ });
+
+ describe('starting the login', () => {
+ it('opens the authorization URL with state and PKCE (S256) in the in-app browser', async () => {
+ const state = await login();
+
+ const url = new URL(windowOpen.calls.mostRecent().args[0] as string);
+ expect(url.origin + url.pathname).toBe('https://idp.example.org/authorize');
+ expect(url.searchParams.get('state')).toBe(state);
+ expect(url.searchParams.get('redirect_uri')).toBe(NATIVE_REDIRECT_URI);
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(url.searchParams.get('code_challenge')).toBeTruthy();
+ });
+
+ it('stores the pending request including the PKCE code_verifier', async () => {
+ const state = await login();
+
+ const stored = JSON.parse(storage.items.get(PENDING_REQUEST_KEY)!);
+ expect(stored.state).toBe(state);
+ expect(stored.internal.code_verifier).toBeTruthy();
+ });
+
+ it('keeps only one pending login, however often the login is started', async () => {
+ await login();
+ await login();
+ await login();
+
+ expect([...storage.items.keys()]).toEqual([PENDING_REQUEST_KEY]);
+ });
+ });
+
+ describe('completing the login', () => {
+ it('delivers the code together with the matching request', async () => {
+ const state = await login();
+ const verifier = JSON.parse(storage.items.get(PENDING_REQUEST_KEY)!).internal.code_verifier;
+
+ await handler.completeAuthorizationRequestFromUrl(callback({code: 'auth-code', state}));
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ const [request, response, error] = listener.calls.mostRecent().args;
+ expect(response?.code).toBe('auth-code');
+ expect(error).toBeNull();
+ expect(request.internal?.['code_verifier']).toBe(verifier);
+ });
+
+ it('closes the in-app browser when the callback arrives', async () => {
+ const state = await login();
+
+ await handler.completeAuthorizationRequestFromUrl(callback({code: 'auth-code', state}));
+
+ expect(browserClose).toHaveBeenCalled();
+ });
+
+ it('accepts a callback only once', async () => {
+ const state = await login();
+ const url = callback({code: 'auth-code', state});
+
+ await handler.completeAuthorizationRequestFromUrl(url);
+ expect(storage.items.has(PENDING_REQUEST_KEY)).toBeFalse();
+
+ await expectAsync(handler.completeAuthorizationRequestFromUrl(url)).toBeRejectedWithError(
+ /No pending authorization request/,
+ );
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects a callback with a different state and keeps the pending request', async () => {
+ const state = await login();
+
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(
+ callback({code: 'attacker-code', state: 'other-state'}),
+ ),
+ ).toBeRejectedWithError(/state does not match/);
+ expect(listener).not.toHaveBeenCalled();
+
+ // The real callback still works afterwards.
+ await handler.completeAuthorizationRequestFromUrl(callback({code: 'auth-code', state}));
+ expect(listener.calls.mostRecent().args[1]?.code).toBe('auth-code');
+ });
+
+ it('rejects a callback without state', async () => {
+ await login();
+
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callback({code: 'auth-code'})),
+ ).toBeRejectedWithError(/state does not match/);
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('rejects a callback when no login was started', async () => {
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callback({code: 'auth-code', state: 'any-state'})),
+ ).toBeRejectedWithError(/No pending authorization request/);
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('delivers an error response from the IdP and ends the pending request', async () => {
+ const state = await login();
+
+ await handler.completeAuthorizationRequestFromUrl(
+ callback({error: 'access_denied', error_description: 'User cancelled', state}),
+ );
+
+ const [, response, error] = listener.calls.mostRecent().args;
+ expect(response).toBeNull();
+ expect(error?.error).toBe('access_denied');
+ expect(error?.errorDescription).toBe('User cancelled');
+ expect(storage.items.has(PENDING_REQUEST_KEY)).toBeFalse();
+ });
+
+ it('rejects a callback with neither code nor error', async () => {
+ const state = await login();
+
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callback({state})),
+ ).toBeRejectedWithError(/neither an authorization code nor an error/);
+ expect(listener).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('on the web', () => {
+ let handler: CapacitorAuthorizationRequestHandler;
+ let listener: AuthorizationListener;
+ let openAuthorizationUrl: jasmine.Spy<(url: string) => Promise>;
+
+ const login = () => startLogin(handler, WEB_REDIRECT_URI);
+ const callback = (parameters: Record) => callbackUrl(WEB_REDIRECT_URI, parameters);
+
+ beforeEach(() => {
+ for (const key of appAuthKeys()) localStorage.removeItem(key);
+
+ // Same setup as AuthProvider on the web: localStorage, page redirect, nothing to close.
+ // The redirect is a spy, so the test does not actually leave the page.
+ openAuthorizationUrl = jasmine.createSpy('openAuthorizationUrl').and.resolveTo();
+ handler = new CapacitorAuthorizationRequestHandler(
+ new LocalStorageBackend(),
+ openAuthorizationUrl,
+ async () => {},
+ );
+ listener = listenForResults(handler);
+ });
+
+ afterEach(() => {
+ for (const key of appAuthKeys()) localStorage.removeItem(key);
+ });
+
+ it('opens the IdP login through the given function (page redirect)', async () => {
+ const state = await login();
+
+ const url = new URL(openAuthorizationUrl.calls.mostRecent().args[0]);
+ expect(url.searchParams.get('state')).toBe(state);
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
+ });
+
+ it('keeps only one pending login, however often the login is started', async () => {
+ await login();
+ await login();
+ await login();
+
+ expect(appAuthKeys()).toEqual([PENDING_REQUEST_KEY]);
+ });
+
+ it('completes the login from the callback page URL and leaves nothing behind', async () => {
+ const state = await login();
+
+ await handler.completeAuthorizationRequestFromUrl(callback({code: 'auth-code', state}));
+
+ expect(listener.calls.mostRecent().args[1]?.code).toBe('auth-code');
+ expect(appAuthKeys()).toEqual([]);
+ });
+
+ it('only accepts the callback of the latest login', async () => {
+ const firstState = await login();
+ const latestState = await login();
+
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callback({code: 'old', state: firstState})),
+ ).toBeRejectedWithError(/state does not match/);
+
+ await handler.completeAuthorizationRequestFromUrl(callback({code: 'new', state: latestState}));
+ expect(listener.calls.mostRecent().args[1]?.code).toBe('new');
+ });
+
+ it('reports an opened callback without a pending login instead of waiting forever', async () => {
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callback({code: 'bookmarked', state: 'old'})),
+ ).toBeRejectedWithError(/No pending authorization request/);
+ });
+ });
+});
diff --git a/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts
new file mode 100644
index 00000000..554bf9d9
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts
@@ -0,0 +1,287 @@
+/*
+ * Copyright (C) 2026 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+
+import {Browser} from '@capacitor/browser';
+import {
+ AuthorizationError,
+ AuthorizationErrorJson,
+ AuthorizationRequest,
+ AuthorizationRequestHandler,
+ AuthorizationRequestJson,
+ AuthorizationRequestResponse,
+ AuthorizationResponse,
+ AuthorizationResponseJson,
+ AuthorizationServiceConfiguration,
+ BasicQueryStringUtils,
+ DefaultCrypto,
+ StorageBackend,
+ StringMap,
+} from '@openid/appauth';
+
+const AUTHORIZATION_REQUEST_KEY =
+ 'appauth_capacitor_authorization_request';
+
+/**
+ * AppAuth authorization request handler for the native apps and the web.
+ *
+ * AppAuth-JS handles:
+ * - state generation and validation data
+ * - PKCE code_verifier / code_challenge
+ * - authorization URL construction
+ * - AuthorizationNotifier
+ *
+ * This handler adds:
+ * - persist the pending authorization request
+ * - open the authorization URL (native: @capacitor/browser, web: page redirect)
+ * - process the callback URL (native: delivered through appUrlOpen,
+ * web: the URL of the callback page)
+ *
+ * The pending request is stored under one fixed key, so each new login
+ * replaces a canceled one instead of leaving it behind.
+ */
+export class CapacitorAuthorizationRequestHandler extends AuthorizationRequestHandler {
+ private callbackUrl?: string;
+
+ /**
+ * @param storage Where the pending request is kept until the callback.
+ * @param openAuthorizationUrl Opens the IdP login (default: Capacitor browser).
+ * @param closeBrowser Closes it after the callback (default: Capacitor browser).
+ */
+ constructor(
+ private readonly storage: StorageBackend,
+ private readonly openAuthorizationUrl: (url: string) => Promise = async url => {
+ await Browser.open({url});
+ },
+ private readonly closeBrowser: () => Promise = async () => {
+ await Browser.close();
+ },
+ ) {
+ super(
+ new BasicQueryStringUtils(),
+ new DefaultCrypto(),
+ );
+ }
+
+ public async performAuthorizationRequest(
+ configuration: AuthorizationServiceConfiguration,
+ request: AuthorizationRequest,
+ ): Promise {
+ /*
+ * Important:
+ *
+ * toJson() calls setupCodeVerifier() internally.
+ * Therefore PKCE is initialized before buildRequestUrl()
+ * constructs the authorization URL.
+ */
+ const requestJson =
+ await request.toJson();
+
+ await this.storage.setItem(
+ AUTHORIZATION_REQUEST_KEY,
+ JSON.stringify(requestJson),
+ );
+
+ const url =
+ this.buildRequestUrl(
+ configuration,
+ request,
+ );
+
+ await this.openAuthorizationUrl(url);
+ }
+
+ /**
+ * Completes the authorization flow using the URL received from
+ * Capacitor's App.addListener('appUrlOpen', ...).
+ */
+ public async completeAuthorizationRequestFromUrl(
+ callbackUrl: string,
+ ): Promise {
+ this.callbackUrl = callbackUrl;
+
+ /*
+ * The deep link has returned control to the application.
+ * Close the SFSafariViewController / Custom Tab if it is still open.
+ */
+ try {
+ await this.closeBrowser();
+ } catch {
+ // Browser might already have been closed by the platform.
+ }
+
+ await this.completeAuthorizationRequestIfPossible();
+ }
+
+ protected async completeAuthorizationRequest():
+ Promise {
+ if (!this.callbackUrl) {
+ return null;
+ }
+
+ const storedRequest =
+ await this.storage.getItem(
+ AUTHORIZATION_REQUEST_KEY,
+ );
+
+ if (!storedRequest) {
+ this.callbackUrl = undefined;
+
+ throw new Error(
+ 'No pending authorization request found.',
+ );
+ }
+
+ const requestJson =
+ JSON.parse(
+ storedRequest,
+ ) as AuthorizationRequestJson;
+
+ /*
+ * The serialized request also contains `internal`, including
+ * AppAuth's PKCE code_verifier.
+ */
+ const request =
+ new AuthorizationRequest(requestJson);
+
+ const parameters =
+ this.parseCallbackUrl(
+ this.callbackUrl,
+ );
+
+ const state =
+ parameters['state'];
+
+ /*
+ * Never accept an authorization response for a different request.
+ */
+ if (!state || state !== request.state) {
+ this.callbackUrl = undefined;
+
+ throw new Error(
+ 'Authorization response state does not match the authorization request.',
+ );
+ }
+
+ const error =
+ parameters['error'];
+
+ let response:
+ AuthorizationResponse | null = null;
+
+ let authorizationError:
+ AuthorizationError | null = null;
+
+ if (error) {
+ const errorJson:
+ AuthorizationErrorJson = {
+ error,
+
+ error_description:
+ parameters[
+ 'error_description'
+ ],
+
+ error_uri:
+ parameters['error_uri'],
+
+ state,
+ };
+
+ authorizationError =
+ new AuthorizationError(
+ errorJson,
+ );
+ } else {
+ const code =
+ parameters['code'];
+
+ if (!code) {
+ this.callbackUrl = undefined;
+
+ throw new Error(
+ 'Authorization callback contains neither an authorization code nor an error.',
+ );
+ }
+
+ const responseJson:
+ AuthorizationResponseJson = {
+ code,
+ state,
+ };
+
+ response =
+ new AuthorizationResponse(
+ responseJson,
+ );
+ }
+
+ /*
+ * The response has been successfully associated with the
+ * pending request. It can now be consumed exactly once.
+ */
+ await this.storage.removeItem(
+ AUTHORIZATION_REQUEST_KEY,
+ );
+
+ this.callbackUrl = undefined;
+
+ return {
+ request,
+ response,
+ error: authorizationError,
+ };
+ }
+
+ private parseCallbackUrl(
+ callbackUrl: string,
+ ): StringMap {
+ const result: StringMap = {};
+
+ const url = new URL(callbackUrl);
+
+ /*
+ * Authorization Code Flow normally returns code/state in
+ * the query string:
+ *
+ * openstapps:/callback?code=...&state=...
+ */
+ url.searchParams.forEach(
+ (value, key) => {
+ result[key] = value;
+ },
+ );
+
+ /*
+ * Also accept parameters in the fragment as a fallback.
+ */
+ if (url.hash.length > 1) {
+ const hash =
+ url.hash.substring(1);
+
+ const hashParameters =
+ new URLSearchParams(hash);
+
+ hashParameters.forEach(
+ (value, key) => {
+ if (!(key in result)) {
+ result[key] = value;
+ }
+ },
+ );
+ }
+
+ return result;
+ }
+}
diff --git a/frontend/app/src/app/modules/auth/capacitor-requestor.ts b/frontend/app/src/app/modules/auth/capacitor-requestor.ts
index cdd2d601..84e5be17 100644
--- a/frontend/app/src/app/modules/auth/capacitor-requestor.ts
+++ b/frontend/app/src/app/modules/auth/capacitor-requestor.ts
@@ -13,61 +13,96 @@
* this program. If not, see .
*/
-import {Requestor} from '@openid/appauth';
-import {CapacitorHttp, HttpHeaders, HttpResponse} from '@capacitor/core';
-import {XhrSettings} from 'ionic-appauth/lib/cordova';
+import {
+ AppAuthError,
+ Requestor,
+} from '@openid/appauth';
+import {
+ CapacitorHttp,
+ HttpHeaders,
+ HttpOptions,
+} from '@capacitor/core';
-// REQUIRES CAPACITOR PLUGIN
-// @capacitor-community/http
-export class CapacitorRequestor extends Requestor {
- constructor() {
- super();
- }
+type XhrSettings = Parameters[0];
+export class CapacitorRequestor implements Requestor {
public async xhr(settings: XhrSettings): Promise {
- if (!settings.method) settings.method = 'GET';
+ if (!settings.url) {
+ throw new AppAuthError('A URL must be provided.');
+ }
- switch (settings.method) {
- case 'GET': {
- return this.get(settings.url, settings.headers);
- }
- case 'POST': {
- return this.post(settings.url, settings.data, settings.headers);
- }
- case 'PUT': {
- return this.put(settings.url, settings.data, settings.headers);
- }
- case 'DELETE': {
- return this.delete(settings.url, settings.headers);
+ const method = (settings.method ?? 'GET').toUpperCase();
+ const url = new URL(settings.url);
+
+ let data: unknown;
+
+ if (settings.data) {
+ if (method === 'POST') {
+ data = settings.data;
+ } else {
+ const searchParams = new URLSearchParams(settings.data);
+
+ searchParams.forEach((value, key) => {
+ url.searchParams.append(key, value);
+ });
}
}
- }
- private async get(url: string, headers: HttpHeaders) {
- return CapacitorHttp.get({url, headers}).then((response: HttpResponse) => response.data as T);
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private async post(url: string, data: any, headers: HttpHeaders) {
- return CapacitorHttp.post({
- url,
+ const options: HttpOptions = {
+ url: url.toString(),
+ method,
+ headers: this.getHeaders(settings.headers),
data,
- headers,
- }).then((response: HttpResponse) => {
- return response.data as T;
- });
+ };
+
+ if (settings.dataType?.toLowerCase() === 'json') {
+ options.responseType = 'json';
+ }
+
+ const response = await CapacitorHttp.request(options);
+
+ if (response.status < 200 || response.status >= 300) {
+ const body = this.parseBody(response.data);
+
+ /*
+ * Use the OAuth error code (e.g. 'invalid_grant') as message when the
+ * server sent one (RFC 6749, 5.2), so callers can tell a rejected
+ * refresh token apart from temporary failures.
+ */
+ throw new AppAuthError(
+ typeof body?.error === 'string' ? body.error : `HTTP ${response.status}`,
+ body ?? response.data,
+ );
+ }
+
+ return response.data as T;
}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private async put(url: string, data: any, headers: HttpHeaders) {
- return CapacitorHttp.put({
- url,
- data,
- headers,
- }).then((response: HttpResponse) => response.data as T);
+ private parseBody(data: unknown): Record | undefined {
+ if (typeof data === 'string') {
+ try {
+ data = JSON.parse(data);
+ } catch {
+ return undefined;
+ }
+ }
+
+ return data !== null && typeof data === 'object' ? (data as Record) : undefined;
}
- private async delete(url: string, headers: HttpHeaders) {
- return CapacitorHttp.delete({url, headers}).then((response: HttpResponse) => response.data as T);
+ private getHeaders(headers: XhrSettings['headers']): HttpHeaders {
+ const result: HttpHeaders = {};
+
+ if (!headers) {
+ return result;
+ }
+
+ for (const [key, value] of Object.entries(headers)) {
+ if (value !== undefined && value !== null) {
+ result[key] = String(value);
+ }
+ }
+
+ return result;
}
}
diff --git a/frontend/app/src/app/modules/auth/default-auth.service.spec.ts b/frontend/app/src/app/modules/auth/default-auth.service.spec.ts
deleted file mode 100644
index 4b5e1e0c..00000000
--- a/frontend/app/src/app/modules/auth/default-auth.service.spec.ts
+++ /dev/null
@@ -1,104 +0,0 @@
-/*
- * Copyright (C) 2023 StApps
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, version 3.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-import {TestBed} from '@angular/core/testing';
-import {ConfigProvider} from '../config/config.provider';
-import {StorageProvider} from '../storage/storage.provider';
-import {DefaultAuthService} from './default-auth.service';
-import {Browser} from 'ionic-appauth';
-import {nowInSeconds, Requestor, StorageBackend} from '@openid/appauth';
-import {TranslateService} from '@ngx-translate/core';
-import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
-import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http';
-import {IonicStorage} from 'ionic-appauth/lib';
-import {RouterModule} from '@angular/router';
-import {LoggerTestingModule} from 'ngx-logger/testing';
-
-describe('AuthService', () => {
- let defaultAuthService: DefaultAuthService;
- let storageBackendSpy: jasmine.SpyObj;
- const storageProviderSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put', 'search']);
- const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
-
- beforeEach(() => {
- storageBackendSpy = jasmine.createSpyObj('StorageBackend', ['getItem']);
-
- TestBed.configureTestingModule({
- imports: [LoggerTestingModule, RouterModule.forRoot([])],
- providers: [
- StAppsWebHttpClient,
- {
- provide: TranslateService,
- useValue: translateServiceSpy,
- },
- {
- provide: StorageProvider,
- useValue: storageProviderSpy,
- },
- IonicStorage,
- ConfigProvider,
- Browser,
- {
- provide: StorageBackend,
- useValue: storageBackendSpy,
- },
- Requestor,
- provideHttpClient(withInterceptorsFromDi()),
- ],
- });
- defaultAuthService = TestBed.inject(DefaultAuthService);
- });
-
- describe('loadTokenFromStorage', () => {
- it('should provide false through isAuthenticated$ when there is no token response', async () => {
- // eslint-disable-next-line unicorn/no-null
- storageBackendSpy.getItem.and.returnValue(Promise.resolve(null));
- let loggedInHolder;
- defaultAuthService.isAuthenticated$.subscribe(loggedIn => {
- loggedInHolder = loggedIn;
- });
- await defaultAuthService.loadTokenFromStorage();
-
- expect(loggedInHolder).toBeFalse();
- });
-
- it('should provide true through isAuthenticated$ when access token is valid', async () => {
- const validToken = `{"access_token":"AT-XXXX","refresh_token":"RT-XXXX","scope":"","token_type":"bearer","issued_at":${nowInSeconds()},"expires_in":"${
- 8 * 60 * 60
- }"}`;
- storageBackendSpy.getItem.and.returnValue(Promise.resolve(validToken));
- let loggedInHolder;
- defaultAuthService.isAuthenticated$.subscribe(loggedIn => {
- loggedInHolder = loggedIn;
- });
- await defaultAuthService.loadTokenFromStorage();
-
- expect(loggedInHolder).toBeTrue();
- });
-
- it('should provide false through isAuthenticated$ when access token is invalid', async () => {
- const invalidToken = `{"access_token":"AT-INVALID-XXXX","refresh_token":"RT-XXXX","scope":"","token_type":"bearer","issued_at":${
- nowInSeconds() - 9 * 60 * 60
- },"expires_in":"${8 * 60 * 60}"}`;
- storageBackendSpy.getItem.and.returnValue(Promise.resolve(invalidToken));
- let loggedInHolder;
- defaultAuthService.isAuthenticated$.subscribe(loggedIn => {
- loggedInHolder = loggedIn;
- });
- await defaultAuthService.loadTokenFromStorage();
-
- expect(loggedInHolder).toBeFalse();
- });
- });
-});
diff --git a/frontend/app/src/app/modules/auth/default-auth.service.ts b/frontend/app/src/app/modules/auth/default-auth.service.ts
deleted file mode 100644
index e9659d9e..00000000
--- a/frontend/app/src/app/modules/auth/default-auth.service.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * Copyright (C) 2023 StApps
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, version 3.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-
-import {
- AuthorizationRequestHandler,
- AuthorizationServiceConfiguration,
- JQueryRequestor,
- LocalStorageBackend,
- Requestor,
- StorageBackend,
- TokenRequestHandler,
-} from '@openid/appauth';
-import {AuthActionBuilder, Browser, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth';
-import {ConfigProvider} from '../config/config.provider';
-import {SCAuthorizationProvider} from '@openstapps/core';
-import {getClientConfig, getEndpointsConfig} from './auth.provider.methods';
-import {Injectable, inject} from '@angular/core';
-import {AuthService} from './auth.service';
-
-const TOKEN_RESPONSE_KEY = 'token_response';
-
-@Injectable({
- providedIn: 'root',
-})
-export class DefaultAuthService extends AuthService {
- protected browser: Browser;
-
- protected storage: StorageBackend;
-
- protected requestor: Requestor;
-
- private readonly configProvider = inject(ConfigProvider);
-
- public localConfiguration: AuthorizationServiceConfiguration;
-
- protected tokenHandler: TokenRequestHandler;
-
- protected userInfoHandler: UserInfoHandler;
-
- protected requestHandler: AuthorizationRequestHandler;
-
- protected endSessionHandler: EndSessionHandler;
-
- constructor() {
- const browser = inject(Browser) ?? new DefaultBrowser();
- const storage = inject(StorageBackend) ?? new LocalStorageBackend();
- const requestor = inject(Requestor) ?? new JQueryRequestor();
-
- super(browser, storage, requestor);
-
- this.browser = browser;
- this.storage = storage;
- this.requestor = requestor;
- }
-
- get configuration(): Promise {
- if (!this.localConfiguration) throw new Error('Local Configuration Not Defined');
-
- return Promise.resolve(this.localConfiguration);
- }
-
- public async init() {
- this.setupConfiguration();
- this.setupAuthorizationNotifier();
- await this.loadTokenFromStorage();
- }
-
- setupConfiguration() {
- const authConfig = this.configProvider.getAnyValue('auth') as {
- default: SCAuthorizationProvider;
- };
- this.authConfig = getClientConfig('default', authConfig);
- this.localConfiguration = new AuthorizationServiceConfiguration(
- getEndpointsConfig('default', authConfig),
- );
- }
-
- public async signOut() {
- await this.revokeTokens().catch(error => {
- this.notifyActionListers(AuthActionBuilder.SignOutFailed(error));
- });
- this.notifyActionListers(AuthActionBuilder.SignOutSuccess());
- }
-
- public async revokeTokens() {
- // Note: only locally
- await this.storage.removeItem(TOKEN_RESPONSE_KEY);
- this.notifyActionListers(AuthActionBuilder.RevokeTokensSuccess());
- }
-}
diff --git a/frontend/app/src/app/modules/auth/factories/http.factory.ts b/frontend/app/src/app/modules/auth/factories/requestor.factory.ts
similarity index 76%
rename from frontend/app/src/app/modules/auth/factories/http.factory.ts
rename to frontend/app/src/app/modules/auth/factories/requestor.factory.ts
index 0a7e28b3..24345917 100644
--- a/frontend/app/src/app/modules/auth/factories/http.factory.ts
+++ b/frontend/app/src/app/modules/auth/factories/requestor.factory.ts
@@ -14,9 +14,11 @@
*/
import {Platform} from '@ionic/angular/standalone';
+import {FetchRequestor, Requestor} from '@openid/appauth';
import {CapacitorRequestor} from '../capacitor-requestor';
-import {NgHttpService} from '../ng-http.service';
-export const httpFactory = (platform: Platform) => {
- return platform.is('capacitor') ? new CapacitorRequestor() : new NgHttpService();
+export const requestorFactory = (platform: Platform): Requestor => {
+ return platform.is('capacitor')
+ ? new CapacitorRequestor()
+ : new FetchRequestor();
};
diff --git a/frontend/app/src/app/modules/auth/factories/storage.factory.ts b/frontend/app/src/app/modules/auth/factories/storage.factory.ts
index 2b9a3449..cd4a2c7c 100644
--- a/frontend/app/src/app/modules/auth/factories/storage.factory.ts
+++ b/frontend/app/src/app/modules/auth/factories/storage.factory.ts
@@ -14,9 +14,11 @@
*/
import {Platform} from '@ionic/angular/standalone';
-import {IonicStorage} from 'ionic-appauth/lib';
+import {LocalStorageBackend, StorageBackend} from '@openid/appauth';
import {SafeCapacitorSecureStorage} from '../../storage/capacitor-secure-storage';
-export const storageFactory = (platform: Platform) => {
- return platform.is('capacitor') ? new SafeCapacitorSecureStorage() : new IonicStorage();
+export const storageFactory = (platform: Platform): StorageBackend => {
+ return platform.is('capacitor')
+ ? new SafeCapacitorSecureStorage()
+ : new LocalStorageBackend();
};
diff --git a/frontend/app/src/app/modules/auth/ng-http.service.ts b/frontend/app/src/app/modules/auth/ng-http.service.ts
deleted file mode 100644
index bbb13a4f..00000000
--- a/frontend/app/src/app/modules/auth/ng-http.service.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * Copyright (C) 2023 StApps
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, version 3.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-
-import {Injectable, inject} from '@angular/core';
-import {Requestor} from '@openid/appauth';
-import {HttpClient, HttpHeaders} from '@angular/common/http';
-import {XhrSettings} from 'ionic-appauth/lib/cordova';
-import {firstValueFrom, Observable} from 'rxjs';
-
-@Injectable({
- providedIn: 'root',
-})
-export class NgHttpService implements Requestor {
- private http = inject(HttpClient);
-
- public async xhr(settings: XhrSettings): Promise {
- if (!settings.method) {
- settings.method = 'GET';
- }
-
- let observable: Observable;
-
- switch (settings.method) {
- case 'GET': {
- observable = this.http.get(settings.url, {
- headers: this.getHeaders(settings.headers),
- });
- break;
- }
- case 'POST': {
- observable = this.http.post(settings.url, settings.data, {
- headers: this.getHeaders(settings.headers),
- });
- break;
- }
- case 'PUT': {
- observable = this.http.put(settings.url, settings.data, {
- headers: this.getHeaders(settings.headers),
- });
- break;
- }
- case 'DELETE': {
- observable = this.http.delete(settings.url, {
- headers: this.getHeaders(settings.headers),
- });
- break;
- }
- }
-
- return firstValueFrom(observable);
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private getHeaders(headers: any): HttpHeaders {
- let httpHeaders: HttpHeaders = new HttpHeaders();
-
- if (headers !== undefined) {
- for (const key of Object.keys(headers)) {
- httpHeaders = httpHeaders.append(key, headers[key]);
- }
- }
-
- return httpHeaders;
- }
-}
diff --git a/frontend/app/src/app/modules/auth/user-info.model.ts b/frontend/app/src/app/modules/auth/user-info.model.ts
deleted file mode 100644
index c8bf3aff..00000000
--- a/frontend/app/src/app/modules/auth/user-info.model.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/*
- * Copyright (C) 2022 StApps
- * This program is free software: you can redistribute it and/or modify it
- * under the terms of the GNU General Public License as published by the Free
- * Software Foundation, version 3.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT
- * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
- * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
- * more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
-
-export interface IUserInfo {
- display_name: string;
- role: string;
- email: string;
- user_name: string;
-}
diff --git a/frontend/app/src/app/modules/profile/id-cards.provider.ts b/frontend/app/src/app/modules/profile/id-cards.provider.ts
index 1abd0ea6..ec9b7c97 100644
--- a/frontend/app/src/app/modules/profile/id-cards.provider.ts
+++ b/frontend/app/src/app/modules/profile/id-cards.provider.ts
@@ -1,16 +1,19 @@
import {Injectable, inject} from '@angular/core';
import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core';
import {from, of, Observable} from 'rxjs';
-import {AuthHelperService} from '../auth/auth-helper.service';
import {mergeMap, concatWith, filter, map, startWith, catchError, tap} from 'rxjs/operators';
import {ConfigProvider} from '../config/config.provider';
import {HttpClient} from '@angular/common/http';
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
+import {AuthProvider} from "../auth/auth.provider";
+import {AuthHelperService} from "../auth/auth-helper.service";
@Injectable({providedIn: 'root'})
export class IdCardsProvider {
private authHelper = inject(AuthHelperService);
+ private authProvider = inject(AuthProvider);
+
private config = inject(ConfigProvider);
private httpClient = inject(HttpClient);
@@ -19,7 +22,7 @@ export class IdCardsProvider {
getIdCards(): Observable {
const feature = this.config.config.app.features.extern?.['idCards'];
- const auth = this.authHelper.getProvider();
+ const auth = this.authProvider;
const storedIdCards = from(
this.encryptedStorageProvider.get('id-cards') as Promise,
).pipe(filter(it => it !== undefined));
diff --git a/frontend/app/src/app/modules/profile/id-cards.spec.ts b/frontend/app/src/app/modules/profile/id-cards.spec.ts
index 66d710f0..488b1f1e 100644
--- a/frontend/app/src/app/modules/profile/id-cards.spec.ts
+++ b/frontend/app/src/app/modules/profile/id-cards.spec.ts
@@ -2,19 +2,18 @@ import {ConfigProvider} from '../config/config.provider';
import {IdCardsProvider} from './id-cards.provider';
import {HttpClient} from '@angular/common/http';
import {AuthHelperService} from '../auth/auth-helper.service';
+import {AuthProvider} from '../auth/auth.provider';
import {BehaviorSubject, firstValueFrom, of} from 'rxjs';
import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider';
import {TestBed} from '@angular/core/testing';
import {TranslateService} from '@ngx-translate/core';
-import {StorageBackend, Requestor} from '@openid/appauth';
-import {IonicStorage, Browser} from 'ionic-appauth';
import {LoggerTestingModule} from 'ngx-logger/testing';
-import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider';
-import {SimpleBrowser} from '../../util/browser.factory';
class FakeAuth {
isLoggedIn$ = new BehaviorSubject(false);
+ user$ = new BehaviorSubject(undefined);
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
getValidToken() {}
}
@@ -25,16 +24,14 @@ describe('IdCards', () => {
let encryptedStorageProvider: EncryptedStorageProvider;
let fakeAuth: FakeAuth;
- const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getProvider']);
+ const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['getUserFromUserInfo']);
const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']);
- let storageBackendSpy: jasmine.SpyObj;
- const simpleBrowserMock = jasmine.createSpyObj('SimpleBrowser', ['open']);
beforeEach(() => {
- storageBackendSpy = jasmine.createSpyObj('StorageBackend', ['getItem']);
+ fakeAuth = new FakeAuth();
configProviderSpy = jasmine.createSpyObj('ConfigProvider', ['config', 'getAnyValue']);
configProviderSpy.config = {
- app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'fakeAuth'}}}},
+ app: {features: {extern: {idCards: {url: 'http://id-cards.local', authProvider: 'default'}}}},
} as never;
configProviderSpy.getAnyValue = () =>
({
@@ -62,7 +59,6 @@ describe('IdCards', () => {
imports: [LoggerTestingModule],
providers: [
IdCardsProvider,
- StAppsWebHttpClient,
{
provide: TranslateService,
useValue: translateServiceSpy,
@@ -71,40 +67,29 @@ describe('IdCards', () => {
provide: EncryptedStorageProvider,
useValue: encryptedStorageProvider,
},
- IonicStorage,
{
provide: ConfigProvider,
useValue: configProviderSpy,
},
- Browser,
- {
- provide: StorageBackend,
- useValue: storageBackendSpy,
- },
{
provide: AuthHelperService,
useValue: authHelperServiceMock,
},
{
- provide: SimpleBrowser,
- useValue: simpleBrowserMock,
+ provide: AuthProvider,
+ useValue: fakeAuth,
},
{
provide: HttpClient,
useValue: httpClient,
},
- Requestor,
],
});
-
- fakeAuth = new FakeAuth();
- authHelperServiceMock.getProvider = jasmine.createSpy().and.returnValue(fakeAuth);
});
it('should emit undefined if not logged in', async () => {
const provider = TestBed.inject(IdCardsProvider);
expect(await firstValueFrom(provider.getIdCards())).toEqual([]);
- expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
});
it('should emit network result when logged in', async () => {
@@ -113,7 +98,6 @@ describe('IdCards', () => {
fakeAuth.getValidToken = jasmine.createSpy().and.resolveTo({accessToken: 'fake-token'});
const provider = TestBed.inject(IdCardsProvider);
expect(await firstValueFrom(provider.getIdCards())).toEqual(['abc' as never]);
- expect(authHelperServiceMock.getProvider).toHaveBeenCalledTimes(1);
// eslint-disable-next-line unicorn/no-null
expect(httpClient.get).toHaveBeenCalledOnceWith('http://id-cards.local', {
headers: {
diff --git a/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts b/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts
index 160a7d4d..25c6934c 100644
--- a/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts
+++ b/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts
@@ -19,6 +19,7 @@ import {AuthHelperService} from '../../auth/auth-helper.service';
import {mergeMap, of} from 'rxjs';
import Swiper from 'swiper';
import {toObservable, toSignal} from '@angular/core/rxjs-interop';
+import {AuthProvider} from "../../auth/auth.provider";
@Component({
selector: 'stapps-profile-page-section',
@@ -32,10 +33,11 @@ export class ProfilePageSectionComponent {
minSlideWidth = input(110);
authHelper = inject(AuthHelperService);
+ authProvider = inject(AuthProvider);
loggedIn = toSignal(
toObservable(this.item).pipe(
- mergeMap(item => (item.authProvider ? this.authHelper.getProvider().isLoggedIn$ : of(false))),
+ mergeMap(item => (item.authProvider ? this.authProvider.isLoggedIn$ : of(false))),
),
);
@@ -66,13 +68,11 @@ export class ProfilePageSectionComponent {
}
async toggleLogIn() {
- const providerType = this.item().authProvider;
- if (!providerType) return;
if (this.loggedIn()) {
- await this.authHelper.getProvider().signOut();
- await this.authHelper.endBrowserSession(providerType);
+ await this.authProvider.signOut();
+ await this.authHelper.endBrowserSession();
} else {
- await this.authHelper.getProvider().signIn();
+ await this.authProvider.signIn();
}
}
diff --git a/frontend/app/src/app/modules/profile/page/profile-page.component.ts b/frontend/app/src/app/modules/profile/page/profile-page.component.ts
index cee601d3..79fd8522 100644
--- a/frontend/app/src/app/modules/profile/page/profile-page.component.ts
+++ b/frontend/app/src/app/modules/profile/page/profile-page.component.ts
@@ -13,7 +13,7 @@
* this program. If not, see .
*/
import {Component, inject} from '@angular/core';
-import {AuthHelperService} from '../../auth/auth-helper.service';
+import {AuthProvider} from '../../auth/auth.provider';
import {ActivatedRoute} from '@angular/router';
import {ScheduleProvider} from '../../calendar/schedule.provider';
import {profilePageSections} from '../../../../config/profile-page-sections';
@@ -25,7 +25,7 @@ import {profilePageSections} from '../../../../config/profile-page-sections';
standalone: false,
})
export class ProfilePageComponent {
- readonly authHelper = inject(AuthHelperService);
+ readonly authProvider = inject(AuthProvider);
readonly activatedRoute = inject(ActivatedRoute);
@@ -33,23 +33,7 @@ export class ProfilePageComponent {
sections = profilePageSections;
- async signIn() {
- const originPath = this.activatedRoute.snapshot.queryParamMap.get('origin_path');
- await (originPath ? this.authHelper.setOriginPath(originPath) : this.authHelper.deleteOriginPath());
- await this.authHelper.getProvider().signIn();
- }
-
- async signOut() {
- await this.authHelper.getProvider().signOut();
- }
-
- ionViewWillEnter() {
- this.authHelper
- .getProvider()
- .getValidToken()
- .then(() => void this.authHelper.getProvider().loadUserInfo())
- .catch(() => {
- // noop
- });
+ ionViewWillEnter(): void {
+ void this.authProvider.loadUserInfo();
}
}
diff --git a/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts b/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts
index 8db8a3dd..3ac28c39 100644
--- a/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts
+++ b/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts
@@ -12,19 +12,58 @@
* You should have received a copy of the GNU General Public License along with
* this program. If not, see .
*/
-import {CapacitorSecureStorage} from 'ionic-appauth/lib/capacitor';
-/*
- * Removes an item from storage before entering the new one to avoid issues
- * after iOS upgrade (iOS 16)
+import {StorageBackend} from '@openid/appauth';
+import {SecureStoragePlugin} from 'capacitor-secure-storage-plugin';
+
+/**
+ * Secure storage backend for AppAuth on native Capacitor platforms.
+ *
+ * Removes an existing item before writing it again to avoid issues
+ * observed after iOS upgrades.
*/
-export class SafeCapacitorSecureStorage extends CapacitorSecureStorage {
- async setItem(name: string, value: string): Promise {
- if (!Storage) throw new Error('Capacitor Storage Is Undefined!');
-
+export class SafeCapacitorSecureStorage implements StorageBackend {
+ public async getItem(name: string): Promise {
try {
- await super.removeItem(name);
- } catch {}
- return super.setItem(name, value);
+ const {value} = await SecureStoragePlugin.get({
+ key: name,
+ });
+
+ return value;
+ } catch {
+ return null;
+ }
+ }
+
+ public async setItem(
+ name: string,
+ value: string,
+ ): Promise {
+ try {
+ await SecureStoragePlugin.remove({
+ key: name,
+ });
+ } catch {
+ // Item does not exist yet.
+ }
+
+ await SecureStoragePlugin.set({
+ key: name,
+ value,
+ });
+ }
+
+ public async removeItem(name: string): Promise {
+ try {
+ await SecureStoragePlugin.remove({
+ key: name,
+ });
+ } catch {
+ // Removing a non-existing item is fine.
+ }
+ }
+
+ public async clear(): Promise {
+ await SecureStoragePlugin.clear();
}
}
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\"."
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f4a7a4ef..5f45cb45 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -826,8 +826,8 @@ importers:
specifier: 8.0.0
version: 8.0.0(@angular/common@20.3.31(@angular/core@20.3.31(@angular/compiler@20.3.31)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@20.3.31(@angular/compiler@20.3.31)(rxjs@7.8.2)(zone.js@0.16.2))(@ngx-translate/core@15.0.0(@angular/common@20.3.31(@angular/core@20.3.31(@angular/compiler@20.3.31)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(@angular/core@20.3.31(@angular/compiler@20.3.31)(rxjs@7.8.2)(zone.js@0.16.2))(rxjs@7.8.2))(rxjs@7.8.2)
'@openid/appauth':
- specifier: 1.3.2
- version: 1.3.2
+ specifier: 1.4.0
+ version: 1.4.0
'@openstapps/api':
specifier: workspace:*
version: link:../../packages/api
@@ -861,9 +861,6 @@ importers:
geojson:
specifier: 0.5.0
version: 0.5.0
- ionic-appauth:
- specifier: 2.1.0
- version: 2.1.0(rxjs@7.8.2)
ionicons:
specifier: 8.1.0
version: 8.1.0
@@ -2506,40 +2503,11 @@ packages:
'@awesome-cordova-plugins/core': ^8.0.2
rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
- '@awesome-cordova-plugins/core@6.16.0':
- resolution: {integrity: sha512-ep+nkDY6CyFBfqS/HS03PE+MZWiQoJWb/nLMwfj2ndC14UgZeVO3ecmEFknYjJOBf04emTbs7hbLewFrVkazmg==}
- peerDependencies:
- rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
-
'@awesome-cordova-plugins/core@8.1.0':
resolution: {integrity: sha512-7NkUC2FcXSlIuIH2xc1miIdN8/by2uewrIg5JBWauN1KCzCXLhCwZ39C8nBsWxCloAxCvec+MMU5SzJGGWfjTg==}
peerDependencies:
rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
- '@awesome-cordova-plugins/http@6.16.0':
- resolution: {integrity: sha512-12YBMzyTXgWv9jnuiY1gh7ZKESLHQQNFTtsAvo78uNkEEk4ATPX9nLpWfC8kHzjI5aHNP+15bP85xt5nwntOcA==}
- peerDependencies:
- '@awesome-cordova-plugins/core': ^6.0.1
- rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
-
- '@awesome-cordova-plugins/in-app-browser@6.16.0':
- resolution: {integrity: sha512-5PANbiM2a5oTU4ce+UGZAMFgbuKi1XDXVhSuYrbCbZTiroWcfA4myj6y9s4sawtA8psWeUh+dksApr0ytrslBQ==}
- peerDependencies:
- '@awesome-cordova-plugins/core': ^6.0.1
- rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
-
- '@awesome-cordova-plugins/safari-view-controller@6.16.0':
- resolution: {integrity: sha512-K754S93w4itxiivOpJZjG/CpiAfczsRq6LFZtnuigoeZ6UOCIYAFvxEjmxXUsCHII9LJkmdwdrO0cKf1Ys8SpQ==}
- peerDependencies:
- '@awesome-cordova-plugins/core': ^6.0.1
- rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
-
- '@awesome-cordova-plugins/secure-storage@6.16.0':
- resolution: {integrity: sha512-8urVrC6HxgblmTexeHN+hZZXbuu+Ru9ifpOvPokih/4LJrHaoaB3vrSUAxsZdh/yulqxo0fTtdDtCKpKx58tUA==}
- peerDependencies:
- '@awesome-cordova-plugins/core': ^6.0.1
- rxjs: ^5.5.0 || ^6.5.0 || ^7.3.0
-
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -3191,11 +3159,6 @@ packages:
engines: {node: '>=10.3.0'}
hasBin: true
- '@capacitor/browser@6.0.6':
- resolution: {integrity: sha512-VHOPkMR+JqKz2mf5YncnnOWvFdVYFTTLnPW+JRcsP1LobXmA0gtchER35PQ7XXqXU16eeacrehX+XF+rC6wmQw==}
- peerDependencies:
- '@capacitor/core': ^6.0.0
-
'@capacitor/browser@8.0.4':
resolution: {integrity: sha512-ORZ4u/y+7aMuu6yVuk6SP529fUZyqcbnuHB1q5McpA3o2SYfVoi28iB8rsQjI2ad1qlKJd29ZUuGtspXLBDlWg==}
peerDependencies:
@@ -3216,9 +3179,6 @@ packages:
peerDependencies:
'@capacitor/core': '>=8.0.0'
- '@capacitor/core@6.2.1':
- resolution: {integrity: sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==}
-
'@capacitor/core@8.5.2':
resolution: {integrity: sha512-agnQbGdhGw/KZFuLf7H9SY2o+oE7upkr/WPQsYaokXP6f9chaE8iVmbKkfN6SVl4w3pmz7oZ3Ur1hbkmy1hx+w==}
@@ -3267,11 +3227,6 @@ packages:
peerDependencies:
'@capacitor/core': '>=8.0.0'
- '@capacitor/preferences@6.0.4':
- resolution: {integrity: sha512-ziauSI1pgdyl+gduvvf8lInvzF3Wdyu/ok+u7NlnhKp8XOj9plJgtnXZWFiR8CiCK5wMvo+gFaNh3zhMyEUwpA==}
- peerDependencies:
- '@capacitor/core': ^6.0.0
-
'@capacitor/preferences@8.0.1':
resolution: {integrity: sha512-T6no3ebi79XJCk91U3Jp/liJUwgBdvHR+s6vhvPkPxSuch7z3zx5Rv1bdWM6sWruNx+pViuEGqZvbfCdyBvcHQ==}
peerDependencies:
@@ -4585,8 +4540,8 @@ packages:
resolution: {integrity: sha512-T8TbSnGsxo6TDBJx/Sgv/BlVJL3tshxZP7Aq5R1mSnM5OcHY2dQaxLMu2+E8u3gN0MLOzdjurqN4ZRVuzQycOQ==}
engines: {node: '>=8.0'}
- '@openid/appauth@1.3.2':
- resolution: {integrity: sha512-NoOejniaqzOEbHg3RcBZtTriYqhqpQFgTC4lDNaRbgRCnpz6n8PlxWlCbh2N1K5qKawfxRP29/Wiho3FrXQ3Qw==}
+ '@openid/appauth@1.4.0':
+ resolution: {integrity: sha512-/l6GlJwloaWu1yiojMSrrR1zu7EQ84DK88RxBK2xw6uqfup+gKFEzbDoG2LOVUnBM3L5HMJNYAJfDFelKri3qA==}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
@@ -5299,8 +5254,8 @@ packages:
'@types/jasminewd2@2.0.13':
resolution: {integrity: sha512-aJ3wj8tXMpBrzQ5ghIaqMisD8C3FIrcO6sDKHqFbuqAsI7yOxj0fA7MrRCPLZHIVUjERIwsMmGn/vB0UQ9u0Hg==}
- '@types/jquery@3.5.34':
- resolution: {integrity: sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==}
+ '@types/jquery@4.0.1':
+ resolution: {integrity: sha512-9a59A/tycXgYuPABcp6/3spSShn0NT2UOM4EfHvMumjYi4lJWTsK5SZWjhx3yRm9IHGCeWXdV2YfNsrWrft/CA==}
'@types/json-patch@0.0.30':
resolution: {integrity: sha512-MhCUjojzDhVLnZnxwPwa+rETFRDQ0ffjxYdrqOP6TBO2O0/Z64PV5tNeYApo4bc4y4frbWOrRwv/eEkXlI13Rw==}
@@ -6216,11 +6171,6 @@ packages:
caniuse-lite@1.0.30001778:
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
- capacitor-secure-storage-plugin@0.10.0:
- resolution: {integrity: sha512-dV4E+HTZAJWC3gef7sBXaAkkb6wvcZHyXjJIHXNb3yz9gRQ/5VMLqCxa0khqpwgWh5oIbo4XFxg3g5tEkfaNMg==}
- peerDependencies:
- '@capacitor/core': ^6.0.0
-
capacitor-secure-storage-plugin@0.13.0:
resolution: {integrity: sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==}
peerDependencies:
@@ -7820,6 +7770,15 @@ packages:
debug:
optional: true
+ follow-redirects@1.16.1:
+ resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==}
+ engines: {node: '>=4.0'}
+ peerDependencies:
+ debug: '*'
+ peerDependenciesMeta:
+ debug:
+ optional: true
+
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
@@ -8441,11 +8400,6 @@ packages:
resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==}
engines: {node: '>= 0.10'}
- ionic-appauth@2.1.0:
- resolution: {integrity: sha512-KSNavekWYe1xD3vn9IOxa48ZM8gVuiUnUuhxKzPLzvLwE4Nr4aEs3XMUyM0HEcoCytiVhuO9ddN3HA+eqhb4Hw==}
- peerDependencies:
- rxjs: ^6.5.5 || ^7.4.0
-
ionicons@8.1.0:
resolution: {integrity: sha512-XSM2gYWTXxSYTwjmKWAoy4yR7AbAFDjpc5ZIivAiGXskM0fe5CdMPfs6InQRUcrYtVb2WZ/0HQ1/jeh3JW78SA==}
@@ -13231,45 +13185,11 @@ snapshots:
'@types/cordova': 11.0.3
rxjs: 7.8.2
- '@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2)':
- dependencies:
- '@types/cordova': 11.0.3
- rxjs: 7.8.2
- optional: true
-
'@awesome-cordova-plugins/core@8.1.0(rxjs@7.8.2)':
dependencies:
'@types/cordova': 11.0.3
rxjs: 7.8.2
- '@awesome-cordova-plugins/http@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)':
- dependencies:
- '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.2)
- '@types/cordova': 11.0.3
- rxjs: 7.8.2
- optional: true
-
- '@awesome-cordova-plugins/in-app-browser@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)':
- dependencies:
- '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.2)
- '@types/cordova': 11.0.3
- rxjs: 7.8.2
- optional: true
-
- '@awesome-cordova-plugins/safari-view-controller@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)':
- dependencies:
- '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.2)
- '@types/cordova': 11.0.3
- rxjs: 7.8.2
- optional: true
-
- '@awesome-cordova-plugins/secure-storage@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)':
- dependencies:
- '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.2)
- '@types/cordova': 11.0.3
- rxjs: 7.8.2
- optional: true
-
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -14694,11 +14614,6 @@ snapshots:
- supports-color
- typescript
- '@capacitor/browser@6.0.6(@capacitor/core@6.2.1)':
- dependencies:
- '@capacitor/core': 6.2.1
- optional: true
-
'@capacitor/browser@8.0.4(@capacitor/core@8.5.2)':
dependencies:
'@capacitor/core': 8.5.2
@@ -14752,11 +14667,6 @@ snapshots:
dependencies:
'@capacitor/core': 8.5.2
- '@capacitor/core@6.2.1':
- dependencies:
- tslib: 2.6.2
- optional: true
-
'@capacitor/core@8.5.2':
dependencies:
tslib: 2.6.2
@@ -14799,11 +14709,6 @@ snapshots:
dependencies:
'@capacitor/core': 8.5.2
- '@capacitor/preferences@6.0.4(@capacitor/core@6.2.1)':
- dependencies:
- '@capacitor/core': 6.2.1
- optional: true
-
'@capacitor/preferences@8.0.1(@capacitor/core@8.5.2)':
dependencies:
'@capacitor/core': 8.5.2
@@ -16259,12 +16164,12 @@ snapshots:
'@oozcitak/util@8.3.8': {}
- '@openid/appauth@1.3.2':
+ '@openid/appauth@1.4.0':
dependencies:
'@types/base64-js': 1.5.0
- '@types/jquery': 3.5.34
+ '@types/jquery': 4.0.1
base64-js: 1.5.1
- follow-redirects: 1.15.11(debug@4.4.3)
+ follow-redirects: 1.16.1
form-data: 4.0.6
opener: 1.5.2
transitivePeerDependencies:
@@ -16967,9 +16872,7 @@ snapshots:
dependencies:
'@types/jasmine': 5.1.4
- '@types/jquery@3.5.34':
- dependencies:
- '@types/sizzle': 2.3.10
+ '@types/jquery@4.0.1': {}
'@types/json-patch@0.0.30': {}
@@ -18100,11 +18003,6 @@ snapshots:
caniuse-lite@1.0.30001778: {}
- capacitor-secure-storage-plugin@0.10.0(@capacitor/core@6.2.1):
- dependencies:
- '@capacitor/core': 6.2.1
- optional: true
-
capacitor-secure-storage-plugin@0.13.0(@capacitor/core@8.5.2):
dependencies:
'@capacitor/core': 8.5.2
@@ -20072,6 +19970,8 @@ snapshots:
follow-redirects@1.15.9: {}
+ follow-redirects@1.16.1: {}
+
fontkit@2.0.4:
dependencies:
'@swc/helpers': 0.5.23
@@ -20786,27 +20686,6 @@ snapshots:
interpret@1.4.0: {}
- ionic-appauth@2.1.0(rxjs@7.8.2):
- dependencies:
- '@openid/appauth': 1.3.2
- '@types/chai-as-promised': 7.1.8
- rxjs: 7.8.2
- tslib: 2.6.2
- uuid: 9.0.1
- optionalDependencies:
- '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.2)
- '@awesome-cordova-plugins/http': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)
- '@awesome-cordova-plugins/in-app-browser': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)
- '@awesome-cordova-plugins/safari-view-controller': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)
- '@awesome-cordova-plugins/secure-storage': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.2))(rxjs@7.8.2)
- '@capacitor/browser': 6.0.6(@capacitor/core@6.2.1)
- '@capacitor/core': 6.2.1
- '@capacitor/preferences': 6.0.4(@capacitor/core@6.2.1)
- '@ionic/storage': 4.0.0
- capacitor-secure-storage-plugin: 0.10.0(@capacitor/core@6.2.1)
- transitivePeerDependencies:
- - debug
-
ionicons@8.1.0:
dependencies:
'@stencil/core': 4.43.5
@@ -25178,7 +25057,8 @@ snapshots:
uuid@8.3.2: {}
- uuid@9.0.1: {}
+ uuid@9.0.1:
+ optional: true
uvu@0.5.6:
dependencies: