diff --git a/backend/backend/config/f-u/backendrc.js b/backend/backend/config/f-u/backendrc.js index 8138cdc2..1a091a2d 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://idp5-test.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://idp5-test.ub.uni-frankfurt.de/idp/profile/oidc/authorize', + endSession: 'https://idp5-test.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://idp5-test.ub.uni-frankfurt.de/idp/profile/oidc/token', + userinfo: 'https://idp5-test.ub.uni-frankfurt.de/idp/profile/oidc/userinfo', }, }, }, diff --git a/backend/backend/package.json b/backend/backend/package.json index aa851173..af8fe0b1 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/backend/backend/test_decode_access_token.js b/backend/backend/test_decode_access_token.js new file mode 100644 index 00000000..eba10406 --- /dev/null +++ b/backend/backend/test_decode_access_token.js @@ -0,0 +1,7 @@ +import jwt from 'jsonwebtoken'; + +const token = + 'AAdzZWNyZXQyo6P-FC41Z55r2N3phG84ZaFN5WQS-Q4GRYVeOLAj8yidLW8ErnsVHdH2lhsPm43UYdE3fCchxryqkDEeqxyw8lqR4aDGjJjKvVIMqWnlHWWhBwXG169NGAGpfwGyxMRp7MropxSFOsY0ZGgy6Sjo6Au65xvEFn2p4mHlVhN8HCZAjfwS7mD1g0jdYM_FO2eeAQHzAct_AxN5ttbREiwu00flZhJbURl3JN0QI4KcGL82nBFCS-Izui5mTbrv_ynRjds9W6oFVO1wU8E23-AvR8h9dwadVj1_J46EPUPZFrLrXd0ggZDxCq6MXMGThXKdNAdPpmJ7VWLk-N-tpLF84dsbcjda55KK6sVaj-T1WhHxgQYcu_j-cDnLNI9TDaFOnQZbtgDT0qX4W0166iGxMQw9gT7fMIkjaxx02_dOwETjywKubFFuvCaHogn5mdnqsNiNL0mUgYTKmXfKyKwJGVxk2RHNt0xd1LRlERVzPhGy0uoB5mxydo6AArifybhXmBDL_1i6PbMOktyveBcnknoJfyslNnO4Tc7edxWZx_oBOM1zAJnpoZGlJoCZlN6kW9o49AlvY4DuNPDXUSEOO6A1Bd7bgZFDVJ3OgsGaoFTdBrX1CAVapcy8'; + +const decoded = jwt.decode(token, {complete: false}); +console.log(decoded); diff --git a/backend/backend/test_decode_id_token.js b/backend/backend/test_decode_id_token.js new file mode 100644 index 00000000..03623fe9 --- /dev/null +++ b/backend/backend/test_decode_id_token.js @@ -0,0 +1,36 @@ +// ----------------------------------------------------------- +// 1. Hier Ihr ID Token einfügen: +const idToken = + 'eyJraWQiOiJkZWZhdWx0UlNBU2lnbiIsImFsZyI6IlJTMjU2In0.eyJhdF9oYXNoIjoibVJpdzR3cDVKMUw0bzgtenVaWnlMQSIsInN1YiI6InM1MTgxNzAxQHVuaS1mcmFua2Z1cnQuZGUiLCJhdWQiOiIxY2FjM2Y5OS0zM2ZhLTQyMzQtODQzOC05NzlmMDdlMGNkYWIiLCJhdXRoX3RpbWUiOjE3NjU5Njc1NjEsImlzcyI6Imh0dHBzOi8vaWRwNS10ZXN0LnViLnVuaS1mcmFua2Z1cnQuZGUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJzNTE4MTcwMSIsImV4cCI6MTc2NTk3MTE2OSwiaWF0IjoxNzY1OTY3NTY5LCJzaWQiOiJfNTg1NmVkOGNlZmNlZTQ2ODQ0OTA4YTM0ZjNlZTQxMWQifQ.eDbIPjB6zeYiHrbYwqxG2_iV6loJHcvA3l0BT9W0kT4_NZxVVXL_OK7Z-qnC4ppw-SwPzUP7KSiFVBmSiqjQsTF9JWx7xWgcNo9MDxn8MT7F8T_a0CuaAKQtVMn9_adOyyiGgLiQ7_aSwnE0NfE_RomE3pWCQwK1-hr4au3QKR-MKwZehrDdcq0pmAzmCrlWJ8SZ6DmcErxvyXZ799LTjj2Vyn4kpp2uXWTuA5TxgpoKGdeVfTPzD6s6ZRshoVR1bLpkZbIYviE9mtDOw9RmAvZbdZ4-ZVDBX6AXktfq7WnNLDyT43pKRRql3oqdOVs9BD7e7LdJQRy4HgcK5IluSsEXuGNSV_iysvd3HV0yoLFXBkKiRnWsa0-U1WNz-Rnxb4OL_6ZxEfEjWIrdHxnDbbzq0iQomEeAaJRHU9FUSdch5NnHKpPz3W9cbd_9a9geMn5FXkGWwZKOVFPOQEPNBwVflpp_vYzGad0pvOTRSX4lMFnZ0FA1ADuUXuqaOJCk'; +// ----------------------------------------------------------- + +// Payload isolieren +const payloadBase64 = idToken.split('.')[1]; + +// Base64Url in Base64 konvertieren und dekodieren +try { + const sanitizedPayload = payloadBase64.replace(/-/g, '+').replace(/_/g, '/'); + + // HINWEIS: Manchmal muss Padding (das '=' am Ende) hinzugefügt werden, + // aber moderne Browser-Implementierungen kommen oft ohne aus. + + // Dekodieren des Base64-Strings zur JSON-Zeichenkette + const jsonPayload = decodeURIComponent( + atob(sanitizedPayload) + .split('') + .map(function (c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }) + .join(''), + ); + + // Parsen in ein JavaScript-Objekt + const claims = JSON.parse(jsonPayload); + + console.log('*** Erfolgreich dekodierte ID Token Claims ***'); + console.table(claims); // Zeigt die Claims in einer schönen Tabelle an + console.log(claims); +} catch (e) { + console.error('Fehler beim Dekodieren des ID Tokens. Haben Sie das gesamte Token korrekt eingefügt?'); + console.error(e); +} diff --git a/frontend/app/android/capacitor.settings.gradle b/frontend/app/android/capacitor.settings.gradle index ed17b8b0..00fa83d4 100644 --- a/frontend/app/android/capacitor.settings.gradle +++ b/frontend/app/android/capacitor.settings.gradle @@ -57,4 +57,4 @@ include ':transistorsoft-capacitor-background-fetch' project(':transistorsoft-capacitor-background-fetch').projectDir = new File('../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@8.0.0_@capacitor+core@8.2.0/node_modules/@transistorsoft/capacitor-background-fetch/android') include ':capacitor-secure-storage-plugin' -project(':capacitor-secure-storage-plugin').projectDir = new File('../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.12.0_@capacitor+core@8.2.0/node_modules/capacitor-secure-storage-plugin/android') +project(':capacitor-secure-storage-plugin').projectDir = new File('../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.13.0_@capacitor+core@8.2.0/node_modules/capacitor-secure-storage-plugin/android') diff --git a/frontend/app/ios/App/Podfile b/frontend/app/ios/App/Podfile index fd9d9d84..fe5ca4cd 100644 --- a/frontend/app/ios/App/Podfile +++ b/frontend/app/ios/App/Podfile @@ -29,7 +29,7 @@ def capacitor_pods pod 'CapacitorShare', :path => '../../../../node_modules/.pnpm/@capacitor+share@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/share' pod 'CapacitorSplashScreen', :path => '../../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen' pod 'TransistorsoftCapacitorBackgroundFetch', :path => '../../../../node_modules/.pnpm/@transistorsoft+capacitor-background-fetch@8.0.0_@capacitor+core@8.2.0/node_modules/@transistorsoft/capacitor-background-fetch' - pod 'CapacitorSecureStoragePlugin', :path => '../../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.12.0_@capacitor+core@8.2.0/node_modules/capacitor-secure-storage-plugin' + pod 'CapacitorSecureStoragePlugin', :path => '../../../../node_modules/.pnpm/capacitor-secure-storage-plugin@0.13.0_@capacitor+core@8.2.0/node_modules/capacitor-secure-storage-plugin' pod 'CordovaPlugins', :path => '../capacitor-cordova-ios-plugins' end diff --git a/frontend/app/package.json b/frontend/app/package.json index 3ddfa3c4..ad4280e9 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -20,7 +20,7 @@ "changelog": "conventional-changelog -p angular -i src/assets/about/CHANGELOG.md -s -r 0", "check-icons": "node scripts/check-icon-correctness.mjs", "chromium:no-cors": "chromium --disable-web-security --user-data-dir=\".browser-data/chromium\"", - "chromium:virtual-host": "chromium --host-resolver-rules=\"MAP mobile.app.uni-frankfurt.de:* localhost:8100\" --ignore-certificate-errors", + "chromium:virtual-host": "chromium --host-resolver-rules=\"MAP dev.app.uni-frankfurt.de:* localhost:8100\" --ignore-certificate-errors --disable-web-security --user-data-dir=\".browser-data/chromium\"", "cypress:open": "cypress open", "cypress:run": "cypress run", "docker:build": "sudo docker run -p 8100:8100 -p 35729:35729 -p 53703:53703 -v $PWD:/app -it registry.gitlab.com/openstapps/app bash -c \"npm install && npm run build\"", @@ -43,7 +43,7 @@ "start": "ionic serve", "start:external": "ionic serve --external", "start:prod": "ionic serve --prod", - "start:virtual-host": "ionic serve --public-host=mobile.app.uni-frankfurt.de --ssl=true --open=false", + "start:virtual-host": "ionic serve --public-host=dev.app.uni-frankfurt.de --ssl=true --open=false", "test": "ng test --code-coverage", "test:integration": "sh integration-test.sh" }, @@ -81,19 +81,18 @@ "@maplibre/ngx-maplibre-gl": "17.4.1", "@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:*", "@transistorsoft/capacitor-background-fetch": "8.0.0", "@types/dom-view-transitions": "1.0.4", - "capacitor-secure-storage-plugin": "0.12.0", + "capacitor-secure-storage-plugin": "0.13.0", "cordova-plugin-calendar": "5.1.6", "date-fns": "3.6.0", "deepmerge": "4.3.1", "form-data": "4.0.4", "geojson": "0.5.0", - "ionic-appauth": "2.1.0", "ionicons": "8.0.13", "jsonpath-plus": "10.3.0", "maplibre-gl": "4.0.2", diff --git a/frontend/app/src/app/_helpers/fake-config.interceptor.ts b/frontend/app/src/app/_helpers/fake-config.interceptor.ts new file mode 100644 index 00000000..a5f226ee --- /dev/null +++ b/frontend/app/src/app/_helpers/fake-config.interceptor.ts @@ -0,0 +1,43 @@ +// // src/app/_interceptors/fake-config.interceptor.ts +// import { Injectable } from '@angular/core'; +// import { +// HttpRequest, +// HttpHandler, +// HttpEvent, +// HttpInterceptor, +// HttpResponse +// } from '@angular/common/http'; +// import { Observable, of } from 'rxjs'; +// import { delay } from 'rxjs/operators'; +// +// @Injectable() +// export class FakeConfigInterceptor implements HttpInterceptor { +// intercept(request: HttpRequest, next: HttpHandler): Observable> { +// const targetUrl = 'https://mobile.server.uni-frankfur.de'; +// +// const isTargetUrl = request.url === targetUrl; +// const isPostMethod = request.method === 'POST'; +// +// if (isTargetUrl && isPostMethod) { +// const fakeConfig = { +// appName: 'Uni Frankfurt Mobile', +// version: '1.0.0-fake', +// environment: 'development', +// features: { +// login: true, +// offlineMode: true, +// darkMode: false +// }, +// message: 'Diese Konfiguration kommt vom FakeConfigInterceptor 🤖' +// }; +// +// return of(new HttpResponse({ +// status: 200, +// body: fakeConfig +// })).pipe(delay(300)); // Simuliere kurze Serververzögerung +// } +// +// // Weiterleiten, wenn nicht exakt die Zielroute + POST +// return next.handle(request); +// } +// } diff --git a/frontend/app/src/app/app.component.ts b/frontend/app/src/app/app.component.ts index d39d8022..178e16bf 100644 --- a/frontend/app/src/app/app.component.ts +++ b/frontend/app/src/app/app.component.ts @@ -25,6 +25,7 @@ import {ScheduleSyncService} from './modules/background/schedule/schedule-sync.s import {Keyboard, KeyboardResize} from '@capacitor/keyboard'; import {AppVersionService} from './modules/about/app-version.service'; import {SplashScreen} from '@capacitor/splash-screen'; +import {AuthService} from "./modules/auth/auth.service"; /** * TODO @@ -60,6 +61,7 @@ export class AppComponent implements AfterContentInit { private readonly router: Router, private readonly zone: NgZone, private readonly authHelper: AuthHelperService, + private readonly authService: AuthService, private readonly toastController: ToastController, private readonly scheduleSyncService: ScheduleSyncService, private readonly versionService: AppVersionService, @@ -120,9 +122,8 @@ export class AppComponent implements AfterContentInit { } private async authNotificationsInit() { - this.authHelper - .getProvider() - .events$.subscribe(action => this.showMessage(this.authHelper.getAuthMessage('default', action))); + this.authService + .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 42fe3b10..89ff642f 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 maplibregl from 'maplibre-gl'; import {Protocol} from 'pmtiles'; +import {AuthService} from "./modules/auth/auth.service"; registerLocaleData(localeDe); @@ -82,7 +82,7 @@ export function initializerFactory( configProvider: ConfigProvider, translateService: TranslateService, _routingStackService: RoutingStackService, - defaultAuthService: DefaultAuthService, + authService: AuthService, dateFnsConfigurationService: DateFnsConfigurationService, ) { return async () => { @@ -110,7 +110,7 @@ export function initializerFactory( setDefaultOptions({locale: dateFnsLocale}); dateFnsConfigurationService.setLocale(dateFnsLocale); - await defaultAuthService.init(); + await authService.init(); } catch (error) { logger.warn(error); } @@ -200,7 +200,7 @@ export function createTranslateLoader(http: HttpClient) { ConfigProvider, TranslateService, RoutingStackService, - DefaultAuthService, + AuthService, 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 b0f94abf..3b10503e 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} 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 {AuthService} from "../auth/auth.service"; /** * @@ -66,7 +66,7 @@ export class AssessmentsProvider { constructor( readonly configProvider: ConfigProvider, - readonly defaultAuth: DefaultAuthService, + readonly authService: AuthService, readonly http: HttpClient, ) {} @@ -97,7 +97,7 @@ export class AssessmentsProvider { this.cache = this.http .get<{data: SCAssessment[]}>(`${url}/${this.assessmentPath}`, { headers: { - Authorization: `Bearer ${accessToken ?? (await this.defaultAuth.getValidToken()).accessToken}`, + Authorization: `Bearer ${accessToken ?? (await this.authService.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 c90a1438..9c902d8c 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 @@ -12,14 +12,17 @@ * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ + import {Component} 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, + AuthService, + IAuthAction, +} from '../../auth.service'; +import {AuthHelperService} from '../../auth-helper.service'; @Component({ templateUrl: 'auth-callback-page.component.html', @@ -27,25 +30,43 @@ import {DefaultAuthService} from '../../default-auth.service'; }) export class AuthCallbackPageComponent { constructor( - private navCtrl: NavController, - private router: Router, - private authHelper: AuthHelperService, - private auth: DefaultAuthService, + private readonly navCtrl: NavController, + private readonly authHelper: AuthHelperService, + private readonly authService: AuthService, ) { - const events: Observable = this.auth.events$; + this.authService.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.authService.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 d2f29bc9..3cbefc58 100644 --- a/frontend/app/src/app/modules/auth/auth-guard.service.ts +++ b/frontend/app/src/app/modules/auth/auth-guard.service.ts @@ -17,6 +17,7 @@ import {Injectable} from '@angular/core'; import {CanActivate, NavigationExtras, Router, RouterStateSnapshot} from '@angular/router'; import {ActivatedProtectedRouteSnapshot} from './protected.routes'; import {AuthHelperService} from './auth-helper.service'; +import {AuthService} from "./auth.service"; @Injectable({ providedIn: 'root', @@ -24,6 +25,7 @@ import {AuthHelperService} from './auth-helper.service'; export class AuthGuardService implements CanActivate { constructor( private authHelper: AuthHelperService, + private authService: AuthService, private router: Router, ) {} @@ -33,7 +35,7 @@ export class AuthGuardService implements CanActivate { } try { - await this.authHelper.getProvider().getValidToken(); + await this.authService.getValidToken(); } catch { const originNavigation = this.router.getCurrentNavigation(); let extras: NavigationExtras = {}; @@ -42,7 +44,7 @@ export class AuthGuardService implements CanActivate { extras = {queryParams: {origin_path: url}}; } this.router.navigate(['profile'], extras); - await this.authHelper.getProvider().signIn(); + await this.authService.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 deleted file mode 100644 index 69a98d1a..00000000 --- a/frontend/app/src/app/modules/auth/auth-helper.service.spec.ts +++ /dev/null @@ -1,118 +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 {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'; - -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', - }, - }, - }, - }, - }); - - 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()), - ], - }); - 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', - }); - - expect(userConfiguration).toEqual({ - id: 'emuster', - givenName: 'Erika', - familyName: 'Musterfrau', - name: 'Erika Musterfrau', - email: 'emuster@anyschool.de', - role: 'student', - studentId: '123456', - }); - }); - }); -}); 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 97099880..a0684aad 100644 --- a/frontend/app/src/app/modules/auth/auth-helper.service.ts +++ b/frontend/app/src/app/modules/auth/auth-helper.service.ts @@ -14,36 +14,40 @@ */ import {Injectable} 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, + AuthService, + IAuthAction, +} from './auth.service'; const AUTH_ORIGIN_PATH = 'stapps.auth.origin_path'; +const AUTH_MESSAGE_PREFIX = 'auth.messages.default'; @Injectable({ providedIn: 'root', }) export class AuthHelperService { - userConfigurationMap: SCUserConfigurationMap; + readonly userConfigurationMap: SCUserConfigurationMap; constructor( - private translateService: TranslateService, - private configProvider: ConfigProvider, - private storageProvider: StorageProvider, - private defaultAuth: DefaultAuthService, - private browser: SimpleBrowser, - private alertController: AlertController, + private readonly translateService: TranslateService, + private readonly configProvider: ConfigProvider, + private readonly storageProvider: StorageProvider, + private readonly authService: AuthService, + private readonly browser: SimpleBrowser, + private readonly alertController: AlertController, ) { this.userConfigurationMap = ( @@ -53,93 +57,114 @@ 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.authService.getEndSessionEndpoint(); + + if (!endSessionEndpoint) { return; } - return originPath; - } - /** - * Provides appropriate auth service instance based on type (string) parameter - */ - getProvider(): DefaultAuthService { - return this.defaultAuth; - } - - /** - * 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; - - 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`), + const alert = + await this.alertController.create({ + header: this.translateService.instant( + `${AUTH_MESSAGE_PREFIX}.log_out_alert.header`, + ), + message: this.translateService.instant( + `${AUTH_MESSAGE_PREFIX}.log_out_alert.message`, + ), buttons: [ { - text: this.translateService.instant('no'), + text: + this.translateService.instant('no'), cssClass: 'default', }, { - text: this.translateService.instant('yes'), + text: + this.translateService.instant('yes'), role: 'confirm', cssClass: 'preferred', handler: () => { - this.browser.open(new URL(endSessionEndpoint).href); + 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..39dbe1d7 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 {AuthService} from "./auth.service"; @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, + AuthService, ], }) 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..e3869837 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.service'; + +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.service.ts b/frontend/app/src/app/modules/auth/auth.service.ts index 1b38118f..22c18ef2 100644 --- a/frontend/app/src/app/modules/auth/auth.service.ts +++ b/frontend/app/src/app/modules/auth/auth.service.ts @@ -12,14 +12,14 @@ * 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 {Injectable} from '@angular/core'; +import {Browser} from '@capacitor/browser'; +import {Platform} from '@ionic/angular/standalone'; import { AuthorizationError, AuthorizationNotifier, AuthorizationRequest, - AuthorizationRequestHandler, AuthorizationRequestJson, AuthorizationResponse, AuthorizationServiceConfiguration, @@ -27,8 +27,7 @@ import { DefaultCrypto, GRANT_TYPE_AUTHORIZATION_CODE, GRANT_TYPE_REFRESH_TOKEN, - JQueryRequestor, - LocalStorageBackend, + RedirectRequestHandler, Requestor, RevokeTokenRequest, RevokeTokenRequestJson, @@ -39,488 +38,1050 @@ import { 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 {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'; -const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 mins in seconds +const AUTH_EXPIRY_BUFFER = -10 * 60; -export abstract class AuthService implements IAuthService { - private _configuration?: AuthorizationServiceConfiguration; +export interface AuthConfig { + server_host: string; + client_id: string; + redirect_url: string; + end_session_redirect_url?: string; + scopes: string; + pkce: boolean; +} - private _authConfig?: IAuthConfig; +export enum AuthActions { + Init = 'Init', - private _authSubject: AuthSubject = new AuthSubject(); + SignInSuccess = 'SignInSuccess', + SignInFailed = 'SignInFailed', - private _actionHistory: ActionHistoryObserver = new ActionHistoryObserver(); + SignOutSuccess = 'SignOutSuccess', + SignOutFailed = 'SignOutFailed', - private _session: SessionObserver = new SessionObserver(); + RefreshSuccess = 'RefreshSuccess', + RefreshFailed = 'RefreshFailed', - private _authSubjectV2 = new BehaviorSubject(AuthActionBuilder.Init()); + LoadTokenFromStorageSuccess = + 'LoadTokenFromStorageSuccess', - private _tokenSubject = new BehaviorSubject(undefined); + LoadTokenFromStorageFailed = + 'LoadTokenFromStorageFailed', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private _userSubject = new BehaviorSubject(undefined); + RevokeTokensSuccess = + 'RevokeTokensSuccess', - private _authenticatedSubject = new BehaviorSubject(false); + RevokeTokensFailed = + 'RevokeTokensFailed', - private _loggedInSubject = new BehaviorSubject(false); + LoadUserInfoSuccess = + 'LoadUserInfoSuccess', - private _initComplete = new BehaviorSubject(false); + LoadUserInfoFailed = + 'LoadUserInfoFailed', +} - protected tokenHandler: TokenRequestHandler; +export interface IAuthAction { + action: AuthActions; + tokenResponse?: TokenResponse; + user?: unknown; + error?: unknown; +} - protected userInfoHandler: UserInfoHandler; +/** + * Common subset implemented by AppAuth's RedirectRequestHandler + * and our CapacitorAuthorizationRequestHandler. + */ +interface AuthorizationHandler { + setAuthorizationNotifier( + notifier: AuthorizationNotifier, + ): unknown; - protected requestHandler: AuthorizationRequestHandler; + performAuthorizationRequest( + configuration: AuthorizationServiceConfiguration, + request: AuthorizationRequest, + ): void | Promise; - protected endSessionHandler: EndSessionHandler; + completeAuthorizationRequestIfPossible(): + Promise; +} + +@Injectable({ + providedIn: 'root', +}) +export class AuthService { + 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( - protected browser: Browser = new DefaultBrowser(), - protected storage: StorageBackend = new LocalStorageBackend(), - protected requestor: Requestor = new JQueryRequestor(), + private readonly platform: Platform, + private readonly configProvider: ConfigProvider, ) { - this.tokenHandler = new BaseTokenRequestHandler(requestor); - this.userInfoHandler = new IonicUserInfoHandler(requestor); - this.requestHandler = new IonicAuthorizationRequestHandler(browser, storage); - this.endSessionHandler = new IonicEndSessionHandler(browser); - } + this.storage = + storageFactory(platform); - /** - * @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]; - } + this.requestor = + requestorFactory(platform); - /** - * @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; - } + this.requestHandler = + platform.is('capacitor') + ? new CapacitorAuthorizationRequestHandler( + this.storage, + ) + : new RedirectRequestHandler( + this.storage, + ); - 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.tokenHandler = + new BaseTokenRequestHandler( this.requestor, - ).catch(() => { - throw new Error('Unable To Obtain Server Configuration'); + ); + } + + 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, }); } + } - if (this._configuration == undefined) { - throw new Error('Unable To Obtain Server Configuration'); - } else { - return Promise.resolve(this._configuration); + 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) { + this.notify({ + action: + AuthActions.RefreshFailed, + + error, + }); } } - public async init() { - this.setupAuthorizationNotifier(); - this.loadTokenFromStorage(); - this.addActionObserver(this._actionHistory); - this.addActionObserver(this._session); + 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, + }); + } } - protected notifyActionListers(action: IAuthAction) { - /* eslint-disable unicorn/no-useless-undefined */ + public async getEndSessionEndpoint(): + Promise { + return this.oidcEndpoints + ?.endSession; + } + + public async signOut( + state?: string, + revokeTokens = false, + ): Promise { + const currentToken = + this.tokenSubject.value; + + try { + if (revokeTokens) { + await this.revokeTokens(); + } + + await this.storage.removeItem( + TOKEN_RESPONSE_KEY, + ); + + const endSessionEndpoint = + await this.getEndSessionEndpoint(); + + if ( + endSessionEndpoint && + currentToken + ) { + await this.performEndSessionRequest( + endSessionEndpoint, + currentToken, + state, + ); + + return; + } + + await this.internalEndSessionCallback(); + } 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; + + if ( + currentToken?.isValid(buffer) + ) { + return currentToken; + } + + if (!currentToken?.refreshToken) { + throw new Error( + 'Unable To Obtain Valid Token', + ); + } + + await this.refreshToken(); + + const refreshedToken = + this.tokenSubject.value; + + if ( + refreshedToken?.isValid(buffer) + ) { + return refreshedToken; + } + + throw new Error( + 'Unable To Obtain Valid Token', + ); + } + + 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 performEndSessionRequest( + endpoint: string, + token: TokenResponse, + state?: string, + ): Promise { + const url = + new URL(endpoint); + + if (token.idToken) { + url.searchParams.set( + 'id_token_hint', + token.idToken, + ); + } + + if ( + this.authConfig + .end_session_redirect_url + ) { + url.searchParams.set( + 'post_logout_redirect_uri', + this.authConfig + .end_session_redirect_url, + ); + } + + if (state) { + url.searchParams.set( + 'state', + state, + ); + } + + if ( + this.platform.is('capacitor') + ) { + await Browser.open({ + url: url.toString(), + }); + + return; + } + + window.location.assign( + url.toString(), + ); + } + + 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); + 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); + + 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: { - 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; - } - } + if (action.tokenResponse) { + this.tokenSubject.next( + action.tokenResponse, + ); - this._authSubjectV2.next(action); - this._authSubject.notify(action); - } + this.authenticatedSubject.next( + true, + ); - 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; + 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; } - 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); + this.authSubject.next( + action, + ); } } 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 701365a2..00000000 --- a/frontend/app/src/app/modules/auth/default-auth.service.ts +++ /dev/null @@ -1,91 +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} from '@angular/core'; -import {AuthService} from './auth.service'; - -const TOKEN_RESPONSE_KEY = 'token_response'; - -@Injectable({ - providedIn: 'root', -}) -export class DefaultAuthService extends AuthService { - public localConfiguration: AuthorizationServiceConfiguration; - - 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(), - private readonly configProvider: ConfigProvider, - ) { - super(browser, storage, 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 73% 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 ec137d73..24345917 100644 --- a/frontend/app/src/app/modules/auth/factories/http.factory.ts +++ b/frontend/app/src/app/modules/auth/factories/requestor.factory.ts @@ -13,11 +13,12 @@ * this program. If not, see . */ -import {HttpClient} from '@angular/common/http'; 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, httpClient: HttpClient) => { - return platform.is('capacitor') ? new CapacitorRequestor() : new NgHttpService(httpClient); +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 2cdb3617..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} 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 { - constructor(private http: 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 16d5dae3..b3723ce0 100644 --- a/frontend/app/src/app/modules/profile/id-cards.provider.ts +++ b/frontend/app/src/app/modules/profile/id-cards.provider.ts @@ -6,11 +6,13 @@ import {mergeMap, concatWith, filter, map, startWith, catchError, tap} from 'rxj import {ConfigProvider} from '../config/config.provider'; import {HttpClient} from '@angular/common/http'; import {EncryptedStorageProvider} from '../storage/encrypted-storage.provider'; +import {AuthService} from "../auth/auth.service"; @Injectable({providedIn: 'root'}) export class IdCardsProvider { constructor( private authHelper: AuthHelperService, + private authService: AuthService, private config: ConfigProvider, private httpClient: HttpClient, private encryptedStorageProvider: EncryptedStorageProvider, @@ -18,24 +20,23 @@ export class IdCardsProvider { getIdCards(): Observable { const feature = this.config.config.app.features.extern?.['idCards']; - const auth = this.authHelper.getProvider(); const storedIdCards = from( this.encryptedStorageProvider.get('id-cards') as Promise, ).pipe(filter(it => it !== undefined)); - return auth.isLoggedIn$.pipe( + return this.authService.isLoggedIn$.pipe( mergeMap(isLoggedIn => isLoggedIn ? feature ? storedIdCards.pipe( concatWith( - from(auth.getValidToken()).pipe( + from(this.authService.getValidToken()).pipe( mergeMap(token => this.fetchIdCards(feature.url, token.accessToken)), catchError(() => storedIdCards), ), ), ) - : auth.user$.pipe( + : this.authService.user$.pipe( filter(user => user !== undefined), map(userInfo => this.authHelper.getUserFromUserInfo(userInfo as object)), mergeMap(user => this.fetchFallbackIdCards(user)), 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 0f2453b1..9334ae56 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 {AuthService} from "../../auth/auth.service"; @Component({ selector: 'stapps-profile-page-section', @@ -31,10 +32,11 @@ export class ProfilePageSectionComponent { minSlideWidth = input(110); authHelper = inject(AuthHelperService); + authService = inject(AuthService); loggedIn = toSignal( toObservable(this.item).pipe( - mergeMap(item => (item.authProvider ? this.authHelper.getProvider().isLoggedIn$ : of(false))), + mergeMap(item => (item.authProvider ? this.authService.isLoggedIn$ : of(false))), ), ); @@ -65,13 +67,20 @@ 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.authService.signOut(); + await this.authHelper.endBrowserSession(); } else { - await this.authHelper.getProvider().signIn(); + alert(123); + + console.log('AuthService instance:', this.authService); + console.log('signIn:', this.authService.signIn); + console.log( + 'constructor:', + this.authService.constructor.name, + ); + + await this.authService.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 368b3532..6dc1477c 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 @@ -17,6 +17,7 @@ import {AuthHelperService} from '../../auth/auth-helper.service'; import {ActivatedRoute} from '@angular/router'; import {ScheduleProvider} from '../../calendar/schedule.provider'; import {profilePageSections} from '../../../../config/profile-page-sections'; +import {AuthService} from "../../auth/auth.service"; @Component({ selector: 'app-home', @@ -28,6 +29,7 @@ export class ProfilePageComponent { constructor( readonly authHelper: AuthHelperService, + readonly authService: AuthService, readonly activatedRoute: ActivatedRoute, readonly scheduleProvider: ScheduleProvider, ) {} @@ -35,18 +37,17 @@ export class ProfilePageComponent { 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(); + await this.authService.signIn(); } async signOut() { - await this.authHelper.getProvider().signOut(); + await this.authService.signOut(); } ionViewWillEnter() { - this.authHelper - .getProvider() + this.authService .getValidToken() - .then(() => void this.authHelper.getProvider().loadUserInfo()) + .then(() => void this.authService.loadUserInfo()) .catch(() => { // noop }); 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..0eb0a185 100644 --- a/frontend/app/src/environments/environment.ts +++ b/frontend/app/src/environments/environment.ts @@ -18,7 +18,8 @@ // The list of which env maps to which file can be found in `.angular-cli.json`. export const environment = { - backend_url: 'https://mobile.server.uni-frankfurt.de', + // 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', backend_version: '999.0.0', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3cb30e87..063ff11c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -804,8 +804,8 @@ importers: specifier: 8.0.0 version: 8.0.0(@angular/common@18.2.13(@angular/core@18.2.13(rxjs@7.8.1)(zone.js@0.14.10))(rxjs@7.8.1))(@angular/core@18.2.13(rxjs@7.8.1)(zone.js@0.14.10))(@ngx-translate/core@15.0.0(@angular/common@18.2.13(@angular/core@18.2.13(rxjs@7.8.1)(zone.js@0.14.10))(rxjs@7.8.1))(@angular/core@18.2.13(rxjs@7.8.1)(zone.js@0.14.10))(rxjs@7.8.1))(rxjs@7.8.1) '@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 @@ -822,8 +822,8 @@ importers: specifier: 1.0.4 version: 1.0.4 capacitor-secure-storage-plugin: - specifier: 0.12.0 - version: 0.12.0(@capacitor/core@8.2.0) + specifier: 0.13.0 + version: 0.13.0(@capacitor/core@8.2.0) cordova-plugin-calendar: specifier: 5.1.6 version: 5.1.6 @@ -839,9 +839,6 @@ importers: geojson: specifier: 0.5.0 version: 0.5.0 - ionic-appauth: - specifier: 2.1.0 - version: 2.1.0(rxjs@7.8.1) ionicons: specifier: 8.0.13 version: 8.0.13 @@ -2383,40 +2380,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'} @@ -3020,11 +2988,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.2': resolution: {integrity: sha512-oJEtVP5huwPJ8GgOCH3OewhsBPTW6aVtWg8hU6g65MxBdhkcAKyzYrhsyXupC5GaMxltqLf1JzKETMmGkh6tDw==} peerDependencies: @@ -3045,9 +3008,6 @@ packages: peerDependencies: '@capacitor/core': '>=8.0.0' - '@capacitor/core@6.2.1': - resolution: {integrity: sha512-urZwxa7hVE/BnA18oCFAdizXPse6fCKanQyEqpmz6cBJ2vObwMpyJDG5jBeoSsgocS9+Ax+9vb4ducWJn0y2qQ==} - '@capacitor/core@8.2.0': resolution: {integrity: sha512-oKaoNeNtH2iIZMDFVrb1atoyRECDGHcfLMunJ5KWN8DtvpVBeeA4c41e20NTuhMxw1cSYbpq2PV2hb+/9CJxlQ==} @@ -3096,11 +3056,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: @@ -4297,8 +4252,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==} @@ -4861,8 +4816,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==} @@ -5774,15 +5729,10 @@ packages: caniuse-lite@1.0.30001778: resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==} - capacitor-secure-storage-plugin@0.10.0: - resolution: {integrity: sha512-dV4E+HTZAJWC3gef7sBXaAkkb6wvcZHyXjJIHXNb3yz9gRQ/5VMLqCxa0khqpwgWh5oIbo4XFxg3g5tEkfaNMg==} + capacitor-secure-storage-plugin@0.13.0: + resolution: {integrity: sha512-+rLC/9Z0LTaRRt6L6HjBwcDh5gqgI3NPmDSwo4hk41XQOy3EBrRo81VleIqFsowsMA3oMT+E59Bl8/HiWk0nhQ==} peerDependencies: - '@capacitor/core': ^6.0.0 - - capacitor-secure-storage-plugin@0.12.0: - resolution: {integrity: sha512-98rljshpX5uXdxUNc78mhq+nJGsf/hiaE1MfAkDH4s+Gqn6a2/VkxT7Iet5TskAZkc38b85adLljO/eVcrrzdg==} - peerDependencies: - '@capacitor/core': '>=7.0.0' + '@capacitor/core': '>=8.0.0' caseless@0.12.0: resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} @@ -7341,6 +7291,15 @@ packages: debug: optional: true + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + fontkit@2.0.2: resolution: {integrity: sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==} @@ -7367,6 +7326,10 @@ packages: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + formidable@2.1.5: resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==} @@ -7685,6 +7648,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -7937,11 +7904,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.0.13: resolution: {integrity: sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ==} @@ -12528,45 +12490,11 @@ snapshots: '@types/cordova': 11.0.3 rxjs: 7.8.1 - '@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1)': - dependencies: - '@types/cordova': 11.0.3 - rxjs: 7.8.1 - optional: true - '@awesome-cordova-plugins/core@8.1.0(rxjs@7.8.1)': dependencies: '@types/cordova': 11.0.3 rxjs: 7.8.1 - '@awesome-cordova-plugins/http@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1)': - dependencies: - '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.1) - '@types/cordova': 11.0.3 - rxjs: 7.8.1 - optional: true - - '@awesome-cordova-plugins/in-app-browser@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1)': - dependencies: - '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.1) - '@types/cordova': 11.0.3 - rxjs: 7.8.1 - optional: true - - '@awesome-cordova-plugins/safari-view-controller@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1)': - dependencies: - '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.1) - '@types/cordova': 11.0.3 - rxjs: 7.8.1 - optional: true - - '@awesome-cordova-plugins/secure-storage@6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1)': - dependencies: - '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.1) - '@types/cordova': 11.0.3 - rxjs: 7.8.1 - optional: true - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -13895,11 +13823,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.2(@capacitor/core@8.2.0)': dependencies: '@capacitor/core': 8.2.0 @@ -13952,11 +13875,6 @@ snapshots: dependencies: '@capacitor/core': 8.2.0 - '@capacitor/core@6.2.1': - dependencies: - tslib: 2.6.2 - optional: true - '@capacitor/core@8.2.0': dependencies: tslib: 2.6.2 @@ -13999,11 +13917,6 @@ snapshots: dependencies: '@capacitor/core': 8.2.0 - '@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.2.0)': dependencies: '@capacitor/core': 8.2.0 @@ -15372,13 +15285,13 @@ 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) - form-data: 4.0.4 + follow-redirects: 1.16.0 + form-data: 4.0.6 opener: 1.5.2 transitivePeerDependencies: - debug @@ -15916,9 +15829,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': {} @@ -16989,12 +16900,7 @@ 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.12.0(@capacitor/core@8.2.0): + capacitor-secure-storage-plugin@0.13.0(@capacitor/core@8.2.0): dependencies: '@capacitor/core': 8.2.0 @@ -18947,6 +18853,8 @@ snapshots: follow-redirects@1.15.9: {} + follow-redirects@1.16.0: {} + fontkit@2.0.2: dependencies: '@swc/helpers': 0.4.37 @@ -18986,6 +18894,14 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + formidable@2.1.5: dependencies: '@paralleldrive/cuid2': 2.3.1 @@ -19344,6 +19260,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + he@1.2.0: {} highlight.js@10.7.3: {} @@ -19647,27 +19567,6 @@ snapshots: interpret@1.4.0: {} - ionic-appauth@2.1.0(rxjs@7.8.1): - dependencies: - '@openid/appauth': 1.3.2 - '@types/chai-as-promised': 7.1.8 - rxjs: 7.8.1 - tslib: 2.6.2 - uuid: 9.0.1 - optionalDependencies: - '@awesome-cordova-plugins/core': 6.16.0(rxjs@7.8.1) - '@awesome-cordova-plugins/http': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1) - '@awesome-cordova-plugins/in-app-browser': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1) - '@awesome-cordova-plugins/safari-view-controller': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1) - '@awesome-cordova-plugins/secure-storage': 6.16.0(@awesome-cordova-plugins/core@6.16.0(rxjs@7.8.1))(rxjs@7.8.1) - '@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.0.13: dependencies: '@stencil/core': 4.43.2