mirror of
https://gitlab.com/openstapps/openstapps.git
synced 2026-10-20 00:11:44 +00:00
Additionally remove ionic-appauth dependency, refactor the relevant code and fix some issues. Closes #245
1046 lines
27 KiB
TypeScript
1046 lines
27 KiB
TypeScript
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {Injectable, inject} from '@angular/core';
|
|
import {Browser} from '@capacitor/browser';
|
|
import {Platform} from '@ionic/angular/standalone';
|
|
import {
|
|
AuthorizationError,
|
|
AuthorizationNotifier,
|
|
AuthorizationRequest,
|
|
AuthorizationRequestJson,
|
|
AuthorizationResponse,
|
|
AuthorizationServiceConfiguration,
|
|
AppAuthError,
|
|
BaseTokenRequestHandler,
|
|
DefaultCrypto,
|
|
ErrorType,
|
|
GRANT_TYPE_AUTHORIZATION_CODE,
|
|
GRANT_TYPE_REFRESH_TOKEN,
|
|
Requestor,
|
|
RevokeTokenRequest,
|
|
RevokeTokenRequestJson,
|
|
StorageBackend,
|
|
StringMap,
|
|
TokenRequest,
|
|
TokenRequestHandler,
|
|
TokenRequestJson,
|
|
TokenResponse,
|
|
} from '@openid/appauth';
|
|
import {SCAuthorizationProvider, SCAuthorizationProviderEndpoints} from '@openstapps/core';
|
|
import {BehaviorSubject, Observable} from 'rxjs';
|
|
|
|
import {ConfigProvider} from '../config/config.provider';
|
|
import {CapacitorAuthorizationRequestHandler} from './capacitor-authorization-request-handler';
|
|
import {getClientConfig, getServiceConfiguration} from './auth.provider.methods';
|
|
import {requestorFactory} from './factories/requestor.factory';
|
|
import {storageFactory} from './factories/storage.factory';
|
|
|
|
const TOKEN_RESPONSE_KEY = 'token_response';
|
|
export const INVALID_GRANT: ErrorType = 'invalid_grant';
|
|
|
|
/**
|
|
* Refresh at most this many seconds before the access token expires.
|
|
*/
|
|
const MAX_EARLY_REFRESH_SECONDS = 60;
|
|
|
|
/**
|
|
* Retry interval after a failed background session check (e.g. offline).
|
|
*/
|
|
const SESSION_CHECK_RETRY_MS = 60 * 1000;
|
|
|
|
/**
|
|
* Logout never waits longer than this for the IdP to revoke the tokens.
|
|
*/
|
|
const REVOKE_TIMEOUT_MS = 5000;
|
|
|
|
/**
|
|
* Largest delay setTimeout accepts (about 24.8 days).
|
|
*/
|
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
|
|
/**
|
|
* Returns the AppAuth isValid() buffer (negative seconds) for a token:
|
|
* refresh shortly before expiry, but never earlier than after three quarters
|
|
* of the token lifetime. A fixed buffer would make short-lived tokens
|
|
* (e.g. Shibboleth's default of 10 minutes) count as expired right away.
|
|
*/
|
|
export function earlyRefreshBuffer(token: TokenResponse): number {
|
|
const lifetime = token.expiresIn ?? 0;
|
|
|
|
if (lifetime <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return -Math.min(MAX_EARLY_REFRESH_SECONDS, Math.floor(lifetime / 4));
|
|
}
|
|
|
|
export interface AuthConfig {
|
|
server_host: string;
|
|
client_id: string;
|
|
redirect_url: string;
|
|
end_session_redirect_url?: string;
|
|
scopes: string;
|
|
pkce: boolean;
|
|
}
|
|
|
|
export enum AuthActions {
|
|
Init = 'Init',
|
|
|
|
SignInSuccess = 'SignInSuccess',
|
|
SignInFailed = 'SignInFailed',
|
|
|
|
SignOutSuccess = 'SignOutSuccess',
|
|
SignOutFailed = 'SignOutFailed',
|
|
|
|
RefreshSuccess = 'RefreshSuccess',
|
|
RefreshFailed = 'RefreshFailed',
|
|
|
|
LoadTokenFromStorageSuccess = 'LoadTokenFromStorageSuccess',
|
|
|
|
LoadTokenFromStorageFailed = 'LoadTokenFromStorageFailed',
|
|
|
|
RevokeTokensSuccess = 'RevokeTokensSuccess',
|
|
|
|
RevokeTokensFailed = 'RevokeTokensFailed',
|
|
|
|
LoadUserInfoSuccess = 'LoadUserInfoSuccess',
|
|
|
|
LoadUserInfoFailed = 'LoadUserInfoFailed',
|
|
|
|
/**
|
|
* The session can no longer be renewed: the access token expired without a
|
|
* refresh token (browser), or the IdP rejected the refresh token with
|
|
* invalid_grant (e.g. refresh token chain lifetime reached).
|
|
* The user is logged out locally.
|
|
*/
|
|
SessionExpired = 'SessionExpired',
|
|
}
|
|
|
|
export interface IAuthAction {
|
|
action: AuthActions;
|
|
tokenResponse?: TokenResponse;
|
|
user?: unknown;
|
|
error?: unknown;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the token endpoint rejected the grant (RFC 6749, 5.2).
|
|
*
|
|
* AppAuth's BaseTokenRequestHandler only produces
|
|
* AppAuthError('invalid_grant') when the requestor resolves with the error
|
|
* body. Our requestors reject non-2xx responses themselves, so the OAuth error
|
|
* may also be found in the error's extras (the parsed response body).
|
|
*/
|
|
export function isInvalidGrant(error?: unknown): boolean {
|
|
if (!(error instanceof AppAuthError)) {
|
|
return false;
|
|
}
|
|
|
|
if (error.message === INVALID_GRANT) {
|
|
return true;
|
|
}
|
|
|
|
const extras = error.extras as {error?: unknown} | undefined;
|
|
|
|
return extras?.error === INVALID_GRANT;
|
|
}
|
|
|
|
@Injectable({
|
|
providedIn: 'root',
|
|
})
|
|
export class AuthProvider {
|
|
private readonly platform = inject(Platform);
|
|
|
|
private readonly configProvider = inject(ConfigProvider);
|
|
|
|
private authConfigValue?: AuthConfig;
|
|
|
|
private localConfiguration?: AuthorizationServiceConfiguration;
|
|
|
|
/**
|
|
* Endpoints from the backend configuration. AppAuth's
|
|
* AuthorizationServiceConfiguration has no userinfo / end-session.
|
|
*/
|
|
private endpoints?: SCAuthorizationProviderEndpoints;
|
|
|
|
private readonly authSubject = new BehaviorSubject<IAuthAction>({
|
|
action: AuthActions.Init,
|
|
});
|
|
|
|
private readonly tokenSubject = new BehaviorSubject<TokenResponse | undefined>(undefined);
|
|
|
|
private readonly userSubject = new BehaviorSubject<unknown>(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: CapacitorAuthorizationRequestHandler;
|
|
|
|
private readonly tokenHandler: TokenRequestHandler;
|
|
|
|
private notifierInitialized = false;
|
|
|
|
/**
|
|
* Refresh request currently in progress. Parallel callers share it, so the
|
|
* same refresh token is never sent twice (with refresh token rotation the
|
|
* second request would fail with invalid_grant and log the user out).
|
|
*/
|
|
private refreshInFlight?: Promise<void>;
|
|
|
|
/**
|
|
* Fires when the current access token expires (or is due for refresh),
|
|
* so the UI does not keep showing a session that has already ended.
|
|
*/
|
|
private expiryTimer?: ReturnType<typeof setTimeout>;
|
|
|
|
private visibilityListenerInitialized = false;
|
|
|
|
constructor() {
|
|
this.storage = storageFactory(this.platform);
|
|
|
|
this.requestor = requestorFactory(this.platform);
|
|
|
|
/*
|
|
* Same handler on all platforms. On the web, the login is a page
|
|
* redirect and there is no in-app browser to close.
|
|
*/
|
|
this.requestHandler = this.platform.is('capacitor')
|
|
? new CapacitorAuthorizationRequestHandler(this.storage)
|
|
: new CapacitorAuthorizationRequestHandler(
|
|
this.storage,
|
|
async url => {
|
|
window.location.assign(url);
|
|
},
|
|
async () => {
|
|
// Nothing to close on the web.
|
|
},
|
|
);
|
|
|
|
this.tokenHandler = new BaseTokenRequestHandler(this.requestor);
|
|
|
|
/*
|
|
* Every new (or removed) token reschedules the expiry check.
|
|
*/
|
|
this.tokenSubject.subscribe(token => {
|
|
this.scheduleExpiryCheck(token);
|
|
});
|
|
}
|
|
|
|
get token$(): Observable<TokenResponse | undefined> {
|
|
return this.tokenSubject.asObservable();
|
|
}
|
|
|
|
get isAuthenticated$(): Observable<boolean> {
|
|
return this.authenticatedSubject.asObservable();
|
|
}
|
|
|
|
get isLoggedIn$(): Observable<boolean> {
|
|
return this.loggedInSubject.asObservable();
|
|
}
|
|
|
|
get initComplete$(): Observable<boolean> {
|
|
return this.initCompleteSubject.asObservable();
|
|
}
|
|
|
|
get user$(): Observable<unknown> {
|
|
return this.userSubject.asObservable();
|
|
}
|
|
|
|
get events$(): Observable<IAuthAction> {
|
|
return this.authSubject.asObservable();
|
|
}
|
|
|
|
get authConfig(): AuthConfig {
|
|
if (!this.authConfigValue) {
|
|
throw new Error('AuthConfig Not Defined');
|
|
}
|
|
|
|
return this.authConfigValue;
|
|
}
|
|
|
|
get configuration(): Promise<AuthorizationServiceConfiguration> {
|
|
if (!this.localConfiguration) {
|
|
throw new Error('Local Configuration Not Defined');
|
|
}
|
|
|
|
return Promise.resolve(this.localConfiguration);
|
|
}
|
|
|
|
public async init(): Promise<void> {
|
|
this.setupConfiguration();
|
|
this.setupAuthorizationNotifier();
|
|
this.setupVisibilityListener();
|
|
|
|
await this.loadTokenFromStorage();
|
|
}
|
|
|
|
private setupConfiguration(): void {
|
|
const config = this.configProvider.getAnyValue('auth') as {
|
|
default: SCAuthorizationProvider;
|
|
};
|
|
|
|
this.authConfigValue = getClientConfig(config);
|
|
|
|
this.localConfiguration = getServiceConfiguration(config);
|
|
|
|
this.endpoints = config.default.endpoints;
|
|
}
|
|
|
|
public async signIn(authExtras?: StringMap, state?: string): Promise<void> {
|
|
try {
|
|
const requestJson: AuthorizationRequestJson = {
|
|
response_type: AuthorizationRequest.RESPONSE_TYPE_CODE,
|
|
|
|
client_id: this.authConfig.client_id,
|
|
|
|
redirect_uri: this.authConfig.redirect_url,
|
|
|
|
scope: this.authConfig.scopes,
|
|
|
|
extras: authExtras,
|
|
|
|
state,
|
|
};
|
|
|
|
const request = new AuthorizationRequest(requestJson, new DefaultCrypto(), this.authConfig.pkce);
|
|
|
|
await this.requestHandler.performAuthorizationRequest(await this.configuration, request);
|
|
} catch (error) {
|
|
this.notify({
|
|
action: AuthActions.SignInFailed,
|
|
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
public authorizationCallback(callbackUrl: string): void {
|
|
void this.handleAuthorizationCallback(callbackUrl).catch(error => {
|
|
this.notify({
|
|
action: AuthActions.SignInFailed,
|
|
|
|
error,
|
|
});
|
|
});
|
|
}
|
|
|
|
public async refreshToken(): Promise<void> {
|
|
try {
|
|
await this.refreshSession();
|
|
} catch {
|
|
/*
|
|
* Already handled by refreshSession(): the session is expired on
|
|
* invalid_grant and kept on temporary errors.
|
|
*/
|
|
}
|
|
}
|
|
|
|
public async loadUserInfo(): Promise<void> {
|
|
try {
|
|
const token = await this.getValidToken(0);
|
|
|
|
const endpoint = this.endpoints?.userinfo;
|
|
|
|
if (!endpoint) {
|
|
throw new Error('OIDC provider does not expose a userinfo endpoint.');
|
|
}
|
|
|
|
const user = await this.requestor.xhr<Record<string, unknown>>({
|
|
url: endpoint,
|
|
|
|
method: 'GET',
|
|
|
|
dataType: 'json',
|
|
|
|
headers: {
|
|
Authorization: `Bearer ${token.accessToken}`,
|
|
},
|
|
});
|
|
|
|
this.notify({
|
|
action: AuthActions.LoadUserInfoSuccess,
|
|
|
|
user,
|
|
});
|
|
} catch (error) {
|
|
this.notify({
|
|
action: AuthActions.LoadUserInfoFailed,
|
|
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
public async getEndSessionEndpoint(): Promise<string | undefined> {
|
|
return this.endpoints?.endSession;
|
|
}
|
|
|
|
/**
|
|
* Logs the user out of the app.
|
|
*
|
|
* With revokeTokens (default), the refresh and access tokens are also
|
|
* revoked at the IdP (RFC 7009), so a copied token cannot be used anymore.
|
|
* This is best effort: the local logout always happens first and never
|
|
* waits longer than REVOKE_TIMEOUT_MS for the IdP.
|
|
* @param _state Unused.
|
|
* @param revokeTokens Revoke the tokens at the IdP.
|
|
*/
|
|
public async signOut(_state?: string, revokeTokens = true): Promise<void> {
|
|
const token = this.tokenSubject.value;
|
|
|
|
try {
|
|
await this.storage.removeItem(TOKEN_RESPONSE_KEY);
|
|
|
|
/*
|
|
* Local logout only.
|
|
*
|
|
* The optional logout from the identity provider is
|
|
* deliberately handled by AuthHelperService after the
|
|
* confirmation dialog.
|
|
*/
|
|
this.notify({
|
|
action: AuthActions.SignOutSuccess,
|
|
});
|
|
} catch (error) {
|
|
this.notify({
|
|
action: AuthActions.SignOutFailed,
|
|
|
|
error,
|
|
});
|
|
}
|
|
|
|
if (revokeTokens && token) {
|
|
try {
|
|
await this.withTimeout(this.revokeTokenResponse(token), REVOKE_TIMEOUT_MS);
|
|
} catch (error) {
|
|
/*
|
|
* Offline, IdP unreachable or revocation not allowed: the user is
|
|
* logged out locally anyway.
|
|
*/
|
|
this.notify({
|
|
action: AuthActions.RevokeTokensFailed,
|
|
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
public async revokeTokens(): Promise<void> {
|
|
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<void> {
|
|
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));
|
|
|
|
/*
|
|
* Expired and not renewable (browser, no refresh token):
|
|
* this session is over, do not show the user as logged in.
|
|
*/
|
|
if (!token.isValid(0) && !token.refreshToken) {
|
|
await this.storage.removeItem(TOKEN_RESPONSE_KEY);
|
|
|
|
throw new Error('Stored Token Expired');
|
|
}
|
|
|
|
this.notify({
|
|
action: AuthActions.LoadTokenFromStorageSuccess,
|
|
|
|
tokenResponse: token,
|
|
});
|
|
} catch (error) {
|
|
this.notify({
|
|
action: AuthActions.LoadTokenFromStorageFailed,
|
|
|
|
error,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a usable access token, refreshing it first if it is expired
|
|
* or about to expire.
|
|
* @param buffer AppAuth isValid() buffer in (negative) seconds.
|
|
* Defaults to earlyRefreshBuffer() of the current token.
|
|
*/
|
|
public async getValidToken(buffer?: number): Promise<TokenResponse> {
|
|
const currentToken = this.tokenSubject.value;
|
|
|
|
/*
|
|
* Access token still valid for longer than the buffer.
|
|
*/
|
|
if (currentToken?.isValid(buffer ?? earlyRefreshBuffer(currentToken))) {
|
|
return currentToken;
|
|
}
|
|
|
|
if (!currentToken?.refreshToken) {
|
|
/*
|
|
* Without a refresh token (browser) nothing can be renewed early,
|
|
* so the buffer is pointless: use the token until it really expires.
|
|
*/
|
|
if (currentToken?.isValid(0)) {
|
|
return currentToken;
|
|
}
|
|
|
|
/*
|
|
* Expired and not renewable: the session is over.
|
|
*/
|
|
if (currentToken) {
|
|
await this.expireSession(new Error('Access Token Expired Without Refresh Token'));
|
|
}
|
|
|
|
throw new Error('Unable To Obtain Valid Token: No Refresh Token Available');
|
|
}
|
|
|
|
/*
|
|
* Access token is expired or will expire soon: refresh.
|
|
*
|
|
* refreshSession() stores the newly returned token and updates
|
|
* tokenSubject via RefreshSuccess. On invalid_grant it has already
|
|
* logged the user out.
|
|
*/
|
|
await this.refreshSession();
|
|
|
|
const refreshedToken = this.tokenSubject.value;
|
|
|
|
if (!refreshedToken) {
|
|
throw new Error('Unable To Obtain Valid Token: Refresh Returned No Token');
|
|
}
|
|
|
|
/*
|
|
* A freshly issued token is usable even if it is short-lived.
|
|
* Only reject one that is already expired.
|
|
*/
|
|
if (!refreshedToken.isValid(0)) {
|
|
throw new Error('Unable To Obtain Valid Token: Refreshed Token Is Already Expired');
|
|
}
|
|
|
|
return refreshedToken;
|
|
}
|
|
|
|
/**
|
|
* Refreshes the token once, shared by all parallel callers.
|
|
* Rejects with the original error after it has been handled.
|
|
*/
|
|
private refreshSession(): Promise<void> {
|
|
this.refreshInFlight ??= (async () => {
|
|
try {
|
|
await this.requestTokenRefresh();
|
|
} catch (error) {
|
|
await this.handleRefreshFailure(error);
|
|
|
|
throw error;
|
|
}
|
|
})().finally(() => {
|
|
this.refreshInFlight = undefined;
|
|
});
|
|
|
|
return this.refreshInFlight;
|
|
}
|
|
|
|
private async handleRefreshFailure(error: unknown): Promise<void> {
|
|
this.notify({
|
|
action: AuthActions.RefreshFailed,
|
|
|
|
error,
|
|
});
|
|
|
|
/*
|
|
* invalid_grant means that the refresh token is no longer usable,
|
|
* for example because it expired, was revoked or the refresh-token
|
|
* chain lifetime has been reached. Log the user out.
|
|
*
|
|
* Network and other temporary errors must not log the user out.
|
|
*/
|
|
if (isInvalidGrant(error)) {
|
|
await this.expireSession(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ends the local session because it cannot be renewed anymore.
|
|
*/
|
|
private async expireSession(error: unknown): Promise<void> {
|
|
/*
|
|
* Already logged out, e.g. the expiry timer and a request noticed the
|
|
* expiry at the same time. Report the expiry only once.
|
|
*/
|
|
if (!this.tokenSubject.value) {
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Log out in memory first (synchronously), so parallel callers see
|
|
* the session as ended.
|
|
*/
|
|
this.notify({
|
|
action: AuthActions.SessionExpired,
|
|
|
|
error,
|
|
});
|
|
|
|
try {
|
|
await this.storage.removeItem(TOKEN_RESPONSE_KEY);
|
|
} catch {
|
|
/*
|
|
* Still logged out in memory if removing the stored token fails.
|
|
*/
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedules checkSession() for the moment the token expires
|
|
* (or is due for refresh when a refresh token is available).
|
|
*/
|
|
private scheduleExpiryCheck(token: TokenResponse | undefined): void {
|
|
if (this.expiryTimer) {
|
|
clearTimeout(this.expiryTimer);
|
|
this.expiryTimer = undefined;
|
|
}
|
|
|
|
if (!token?.expiresIn) {
|
|
return;
|
|
}
|
|
|
|
const buffer = token.refreshToken ? earlyRefreshBuffer(token) : 0;
|
|
|
|
const dueAtMs = (token.issuedAt + token.expiresIn + buffer) * 1000;
|
|
|
|
/*
|
|
* One extra second so that isValid() is definitely false when
|
|
* the timer fires.
|
|
*/
|
|
const delayMs = Math.min(Math.max(0, dueAtMs - Date.now()) + 1000, MAX_TIMEOUT_MS);
|
|
|
|
this.expiryTimer = setTimeout(() => {
|
|
this.expiryTimer = undefined;
|
|
void this.checkSession();
|
|
}, delayMs);
|
|
}
|
|
|
|
/**
|
|
* Refreshes the token or ends the session if it can no longer be used.
|
|
*
|
|
* Called by the expiry timer and when the app becomes visible again
|
|
* (browsers and mobile OSes pause timers in the background).
|
|
*/
|
|
private async checkSession(): Promise<void> {
|
|
if (!this.tokenSubject.value) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
/*
|
|
* Returns immediately if the token is still valid. Otherwise it
|
|
* refreshes, or logs out via SessionExpired.
|
|
*/
|
|
await this.getValidToken();
|
|
} catch {
|
|
/*
|
|
* Still logged in after a failure means a temporary error
|
|
* (e.g. offline): try again later.
|
|
*/
|
|
if (this.tokenSubject.value && !this.expiryTimer) {
|
|
this.expiryTimer = setTimeout(() => {
|
|
this.expiryTimer = undefined;
|
|
void this.checkSession();
|
|
}, SESSION_CHECK_RETRY_MS);
|
|
}
|
|
}
|
|
}
|
|
|
|
private setupVisibilityListener(): void {
|
|
if (this.visibilityListenerInitialized || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') {
|
|
void this.checkSession();
|
|
}
|
|
});
|
|
|
|
this.visibilityListenerInitialized = true;
|
|
}
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const token = this.tokenSubject.value;
|
|
|
|
if (!token) {
|
|
throw new Error('No Token Defined!');
|
|
}
|
|
|
|
await this.revokeTokenResponse(token);
|
|
|
|
await this.storage.removeItem(TOKEN_RESPONSE_KEY);
|
|
}
|
|
|
|
/**
|
|
* Revokes the refresh token (if any) and the access token at the IdP.
|
|
*
|
|
* The refresh token goes first: it is the long-lived one. Both are tried
|
|
* even if the first request fails; the first error is rethrown.
|
|
* Does nothing if the backend configures no revocation endpoint.
|
|
*/
|
|
private async revokeTokenResponse(token: TokenResponse): Promise<void> {
|
|
const configuration = await this.configuration;
|
|
|
|
if (!configuration.revocationEndpoint) {
|
|
return;
|
|
}
|
|
|
|
const requests: RevokeTokenRequestJson[] = [];
|
|
|
|
if (token.refreshToken) {
|
|
requests.push({
|
|
token: token.refreshToken,
|
|
|
|
token_type_hint: 'refresh_token',
|
|
|
|
client_id: this.authConfig.client_id,
|
|
});
|
|
}
|
|
|
|
requests.push({
|
|
token: token.accessToken,
|
|
|
|
token_type_hint: 'access_token',
|
|
|
|
client_id: this.authConfig.client_id,
|
|
});
|
|
|
|
let firstError: unknown;
|
|
|
|
for (const request of requests) {
|
|
try {
|
|
await this.tokenHandler.performRevokeTokenRequest(
|
|
configuration,
|
|
|
|
new RevokeTokenRequest(request),
|
|
);
|
|
} catch (error) {
|
|
firstError ??= error;
|
|
}
|
|
}
|
|
|
|
if (firstError !== undefined) {
|
|
throw firstError;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rejects if the promise does not settle within the given time.
|
|
*/
|
|
private withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
const timeout = new Promise<never>((_resolve, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new Error('Token Revocation Timed Out'));
|
|
}, timeoutMs);
|
|
});
|
|
|
|
return Promise.race([promise, timeout]).finally(() => {
|
|
clearTimeout(timer);
|
|
});
|
|
}
|
|
|
|
private async handleAuthorizationCallback(callbackUrl: string): Promise<void> {
|
|
await this.requestHandler.completeAuthorizationRequestFromUrl(callbackUrl);
|
|
}
|
|
|
|
private async internalEndSessionCallback(): Promise<void> {
|
|
await this.closeBrowser();
|
|
|
|
await this.storage.removeItem(TOKEN_RESPONSE_KEY);
|
|
|
|
this.notify({
|
|
action: AuthActions.SignOutSuccess,
|
|
});
|
|
}
|
|
|
|
private async closeBrowser(): Promise<void> {
|
|
if (!this.platform.is('capacitor')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await Browser.close();
|
|
} catch {
|
|
// Browser may already be closed.
|
|
}
|
|
}
|
|
|
|
private async storeToken(token: TokenResponse): Promise<void> {
|
|
await this.storage.setItem(
|
|
TOKEN_RESPONSE_KEY,
|
|
|
|
JSON.stringify(token.toJson()),
|
|
);
|
|
}
|
|
|
|
private notify(action: IAuthAction): void {
|
|
switch (action.action) {
|
|
case AuthActions.SignOutSuccess:
|
|
case AuthActions.SignOutFailed:
|
|
case AuthActions.SessionExpired: {
|
|
// eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument
|
|
this.tokenSubject.next(undefined);
|
|
|
|
// eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument
|
|
this.userSubject.next(undefined);
|
|
|
|
this.authenticatedSubject.next(false);
|
|
|
|
this.loggedInSubject.next(false);
|
|
|
|
break;
|
|
}
|
|
|
|
case AuthActions.LoadTokenFromStorageFailed: {
|
|
// eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument
|
|
this.tokenSubject.next(undefined);
|
|
|
|
// eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument
|
|
this.userSubject.next(undefined);
|
|
|
|
this.authenticatedSubject.next(false);
|
|
|
|
this.loggedInSubject.next(false);
|
|
|
|
this.initCompleteSubject.next(true);
|
|
|
|
break;
|
|
}
|
|
|
|
case AuthActions.SignInSuccess:
|
|
case AuthActions.RefreshSuccess: {
|
|
if (action.tokenResponse) {
|
|
this.tokenSubject.next(action.tokenResponse);
|
|
|
|
this.authenticatedSubject.next(true);
|
|
|
|
this.loggedInSubject.next(true);
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case AuthActions.LoadTokenFromStorageSuccess: {
|
|
if (action.tokenResponse) {
|
|
this.tokenSubject.next(action.tokenResponse);
|
|
|
|
this.authenticatedSubject.next(action.tokenResponse.isValid(0));
|
|
|
|
this.loggedInSubject.next(true);
|
|
}
|
|
|
|
this.initCompleteSubject.next(true);
|
|
|
|
break;
|
|
}
|
|
|
|
case AuthActions.RevokeTokensSuccess: {
|
|
// eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument
|
|
this.tokenSubject.next(undefined);
|
|
|
|
// eslint-disable-next-line unicorn/no-useless-undefined -- BehaviorSubject.next() requires an argument
|
|
this.userSubject.next(undefined);
|
|
|
|
this.authenticatedSubject.next(false);
|
|
|
|
this.loggedInSubject.next(false);
|
|
|
|
break;
|
|
}
|
|
|
|
case AuthActions.LoadUserInfoSuccess: {
|
|
this.userSubject.next(action.user);
|
|
|
|
break;
|
|
}
|
|
|
|
default: {
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.authSubject.next(action);
|
|
}
|
|
}
|