From 29e9d0753b4e6a236e6ed0eb3c366906f4ff92f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jovan=20Kruni=C4=87?= Date: Fri, 9 Oct 2026 17:19:32 +0200 Subject: [PATCH] WIP --- backend/backend/config/f-u/backendrc.js | 26 +- backend/backend/package.json | 2 +- frontend/app/capacitor.config.ts | 2 +- frontend/app/diff.txt | 0 frontend/app/package.json | 5 +- frontend/app/src/app/app.component.ts | 10 +- frontend/app/src/app/app.module.ts | 8 +- .../assessments/assessments.provider.ts | 6 +- .../page/auth-callback-page.component.ts | 56 +- .../app/modules/auth/auth-guard.service.ts | 8 +- .../modules/auth/auth-helper.service.spec.ts | 337 +++-- .../app/modules/auth/auth-helper.service.ts | 159 ++- .../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 +- .../app/modules/auth/auth.provider.methods.ts | 103 +- .../app/modules/auth/auth.provider.spec.ts | 239 ++++ .../app/src/app/modules/auth/auth.provider.ts | 1122 +++++++++++++++++ .../app/src/app/modules/auth/auth.service.ts | 526 -------- ...capacitor-authorization-request-handler.ts | 270 ++++ .../app/modules/auth/capacitor-requestor.ts | 106 +- .../modules/auth/default-auth.service.spec.ts | 104 -- .../app/modules/auth/default-auth.service.ts | 102 -- .../{http.factory.ts => requestor.factory.ts} | 8 +- .../modules/auth/factories/storage.factory.ts | 8 +- .../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 | 1 - .../page/profile-page-section.component.ts | 12 +- .../profile/page/profile-page.component.ts | 24 +- .../storage/capacitor-secure-storage.ts | 61 +- .../environments/environment.production.ts | 4 +- frontend/app/src/environments/environment.ts | 3 +- pnpm-lock.yaml | 166 +-- 35 files changed, 2275 insertions(+), 1338 deletions(-) create mode 100644 frontend/app/diff.txt 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.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} (76%) 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..299129f9 100644 --- a/backend/backend/config/f-u/backendrc.js +++ b/backend/backend/config/f-u/backendrc.js @@ -13,23 +13,23 @@ 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', }, }, }, diff --git a/backend/backend/package.json b/backend/backend/package.json index 0089611e..8f49c719 100644 --- a/backend/backend/package.json +++ b/backend/backend/package.json @@ -36,7 +36,7 @@ "format:fix": "prettier --write . --ignore-path ../../.gitignore", "lint": "tsc --noEmit && eslint --ext .ts src/", "lint:fix": "eslint --fix --ext .ts src/", - "start": "cross-env NODE_CONFIG_ENV=elasticsearch ALLOW_NO_TRANSPORT=true node app.js", + "start": "cross-env NODE_CONFIG_ENV=elasticsearch ALLOW_NO_TRANSPORT=true NODE_APP_INSTANCE=\"f-u\" node app.js", "start:debug": "cross-env STAPPS_LOG_LEVEL=31 NODE_CONFIG_ENV=elasticsearch ALLOW_NO_TRANSPORT=true node app.js", "test": "pnpm run test:unit", "test:integration": "sh integration-test.sh", diff --git a/frontend/app/capacitor.config.ts b/frontend/app/capacitor.config.ts index f3bee977..cf4b22b3 100644 --- a/frontend/app/capacitor.config.ts +++ b/frontend/app/capacitor.config.ts @@ -1,7 +1,7 @@ import {CapacitorConfig} from '@capacitor/cli'; const config: CapacitorConfig = { - appId: 'de.anyschool.app', + appId: 'de.unifrankfurt.app', appName: 'StApps', webDir: 'www/browser', cordova: { diff --git a/frontend/app/diff.txt b/frontend/app/diff.txt new file mode 100644 index 00000000..e69de29b diff --git a/frontend/app/package.json b/frontend/app/package.json index 659eebf4..82a49779 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -41,7 +41,7 @@ "resources:ios": "capacitor-assets generate --ios --iconBackgroundColor $(grep -oE \"^@include color-system\\.ion-color\\(primary, #[a-fA-F0-9]{3,6}\" src/theme/colors.scss | grep -oE \"#[a-fA-F0-9]{3,6}\") --splashBackgroundColor $(grep -oE \"^@include color-system\\.ion-color\\(primary, #[a-fA-F0-9]{3,6}\" src/theme/colors.scss | grep -oE \"#[a-fA-F0-9]{3,6}\")", "run:android": "ionic capacitor run android --livereload --external", "start": "ionic serve", - "start:external": "ionic serve --external", + "start:external": "ionic serve --external --ssl=true", "start:prod": "ionic serve --prod", "start:virtual-host": "ionic serve --public-host=mobile.app.uni-frankfurt.de --ssl=true --open=false", "test": "ng test --code-coverage", @@ -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/app.component.ts b/frontend/app/src/app/app.component.ts index a5ecf0bb..827a7626 100644 --- a/frontend/app/src/app/app.component.ts +++ b/frontend/app/src/app/app.component.ts @@ -18,13 +18,14 @@ 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 {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 @@ -45,6 +46,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,9 +132,8 @@ 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 => this.showMessage(this.authHelper.getAuthMessage(action))); } private async showMessage(message?: string) { diff --git a/frontend/app/src/app/app.module.ts b/frontend/app/src/app/app.module.ts index 894e72f4..9f974b7c 100644 --- a/frontend/app/src/app/app.module.ts +++ b/frontend/app/src/app/app.module.ts @@ -57,7 +57,6 @@ import {AssessmentsModule} from './modules/assessments/assessments.module'; import {ServiceHandlerInterceptor} from './_helpers/service-handler.interceptor'; import {RoutingStackService} from './util/routing-stack.service'; import {SCLanguageCode, SCSettingValue} from '@openstapps/core'; -import {DefaultAuthService} from './modules/auth/default-auth.service'; import {NavigationModule} from './modules/menu/navigation/navigation.module'; import {browserFactory, SimpleBrowser} from './util/browser.factory'; import {getDateFnsLocale} from './translation/dfns-locale'; @@ -67,6 +66,7 @@ import {Capacitor} from '@capacitor/core'; import {SplashScreen} from '@capacitor/splash-screen'; import * as maplibregl from 'maplibre-gl'; import {Protocol} from 'pmtiles'; +import {AuthProvider} from "./modules/auth/auth.provider"; registerLocaleData(localeDe); @@ -82,7 +82,7 @@ export function initializerFactory( configProvider: ConfigProvider, translateService: TranslateService, _routingStackService: RoutingStackService, - defaultAuthService: DefaultAuthService, + authProvider: AuthProvider, dateFnsConfigurationService: DateFnsConfigurationService, ) { return async () => { @@ -111,7 +111,7 @@ export function initializerFactory( setDefaultOptions({locale: dateFnsLocale}); dateFnsConfigurationService.setLocale(dateFnsLocale); - await defaultAuthService.init(); + await authProvider.init(); } catch (error) { logger.warn(error); } @@ -201,7 +201,7 @@ export function createTranslateLoader(http: HttpClient) { ConfigProvider, TranslateService, RoutingStackService, - DefaultAuthService, + AuthProvider, DateFnsConfigurationService, ], useFactory: initializerFactory, diff --git a/frontend/app/src/app/modules/assessments/assessments.provider.ts b/frontend/app/src/app/modules/assessments/assessments.provider.ts index 6c419e75..e6528267 100644 --- a/frontend/app/src/app/modules/assessments/assessments.provider.ts +++ b/frontend/app/src/app/modules/assessments/assessments.provider.ts @@ -15,9 +15,9 @@ import {Injectable, inject} from '@angular/core'; import {ConfigProvider} from '../config/config.provider'; import {SCAssessment, SCUuid} from '@openstapps/core'; -import {DefaultAuthService} from '../auth/default-auth.service'; import {HttpClient} from '@angular/common/http'; import {uniqBy, keyBy} from '@openstapps/collection-utils'; +import {AuthProvider} from "../auth/auth.provider"; /** * @@ -53,7 +53,7 @@ export function toAssessmentMap(data: SCAssessment[]): Record(`${url}/${this.assessmentPath}`, { headers: { - Authorization: `Bearer ${accessToken ?? (await this.defaultAuth.getValidToken()).accessToken}`, + Authorization: `Bearer ${accessToken ?? (await this.authProvider.getValidToken()).accessToken}`, }, }) .toPromise() diff --git a/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts index d3a76cd7..d3d7a18b 100644 --- a/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts +++ b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.ts @@ -13,13 +13,16 @@ * this program. If not, see . */ import {Component, inject} from '@angular/core'; -import {NavController} from '@ionic/angular/standalone'; import {Router} from '@angular/router'; -import {AuthActions, IAuthAction} from 'ionic-appauth'; -import {AuthHelperService} from '../../auth-helper.service'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; -import {Observable} from 'rxjs'; -import {DefaultAuthService} from '../../default-auth.service'; +import {NavController} from '@ionic/angular/standalone'; + +import { + AuthActions, + AuthProvider, + IAuthAction, +} from '../../auth.provider'; +import {AuthHelperService} from '../../auth-helper.service'; @Component({ templateUrl: 'auth-callback-page.component.html', @@ -33,23 +36,42 @@ export class AuthCallbackPageComponent { private authHelper = inject(AuthHelperService); - private auth = inject(DefaultAuthService); + private authProvider = inject(AuthProvider); constructor() { - const events: Observable = this.auth.events$; + this.authProvider.events$ + .pipe(takeUntilDestroyed()) + .subscribe(action => { + void this.postCallback(action); + }); - events.pipe(takeUntilDestroyed()).subscribe((action: IAuthAction) => this.postCallback(action)); - this.auth.authorizationCallback(window.location.origin + this.router.url); + this.authProvider.authorizationCallback( + window.location.href, + ); } - async postCallback(action: IAuthAction) { - if (action.action === AuthActions.SignInSuccess) { - const originPath = await this.authHelper.getOriginPath(); - this.navCtrl.navigateRoot(originPath ?? 'profile'); - this.authHelper.deleteOriginPath(); - } - if (action.action === AuthActions.SignInFailed) { - this.navCtrl.navigateRoot('profile'); + private async postCallback( + action: IAuthAction, + ): Promise { + switch (action.action) { + case AuthActions.SignInSuccess: { + const originPath = + await this.authHelper.getOriginPath(); + + await this.authHelper.deleteOriginPath(); + + await this.navCtrl.navigateRoot( + originPath ?? 'profile', + ); + + break; + } + + case AuthActions.SignInFailed: { + await this.navCtrl.navigateRoot('profile'); + + break; + } } } } diff --git a/frontend/app/src/app/modules/auth/auth-guard.service.ts b/frontend/app/src/app/modules/auth/auth-guard.service.ts index a700f532..ead61617 100644 --- a/frontend/app/src/app/modules/auth/auth-guard.service.ts +++ b/frontend/app/src/app/modules/auth/auth-guard.service.ts @@ -16,13 +16,13 @@ import {Injectable, inject} from '@angular/core'; import {CanActivate, NavigationExtras, Router, RouterStateSnapshot} from '@angular/router'; import {ActivatedProtectedRouteSnapshot} from './protected.routes'; -import {AuthHelperService} from './auth-helper.service'; +import {AuthProvider} from "./auth.provider"; @Injectable({ providedIn: 'root', }) export class AuthGuardService implements CanActivate { - private authHelper = inject(AuthHelperService); + private authProvider = inject(AuthProvider); private router = inject(Router); @@ -32,7 +32,7 @@ export class AuthGuardService implements CanActivate { } try { - await this.authHelper.getProvider().getValidToken(); + await this.authProvider.getValidToken(); } catch { const originNavigation = this.router.currentNavigation(); let extras: NavigationExtras = {}; @@ -41,7 +41,7 @@ export class AuthGuardService implements CanActivate { extras = {queryParams: {origin_path: url}}; } this.router.navigate(['profile'], extras); - await this.authHelper.getProvider().signIn(); + await this.authProvider.signIn(); return false; } diff --git a/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts b/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts index 69a98d1a..0003c66e 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,251 @@ -/* - * 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 {AuthActions, AuthProvider} from './auth.provider'; import {AuthHelperService} from './auth-helper.service'; -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'; + +const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path'; +const AUTH_MESSAGE_PREFIX = 'auth.messages.default'; +const END_SESSION_ENDPOINT = 'https://idp.example.org/logout'; + +const USER_MAPPING = { + id: '$.sub', + givenName: '$.given_name', + familyName: '$.family_name', + email: '$.attributes.mail', + role: '$.role', +}; + +interface TestAlertButton { + text: string; + role?: string; + cssClass?: string; + handler?: () => Promise | void; +} describe('AuthHelperService', () => { - let authHelperService: AuthHelperService; - const storageProviderSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put', 'search']); - const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']); - const defaultAuthServiceMock = jasmine.createSpyObj('DefaultAuthService', ['init', 'setupConfiguration']); - const authHelperServiceMock = jasmine.createSpyObj('AuthHelperService', ['constructor']); - const simpleBrowserMock = jasmine.createSpyObj('SimpleBrowser', ['open']); - const configProvider = jasmine.createSpyObj('ConfigProvider', { - getAnyValue: { - default: { - endpoints: { - mapping: { - id: '$.id', - email: '$.attributes.mailPrimaryAddress', - givenName: '$.attributes.givenName', - familyName: '$.attributes.sn', - name: '$.attributes.sn', - role: '$.attributes.eduPersonPrimaryAffiliation', - studentId: '$.attributes.employeeNumber', - }, - }, - }, - }, - }); + let service: AuthHelperService; + + let translateService: jasmine.SpyObj<{instant(key: string): string}>; + let configProvider: jasmine.SpyObj<{getAnyValue(key: string): unknown}>; + let storageProvider: jasmine.SpyObj<{ + get(key: string): Promise; + put(key: string, value: unknown): Promise; + delete(key: string): Promise; + }>; + let authProvider: jasmine.SpyObj>; + let browser: jasmine.SpyObj<{open(url: string): Promise}>; + let alertController: jasmine.SpyObj<{create(options: unknown): Promise}>; + let alert: jasmine.SpyObj<{present(): Promise}>; + + function createService(authConfig: unknown = {default: {endpoints: {mapping: USER_MAPPING}}}) { + configProvider.getAnyValue.and.returnValue(authConfig); + + return new AuthHelperService( + translateService as any, + configProvider as any, + storageProvider as any, + authProvider as any, + browser as any, + alertController as any, + ); + } + + /** Options passed to the most recent AlertController.create() call. */ + function lastAlertOptions(): {header: string; message: string; buttons: TestAlertButton[]} { + return alertController.create.calls.mostRecent().args[0] as any; + } + + function alertButton(role: string): TestAlertButton { + const button = lastAlertOptions().buttons.find(b => b.role === role); + if (!button) { + throw new Error(`No alert button with role "${role}"`); + } + return button; + } beforeEach(() => { - 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()), - ], + translateService = jasmine.createSpyObj('TranslateService', ['instant']); + translateService.instant.and.callFake((key: string) => `translated:${key}`); + + configProvider = jasmine.createSpyObj('ConfigProvider', ['getAnyValue']); + + storageProvider = jasmine.createSpyObj('StorageProvider', ['get', 'put', 'delete']); + storageProvider.put.and.resolveTo(); + storageProvider.delete.and.resolveTo(); + + authProvider = jasmine.createSpyObj('AuthProvider', ['getEndSessionEndpoint']); + authProvider.getEndSessionEndpoint.and.resolveTo(END_SESSION_ENDPOINT); + + browser = jasmine.createSpyObj('SimpleBrowser', ['open']); + browser.open.and.resolveTo(); + + alert = jasmine.createSpyObj('HTMLIonAlertElement', ['present']); + alert.present.and.resolveTo(); + + alertController = jasmine.createSpyObj('AlertController', ['create']); + alertController.create.and.resolveTo(alert); + + service = createService(); + }); + + describe('userConfigurationMap', () => { + it('reads the user mapping from the default auth provider', () => { + expect(configProvider.getAnyValue).toHaveBeenCalledWith('auth'); + expect(service.userConfigurationMap).toEqual(USER_MAPPING as any); + }); + + it('falls back to an empty mapping when no default auth provider is configured', () => { + service = createService({}); + + expect(service.userConfigurationMap).toEqual({} as any); + }); + }); + + describe('getAuthMessage', () => { + it('returns the translated message for SignInSuccess', () => { + expect(service.getAuthMessage({action: AuthActions.SignInSuccess})).toBe( + `translated:${AUTH_MESSAGE_PREFIX}.logged_in_success`, + ); + }); + + it('returns the translated message for SignOutSuccess', () => { + expect(service.getAuthMessage({action: AuthActions.SignOutSuccess})).toBe( + `translated:${AUTH_MESSAGE_PREFIX}.logged_out_success`, + ); + }); + + it('returns undefined for all other actions without translating', () => { + const otherActions = Object.values(AuthActions).filter( + action => action !== AuthActions.SignInSuccess && action !== AuthActions.SignOutSuccess, + ); + + for (const action of otherActions) { + expect(service.getAuthMessage({action})).withContext(action).toBeUndefined(); + } + expect(translateService.instant).not.toHaveBeenCalled(); }); - authHelperService = TestBed.inject(AuthHelperService); }); describe('getUserFromUserInfo', () => { - it('should provide user configuration from userInfo', async () => { - const userConfiguration = authHelperService.getUserFromUserInfo({ - attributes: { - eduPersonPrimaryAffiliation: 'student', - employeeNumber: '123456', - givenName: 'Erika', - mailPrimaryAddress: 'emuster@anyschool.de', - oauthClientId: '123-abc-123', - sn: 'Musterfrau', - uid: 'emuster', - }, - id: 'emuster', - client_id: '123-abc-123', + it('maps user info fields via the configured JSONPath mapping', () => { + const user = service.getUserFromUserInfo({ + sub: 'user-123', + given_name: 'Erika', + family_name: 'Mustermann', + attributes: {mail: 'erika@example.org'}, + role: 'employee', }); - expect(userConfiguration).toEqual({ - id: 'emuster', - givenName: 'Erika', - familyName: 'Musterfrau', - name: 'Erika Musterfrau', - email: 'emuster@anyschool.de', + expect(user).toEqual( + jasmine.objectContaining({ + id: 'user-123', + givenName: 'Erika', + familyName: 'Mustermann', + email: 'erika@example.org', + role: 'employee', + }), + ); + }); + + it('builds the name from given name and family name', () => { + const user = service.getUserFromUserInfo({ + sub: 'user-123', + given_name: 'Erika', + family_name: 'Mustermann', + role: 'student', + }); + + expect(user.name).toBe('Erika Mustermann'); + }); + + it('keeps the default name when the family name is missing', () => { + const user = service.getUserFromUserInfo({ + sub: 'user-123', + given_name: 'Erika', + role: 'student', + }); + + expect(user.name).toBe(''); + }); + + it('returns the default user when no mapping is configured', () => { + service = createService({}); + + expect(service.getUserFromUserInfo({sub: 'user-123'})).toEqual({ + id: '', + name: '', role: 'student', - studentId: '123456', }); }); }); + + describe('origin path', () => { + it('stores the origin path', async () => { + await service.setOriginPath('/profile'); + + expect(storageProvider.put).toHaveBeenCalledOnceWith(AUTH_ORIGIN_PATH, '/profile'); + }); + + it('returns the stored origin path', async () => { + storageProvider.get.and.resolveTo('/profile'); + + expect(await service.getOriginPath()).toBe('/profile'); + expect(storageProvider.get).toHaveBeenCalledOnceWith(AUTH_ORIGIN_PATH); + }); + + it('returns undefined when reading the origin path fails', async () => { + storageProvider.get.and.rejectWith(new Error('Value not found')); + + expect(await service.getOriginPath()).toBeUndefined(); + }); + + it('deletes the origin path', async () => { + await service.deleteOriginPath(); + + expect(storageProvider.delete).toHaveBeenCalledOnceWith(AUTH_ORIGIN_PATH); + }); + }); + + describe('endBrowserSession', () => { + it('does nothing when the provider has no end-session endpoint', async () => { + authProvider.getEndSessionEndpoint.and.resolveTo(undefined); + + await service.endBrowserSession(); + + expect(alertController.create).not.toHaveBeenCalled(); + expect(browser.open).not.toHaveBeenCalled(); + }); + + it('presents a translated confirmation alert', async () => { + await service.endBrowserSession(); + + const options = lastAlertOptions(); + expect(options.header).toBe(`translated:${AUTH_MESSAGE_PREFIX}.log_out_alert.header`); + expect(options.message).toBe(`translated:${AUTH_MESSAGE_PREFIX}.log_out_alert.message`); + expect(options.buttons.map(b => b.role)).toEqual(['cancel', 'confirm']); + expect(alert.present).toHaveBeenCalledTimes(1); + }); + + it('does not open the end-session endpoint before confirmation', async () => { + await service.endBrowserSession(); + + expect(browser.open).not.toHaveBeenCalled(); + }); + + it('opens the end-session endpoint when the user confirms', async () => { + await service.endBrowserSession(); + + await alertButton('confirm').handler?.(); + + expect(browser.open).toHaveBeenCalledOnceWith(END_SESSION_ENDPOINT); + }); + + it('does not open the end-session endpoint when the user cancels', async () => { + await service.endBrowserSession(); + + const cancelButton = alertButton('cancel'); + await cancelButton.handler?.(); + + expect(cancelButton.handler).toBeUndefined(); + expect(browser.open).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/app/src/app/modules/auth/auth-helper.service.ts b/frontend/app/src/app/modules/auth/auth-helper.service.ts index 86dfdce6..546977ea 100644 --- a/frontend/app/src/app/modules/auth/auth-helper.service.ts +++ b/frontend/app/src/app/modules/auth/auth-helper.service.ts @@ -14,35 +14,38 @@ */ import {Injectable, inject} from '@angular/core'; -import {AuthActions, IAuthAction} from 'ionic-appauth'; +import {AlertController} from '@ionic/angular/standalone'; import {TranslateService} from '@ngx-translate/core'; -import {JSONPath} from 'jsonpath-plus'; import { SCAuthorizationProvider, - SCAuthorizationProviderType, SCUserConfiguration, SCUserConfigurationMap, } from '@openstapps/core'; +import {JSONPath} from 'jsonpath-plus'; + +import {SimpleBrowser} from '../../util/browser.factory'; import {ConfigProvider} from '../config/config.provider'; import {StorageProvider} from '../storage/storage.provider'; -import {DefaultAuthService} from './default-auth.service'; -import {SimpleBrowser} from '../../util/browser.factory'; -import {AlertController} from '@ionic/angular/standalone'; +import { + AuthActions, AuthProvider, + IAuthAction, +} from './auth.provider'; const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path'; +const AUTH_MESSAGE_PREFIX = 'auth.messages.default'; @Injectable({ providedIn: 'root', }) export class AuthHelperService { + private authProvider = inject(AuthProvider); + private translateService = inject(TranslateService); private configProvider = inject(ConfigProvider); private storageProvider = inject(StorageProvider); - private defaultAuth = inject(DefaultAuthService); - private browser = inject(SimpleBrowser); private alertController = inject(AlertController); @@ -58,93 +61,139 @@ export class AuthHelperService { ).default?.endpoints.mapping ?? {}; } - public getAuthMessage(provider: SCAuthorizationProviderType, action: IAuthAction) { - let message: string | undefined; + public getAuthMessage( + action: IAuthAction, + ): string | undefined { switch (action.action) { - case AuthActions.SignInSuccess: { - message = this.translateService.instant(`auth.messages.${provider}.logged_in_success`); - break; - } - case AuthActions.SignOutSuccess: { - message = this.translateService.instant(`auth.messages.${provider}.logged_out_success`); - break; - } + case AuthActions.SignInSuccess: + return this.translateService.instant( + `${AUTH_MESSAGE_PREFIX}.logged_in_success`, + ); + + case AuthActions.SignOutSuccess: + return this.translateService.instant( + `${AUTH_MESSAGE_PREFIX}.logged_out_success`, + ); + + default: + return undefined; } - return message; } - getUserFromUserInfo(userInfo: object) { + public getUserFromUserInfo( + userInfo: object, + ): SCUserConfiguration { const user: SCUserConfiguration = { id: '', name: '', role: 'student', }; + for (const key in this.userConfigurationMap) { - user[key as keyof SCUserConfiguration] = JSONPath({ - path: this.userConfigurationMap[key as keyof SCUserConfiguration] as string, + const userKey = + key as keyof SCUserConfiguration; + + user[userKey] = JSONPath({ + path: + this.userConfigurationMap[ + userKey + ] as string, json: userInfo, })[0]; } - if (user.givenName && user.givenName.length > 0 && user.familyName && user.familyName.length > 0) { - user.name = `${user.givenName} ${user.familyName}`; + + if ( + user.givenName && + user.familyName + ) { + user.name = + `${user.givenName} ${user.familyName}`; } return user; } - async deleteOriginPath() { - return this.storageProvider.delete(AUTH_ORIGIN_PATH); + public async deleteOriginPath(): + Promise { + await this.storageProvider.delete( + AUTH_ORIGIN_PATH, + ); } - async setOriginPath(path: string) { - return this.storageProvider.put(AUTH_ORIGIN_PATH, path); + public async setOriginPath( + path: string, + ): Promise { + await this.storageProvider.put( + AUTH_ORIGIN_PATH, + path, + ); } - async getOriginPath() { - let originPath: string; + public async getOriginPath(): + Promise { try { - originPath = await this.storageProvider.get(AUTH_ORIGIN_PATH); + return await this.storageProvider.get( + AUTH_ORIGIN_PATH, + ); } catch { + return undefined; + } + } + + public async endBrowserSession(): + Promise { + const endSessionEndpoint = + await this.authProvider + .getEndSessionEndpoint(); + + if (!endSessionEndpoint) { return; } - return originPath; - } - /** - * Provides appropriate auth service instance based on type (string) parameter - */ - getProvider(): DefaultAuthService { - return this.defaultAuth; - } + const alert = + await this.alertController.create({ + header: + this.translateService.instant( + `${AUTH_MESSAGE_PREFIX}.log_out_alert.header`, + ), - /** - * Ends browser session by opening endSessionEndpoint URL of the provider - * @param providerType Type of the provider (e.g. 'default' or 'paia') - */ - async endBrowserSession(providerType: SCAuthorizationProviderType) { - const endSessionEndpoint = (await this.getProvider().configuration).endSessionEndpoint; + message: + this.translateService.instant( + `${AUTH_MESSAGE_PREFIX}.log_out_alert.message`, + ), - if (endSessionEndpoint) { - const alert: HTMLIonAlertElement = await this.alertController.create({ - header: this.translateService.instant(`auth.messages.${providerType}.log_out_alert.header`), - message: this.translateService.instant(`auth.messages.${providerType}.log_out_alert.message`), buttons: [ { - text: this.translateService.instant('no'), + text: + this.translateService.instant( + 'no', + ), + + role: 'cancel', cssClass: 'default', }, { - text: this.translateService.instant('yes'), + text: + this.translateService.instant( + 'yes', + ), + role: 'confirm', cssClass: 'preferred', - handler: () => { - this.browser.open(new URL(endSessionEndpoint).href); + + handler: async () => { + /* + * Only now, after explicit confirmation, + * open the IdP end-session endpoint. + */ + await this.browser.open( + endSessionEndpoint, + ); }, }, ], }); - await alert.present(); - } + await alert.present(); } } diff --git a/frontend/app/src/app/modules/auth/auth-paths.ts b/frontend/app/src/app/modules/auth/auth-paths.ts index 3471c846..9cda3b4b 100644 --- a/frontend/app/src/app/modules/auth/auth-paths.ts +++ b/frontend/app/src/app/modules/auth/auth-paths.ts @@ -12,15 +12,5 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {SCAuthorizationProviderType} from '@openstapps/core'; -export const authPaths: { - [key in SCAuthorizationProviderType]: {redirect_path: string}; -} = { - default: { - redirect_path: 'auth/callback', - }, - paia: { - redirect_path: 'auth/paia/callback', - }, -}; +export const AUTH_REDIRECT_PATH = 'auth/callback'; diff --git a/frontend/app/src/app/modules/auth/auth-routing.module.ts b/frontend/app/src/app/modules/auth/auth-routing.module.ts index 3f52edb0..beb30d22 100644 --- a/frontend/app/src/app/modules/auth/auth-routing.module.ts +++ b/frontend/app/src/app/modules/auth/auth-routing.module.ts @@ -15,12 +15,12 @@ import {RouterModule, Routes} from '@angular/router'; import {NgModule} from '@angular/core'; -import {authPaths} from './auth-paths'; +import {AUTH_REDIRECT_PATH} from './auth-paths'; import {AuthCallbackPageComponent} from './auth-callback/page/auth-callback-page.component'; const authRoutes: Routes = [ { - path: authPaths.default.redirect_path, + path: AUTH_REDIRECT_PATH, component: AuthCallbackPageComponent, }, ]; diff --git a/frontend/app/src/app/modules/auth/auth.module.ts b/frontend/app/src/app/modules/auth/auth.module.ts index 0fd9b82a..319cd962 100644 --- a/frontend/app/src/app/modules/auth/auth.module.ts +++ b/frontend/app/src/app/modules/auth/auth.module.ts @@ -3,14 +3,12 @@ import {CommonModule} from '@angular/common'; import {Platform} from '@ionic/angular/standalone'; import {Requestor, StorageBackend} from '@openid/appauth'; import {storageFactory} from './factories'; -import {Browser} from 'ionic-appauth'; -import {CapacitorBrowser} from 'ionic-appauth/lib/capacitor'; -import {httpFactory} from './factories/http.factory'; +import {requestorFactory} from './factories/requestor.factory'; import {HttpClient} from '@angular/common/http'; import {AuthRoutingModule} from './auth-routing.module'; import {TranslateModule} from '@ngx-translate/core'; import {AuthCallbackPageComponent} from './auth-callback/page/auth-callback-page.component'; -import {DefaultAuthService} from './default-auth.service'; +import {AuthProvider} from "./auth.provider"; @NgModule({ declarations: [AuthCallbackPageComponent], @@ -23,14 +21,10 @@ import {DefaultAuthService} from './default-auth.service'; }, { provide: Requestor, - useFactory: httpFactory, + useFactory: requestorFactory, deps: [Platform, HttpClient], }, - { - provide: Browser, - useClass: CapacitorBrowser, - }, - DefaultAuthService, + AuthProvider, ], }) export class AuthModule {} diff --git a/frontend/app/src/app/modules/auth/auth.provider.methods.ts b/frontend/app/src/app/modules/auth/auth.provider.methods.ts index 5fc4dec0..ce051789 100644 --- a/frontend/app/src/app/modules/auth/auth.provider.methods.ts +++ b/frontend/app/src/app/modules/auth/auth.provider.methods.ts @@ -13,69 +13,90 @@ * this program. If not, see . */ -import {AuthorizationServiceConfigurationJson} from '@openid/appauth'; -import {IAuthConfig} from 'ionic-appauth'; -import {SCAuthorizationProvider, SCAuthorizationProviderType} from '@openstapps/core'; import {Capacitor} from '@capacitor/core'; -import {authPaths} from './auth-paths'; +import {SCAuthorizationProvider} from '@openstapps/core'; +import { + AuthorizationServiceConfiguration, +} from '@openid/appauth'; + import {environment} from '../../../environments/environment'; +import {AUTH_REDIRECT_PATH} from './auth-paths'; +import type {AuthConfig} from './auth.provider'; + +export interface OidcEndpoints { + userinfo?: string; + endSession?: string; +} /** - * Get configuration of an OAuth2 client + * Returns the configuration of the OIDC client. */ export function getClientConfig( - providerType: SCAuthorizationProviderType, authConfig: { - default?: SCAuthorizationProvider; - paia?: SCAuthorizationProvider; + default: SCAuthorizationProvider; }, -): IAuthConfig { - const providerConfig = authConfig[providerType] as SCAuthorizationProvider; +): AuthConfig { + const provider = + authConfig.default; + return { - end_session_redirect_url: '', + server_host: provider.client.url, + client_id: provider.client.clientId, + scopes: 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 AppAuth OAuth/OIDC service configuration. */ -export function getEndpointsConfig( - providerType: SCAuthorizationProviderType, +export function getServiceConfiguration( authConfig: { - default?: SCAuthorizationProvider; - paia?: SCAuthorizationProvider; + default: SCAuthorizationProvider; }, -): AuthorizationServiceConfigurationJson { - const providerConfig = authConfig[providerType] as SCAuthorizationProvider; +): AuthorizationServiceConfiguration { + const endpoints = + authConfig.default.endpoints; + + return new AuthorizationServiceConfiguration({ + authorization_endpoint: endpoints.authorization, + token_endpoint: endpoints.token, + revocation_endpoint: endpoints.revoke || '', + }); +} + +/** + * Returns the additional OIDC endpoints that AppAuth-JS does not expose + * through AuthorizationServiceConfiguration. + */ +export function getOidcEndpoints( + authConfig: { + default: SCAuthorizationProvider; + }, +): OidcEndpoints { + const endpoints = authConfig.default.endpoints; + 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, + userinfo: + endpoints.userinfo, + + endSession: + endpoints.endSession, }; } /** - * Return a URL of the app, depending on the platform where it is running + * Returns the OIDC redirect URL depending on the current platform. */ -function getRedirectUrl(routePath: string): string { - let appHost: string; - let appSchema: string; - if (environment.production) { - appSchema = Capacitor.isNativePlatform() ? environment.custom_url_scheme : 'https'; - appHost = environment.app_host; - } else { - appSchema = Capacitor.isNativePlatform() - ? environment.custom_url_scheme - : window.location.protocol.split(':')[0]; - - appHost = Capacitor.isNativePlatform() ? environment.app_host : window.location.host; +function getRedirectUrl(): string { + if (Capacitor.isNativePlatform()) { + return `${environment.custom_url_scheme}://${environment.app_host}/${AUTH_REDIRECT_PATH}`; } - return `${appSchema}://${appHost}/${routePath}`; + + if (environment.production) { + return `https://${environment.app_host}/${AUTH_REDIRECT_PATH}`; + } + + return `${window.location.origin}/${AUTH_REDIRECT_PATH}`; } diff --git a/frontend/app/src/app/modules/auth/auth.provider.spec.ts b/frontend/app/src/app/modules/auth/auth.provider.spec.ts new file mode 100644 index 00000000..8db72393 --- /dev/null +++ b/frontend/app/src/app/modules/auth/auth.provider.spec.ts @@ -0,0 +1,239 @@ +import { + AppAuthError, + AuthorizationServiceConfiguration, + StorageBackend, + TokenRequestHandler, + TokenResponse, +} from '@openid/appauth'; +import {BehaviorSubject, firstValueFrom} from 'rxjs'; + +import {AuthProvider, IAuthAction, INVALID_GRANT} from './auth.provider'; + +const TOKEN_RESPONSE_KEY = 'token_response'; +const TOKEN_LIFETIME = 3600; +const NOW_MS = Date.parse('2026-01-01T12:00:00Z'); +const NOW = NOW_MS / 1000; + +/** + * Creates a token with a real lifetime instead of spying on isValid(). + * + * remainingSeconds > 0 → still valid for that long + * remainingSeconds < 0 → expired that many seconds ago + * refreshToken: null → token without refresh token + */ +function makeToken( + opts: {accessToken?: string; refreshToken?: string | null; remainingSeconds?: number} = {}, +): TokenResponse { + const remaining = opts.remainingSeconds ?? TOKEN_LIFETIME; + + return new TokenResponse({ + access_token: opts.accessToken ?? 'access-token', + refresh_token: opts.refreshToken === null ? undefined : (opts.refreshToken ?? 'refresh-token'), + token_type: 'bearer', + expires_in: String(TOKEN_LIFETIME), + issued_at: NOW - (TOKEN_LIFETIME - remaining), + }); +} + +/** Typed access to the private members the tests depend on. */ +interface AuthProviderInternals { + storage: StorageBackend; + tokenHandler: TokenRequestHandler; + tokenSubject: BehaviorSubject; + notify(action: IAuthAction): void; +} + +describe('AuthProvider', () => { + let provider: AuthProvider; + let internals: AuthProviderInternals; + + let storageGetItem: jasmine.Spy; + let storageSetItem: jasmine.Spy; + let storageRemoveItem: jasmine.Spy; + let tokenRequest: jasmine.Spy; + + /** Seeds storage with the given token and loads it through the public API. */ + async function givenLoadedToken(token: TokenResponse | null): Promise { + storageGetItem.and.resolveTo(token ? JSON.stringify(token.toJson()) : null); + await provider.loadTokenFromStorage(); + } + + const isAuthenticated = () => firstValueFrom(provider.isAuthenticated$); + const isLoggedIn = () => firstValueFrom(provider.isLoggedIn$); + + beforeEach(() => { + jasmine.clock().install(); + jasmine.clock().mockDate(new Date(NOW_MS)); + + const platform = {is: () => false} as any; + const configProvider = {} as any; + + provider = new AuthProvider(platform, configProvider); + internals = provider as unknown as AuthProviderInternals; + + (provider as any).authConfigValue = { + server_host: 'https://idp.example.org', + client_id: 'test-client', + redirect_url: 'https://app.example.org/callback', + scopes: 'openid profile', + pkce: true, + }; + + (provider as any).localConfiguration = new AuthorizationServiceConfiguration({ + authorization_endpoint: 'https://idp.example.org/authorize', + token_endpoint: 'https://idp.example.org/token', + revocation_endpoint: 'https://idp.example.org/revoke', + }); + + // Never touch real storage or the network. + storageGetItem = spyOn(internals.storage, 'getItem').and.resolveTo(null); + storageSetItem = spyOn(internals.storage, 'setItem').and.resolveTo(); + storageRemoveItem = spyOn(internals.storage, 'removeItem').and.resolveTo(); + tokenRequest = spyOn(internals.tokenHandler, 'performTokenRequest').and.rejectWith( + new Error('Unexpected token request'), + ); + }); + + afterEach(() => { + jasmine.clock().uninstall(); + }); + + describe('isAuthenticated$', () => { + it('should provide false through isAuthenticated$ when there is no token response', async () => { + // Start authenticated so the test cannot pass on the initial value alone. + await givenLoadedToken(makeToken()); + expect(await isAuthenticated()).toBeTrue(); + + await givenLoadedToken(null); + + expect(internals.tokenSubject.value).toBeUndefined(); + expect(await isAuthenticated()).toBeFalse(); + }); + + it('should provide true through isAuthenticated$ when access token is valid', async () => { + await givenLoadedToken(makeToken()); + + expect(await isAuthenticated()).toBeTrue(); + }); + + it('should provide false through isAuthenticated$ when access token is invalid', async () => { + await givenLoadedToken(makeToken({remainingSeconds: -TOKEN_LIFETIME})); + + expect(await isAuthenticated()).toBeFalse(); + // Still logged in: a refresh token is available. + expect(await isLoggedIn()).toBeTrue(); + }); + + it('should provide true through isAuthenticated$ after an expired token was refreshed', async () => { + await givenLoadedToken(makeToken({remainingSeconds: -60})); + expect(await isAuthenticated()).toBeFalse(); + + tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token'})); + await provider.getValidToken(); + + expect(await isAuthenticated()).toBeTrue(); + }); + }); + + describe('getValidToken', () => { + it('returns the current token without refreshing when it is valid', async () => { + await givenLoadedToken(makeToken({accessToken: 'current-access-token'})); + + const token = await provider.getValidToken(); + + expect(tokenRequest).not.toHaveBeenCalled(); + expect(token.accessToken).toBe('current-access-token'); + }); + + it('refreshes the token when the access token is expired', async () => { + await givenLoadedToken(makeToken({remainingSeconds: -60})); + tokenRequest.and.resolveTo( + makeToken({accessToken: 'new-access-token', refreshToken: 'new-refresh-token'}), + ); + + const token = await provider.getValidToken(); + + expect(tokenRequest).toHaveBeenCalledTimes(1); + expect(token.accessToken).toBe('new-access-token'); + expect(token.refreshToken).toBe('new-refresh-token'); + expect(storageSetItem).toHaveBeenCalledOnceWith(TOKEN_RESPONSE_KEY, jasmine.any(String)); + }); + + it('refreshes the token when the access token expires within the buffer', async () => { + // 5 minutes left, default buffer requires more than 10. + await givenLoadedToken(makeToken({remainingSeconds: 5 * 60})); + 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('keeps the previous refresh token when the refresh response contains none', async () => { + await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: 'old-refresh-token'})); + tokenRequest.and.resolveTo(makeToken({accessToken: 'new-access-token', refreshToken: null})); + + const token = await provider.getValidToken(); + + expect(token.accessToken).toBe('new-access-token'); + expect(token.refreshToken).toBe('old-refresh-token'); + }); + + it('rejects without a request when the token is expired and has no refresh token', async () => { + await givenLoadedToken(makeToken({remainingSeconds: -60, refreshToken: null})); + + await expectAsync(provider.getValidToken()).toBeRejectedWithError(/No Refresh Token Available/); + expect(tokenRequest).not.toHaveBeenCalled(); + }); + + 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'); + }); + }); + }); +}); 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..acf6e3cc --- /dev/null +++ b/frontend/app/src/app/modules/auth/auth.provider.ts @@ -0,0 +1,1122 @@ +/* + * Copyright (C) 2023 StApps + * This program is free software: you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the Free + * Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +import {Injectable} from '@angular/core'; +import {Browser} from '@capacitor/browser'; +import {Platform} from '@ionic/angular/standalone'; +import { + AuthorizationError, + AuthorizationNotifier, + AuthorizationRequest, + AuthorizationRequestJson, + AuthorizationResponse, + AuthorizationServiceConfiguration, + AppAuthError, + BaseTokenRequestHandler, + BasicQueryStringUtils, + DefaultCrypto, + ErrorType, + GRANT_TYPE_AUTHORIZATION_CODE, + GRANT_TYPE_REFRESH_TOKEN, + LocationLike, + RedirectRequestHandler, + Requestor, + RevokeTokenRequest, + RevokeTokenRequestJson, + StorageBackend, + StringMap, + TokenRequest, + TokenRequestHandler, + TokenRequestJson, + TokenResponse, +} from '@openid/appauth'; +import {SCAuthorizationProvider} from '@openstapps/core'; +import {BehaviorSubject, Observable} from 'rxjs'; + +import {ConfigProvider} from '../config/config.provider'; +import {CapacitorAuthorizationRequestHandler} from './capacitor-authorization-request-handler'; +import { + getClientConfig, + getOidcEndpoints, + getServiceConfiguration, + OidcEndpoints, +} 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'; +const AUTH_EXPIRY_BUFFER = -3 * 60; + +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', +} + +export interface IAuthAction { + action: AuthActions; + tokenResponse?: TokenResponse; + user?: unknown; + error?: unknown; +} + +/** + * Common subset implemented by AppAuth's RedirectRequestHandler + * and our CapacitorAuthorizationRequestHandler. + */ +interface AuthorizationHandler { + setAuthorizationNotifier( + notifier: AuthorizationNotifier, + ): unknown; + + performAuthorizationRequest( + configuration: AuthorizationServiceConfiguration, + request: AuthorizationRequest, + ): void | Promise; + + completeAuthorizationRequestIfPossible(): + Promise; +} + +class NoHashQueryStringUtils extends BasicQueryStringUtils { + override parse( + input: LocationLike, + _useHash?: boolean, + ): StringMap { + return super.parse( + input, + false, + ); + } +} + +@Injectable({ + providedIn: 'root', +}) +export class AuthProvider { + private authConfigValue?: AuthConfig; + + private localConfiguration?: + AuthorizationServiceConfiguration; + + private oidcEndpoints?: OidcEndpoints; + + 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: + AuthorizationHandler; + + private readonly tokenHandler: + TokenRequestHandler; + + private notifierInitialized = false; + + constructor( + private readonly platform: Platform, + private readonly configProvider: ConfigProvider, + ) { + this.storage = + storageFactory(platform); + + this.requestor = + requestorFactory(platform); + + this.requestHandler = + platform.is('capacitor') + ? new CapacitorAuthorizationRequestHandler( + this.storage, + ) + : new RedirectRequestHandler( + this.storage, + new NoHashQueryStringUtils(), + ); + + this.tokenHandler = + new BaseTokenRequestHandler( + this.requestor, + ); + } + + 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(); + + await this.loadTokenFromStorage(); + } + + private setupConfiguration(): void { + const config = + this.configProvider.getAnyValue( + 'auth', + ) as { + default: SCAuthorizationProvider; + }; + + this.authConfigValue = + getClientConfig(config); + + this.localConfiguration = + getServiceConfiguration(config); + + this.oidcEndpoints = + getOidcEndpoints(config); + } + + 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.requestTokenRefresh(); + } catch (error) { + await this.handleRefreshFailure( + error, + ); + } + } + + public async loadUserInfo(): + Promise { + try { + const token = + await this.getValidToken(0); + + const endpoint = + this.oidcEndpoints?.userinfo; + + if (!endpoint) { + throw new Error( + 'OIDC provider does not expose a userinfo endpoint.', + ); + } + + const user = + await this.requestor.xhr< + Record + >({ + url: + endpoint, + + method: + 'GET', + + dataType: + 'json', + + headers: { + Authorization: + `Bearer ${token.accessToken}`, + }, + }); + + this.notify({ + action: + AuthActions.LoadUserInfoSuccess, + + user, + }); + } catch (error) { + this.notify({ + action: + AuthActions.LoadUserInfoFailed, + + error, + }); + } + } + + public async getEndSessionEndpoint(): + Promise { + return this.oidcEndpoints + ?.endSession; + } + + public async signOut( + _state?: string, + revokeTokens = false, + ): Promise { + try { + if (revokeTokens) { + await this.revokeTokens(); + } + + 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, + }); + } + } + + 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), + ); + + this.notify({ + action: + AuthActions + .LoadTokenFromStorageSuccess, + + tokenResponse: + token, + }); + } catch (error) { + this.notify({ + action: + AuthActions + .LoadTokenFromStorageFailed, + + error, + }); + } + } + + public async getValidToken( + buffer = AUTH_EXPIRY_BUFFER, + ): Promise { + const currentToken = + this.tokenSubject.value; + + /* + * Access token still valid for longer than the configured + * expiry buffer. + */ + if (currentToken?.isValid(buffer)) { + console.log(currentToken?.isValid(buffer)); + return currentToken; + } + + /* + * Access token is expired or will expire soon. + * We can only recover automatically if a refresh token exists. + */ + if (!currentToken?.refreshToken) { + throw new Error( + 'Unable To Obtain Valid Token: No Refresh Token Available', + ); + } + + /* + * Refresh immediately. + * + * requestTokenRefresh() stores the newly returned token + * and updates tokenSubject via RefreshSuccess. + */ + try { + await this.requestTokenRefresh(); + } catch (error) { + await this.handleRefreshFailure( + error, + ); + + throw error; + } + + const refreshedToken = + this.tokenSubject.value; + + if (!refreshedToken) { + throw new Error( + 'Unable To Obtain Valid Token: Refresh Returned No Token', + ); + } + + if (!refreshedToken.isValid(buffer)) { + throw new Error( + 'Unable To Obtain Valid Token: Refreshed Token Is Already Too Close To Expiry', + ); + } + + return refreshedToken; + } + + private async handleRefreshFailure( + error: unknown, + ): Promise { + /* + * AppAuth maps OAuth token endpoint errors to AppAuthError. + * This OAuth error 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. + * + * Network and other temporary errors must not log the user + * out. + */ + if ( + error instanceof AppAuthError && + error.message === INVALID_GRANT + ) { + try { + await this.storage.removeItem( + TOKEN_RESPONSE_KEY, + ); + } catch { + /* + * Do not hide the original OAuth error if removing the + * locally stored token fails. + */ + } + + this.tokenSubject.next( + undefined, + ); + + this.userSubject.next( + undefined, + ); + + this.authenticatedSubject.next( + false, + ); + + this.loggedInSubject.next( + false, + ); + } + + this.notify({ + action: + AuthActions.RefreshFailed, + + error, + }); + } + + 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!', + ); + } + + const configuration = + await this.configuration; + + if ( + !configuration.revocationEndpoint + ) { + /* + * No revocation endpoint is configured by the backend. + * Remove the local token instead. + */ + await this.storage.removeItem( + TOKEN_RESPONSE_KEY, + ); + + return; + } + + if (token.refreshToken) { + const refreshRequest: + RevokeTokenRequestJson = { + token: + token.refreshToken, + + token_type_hint: + 'refresh_token', + + client_id: + this.authConfig.client_id, + }; + + await this.tokenHandler + .performRevokeTokenRequest( + configuration, + + new RevokeTokenRequest( + refreshRequest, + ), + ); + } + + const accessRequest: + RevokeTokenRequestJson = { + token: + token.accessToken, + + token_type_hint: + 'access_token', + + client_id: + this.authConfig.client_id, + }; + + await this.tokenHandler + .performRevokeTokenRequest( + configuration, + + new RevokeTokenRequest( + accessRequest, + ), + ); + + await this.storage.removeItem( + TOKEN_RESPONSE_KEY, + ); + } + + private async handleAuthorizationCallback( + callbackUrl: string, + ): Promise { + if ( + this.requestHandler instanceof + CapacitorAuthorizationRequestHandler + ) { + await this.requestHandler + .completeAuthorizationRequestFromUrl( + callbackUrl, + ); + + return; + } + + await this.requestHandler + .completeAuthorizationRequestIfPossible(); + } + + 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.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.initCompleteSubject.next( + true, + ); + + break; + } + + case AuthActions.SignInSuccess: + case AuthActions.RefreshSuccess: { + if (action.tokenResponse) { + this.tokenSubject.next( + action.tokenResponse, + ); + + this.authenticatedSubject.next( + true, + ); + + this.loggedInSubject.next( + true, + ); + } + + break; + } + + case AuthActions + .LoadTokenFromStorageSuccess: { + if (action.tokenResponse) { + this.tokenSubject.next( + action.tokenResponse, + ); + + this.authenticatedSubject.next( + action.tokenResponse.isValid( + 0, + ), + ); + + this.loggedInSubject.next( + true, + ); + } + + this.initCompleteSubject.next( + true, + ); + + break; + } + + case AuthActions + .RevokeTokensSuccess: { + this.tokenSubject.next( + undefined, + ); + + this.userSubject.next( + undefined, + ); + + this.authenticatedSubject.next( + false, + ); + + this.loggedInSubject.next( + false, + ); + + break; + } + + case AuthActions + .LoadUserInfoSuccess: { + this.userSubject.next( + action.user, + ); + + break; + } + + case AuthActions + .LoadUserInfoFailed: { + this.userSubject.next( + undefined, + ); + + break; + } + + default: + break; + } + + this.authSubject.next( + action, + ); + } +} diff --git a/frontend/app/src/app/modules/auth/auth.service.ts b/frontend/app/src/app/modules/auth/auth.service.ts deleted file mode 100644 index 1b38118f..00000000 --- a/frontend/app/src/app/modules/auth/auth.service.ts +++ /dev/null @@ -1,526 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -// Temporary use of direct file until the version with bug fix is released -// https://github.com/wi3land/ionic-appauth/blob/3716f4fc6b5491b0b75e049be0a47a5af8c4da6f/src/auth-service.ts -// Bug: https://github.com/wi3land/ionic-appauth/issues/154 -import { - AuthorizationError, - AuthorizationNotifier, - AuthorizationRequest, - AuthorizationRequestHandler, - AuthorizationRequestJson, - AuthorizationResponse, - AuthorizationServiceConfiguration, - BaseTokenRequestHandler, - DefaultCrypto, - GRANT_TYPE_AUTHORIZATION_CODE, - GRANT_TYPE_REFRESH_TOKEN, - JQueryRequestor, - LocalStorageBackend, - Requestor, - RevokeTokenRequest, - RevokeTokenRequestJson, - StorageBackend, - StringMap, - TokenRequest, - TokenRequestHandler, - TokenRequestJson, - TokenResponse, -} from '@openid/appauth'; -import { - ActionHistoryObserver, - AuthActionBuilder, - AuthActions, - AuthObserver, - AUTHORIZATION_RESPONSE_KEY, - AuthSubject, - BaseAuthObserver, - Browser, - DefaultBrowser, - EndSessionHandler, - EndSessionRequest, - EndSessionRequestJson, - IAuthAction, - IAuthConfig, - IAuthService, - IonicAuthorizationRequestHandler, - IonicEndSessionHandler, - IonicUserInfoHandler, - SessionObserver, - UserInfoHandler, -} from 'ionic-appauth'; -import {BehaviorSubject, Observable} from 'rxjs'; - -const TOKEN_RESPONSE_KEY = 'token_response'; -const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 mins in seconds - -export abstract class AuthService implements IAuthService { - private _configuration?: AuthorizationServiceConfiguration; - - private _authConfig?: IAuthConfig; - - private _authSubject: AuthSubject = new AuthSubject(); - - private _actionHistory: ActionHistoryObserver = new ActionHistoryObserver(); - - private _session: SessionObserver = new SessionObserver(); - - private _authSubjectV2 = new BehaviorSubject(AuthActionBuilder.Init()); - - private _tokenSubject = new BehaviorSubject(undefined); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _userSubject = new BehaviorSubject(undefined); - - private _authenticatedSubject = new BehaviorSubject(false); - - private _loggedInSubject = new BehaviorSubject(false); - - private _initComplete = new BehaviorSubject(false); - - protected tokenHandler: TokenRequestHandler; - - protected userInfoHandler: UserInfoHandler; - - protected requestHandler: AuthorizationRequestHandler; - - protected endSessionHandler: EndSessionHandler; - - constructor( - protected browser: Browser = new DefaultBrowser(), - protected storage: StorageBackend = new LocalStorageBackend(), - protected requestor: Requestor = new JQueryRequestor(), - ) { - this.tokenHandler = new BaseTokenRequestHandler(requestor); - this.userInfoHandler = new IonicUserInfoHandler(requestor); - this.requestHandler = new IonicAuthorizationRequestHandler(browser, storage); - this.endSessionHandler = new IonicEndSessionHandler(browser); - } - - /** - * @deprecated independant observers have been replaced by Rxjs - * this will be removed in a future release - * please use $ suffixed observers in future - */ - get history(): IAuthAction[] { - return [...this._actionHistory.history]; - } - - /** - * @deprecated independant observers have been replaced by Rxjs - * this will be removed in a future release - * please use $ suffixed observers in future - */ - get session() { - return this._session.session; - } - - get token$(): Observable { - return this._tokenSubject.asObservable(); - } - - get isAuthenticated$(): Observable { - return this._authenticatedSubject.asObservable(); - } - - /** - * Similar to isAuthenticated$, but will also return true if the token is expired - */ - get isLoggedIn$(): Observable { - return this._loggedInSubject.asObservable(); - } - - get initComplete$(): Observable { - return this._initComplete.asObservable(); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - get user$(): Observable { - return this._userSubject.asObservable(); - } - - get events$(): Observable { - return this._authSubjectV2.asObservable(); - } - - get authConfig(): IAuthConfig { - if (!this._authConfig) throw new Error('AuthConfig Not Defined'); - - return this._authConfig; - } - - set authConfig(value: IAuthConfig) { - this._authConfig = value; - } - - get configuration(): Promise { - if (!this._configuration) { - return AuthorizationServiceConfiguration.fetchFromIssuer( - this.authConfig.server_host, - this.requestor, - ).catch(() => { - throw new Error('Unable To Obtain Server Configuration'); - }); - } - - if (this._configuration == undefined) { - throw new Error('Unable To Obtain Server Configuration'); - } else { - return Promise.resolve(this._configuration); - } - } - - public async init() { - this.setupAuthorizationNotifier(); - this.loadTokenFromStorage(); - this.addActionObserver(this._actionHistory); - this.addActionObserver(this._session); - } - - protected notifyActionListers(action: IAuthAction) { - /* eslint-disable unicorn/no-useless-undefined */ - switch (action.action) { - case AuthActions.SignInFailed: - case AuthActions.SignOutSuccess: - case AuthActions.SignOutFailed: { - this._tokenSubject.next(undefined); - this._userSubject.next(undefined); - this._authenticatedSubject.next(false); - this._loggedInSubject.next(false); - break; - } - case AuthActions.LoadTokenFromStorageFailed: { - this._tokenSubject.next(undefined); - this._userSubject.next(undefined); - this._authenticatedSubject.next(false); - this._loggedInSubject.next(false); - this._initComplete.next(true); - break; - } - case AuthActions.SignInSuccess: - case AuthActions.RefreshSuccess: { - this._tokenSubject.next(action.tokenResponse); - this._authenticatedSubject.next(true); - this._loggedInSubject.next(true); - break; - } - case AuthActions.LoadTokenFromStorageSuccess: { - this._tokenSubject.next(action.tokenResponse); - this._authenticatedSubject.next((action.tokenResponse as TokenResponse).isValid(0)); - this._loggedInSubject.next(true); - this._initComplete.next(true); - break; - } - case AuthActions.RevokeTokensSuccess: { - this._tokenSubject.next(undefined); - break; - } - case AuthActions.LoadUserInfoSuccess: { - this._userSubject.next(action.user); - break; - } - case AuthActions.LoadUserInfoFailed: { - this._userSubject.next(undefined); - break; - } - } - - this._authSubjectV2.next(action); - this._authSubject.notify(action); - } - - protected setupAuthorizationNotifier() { - const notifier = new AuthorizationNotifier(); - this.requestHandler.setAuthorizationNotifier(notifier); - notifier.setAuthorizationListener((request, response, error) => - this.onAuthorizationNotification(request, response, error), - ); - } - - protected onAuthorizationNotification( - request: AuthorizationRequest, - response: AuthorizationResponse | null, - error: AuthorizationError | null, - ) { - const codeVerifier: string | undefined = - request.internal != undefined && this.authConfig.pkce ? request.internal.code_verifier : undefined; - - if (response != undefined) { - this.requestAccessToken(response.code, codeVerifier); - } else if (error == undefined) { - throw new Error('Unknown Error With Authentication'); - } else { - throw new Error(error.errorDescription); - } - } - - protected async internalAuthorizationCallback(url: string) { - this.browser.closeWindow(); - await this.storage.setItem(AUTHORIZATION_RESPONSE_KEY, url); - return this.requestHandler.completeAuthorizationRequestIfPossible(); - } - - protected async internalEndSessionCallback() { - this.browser.closeWindow(); - this._actionHistory.clear(); - this.notifyActionListers(AuthActionBuilder.SignOutSuccess()); - } - - protected async performEndSessionRequest(state?: string): Promise { - if (this._tokenSubject.value == undefined) { - //if user has no token they should not be logged in in the first place - this.endSessionCallback(); - } else { - const requestJson: EndSessionRequestJson = { - postLogoutRedirectURI: this.authConfig.end_session_redirect_url, - idTokenHint: this._tokenSubject.value.idToken || '', - state: state || undefined, - }; - - const request: EndSessionRequest = new EndSessionRequest(requestJson); - const returnedUrl: string | undefined = await this.endSessionHandler.performEndSessionRequest( - await this.configuration, - request, - ); - - //callback may come from showWindow or via another method - if (returnedUrl != undefined) { - this.endSessionCallback(); - } - } - } - - protected async performAuthorizationRequest(authExtras?: StringMap, state?: string): Promise { - const requestJson: AuthorizationRequestJson = { - response_type: AuthorizationRequest.RESPONSE_TYPE_CODE, - client_id: this.authConfig.client_id, - redirect_uri: this.authConfig.redirect_url, - scope: this.authConfig.scopes, - extras: authExtras, - state: state || undefined, - }; - - const request = new AuthorizationRequest(requestJson, new DefaultCrypto(), this.authConfig.pkce); - - if (this.authConfig.pkce) await request.setupCodeVerifier(); - - return this.requestHandler.performAuthorizationRequest(await this.configuration, request); - } - - protected async requestAccessToken(code: string, codeVerifier?: string): Promise { - const requestJSON: TokenRequestJson = { - grant_type: GRANT_TYPE_AUTHORIZATION_CODE, - code: code, - refresh_token: undefined, - redirect_uri: this.authConfig.redirect_url, - client_id: this.authConfig.client_id, - extras: codeVerifier - ? { - code_verifier: codeVerifier, - client_secret: this.authConfig.client_secret as string, - } - : { - client_secret: this.authConfig.client_secret as string, - }, - }; - - const token: TokenResponse = await this.tokenHandler.performTokenRequest( - await this.configuration, - new TokenRequest(requestJSON), - ); - await this.storage.setItem(TOKEN_RESPONSE_KEY, JSON.stringify(token.toJson())); - this.notifyActionListers(AuthActionBuilder.SignInSuccess(token)); - } - - protected async requestTokenRefresh() { - if (!this._tokenSubject.value) { - throw new Error('No Token Defined!'); - } - - const requestJSON: TokenRequestJson = { - grant_type: GRANT_TYPE_REFRESH_TOKEN, - refresh_token: this._tokenSubject.value?.refreshToken, - redirect_uri: this.authConfig.redirect_url, - client_id: this.authConfig.client_id, - }; - - const token: TokenResponse = await this.tokenHandler.performTokenRequest( - await this.configuration, - new TokenRequest(requestJSON), - ); - if (!token.accessToken) { - throw new Error('No Access Token Defined In Refresh Response'); - } - await this.storage.setItem(TOKEN_RESPONSE_KEY, JSON.stringify(token.toJson())); - this.notifyActionListers(AuthActionBuilder.RefreshSuccess(token)); - } - - protected async internalLoadTokenFromStorage() { - let token: TokenResponse | undefined; - const tokenResponseString: string | null = await this.storage.getItem(TOKEN_RESPONSE_KEY); - - if (tokenResponseString != undefined) { - token = new TokenResponse(JSON.parse(tokenResponseString)); - - if (token) { - return this.notifyActionListers(AuthActionBuilder.LoadTokenFromStorageSuccess(token)); - } - } - - throw new Error('No Token In Storage'); - } - - protected async requestTokenRevoke() { - const revokeRefreshJson: RevokeTokenRequestJson = { - token: (this._tokenSubject.value as TokenResponse).refreshToken as string, - token_type_hint: 'refresh_token', - client_id: this.authConfig.client_id, - }; - - const revokeAccessJson: RevokeTokenRequestJson = { - token: (this._tokenSubject.value as TokenResponse).accessToken, - token_type_hint: 'access_token', - client_id: this.authConfig.client_id, - }; - - await this.tokenHandler.performRevokeTokenRequest( - await this.configuration, - new RevokeTokenRequest(revokeRefreshJson), - ); - await this.tokenHandler.performRevokeTokenRequest( - await this.configuration, - new RevokeTokenRequest(revokeAccessJson), - ); - await this.storage.removeItem(TOKEN_RESPONSE_KEY); - this.notifyActionListers(AuthActionBuilder.RevokeTokensSuccess()); - } - - protected async internalRequestUserInfo() { - if (this._tokenSubject.value) { - const userInfo = await this.userInfoHandler.performUserInfoRequest( - await this.configuration, - this._tokenSubject.value, - ); - this.notifyActionListers(AuthActionBuilder.LoadUserInfoSuccess(userInfo)); - } else { - throw new Error('No Token Available'); - } - } - - public async loadTokenFromStorage() { - await this.internalLoadTokenFromStorage().catch(error => { - this.notifyActionListers(AuthActionBuilder.LoadTokenFromStorageFailed(error)); - }); - } - - public async signIn(authExtras?: StringMap, state?: string) { - await this.performAuthorizationRequest(authExtras, state).catch(error => { - this.notifyActionListers(AuthActionBuilder.SignInFailed(error)); - }); - } - - public async signOut(state?: string, revokeTokens?: boolean) { - if (revokeTokens) { - await this.revokeTokens(); - } - - await this.storage.removeItem(TOKEN_RESPONSE_KEY); - - if ((await this.configuration).endSessionEndpoint) { - await this.performEndSessionRequest(state).catch(error => { - this.notifyActionListers(AuthActionBuilder.SignOutFailed(error)); - }); - } - } - - public async revokeTokens() { - await this.requestTokenRevoke().catch(error => { - this.storage.removeItem(TOKEN_RESPONSE_KEY); - this.notifyActionListers(AuthActionBuilder.RevokeTokensFailed(error)); - }); - } - - public async refreshToken() { - await this.requestTokenRefresh().catch(error => { - this.notifyActionListers(AuthActionBuilder.RefreshFailed(error)); - }); - } - - public async loadUserInfo() { - await this.internalRequestUserInfo().catch(error => { - this.notifyActionListers(AuthActionBuilder.LoadUserInfoFailed(error)); - }); - } - - public authorizationCallback(callbackUrl: string): void { - this.internalAuthorizationCallback(callbackUrl).catch(error => { - this.notifyActionListers(AuthActionBuilder.SignInFailed(error)); - }); - } - - public endSessionCallback(): void { - this.internalEndSessionCallback().catch(error => { - this.notifyActionListers(AuthActionBuilder.SignOutFailed(error)); - }); - } - - public async getValidToken(buffer: number = AUTH_EXPIRY_BUFFER): Promise { - if (this._tokenSubject.value) { - if (this._tokenSubject.value.isValid(buffer)) { - return this._tokenSubject.value; - } else { - await this.refreshToken(); - if (this._tokenSubject.value) { - return this._tokenSubject.value; - } - } - } - - throw new Error('Unable To Obtain Valid Token'); - } - - /** - * @deprecated independant observers have been replaced by Rxjs - * this will be removed in a future release - * please use $ suffixed observers in future - */ - public addActionListener(function_: (action: IAuthAction) => void): AuthObserver { - const observer: AuthObserver = AuthObserver.Create(function_); - this.addActionObserver(observer); - return observer; - } - - /** - * @deprecated independant observers have been replaced by Rxjs - * this will be removed in a future release - * please use $ suffixed observers in future - */ - public addActionObserver(observer: BaseAuthObserver): void { - if (this._actionHistory.lastAction) { - observer.update(this._actionHistory.lastAction); - } - - this._authSubject.attach(observer); - } - - /** - * @deprecated independant observers have been replaced by Rxjs - * this will be removed in a future release - * please use $ suffixed observers in future - */ - public removeActionObserver(observer: BaseAuthObserver): void { - this._authSubject.detach(observer); - } -} diff --git a/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts new file mode 100644 index 00000000..ac32791f --- /dev/null +++ b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts @@ -0,0 +1,270 @@ +/* + * 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 native Capacitor applications. + * + * AppAuth-JS handles: + * - state generation and validation data + * - PKCE code_verifier / code_challenge + * - authorization URL construction + * - AuthorizationNotifier + * + * This handler adds the Capacitor-specific parts: + * - persist the pending authorization request + * - open the authorization URL using @capacitor/browser + * - process the callback URL delivered through appUrlOpen + */ +export class CapacitorAuthorizationRequestHandler extends AuthorizationRequestHandler { + private callbackUrl?: string; + + constructor(private readonly storage: StorageBackend) { + 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 Browser.open({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 Browser.close(); + } catch { + // Browser might already have been closed by the platform. + } + + await this.completeAuthorizationRequestIfPossible(); + } + + protected async completeAuthorizationRequest(): + Promise { + if (!this.callbackUrl) { + return null; + } + + const storedRequest = + await this.storage.getItem( + AUTHORIZATION_REQUEST_KEY, + ); + + if (!storedRequest) { + this.callbackUrl = undefined; + + throw new Error( + 'No pending authorization request found.', + ); + } + + const requestJson = + JSON.parse( + storedRequest, + ) as AuthorizationRequestJson; + + /* + * The serialized request also contains `internal`, including + * AppAuth's PKCE code_verifier. + */ + const request = + new AuthorizationRequest(requestJson); + + const parameters = + this.parseCallbackUrl( + this.callbackUrl, + ); + + const state = + parameters['state']; + + /* + * Never accept an authorization response for a different request. + */ + if (!state || state !== request.state) { + this.callbackUrl = undefined; + + throw new Error( + 'Authorization response state does not match the authorization request.', + ); + } + + const error = + parameters['error']; + + let response: + AuthorizationResponse | null = null; + + let authorizationError: + AuthorizationError | null = null; + + if (error) { + const errorJson: + AuthorizationErrorJson = { + error, + + error_description: + parameters[ + 'error_description' + ], + + error_uri: + parameters['error_uri'], + + state, + }; + + authorizationError = + new AuthorizationError( + errorJson, + ); + } else { + const code = + parameters['code']; + + if (!code) { + this.callbackUrl = undefined; + + throw new Error( + 'Authorization callback contains neither an authorization code nor an error.', + ); + } + + const responseJson: + AuthorizationResponseJson = { + code, + state, + }; + + response = + new AuthorizationResponse( + responseJson, + ); + } + + /* + * The response has been successfully associated with the + * pending request. It can now be consumed exactly once. + */ + await this.storage.removeItem( + AUTHORIZATION_REQUEST_KEY, + ); + + this.callbackUrl = undefined; + + return { + request, + response, + error: authorizationError, + }; + } + + private parseCallbackUrl( + callbackUrl: string, + ): StringMap { + const result: StringMap = {}; + + const url = new URL(callbackUrl); + + /* + * Authorization Code Flow normally returns code/state in + * the query string: + * + * openstapps:/callback?code=...&state=... + */ + url.searchParams.forEach( + (value, key) => { + result[key] = value; + }, + ); + + /* + * Also accept parameters in the fragment as a fallback. + */ + if (url.hash.length > 1) { + const hash = + url.hash.substring(1); + + const hashParameters = + new URLSearchParams(hash); + + hashParameters.forEach( + (value, key) => { + if (!(key in result)) { + result[key] = value; + } + }, + ); + } + + return result; + } +} diff --git a/frontend/app/src/app/modules/auth/capacitor-requestor.ts b/frontend/app/src/app/modules/auth/capacitor-requestor.ts index cdd2d601..204a94a3 100644 --- a/frontend/app/src/app/modules/auth/capacitor-requestor.ts +++ b/frontend/app/src/app/modules/auth/capacitor-requestor.ts @@ -13,61 +13,77 @@ * this program. If not, see . */ -import {Requestor} from '@openid/appauth'; -import {CapacitorHttp, HttpHeaders, HttpResponse} from '@capacitor/core'; -import {XhrSettings} from 'ionic-appauth/lib/cordova'; +import { + AppAuthError, + Requestor, +} from '@openid/appauth'; +import { + CapacitorHttp, + HttpHeaders, + HttpOptions, +} from '@capacitor/core'; -// REQUIRES CAPACITOR PLUGIN -// @capacitor-community/http -export class CapacitorRequestor extends Requestor { - constructor() { - super(); - } +type XhrSettings = Parameters[0]; +export class CapacitorRequestor implements Requestor { public async xhr(settings: XhrSettings): Promise { - if (!settings.method) settings.method = 'GET'; + if (!settings.url) { + throw new AppAuthError('A URL must be provided.'); + } - switch (settings.method) { - case 'GET': { - return this.get(settings.url, settings.headers); - } - case 'POST': { - return this.post(settings.url, settings.data, settings.headers); - } - case 'PUT': { - return this.put(settings.url, settings.data, settings.headers); - } - case 'DELETE': { - return this.delete(settings.url, settings.headers); + const method = (settings.method ?? 'GET').toUpperCase(); + const url = new URL(settings.url); + + let data: unknown; + + if (settings.data) { + if (method === 'POST') { + data = settings.data; + } else { + const searchParams = new URLSearchParams(settings.data); + + searchParams.forEach((value, key) => { + url.searchParams.append(key, value); + }); } } - } - private async get(url: string, headers: HttpHeaders) { - return CapacitorHttp.get({url, headers}).then((response: HttpResponse) => response.data as T); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async post(url: string, data: any, headers: HttpHeaders) { - return CapacitorHttp.post({ - url, + const options: HttpOptions = { + url: url.toString(), + method, + headers: this.getHeaders(settings.headers), data, - headers, - }).then((response: HttpResponse) => { - return response.data as T; - }); + }; + + if (settings.dataType?.toLowerCase() === 'json') { + options.responseType = 'json'; + } + + const response = await CapacitorHttp.request(options); + + if (response.status < 200 || response.status >= 300) { + throw new AppAuthError( + `HTTP ${response.status}`, + 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 getHeaders(headers: XhrSettings['headers']): HttpHeaders { + const result: HttpHeaders = {}; - private async delete(url: string, headers: HttpHeaders) { - return CapacitorHttp.delete({url, headers}).then((response: HttpResponse) => response.data as T); + if (!headers) { + return result; + } + + for (const [key, value] of Object.entries(headers)) { + if (value !== undefined && value !== null) { + result[key] = String(value); + } + } + + return result; } } diff --git a/frontend/app/src/app/modules/auth/default-auth.service.spec.ts b/frontend/app/src/app/modules/auth/default-auth.service.spec.ts deleted file mode 100644 index 4b5e1e0c..00000000 --- a/frontend/app/src/app/modules/auth/default-auth.service.spec.ts +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -import {TestBed} from '@angular/core/testing'; -import {ConfigProvider} from '../config/config.provider'; -import {StorageProvider} from '../storage/storage.provider'; -import {DefaultAuthService} from './default-auth.service'; -import {Browser} from 'ionic-appauth'; -import {nowInSeconds, Requestor, StorageBackend} from '@openid/appauth'; -import {TranslateService} from '@ngx-translate/core'; -import {StAppsWebHttpClient} from '../data/stapps-web-http-client.provider'; -import {provideHttpClient, withInterceptorsFromDi} from '@angular/common/http'; -import {IonicStorage} from 'ionic-appauth/lib'; -import {RouterModule} from '@angular/router'; -import {LoggerTestingModule} from 'ngx-logger/testing'; - -describe('AuthService', () => { - let defaultAuthService: DefaultAuthService; - let storageBackendSpy: jasmine.SpyObj; - const storageProviderSpy = jasmine.createSpyObj('StorageProvider', ['init', 'get', 'has', 'put', 'search']); - const translateServiceSpy = jasmine.createSpyObj('TranslateService', ['setDefaultLang', 'use']); - - beforeEach(() => { - storageBackendSpy = jasmine.createSpyObj('StorageBackend', ['getItem']); - - TestBed.configureTestingModule({ - imports: [LoggerTestingModule, RouterModule.forRoot([])], - providers: [ - StAppsWebHttpClient, - { - provide: TranslateService, - useValue: translateServiceSpy, - }, - { - provide: StorageProvider, - useValue: storageProviderSpy, - }, - IonicStorage, - ConfigProvider, - Browser, - { - provide: StorageBackend, - useValue: storageBackendSpy, - }, - Requestor, - provideHttpClient(withInterceptorsFromDi()), - ], - }); - defaultAuthService = TestBed.inject(DefaultAuthService); - }); - - describe('loadTokenFromStorage', () => { - it('should provide false through isAuthenticated$ when there is no token response', async () => { - // eslint-disable-next-line unicorn/no-null - storageBackendSpy.getItem.and.returnValue(Promise.resolve(null)); - let loggedInHolder; - defaultAuthService.isAuthenticated$.subscribe(loggedIn => { - loggedInHolder = loggedIn; - }); - await defaultAuthService.loadTokenFromStorage(); - - expect(loggedInHolder).toBeFalse(); - }); - - it('should provide true through isAuthenticated$ when access token is valid', async () => { - const validToken = `{"access_token":"AT-XXXX","refresh_token":"RT-XXXX","scope":"","token_type":"bearer","issued_at":${nowInSeconds()},"expires_in":"${ - 8 * 60 * 60 - }"}`; - storageBackendSpy.getItem.and.returnValue(Promise.resolve(validToken)); - let loggedInHolder; - defaultAuthService.isAuthenticated$.subscribe(loggedIn => { - loggedInHolder = loggedIn; - }); - await defaultAuthService.loadTokenFromStorage(); - - expect(loggedInHolder).toBeTrue(); - }); - - it('should provide false through isAuthenticated$ when access token is invalid', async () => { - const invalidToken = `{"access_token":"AT-INVALID-XXXX","refresh_token":"RT-XXXX","scope":"","token_type":"bearer","issued_at":${ - nowInSeconds() - 9 * 60 * 60 - },"expires_in":"${8 * 60 * 60}"}`; - storageBackendSpy.getItem.and.returnValue(Promise.resolve(invalidToken)); - let loggedInHolder; - defaultAuthService.isAuthenticated$.subscribe(loggedIn => { - loggedInHolder = loggedIn; - }); - await defaultAuthService.loadTokenFromStorage(); - - expect(loggedInHolder).toBeFalse(); - }); - }); -}); diff --git a/frontend/app/src/app/modules/auth/default-auth.service.ts b/frontend/app/src/app/modules/auth/default-auth.service.ts deleted file mode 100644 index e9659d9e..00000000 --- a/frontend/app/src/app/modules/auth/default-auth.service.ts +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -import { - AuthorizationRequestHandler, - AuthorizationServiceConfiguration, - JQueryRequestor, - LocalStorageBackend, - Requestor, - StorageBackend, - TokenRequestHandler, -} from '@openid/appauth'; -import {AuthActionBuilder, Browser, DefaultBrowser, EndSessionHandler, UserInfoHandler} from 'ionic-appauth'; -import {ConfigProvider} from '../config/config.provider'; -import {SCAuthorizationProvider} from '@openstapps/core'; -import {getClientConfig, getEndpointsConfig} from './auth.provider.methods'; -import {Injectable, inject} from '@angular/core'; -import {AuthService} from './auth.service'; - -const TOKEN_RESPONSE_KEY = 'token_response'; - -@Injectable({ - providedIn: 'root', -}) -export class DefaultAuthService extends AuthService { - protected browser: Browser; - - protected storage: StorageBackend; - - protected requestor: Requestor; - - private readonly configProvider = inject(ConfigProvider); - - public localConfiguration: AuthorizationServiceConfiguration; - - protected tokenHandler: TokenRequestHandler; - - protected userInfoHandler: UserInfoHandler; - - protected requestHandler: AuthorizationRequestHandler; - - protected endSessionHandler: EndSessionHandler; - - constructor() { - const browser = inject(Browser) ?? new DefaultBrowser(); - const storage = inject(StorageBackend) ?? new LocalStorageBackend(); - const requestor = inject(Requestor) ?? new JQueryRequestor(); - - super(browser, storage, requestor); - - this.browser = browser; - this.storage = storage; - this.requestor = requestor; - } - - get configuration(): Promise { - if (!this.localConfiguration) throw new Error('Local Configuration Not Defined'); - - return Promise.resolve(this.localConfiguration); - } - - public async init() { - this.setupConfiguration(); - this.setupAuthorizationNotifier(); - await this.loadTokenFromStorage(); - } - - setupConfiguration() { - const authConfig = this.configProvider.getAnyValue('auth') as { - default: SCAuthorizationProvider; - }; - this.authConfig = getClientConfig('default', authConfig); - this.localConfiguration = new AuthorizationServiceConfiguration( - getEndpointsConfig('default', authConfig), - ); - } - - public async signOut() { - await this.revokeTokens().catch(error => { - this.notifyActionListers(AuthActionBuilder.SignOutFailed(error)); - }); - this.notifyActionListers(AuthActionBuilder.SignOutSuccess()); - } - - public async revokeTokens() { - // Note: only locally - await this.storage.removeItem(TOKEN_RESPONSE_KEY); - this.notifyActionListers(AuthActionBuilder.RevokeTokensSuccess()); - } -} diff --git a/frontend/app/src/app/modules/auth/factories/http.factory.ts b/frontend/app/src/app/modules/auth/factories/requestor.factory.ts similarity index 76% rename from frontend/app/src/app/modules/auth/factories/http.factory.ts rename to frontend/app/src/app/modules/auth/factories/requestor.factory.ts index 0a7e28b3..24345917 100644 --- a/frontend/app/src/app/modules/auth/factories/http.factory.ts +++ b/frontend/app/src/app/modules/auth/factories/requestor.factory.ts @@ -14,9 +14,11 @@ */ import {Platform} from '@ionic/angular/standalone'; +import {FetchRequestor, Requestor} from '@openid/appauth'; import {CapacitorRequestor} from '../capacitor-requestor'; -import {NgHttpService} from '../ng-http.service'; -export const httpFactory = (platform: Platform) => { - return platform.is('capacitor') ? new CapacitorRequestor() : new NgHttpService(); +export const requestorFactory = (platform: Platform): Requestor => { + return platform.is('capacitor') + ? new CapacitorRequestor() + : new FetchRequestor(); }; diff --git a/frontend/app/src/app/modules/auth/factories/storage.factory.ts b/frontend/app/src/app/modules/auth/factories/storage.factory.ts index 2b9a3449..cd4a2c7c 100644 --- a/frontend/app/src/app/modules/auth/factories/storage.factory.ts +++ b/frontend/app/src/app/modules/auth/factories/storage.factory.ts @@ -14,9 +14,11 @@ */ import {Platform} from '@ionic/angular/standalone'; -import {IonicStorage} from 'ionic-appauth/lib'; +import {LocalStorageBackend, StorageBackend} from '@openid/appauth'; import {SafeCapacitorSecureStorage} from '../../storage/capacitor-secure-storage'; -export const storageFactory = (platform: Platform) => { - return platform.is('capacitor') ? new SafeCapacitorSecureStorage() : new IonicStorage(); +export const storageFactory = (platform: Platform): StorageBackend => { + return platform.is('capacitor') + ? new SafeCapacitorSecureStorage() + : new LocalStorageBackend(); }; diff --git a/frontend/app/src/app/modules/auth/ng-http.service.ts b/frontend/app/src/app/modules/auth/ng-http.service.ts deleted file mode 100644 index bbb13a4f..00000000 --- a/frontend/app/src/app/modules/auth/ng-http.service.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (C) 2023 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -import {Injectable, inject} from '@angular/core'; -import {Requestor} from '@openid/appauth'; -import {HttpClient, HttpHeaders} from '@angular/common/http'; -import {XhrSettings} from 'ionic-appauth/lib/cordova'; -import {firstValueFrom, Observable} from 'rxjs'; - -@Injectable({ - providedIn: 'root', -}) -export class NgHttpService implements Requestor { - private http = inject(HttpClient); - - public async xhr(settings: XhrSettings): Promise { - if (!settings.method) { - settings.method = 'GET'; - } - - let observable: Observable; - - switch (settings.method) { - case 'GET': { - observable = this.http.get(settings.url, { - headers: this.getHeaders(settings.headers), - }); - break; - } - case 'POST': { - observable = this.http.post(settings.url, settings.data, { - headers: this.getHeaders(settings.headers), - }); - break; - } - case 'PUT': { - observable = this.http.put(settings.url, settings.data, { - headers: this.getHeaders(settings.headers), - }); - break; - } - case 'DELETE': { - observable = this.http.delete(settings.url, { - headers: this.getHeaders(settings.headers), - }); - break; - } - } - - return firstValueFrom(observable); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private getHeaders(headers: any): HttpHeaders { - let httpHeaders: HttpHeaders = new HttpHeaders(); - - if (headers !== undefined) { - for (const key of Object.keys(headers)) { - httpHeaders = httpHeaders.append(key, headers[key]); - } - } - - return httpHeaders; - } -} diff --git a/frontend/app/src/app/modules/auth/user-info.model.ts b/frontend/app/src/app/modules/auth/user-info.model.ts deleted file mode 100644 index c8bf3aff..00000000 --- a/frontend/app/src/app/modules/auth/user-info.model.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright (C) 2022 StApps - * This program is free software: you can redistribute it and/or modify it - * under the terms of the GNU General Public License as published by the Free - * Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for - * more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ - -export interface IUserInfo { - display_name: string; - role: string; - email: string; - user_name: string; -} diff --git a/frontend/app/src/app/modules/profile/id-cards.provider.ts b/frontend/app/src/app/modules/profile/id-cards.provider.ts index 1abd0ea6..ec9b7c97 100644 --- a/frontend/app/src/app/modules/profile/id-cards.provider.ts +++ b/frontend/app/src/app/modules/profile/id-cards.provider.ts @@ -1,16 +1,19 @@ import {Injectable, inject} from '@angular/core'; import {SCIdCard, SCThingOriginType, SCThingType, SCUserConfiguration} from '@openstapps/core'; import {from, of, Observable} from 'rxjs'; -import {AuthHelperService} from '../auth/auth-helper.service'; import {mergeMap, concatWith, filter, map, startWith, catchError, tap} from 'rxjs/operators'; import {ConfigProvider} from '../config/config.provider'; import {HttpClient} from '@angular/common/http'; import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider'; +import {AuthProvider} from "../auth/auth.provider"; +import {AuthHelperService} from "../auth/auth-helper.service"; @Injectable({providedIn: 'root'}) export class IdCardsProvider { private authHelper = inject(AuthHelperService); + private authProvider = inject(AuthProvider); + private config = inject(ConfigProvider); private httpClient = inject(HttpClient); @@ -19,7 +22,7 @@ export class IdCardsProvider { getIdCards(): Observable { const feature = this.config.config.app.features.extern?.['idCards']; - const auth = this.authHelper.getProvider(); + const auth = this.authProvider; const storedIdCards = from( this.encryptedStorageProvider.get('id-cards') as Promise, ).pipe(filter(it => it !== undefined)); diff --git a/frontend/app/src/app/modules/profile/id-cards.spec.ts b/frontend/app/src/app/modules/profile/id-cards.spec.ts index 66d710f0..52dbc6b4 100644 --- a/frontend/app/src/app/modules/profile/id-cards.spec.ts +++ b/frontend/app/src/app/modules/profile/id-cards.spec.ts @@ -7,7 +7,6 @@ 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'; diff --git a/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts b/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts index 160a7d4d..25c6934c 100644 --- a/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts +++ b/frontend/app/src/app/modules/profile/page/profile-page-section.component.ts @@ -19,6 +19,7 @@ import {AuthHelperService} from '../../auth/auth-helper.service'; import {mergeMap, of} from 'rxjs'; import Swiper from 'swiper'; import {toObservable, toSignal} from '@angular/core/rxjs-interop'; +import {AuthProvider} from "../../auth/auth.provider"; @Component({ selector: 'stapps-profile-page-section', @@ -32,10 +33,11 @@ export class ProfilePageSectionComponent { minSlideWidth = input(110); authHelper = inject(AuthHelperService); + authProvider = inject(AuthProvider); loggedIn = toSignal( toObservable(this.item).pipe( - mergeMap(item => (item.authProvider ? this.authHelper.getProvider().isLoggedIn$ : of(false))), + mergeMap(item => (item.authProvider ? this.authProvider.isLoggedIn$ : of(false))), ), ); @@ -66,13 +68,11 @@ export class ProfilePageSectionComponent { } async toggleLogIn() { - const providerType = this.item().authProvider; - if (!providerType) return; if (this.loggedIn()) { - await this.authHelper.getProvider().signOut(); - await this.authHelper.endBrowserSession(providerType); + await this.authProvider.signOut(); + await this.authHelper.endBrowserSession(); } else { - await this.authHelper.getProvider().signIn(); + await this.authProvider.signIn(); } } diff --git a/frontend/app/src/app/modules/profile/page/profile-page.component.ts b/frontend/app/src/app/modules/profile/page/profile-page.component.ts index cee601d3..79fd8522 100644 --- a/frontend/app/src/app/modules/profile/page/profile-page.component.ts +++ b/frontend/app/src/app/modules/profile/page/profile-page.component.ts @@ -13,7 +13,7 @@ * this program. If not, see . */ import {Component, inject} from '@angular/core'; -import {AuthHelperService} from '../../auth/auth-helper.service'; +import {AuthProvider} from '../../auth/auth.provider'; import {ActivatedRoute} from '@angular/router'; import {ScheduleProvider} from '../../calendar/schedule.provider'; import {profilePageSections} from '../../../../config/profile-page-sections'; @@ -25,7 +25,7 @@ import {profilePageSections} from '../../../../config/profile-page-sections'; standalone: false, }) export class ProfilePageComponent { - readonly authHelper = inject(AuthHelperService); + readonly authProvider = inject(AuthProvider); readonly activatedRoute = inject(ActivatedRoute); @@ -33,23 +33,7 @@ export class ProfilePageComponent { sections = profilePageSections; - async signIn() { - const originPath = this.activatedRoute.snapshot.queryParamMap.get('origin_path'); - await (originPath ? this.authHelper.setOriginPath(originPath) : this.authHelper.deleteOriginPath()); - await this.authHelper.getProvider().signIn(); - } - - async signOut() { - await this.authHelper.getProvider().signOut(); - } - - ionViewWillEnter() { - this.authHelper - .getProvider() - .getValidToken() - .then(() => void this.authHelper.getProvider().loadUserInfo()) - .catch(() => { - // noop - }); + ionViewWillEnter(): void { + void this.authProvider.loadUserInfo(); } } diff --git a/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts b/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts index 8db8a3dd..3ac28c39 100644 --- a/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts +++ b/frontend/app/src/app/modules/storage/capacitor-secure-storage.ts @@ -12,19 +12,58 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ -import {CapacitorSecureStorage} from 'ionic-appauth/lib/capacitor'; -/* - * Removes an item from storage before entering the new one to avoid issues - * after iOS upgrade (iOS 16) +import {StorageBackend} from '@openid/appauth'; +import {SecureStoragePlugin} from 'capacitor-secure-storage-plugin'; + +/** + * Secure storage backend for AppAuth on native Capacitor platforms. + * + * Removes an existing item before writing it again to avoid issues + * observed after iOS upgrades. */ -export class SafeCapacitorSecureStorage extends CapacitorSecureStorage { - async setItem(name: string, value: string): Promise { - if (!Storage) throw new Error('Capacitor Storage Is Undefined!'); - +export class SafeCapacitorSecureStorage implements StorageBackend { + public async getItem(name: string): Promise { try { - await super.removeItem(name); - } catch {} - return super.setItem(name, value); + const {value} = await SecureStoragePlugin.get({ + key: name, + }); + + return value; + } catch { + return null; + } + } + + public async setItem( + name: string, + value: string, + ): Promise { + try { + await SecureStoragePlugin.remove({ + key: name, + }); + } catch { + // Item does not exist yet. + } + + await SecureStoragePlugin.set({ + key: name, + value, + }); + } + + public async removeItem(name: string): Promise { + try { + await SecureStoragePlugin.remove({ + key: name, + }); + } catch { + // Removing a non-existing item is fine. + } + } + + public async clear(): Promise { + await SecureStoragePlugin.clear(); } } diff --git a/frontend/app/src/environments/environment.production.ts b/frontend/app/src/environments/environment.production.ts index edaf0db0..4e8662f7 100644 --- a/frontend/app/src/environments/environment.production.ts +++ b/frontend/app/src/environments/environment.production.ts @@ -19,9 +19,9 @@ export const environment = { backend_url: 'https://mobile.server.uni-frankfurt.de', - app_host: 'mobile.app.uni-frankfurt.de', + app_host: 'dev.app.uni-frankfurt.de', custom_url_scheme: 'de.anyschool.app', - backend_version: '4.0.0', + backend_version: '999.0.0', production: true, }; diff --git a/frontend/app/src/environments/environment.ts b/frontend/app/src/environments/environment.ts index b89ada44..fc1777e9 100644 --- a/frontend/app/src/environments/environment.ts +++ b/frontend/app/src/environments/environment.ts @@ -19,8 +19,9 @@ export const environment = { backend_url: 'https://mobile.server.uni-frankfurt.de', + // backend_url: 'http://localhost:3000', app_host: 'mobile.app.uni-frankfurt.de', - custom_url_scheme: 'de.anyschool.app', + custom_url_scheme: 'de.unifrankfurt.app', backend_version: '999.0.0', production: false, }; 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: