Files
openstapps/frontend/app/src/app/modules/auth/capacitor-authorization-request-handler.ts
T
Jovan Krunić ba728d551b feat: use only one identity provider
Additionally remove ionic-appauth dependency,
refactor the relevant code and fix some issues.

Closes #245
2026-10-18 16:10:13 +02:00

233 lines
6.7 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {Browser} from '@capacitor/browser';
import {
AuthorizationError,
AuthorizationErrorJson,
AuthorizationRequest,
AuthorizationRequestHandler,
AuthorizationRequestJson,
AuthorizationRequestResponse,
AuthorizationResponse,
AuthorizationResponseJson,
AuthorizationServiceConfiguration,
BasicQueryStringUtils,
DefaultCrypto,
StorageBackend,
StringMap,
} from '@openid/appauth';
const AUTHORIZATION_REQUEST_KEY = 'appauth_capacitor_authorization_request';
/**
* AppAuth authorization request handler for the native apps and the web.
*
* AppAuth-JS handles:
* - state generation and validation data
* - PKCE code_verifier / code_challenge
* - authorization URL construction
* - AuthorizationNotifier
*
* This handler adds:
* - persist the pending authorization request
* - open the authorization URL (native: @capacitor/browser, web: page redirect)
* - process the callback URL (native: delivered through appUrlOpen,
* web: the URL of the callback page)
*
* The pending request is stored under one fixed key, so each new login
* replaces a canceled one instead of leaving it behind.
*/
export class CapacitorAuthorizationRequestHandler extends AuthorizationRequestHandler {
private callbackUrl?: string;
/**
* @param storage Where the pending request is kept until the callback.
* @param openAuthorizationUrl Opens the IdP login (default: Capacitor browser).
* @param closeBrowser Closes it after the callback (default: Capacitor browser).
*/
constructor(
private readonly storage: StorageBackend,
private readonly openAuthorizationUrl: (url: string) => Promise<void> = async url => {
await Browser.open({url});
},
private readonly closeBrowser: () => Promise<void> = async () => {
await Browser.close();
},
) {
super(new BasicQueryStringUtils(), new DefaultCrypto());
}
public async performAuthorizationRequest(
configuration: AuthorizationServiceConfiguration,
request: AuthorizationRequest,
): Promise<void> {
/*
* Important:
*
* toJson() calls setupCodeVerifier() internally.
* Therefore PKCE is initialized before buildRequestUrl()
* constructs the authorization URL.
*/
const requestJson = await request.toJson();
await this.storage.setItem(AUTHORIZATION_REQUEST_KEY, JSON.stringify(requestJson));
const url = this.buildRequestUrl(configuration, request);
await this.openAuthorizationUrl(url);
}
/**
* Completes the authorization flow using the URL received from
* Capacitor's App.addListener('appUrlOpen', ...).
*/
public async completeAuthorizationRequestFromUrl(callbackUrl: string): Promise<void> {
this.callbackUrl = callbackUrl;
/*
* The deep link has returned control to the application.
* Close the SFSafariViewController / Custom Tab if it is still open.
*/
try {
await this.closeBrowser();
} catch {
// Browser might already have been closed by the platform.
}
await this.completeAuthorizationRequestIfPossible();
}
protected async completeAuthorizationRequest(): Promise<AuthorizationRequestResponse | null> {
if (!this.callbackUrl) {
// eslint-disable-next-line unicorn/no-null -- AppAuth's AuthorizationRequestHandler contract
return null;
}
const storedRequest = await this.storage.getItem(AUTHORIZATION_REQUEST_KEY);
if (!storedRequest) {
this.callbackUrl = undefined;
throw new Error('No pending authorization request found.');
}
const requestJson = JSON.parse(storedRequest) as AuthorizationRequestJson;
/*
* The serialized request also contains `internal`, including
* AppAuth's PKCE code_verifier.
*/
const request = new AuthorizationRequest(requestJson);
const parameters = this.parseCallbackUrl(this.callbackUrl);
const state = parameters['state'];
/*
* Never accept an authorization response for a different request.
*/
if (!state || state !== request.state) {
this.callbackUrl = undefined;
throw new Error('Authorization response state does not match the authorization request.');
}
const error = parameters['error'];
let response: // eslint-disable-next-line unicorn/no-null -- AppAuth's AuthorizationRequestHandler contract
AuthorizationResponse | null = null;
let authorizationError: // eslint-disable-next-line unicorn/no-null -- AppAuth's AuthorizationRequestHandler contract
AuthorizationError | null = null;
if (error) {
const errorJson: AuthorizationErrorJson = {
error,
error_description: parameters['error_description'],
error_uri: parameters['error_uri'],
state,
};
authorizationError = new AuthorizationError(errorJson);
} else {
const code = parameters['code'];
if (!code) {
this.callbackUrl = undefined;
throw new Error('Authorization callback contains neither an authorization code nor an error.');
}
const responseJson: AuthorizationResponseJson = {
code,
state,
};
response = new AuthorizationResponse(responseJson);
}
/*
* The response has been successfully associated with the
* pending request. It can now be consumed exactly once.
*/
await this.storage.removeItem(AUTHORIZATION_REQUEST_KEY);
this.callbackUrl = undefined;
return {
request,
response,
error: authorizationError,
};
}
private parseCallbackUrl(callbackUrl: string): StringMap {
const result: StringMap = {};
const url = new URL(callbackUrl);
/*
* Authorization Code Flow normally returns code/state in
* the query string:
*
* openstapps:/callback?code=...&state=...
*/
for (const [key, value] of url.searchParams.entries()) {
result[key] = value;
}
/*
* Also accept parameters in the fragment as a fallback.
*/
if (url.hash.length > 1) {
const hash = url.hash.slice(1);
const hashParameters = new URLSearchParams(hash);
for (const [key, value] of hashParameters.entries()) {
if (!(key in result)) {
result[key] = value;
}
}
}
return result;
}
}