From c9bb39503d41d0db51c98cf6de5ef103c71034a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jovan=20Kruni=C4=87?= Date: Wed, 14 Oct 2026 18:05:23 +0200 Subject: [PATCH] feat: use only one identity provider Additionally remove ionic-appauth dependency, refactor the relevant code and fix some issues. Closes #245 --- backend/backend/config/f-u/backendrc.js | 27 +- frontend/app/__mocks__/@capacitor/core.ts | 64 + frontend/app/package.json | 3 +- .../app/_helpers/data/sample-configuration.ts | 10 - frontend/app/src/app/app.component.ts | 22 +- frontend/app/src/app/app.module.ts | 8 +- .../assessments/assessments.provider.ts | 6 +- .../page/auth-callback-page.component.spec.ts | 106 ++ .../page/auth-callback-page.component.ts | 43 +- .../app/modules/auth/auth-guard.service.ts | 8 +- .../modules/auth/auth-helper.service.spec.ts | 366 ++++-- .../app/modules/auth/auth-helper.service.ts | 132 ++- .../app/src/app/modules/auth/auth-paths.ts | 12 +- .../app/modules/auth/auth-routing.module.ts | 4 +- .../app/src/app/modules/auth/auth.module.ts | 14 +- .../auth/auth.provider.methods.spec.ts | 78 ++ .../app/modules/auth/auth.provider.methods.ts | 96 +- .../app/modules/auth/auth.provider.spec.ts | 613 ++++++++++ .../app/src/app/modules/auth/auth.provider.ts | 1045 +++++++++++++++++ .../app/src/app/modules/auth/auth.service.ts | 526 --------- ...itor-authorization-request-handler.spec.ts | 328 ++++++ ...capacitor-authorization-request-handler.ts | 232 ++++ .../app/modules/auth/capacitor-requestor.ts | 116 +- .../modules/auth/default-auth.service.spec.ts | 104 -- .../app/modules/auth/default-auth.service.ts | 102 -- .../{http.factory.ts => requestor.factory.ts} | 6 +- .../modules/auth/factories/storage.factory.ts | 6 +- .../src/app/modules/auth/ng-http.service.ts | 77 -- .../src/app/modules/auth/user-info.model.ts | 21 - .../app/modules/profile/id-cards.provider.ts | 7 +- .../src/app/modules/profile/id-cards.spec.ts | 32 +- .../page/profile-page-section.component.ts | 13 +- .../profile/page/profile-page.component.ts | 24 +- .../storage/capacitor-secure-storage.ts | 59 +- frontend/app/src/assets/i18n/de.json | 1 + frontend/app/src/assets/i18n/en.json | 1 + pnpm-lock.yaml | 166 +-- 37 files changed, 3099 insertions(+), 1379 deletions(-) create mode 100644 frontend/app/__mocks__/@capacitor/core.ts create mode 100644 frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts create mode 100644 frontend/app/src/app/modules/auth/auth.provider.methods.spec.ts create mode 100644 frontend/app/src/app/modules/auth/auth.provider.spec.ts create mode 100644 frontend/app/src/app/modules/auth/auth.provider.ts delete mode 100644 frontend/app/src/app/modules/auth/auth.service.ts create mode 100644 frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts create mode 100644 frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts delete mode 100644 frontend/app/src/app/modules/auth/default-auth.service.spec.ts delete mode 100644 frontend/app/src/app/modules/auth/default-auth.service.ts rename frontend/app/src/app/modules/auth/factories/{http.factory.ts => requestor.factory.ts} (84%) delete mode 100644 frontend/app/src/app/modules/auth/ng-http.service.ts delete mode 100644 frontend/app/src/app/modules/auth/user-info.model.ts 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..6f0eba4a 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..5916aa72 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..06edd8b0 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..ec6a4bf7 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,12 @@ * 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 +32,33 @@ 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..54e7907e 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..e4262e0b 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,288 @@ -/* - * 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', +}; + +const DEFAULT_AUTH_CONFIG = {default: {endpoints: {mapping: USER_MAPPING}}}; + +interface TestAlertButton { + text: string; + role?: string; + cssClass?: string; + handler?: () => Promise | void; +} + +interface TestAlertOptions { + header: string; + message: string; + buttons: TestAlertButton[]; +} 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_AUTH_CONFIG) { + 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(): TestAlertOptions { + return alertController.create.calls.mostRecent().args[0] as TestAlertOptions; + } + + /** + * + */ + 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 as unknown).toEqual(USER_MAPPING); + }); + + it('falls back to an empty mapping when no default auth provider is configured', () => { + service = createService({}); + + expect(service.userConfigurationMap as unknown).toEqual({}); + }); + }); + + 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(); + + 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..49555ada 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,31 @@ */ 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 {SCAuthorizationProvider, SCUserConfiguration, SCUserConfigurationMap} from '@openstapps/core'; import {JSONPath} from 'jsonpath-plus'; -import { - SCAuthorizationProvider, - SCAuthorizationProviderType, - SCUserConfiguration, - SCUserConfigurationMap, -} from '@openstapps/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 {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 +54,101 @@ 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; + return this.translateService.instant(`${AUTH_MESSAGE_PREFIX}.logged_in_success`); } + case AuthActions.SignOutSuccess: { - message = this.translateService.instant(`auth.messages.${provider}.logged_out_success`); - break; + 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) { + + 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'), - cssClass: 'default', + buttons: [ + { + text: this.translateService.instant('no'), + + role: 'cancel', + cssClass: 'default', + }, + { + text: this.translateService.instant('yes'), + + role: 'confirm', + cssClass: 'preferred', + + handler: async () => { + /* + * Only now, after explicit confirmation, + * open the IdP end-session endpoint. + */ + await this.browser.open(endSessionEndpoint); }, - { - text: this.translateService.instant('yes'), - role: 'confirm', - cssClass: 'preferred', - handler: () => { - this.browser.open(new URL(endSessionEndpoint).href); - }, - }, - ], - }); + }, + ], + }); - 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..ac202464 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..b5772934 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,71 @@ * 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; - }, -): IAuthConfig { - const providerConfig = authConfig[providerType] as SCAuthorizationProvider; +export function getClientConfig(authConfig: {default: 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, - authConfig: { - default?: SCAuthorizationProvider; - paia?: 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, - }; +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(' '); } /** - * Return a URL of the app, depending on the platform where it is running + * Returns the AppAuth OAuth/OIDC service configuration. */ -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]; +export function getServiceConfiguration(authConfig: { + default: SCAuthorizationProvider; +}): AuthorizationServiceConfiguration { + const endpoints = authConfig.default.endpoints; - appHost = Capacitor.isNativePlatform() ? environment.app_host : window.location.host; + return new AuthorizationServiceConfiguration({ + authorization_endpoint: endpoints.authorization, + token_endpoint: endpoints.token, + revocation_endpoint: endpoints.revoke || '', + }); +} + +/** + * Returns the OIDC redirect URL depending on the current platform. + */ +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..5049b5d4 --- /dev/null +++ b/frontend/app/src/app/modules/auth/auth.provider.spec.ts @@ -0,0 +1,613 @@ +import {TestBed} from '@angular/core/testing'; +import {Platform} from '@ionic/angular/standalone'; +import { + AppAuthError, + AuthorizationServiceConfiguration, + RevokeTokenRequest, + StorageBackend, + TokenRequestHandler, + TokenResponse, +} from '@openid/appauth'; +import {BehaviorSubject, firstValueFrom, skip} from 'rxjs'; + +import {ConfigProvider} from '../config/config.provider'; +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; + +// eslint-disable-next-line unicorn/no-null -- StorageBackend.getItem resolves to null for missing keys +const MISSING_STORAGE_VALUE = null; + +/** + * 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: false → token without refresh token + */ +function makeToken( + options: { + accessToken?: string; + refreshToken?: string | false; + remainingSeconds?: number; + lifetime?: number; + } = {}, +): TokenResponse { + const lifetime = options.lifetime ?? TOKEN_LIFETIME; + const remaining = options.remainingSeconds ?? lifetime; + + return new TokenResponse({ + access_token: options.accessToken ?? 'access-token', + refresh_token: options.refreshToken === false ? undefined : options.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; + authConfigValue: unknown; + localConfiguration: AuthorizationServiceConfiguration; + 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): Promise { + storageGetItem.and.resolveTo(token ? JSON.stringify(token.toJson()) : MISSING_STORAGE_VALUE); + 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)); + + // AuthProvider uses inject(), so it has to be created inside an injection context. + TestBed.configureTestingModule({ + providers: [ + {provide: Platform, useValue: {is: () => false}}, + {provide: ConfigProvider, useValue: {}}, + ], + }); + + provider = TestBed.inject(AuthProvider); + internals = provider as unknown as AuthProviderInternals; + + internals.authConfigValue = { + server_host: 'https://idp.example.org', + client_id: 'test-client', + redirect_url: 'https://app.example.org/callback', + scopes: 'openid profile', + pkce: true, + }; + + internals.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(MISSING_STORAGE_VALUE); + 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(); + + 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: false})); + + 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: false})); + + 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: false}), + ); + + 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: false})); + 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: false})); + + 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: false})); + 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: false})); + 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(); + + 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: false})); + + 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 () => { + internals.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(); + + 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()).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..f976d075 --- /dev/null +++ b/frontend/app/src/app/modules/auth/auth.provider.ts @@ -0,0 +1,1045 @@ +/* + * 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 {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 readonly platform = inject(Platform); + + private readonly configProvider = inject(ConfigProvider); + + 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() { + this.storage = storageFactory(this.platform); + + this.requestor = requestorFactory(this.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 = this.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>({ + 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: { + // eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument + this.tokenSubject.next(undefined); + + // eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument + this.userSubject.next(undefined); + + this.authenticatedSubject.next(false); + + this.loggedInSubject.next(false); + + break; + } + + case AuthActions.LoadTokenFromStorageFailed: { + // eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument + this.tokenSubject.next(undefined); + + // eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument + 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: { + // eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument + this.tokenSubject.next(undefined); + + // eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument + this.userSubject.next(undefined); + + this.authenticatedSubject.next(false); + + this.loggedInSubject.next(false); + + break; + } + + case AuthActions.LoadUserInfoSuccess: { + this.userSubject.next(action.user); + + 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..11682efb --- /dev/null +++ b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts @@ -0,0 +1,232 @@ +/* + * 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) { + // eslint-disable-next-line unicorn/no-null -- AppAuth's AuthorizationRequestHandler contract + 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: // eslint-disable-next-line unicorn/no-null -- AppAuth's AuthorizationRequestHandler contract + AuthorizationResponse | null = null; + + let authorizationError: // eslint-disable-next-line unicorn/no-null -- AppAuth's AuthorizationRequestHandler contract + 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=... + */ + for (const [key, value] of url.searchParams.entries()) { + result[key] = value; + } + + /* + * Also accept parameters in the fragment as a fallback. + */ + if (url.hash.length > 1) { + const hash = url.hash.slice(1); + + const hashParameters = new URLSearchParams(hash); + + for (const [key, value] of hashParameters.entries()) { + 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..b3e0c1e7 100644 --- a/frontend/app/src/app/modules/auth/capacitor-requestor.ts +++ b/frontend/app/src/app/modules/auth/capacitor-requestor.ts @@ -13,61 +13,89 @@ * 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 searchParameters = new URLSearchParams(settings.data); + + for (const [key, value] of searchParameters.entries()) { + 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 84% 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..d3b8d83a 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,9 @@ */ 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..81c6f0f6 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,9 @@ */ 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..016064ba 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..a2f15cd9 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', @@ -33,9 +34,11 @@ export class ProfilePageSectionComponent { 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 +69,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..ecf729a0 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,56 @@ * 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 { + // eslint-disable-next-line unicorn/no-null -- StorageBackend.getItem resolves to null for missing keys + 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: