diff --git a/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts
new file mode 100644
index 00000000..3ec0534b
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/auth-callback/page/auth-callback-page.component.spec.ts
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2026 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import {TestBed} from '@angular/core/testing';
+import {Router} from '@angular/router';
+import {NavController} from '@ionic/angular/standalone';
+import {Subject} from 'rxjs';
+import {AuthActions, AuthProvider, IAuthAction} from '../../auth.provider';
+import {AuthHelperService} from '../../auth-helper.service';
+import {AuthCallbackPageComponent} from './auth-callback-page.component';
+
+/** Lets pending promise callbacks run. */
+async function flushPromises(): Promise {
+ for (let index = 0; index < 10; index++) {
+ await Promise.resolve();
+ }
+}
+
+/** Creates the page like the router does when the IdP redirects back. */
+function openCallbackPage(): AuthCallbackPageComponent {
+ return TestBed.runInInjectionContext(() => new AuthCallbackPageComponent());
+}
+
+describe('AuthCallbackPageComponent', () => {
+ let events: Subject;
+ let authProvider: {events$: Subject; authorizationCallback: jasmine.Spy};
+ let authHelper: jasmine.SpyObj>;
+ let navController: jasmine.SpyObj>;
+
+ beforeEach(() => {
+ events = new Subject();
+ authProvider = {events$: events, authorizationCallback: jasmine.createSpy('authorizationCallback')};
+
+ authHelper = jasmine.createSpyObj('AuthHelperService', ['getOriginPath', 'deleteOriginPath']);
+ authHelper.deleteOriginPath.and.resolveTo();
+
+ navController = jasmine.createSpyObj('NavController', ['navigateRoot']);
+ navController.navigateRoot.and.resolveTo(true);
+
+ TestBed.configureTestingModule({
+ providers: [
+ {provide: AuthProvider, useValue: authProvider},
+ {provide: AuthHelperService, useValue: authHelper},
+ {provide: NavController, useValue: navController},
+ {provide: Router, useValue: {}},
+ ],
+ });
+ });
+
+ it('hands the callback URL to AuthProvider', () => {
+ openCallbackPage();
+
+ expect(authProvider.authorizationCallback).toHaveBeenCalledOnceWith(window.location.href);
+ });
+
+ it('goes back to the page the login was started from', async () => {
+ authHelper.getOriginPath.and.resolveTo('/assessments');
+ openCallbackPage();
+
+ events.next({action: AuthActions.SignInSuccess});
+ await flushPromises();
+
+ expect(navController.navigateRoot).toHaveBeenCalledOnceWith('/assessments');
+ expect(authHelper.deleteOriginPath).toHaveBeenCalled();
+ });
+
+ it('goes to the profile after login when no origin page is known', async () => {
+ openCallbackPage();
+
+ events.next({action: AuthActions.SignInSuccess});
+ await flushPromises();
+
+ expect(navController.navigateRoot).toHaveBeenCalledOnceWith('profile');
+ });
+
+ it('goes to the profile when the login failed', async () => {
+ openCallbackPage();
+
+ events.next({action: AuthActions.SignInFailed, error: new Error('access_denied')});
+ await flushPromises();
+
+ expect(navController.navigateRoot).toHaveBeenCalledOnceWith('profile');
+ expect(authHelper.getOriginPath).not.toHaveBeenCalled();
+ });
+
+ it('ignores other auth events', async () => {
+ openCallbackPage();
+
+ events.next({action: AuthActions.RefreshSuccess});
+ events.next({action: AuthActions.LoadUserInfoSuccess});
+ await flushPromises();
+
+ expect(navController.navigateRoot).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts
new file mode 100644
index 00000000..92edb6cd
--- /dev/null
+++ b/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.spec.ts
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2026 StApps
+ * This program is free software: you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation, version 3.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * this program. If not, see .
+ */
+import {
+ AuthorizationError,
+ AuthorizationNotifier,
+ AuthorizationRequest,
+ AuthorizationResponse,
+ AuthorizationServiceConfiguration,
+ StorageBackend,
+} from '@openid/appauth';
+import {CapacitorAuthorizationRequestHandler} from './capacitor-authorization-request-handler';
+
+const PENDING_REQUEST_KEY = 'appauth_capacitor_authorization_request';
+const REDIRECT_URI = 'de.example.app://app.example.org/auth/callback';
+
+/**
+ * In-memory StorageBackend, so the tests can look at what the handler stores.
+ */
+class MemoryStorage extends StorageBackend {
+ readonly items = new Map();
+
+ async getItem(name: string): Promise {
+ return this.items.get(name) ?? null; // eslint-disable-line unicorn/no-null
+ }
+
+ async removeItem(name: string): Promise {
+ this.items.delete(name);
+ }
+
+ async clear(): Promise {
+ this.items.clear();
+ }
+
+ async setItem(name: string, value: string): Promise {
+ this.items.set(name, value);
+ }
+}
+
+/**
+ * The callback URL as the IdP would send it back to the app.
+ */
+function callbackUrl(parameters: Record): string {
+ return `${REDIRECT_URI}?${new URLSearchParams(parameters).toString()}`;
+}
+
+describe('CapacitorAuthorizationRequestHandler', () => {
+ let storage: MemoryStorage;
+ let handler: CapacitorAuthorizationRequestHandler;
+ let listener: jasmine.Spy<
+ (
+ request: AuthorizationRequest,
+ response: AuthorizationResponse | null,
+ error: AuthorizationError | null,
+ ) => void
+ >;
+ let windowOpen: jasmine.Spy;
+
+ const configuration = new AuthorizationServiceConfiguration({
+ authorization_endpoint: 'https://idp.example.org/authorize',
+ token_endpoint: 'https://idp.example.org/token',
+ revocation_endpoint: '',
+ });
+
+ /**
+ * Starts a login like AuthProvider.signIn() does and returns its state.
+ */
+ async function startLogin(): Promise {
+ const request = new AuthorizationRequest({
+ response_type: AuthorizationRequest.RESPONSE_TYPE_CODE,
+ client_id: 'test-client',
+ redirect_uri: REDIRECT_URI,
+ scope: 'openid',
+ });
+
+ await handler.performAuthorizationRequest(configuration, request);
+
+ return request.state;
+ }
+
+ beforeEach(() => {
+ storage = new MemoryStorage();
+ handler = new CapacitorAuthorizationRequestHandler(storage);
+
+ const notifier = new AuthorizationNotifier();
+ listener = jasmine.createSpy('authorizationListener');
+ notifier.setAuthorizationListener(listener);
+ handler.setAuthorizationNotifier(notifier);
+
+ // In tests, @capacitor/browser uses its web implementation, which calls window.open().
+ windowOpen = spyOn(window, 'open').and.returnValue({close: () => {}} as Window);
+ });
+
+ describe('starting the login', () => {
+ it('opens the authorization URL with state and PKCE (S256)', async () => {
+ const state = await startLogin();
+
+ const url = new URL(windowOpen.calls.mostRecent().args[0] as string);
+ expect(url.origin + url.pathname).toBe('https://idp.example.org/authorize');
+ expect(url.searchParams.get('state')).toBe(state);
+ expect(url.searchParams.get('redirect_uri')).toBe(REDIRECT_URI);
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(url.searchParams.get('code_challenge')).toBeTruthy();
+ });
+
+ it('stores the pending request including the PKCE code_verifier', async () => {
+ const state = await startLogin();
+
+ const stored = JSON.parse(storage.items.get(PENDING_REQUEST_KEY)!);
+ expect(stored.state).toBe(state);
+ expect(stored.internal.code_verifier).toBeTruthy();
+ });
+ });
+
+ describe('completing the login', () => {
+ it('delivers the code together with the matching request', async () => {
+ const state = await startLogin();
+ const verifier = JSON.parse(storage.items.get(PENDING_REQUEST_KEY)!).internal.code_verifier;
+
+ await handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code', state}));
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ const [request, response, error] = listener.calls.mostRecent().args;
+ expect(response?.code).toBe('auth-code');
+ expect(error).toBeNull();
+ expect(request.internal?.['code_verifier']).toBe(verifier);
+ });
+
+ it('accepts a callback only once', async () => {
+ const state = await startLogin();
+ const url = callbackUrl({code: 'auth-code', state});
+
+ await handler.completeAuthorizationRequestFromUrl(url);
+ expect(storage.items.has(PENDING_REQUEST_KEY)).toBeFalse();
+
+ await expectAsync(handler.completeAuthorizationRequestFromUrl(url)).toBeRejectedWithError(
+ /No pending authorization request/,
+ );
+ expect(listener).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects a callback with a different state and keeps the pending request', async () => {
+ const state = await startLogin();
+
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(
+ callbackUrl({code: 'attacker-code', state: 'other-state'}),
+ ),
+ ).toBeRejectedWithError(/state does not match/);
+ expect(listener).not.toHaveBeenCalled();
+
+ // The real callback still works afterwards.
+ await handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code', state}));
+ expect(listener.calls.mostRecent().args[1]?.code).toBe('auth-code');
+ });
+
+ it('rejects a callback without state', async () => {
+ await startLogin();
+
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code'})),
+ ).toBeRejectedWithError(/state does not match/);
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('rejects a callback when no login was started', async () => {
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callbackUrl({code: 'auth-code', state: 'any-state'})),
+ ).toBeRejectedWithError(/No pending authorization request/);
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('delivers an error response from the IdP and ends the pending request', async () => {
+ const state = await startLogin();
+
+ await handler.completeAuthorizationRequestFromUrl(
+ callbackUrl({error: 'access_denied', error_description: 'User cancelled', state}),
+ );
+
+ const [, response, error] = listener.calls.mostRecent().args;
+ expect(response).toBeNull();
+ expect(error?.error).toBe('access_denied');
+ expect(error?.errorDescription).toBe('User cancelled');
+ expect(storage.items.has(PENDING_REQUEST_KEY)).toBeFalse();
+ });
+
+ it('rejects a callback with neither code nor error', async () => {
+ const state = await startLogin();
+
+ await expectAsync(
+ handler.completeAuthorizationRequestFromUrl(callbackUrl({state})),
+ ).toBeRejectedWithError(/neither an authorization code nor an error/);
+ expect(listener).not.toHaveBeenCalled();
+ });
+ });
+});