mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-10-20 16:32:16 +00:00
feat: use only one identity provider
Additionally remove ionic-appauth dependency, refactor the relevant code and fix some issues. Closes #245
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright (C) 2026 StApps
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, version 3.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user