From 15ccbe4c1879417f2fc5849204fa9f6bdcce87fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jovan=20Kruni=C4=87?= Date: Mon, 31 Oct 2022 17:16:26 +0100 Subject: [PATCH] fix: refresh token not used by default auth provider Closes #311 --- src/app/modules/auth/auth.service.ts | 546 +++++++++++++++++++ src/app/modules/auth/default-auth.service.ts | 2 +- 2 files changed, 547 insertions(+), 1 deletion(-) create mode 100644 src/app/modules/auth/auth.service.ts diff --git a/src/app/modules/auth/auth.service.ts b/src/app/modules/auth/auth.service.ts new file mode 100644 index 00000000..c7bad73d --- /dev/null +++ b/src/app/modules/auth/auth.service.ts @@ -0,0 +1,546 @@ +/* + * 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 . + */ +// 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 { + AuthorizationError, + AuthorizationNotifier, + AuthorizationRequest, + AuthorizationRequestHandler, + AuthorizationRequestJson, + AuthorizationResponse, + AuthorizationServiceConfiguration, + BaseTokenRequestHandler, + DefaultCrypto, + GRANT_TYPE_AUTHORIZATION_CODE, + GRANT_TYPE_REFRESH_TOKEN, + JQueryRequestor, + LocalStorageBackend, + Requestor, + RevokeTokenRequest, + RevokeTokenRequestJson, + StorageBackend, + StringMap, + TokenRequest, + TokenRequestHandler, + 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 {BehaviorSubject, Observable} from 'rxjs'; + +const TOKEN_RESPONSE_KEY = 'token_response'; +const AUTH_EXPIRY_BUFFER = 10 * 60 * -1; // 10 mins in seconds + +export abstract class AuthService implements IAuthService { + private _configuration?: AuthorizationServiceConfiguration; + + private _authConfig?: IAuthConfig; + + private _authSubject: AuthSubject = new AuthSubject(); + + private _actionHistory: ActionHistoryObserver = new ActionHistoryObserver(); + + private _session: SessionObserver = new SessionObserver(); + + private _authSubjectV2 = new BehaviorSubject( + AuthActionBuilder.Init(), + ); + + private _tokenSubject = new BehaviorSubject( + undefined, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _userSubject = new BehaviorSubject(undefined); + + private _authenticatedSubject = new BehaviorSubject(false); + + private _initComplete = new BehaviorSubject(false); + + 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(), + ) { + this.tokenHandler = new BaseTokenRequestHandler(requestor); + this.userInfoHandler = new IonicUserInfoHandler(requestor); + this.requestHandler = new IonicAuthorizationRequestHandler( + browser, + storage, + ); + this.endSessionHandler = new IonicEndSessionHandler(browser); + } + + /** + * @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]; + } + + /** + * @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; + } + + get token$(): Observable { + return this._tokenSubject.asObservable(); + } + + get isAuthenticated$(): Observable { + return this._authenticatedSubject.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.requestor, + ).catch(() => { + throw new Error('Unable To Obtain Server Configuration'); + }); + } + + if (this._configuration != undefined) { + return Promise.resolve(this._configuration); + } else { + throw new Error('Unable To Obtain Server Configuration'); + } + } + + public async init() { + this.setupAuthorizationNotifier(); + this.loadTokenFromStorage(); + this.addActionObserver(this._actionHistory); + this.addActionObserver(this._session); + } + + protected notifyActionListers(action: IAuthAction) { + /* eslint-disable unicorn/no-useless-undefined */ + switch (action.action) { + case AuthActions.RefreshFailed: + case AuthActions.SignInFailed: + case AuthActions.SignOutSuccess: + case AuthActions.SignOutFailed: + this._tokenSubject.next(undefined); + this._userSubject.next(undefined); + this._authenticatedSubject.next(false); + break; + case AuthActions.LoadTokenFromStorageFailed: + this._tokenSubject.next(undefined); + this._userSubject.next(undefined); + this._authenticatedSubject.next(false); + this._initComplete.next(true); + break; + case AuthActions.SignInSuccess: + case AuthActions.RefreshSuccess: + this._tokenSubject.next(action.tokenResponse); + this._authenticatedSubject.next(true); + break; + case AuthActions.LoadTokenFromStorageSuccess: + this._tokenSubject.next(action.tokenResponse); + this._authenticatedSubject.next( + (action.tokenResponse as TokenResponse).isValid(0), + ); + 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; + } + + this._authSubjectV2.next(action); + this._authSubject.notify(action); + } + + 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(error.errorDescription); + } else { + throw new Error('Unknown Error With Authentication'); + } + } + + 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) { + 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(); + } + } else { + //if user has no token they should not be logged in in the first place + 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), + ); + 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.storage.removeItem(TOKEN_RESPONSE_KEY); + 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)) { + await this.refreshToken(); + if (this._tokenSubject.value) { + return this._tokenSubject.value; + } + } else { + return this._tokenSubject.value; + } + } + + 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); + } +} diff --git a/src/app/modules/auth/default-auth.service.ts b/src/app/modules/auth/default-auth.service.ts index 7dafaabb..f3384d79 100644 --- a/src/app/modules/auth/default-auth.service.ts +++ b/src/app/modules/auth/default-auth.service.ts @@ -12,13 +12,13 @@ import { EndSessionHandler, Browser, DefaultBrowser, - AuthService, AuthActionBuilder, } 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';