From a839ca1d3da79bf53a0514d288e03a558cdda4f0 Mon Sep 17 00:00:00 2001 From: Eduardo Roth Date: Wed, 31 Jul 2024 08:31:41 -0600 Subject: [PATCH] feat(): enable implicit redirect flow on web (#267) --- README.md | 3 + src/definitions.ts | 15 +++++ src/web-utils.test.ts | 91 +++++++++++++++++++++++++++++++ src/web-utils.ts | 25 ++++++++- src/web.ts | 124 ++++++++++++++++++++++++------------------ 5 files changed, 205 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 7ac1eb0..c49812c 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ pkceEnable: true ... ``` +Supported on Web with the new method `redirectFlowCodeListener` which should be called on your app init process +so it watches for the URL queryString `code` to generate an `access_token` correctly. + Please be aware that some providers (OneDrive, Auth0) allow **Code Flow + PKCE** only for native apps. Web apps have to use implicit flow. ### Important diff --git a/src/definitions.ts b/src/definitions.ts index 2673cb9..20efbb9 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -5,6 +5,14 @@ export interface GenericOAuth2Plugin { * @returns {Promise} the resource url response */ authenticate(options: OAuth2AuthenticateOptions): Promise; + /** + * Listens for OAuth implicit redirect flow queryString CODE to generate an access_token + * @param {OAuth2RedirectAuthenticationOptions} options + * @returns {Promise} the token endpoint response + */ + redirectFlowCodeListener( + options: ImplicitFlowRedirectOptions, + ): Promise; /** * Get a new access token based on the given refresh token. * @param {OAuth2RefreshTokenOptions} options @@ -23,6 +31,13 @@ export interface GenericOAuth2Plugin { ): Promise; } +export interface ImplicitFlowRedirectOptions extends OAuth2AuthenticateOptions { + /** + * The URL where we get the code + */ + response_url: string; +} + export interface OAuth2RefreshTokenOptions { /** * The app id (client id) you get from the oauth provider like Google, Facebook,... diff --git a/src/web-utils.test.ts b/src/web-utils.test.ts index 6d9df3f..912fb63 100644 --- a/src/web-utils.test.ts +++ b/src/web-utils.test.ts @@ -6,6 +6,21 @@ const mGetRandomValues = jest.fn().mockReturnValueOnce(new Uint32Array(10)); Object.defineProperty(window, 'crypto', { value: { getRandomValues: mGetRandomValues }, }); +let store: { + [k: string]: string; +} = {}; +const sessionStorageMock = { + getItem: jest.fn().mockImplementation((key: string) => store[key] ?? null), + setItem: jest + .fn() + .mockImplementation((key: string, value: string) => (store[key] = value)), + removeItem: jest.fn().mockImplementation((key: string) => delete store[key]), + clear: jest.fn().mockImplementation(() => (store = {})), +}; + +Object.defineProperty(window, 'sessionStorage', { + value: sessionStorageMock, +}); const googleOptions: OAuth2AuthenticateOptions = { appId: 'appId', @@ -57,6 +72,15 @@ const oneDriveOptions: OAuth2AuthenticateOptions = { }, }; +const implicitFlowOptions: OAuth2AuthenticateOptions = { + ...oneDriveOptions, + pkceEnabled: true, + web: { + ...oneDriveOptions.web, + pkceEnabled: true, + }, +}; + const redirectUrlOptions: OAuth2AuthenticateOptions = { appId: 'appId', authorizationBaseUrl: @@ -170,6 +194,32 @@ describe('web options', () => { expect(webOptions.sendCacheControlHeader).toBeFalsy(); }); }); + + describe('if pkceCode enabled', () => { + beforeEach(() => { + sessionStorageMock.clear(); + }); + describe('if a code exists in sessionStorage', () => { + beforeEach(() => { + const code = 'DEMO_CODE'; + WebUtils.setCodeVerifier(code); + }); + it('should get the code correctly', async () => { + const spy = jest.spyOn(WebUtils, 'getCodeVerifier'); + const webOptions = await WebUtils.buildWebOptions(implicitFlowOptions); + expect(spy).toBeCalled(); + expect(webOptions.pkceCodeVerifier).toBe('DEMO_CODE'); + }); + }); + describe("if a code doesn't exist in sessionStorage", () => { + it('should set the code', async () => { + const spy = jest.spyOn(WebUtils, 'setCodeVerifier'); + const webOptions = await WebUtils.buildWebOptions(implicitFlowOptions); + expect(webOptions.pkceCodeVerifier).toBeDefined(); + expect(spy).toBeCalled(); + }); + }); + }); }); describe('Url param extraction', () => { @@ -371,3 +421,44 @@ describe('additional resource headers', () => { expect(webOptions.additionalResourceHeaders[headerKey]).toEqual('*'); }); }); + +describe('implicit redirect authentication flow helpers', () => { + beforeEach(() => { + sessionStorageMock.clear(); + }); + + it('should set code in session storage', () => { + const code = 'DEMO_CODE'; + const codeSet = WebUtils.setCodeVerifier(code); + expect(window.sessionStorage.setItem).toBeCalledWith( + `I_Capacitor_GenericOAuth2Plugin_PKCE`, + code, + ); + expect(codeSet).toEqual(true); + }); + + it('should get code if it exists in sessionStorage', () => { + const code = 'DEMO_CODE'; + WebUtils.setCodeVerifier(code); + const readCode = WebUtils.getCodeVerifier(); + expect(readCode).toBe(code); + expect(window.sessionStorage.getItem).toBeCalledWith( + `I_Capacitor_GenericOAuth2Plugin_PKCE`, + ); + }); + + it("should get null if code doesn't exist in sessionStorage", () => { + const readCode = WebUtils.getCodeVerifier(); + expect(readCode).toBeNull(); + expect(window.sessionStorage.getItem).toBeCalledWith( + `I_Capacitor_GenericOAuth2Plugin_PKCE`, + ); + }); + + it('should remove the code from sessionStorage', () => { + WebUtils.clearCodeVerifier(); + expect(window.sessionStorage.removeItem).toBeCalledWith( + `I_Capacitor_GenericOAuth2Plugin_PKCE`, + ); + }); +}); diff --git a/src/web-utils.ts b/src/web-utils.ts index 2a2437a..767a3b2 100644 --- a/src/web-utils.ts +++ b/src/web-utils.ts @@ -73,6 +73,23 @@ export class WebUtils { return body; } + static setCodeVerifier(code: string): boolean { + try { + window.sessionStorage.setItem(`I_Capacitor_GenericOAuth2Plugin_PKCE`, code); + return true; + } catch (err) { + return false; + } + } + + static clearCodeVerifier(): void { + window.sessionStorage.removeItem(`I_Capacitor_GenericOAuth2Plugin_PKCE`); + } + + static getCodeVerifier(): string | null { + return window.sessionStorage.getItem(`I_Capacitor_GenericOAuth2Plugin_PKCE`); + } + /** * Public only for testing */ @@ -175,7 +192,13 @@ export class WebUtils { this.getOverwritableValue(configOptions, 'sendCacheControlHeader') ?? webOptions.sendCacheControlHeader; if (webOptions.pkceEnabled) { - webOptions.pkceCodeVerifier = this.randomString(64); + const pkceCode = this.getCodeVerifier(); + if (pkceCode) { + webOptions.pkceCodeVerifier = pkceCode; + } else { + webOptions.pkceCodeVerifier = this.randomString(64); + this.setCodeVerifier(webOptions.pkceCodeVerifier); + } if (CryptoUtils.HAS_SUBTLE_CRYPTO) { await CryptoUtils.deriveChallenge(webOptions.pkceCodeVerifier).then( c => { diff --git a/src/web.ts b/src/web.ts index 3334128..03c0f6f 100644 --- a/src/web.ts +++ b/src/web.ts @@ -4,6 +4,7 @@ import type { OAuth2AuthenticateOptions, GenericOAuth2Plugin, OAuth2RefreshTokenOptions, + ImplicitFlowRedirectOptions, } from './definitions'; import type { WebOptions } from './web-utils'; import { WebUtils } from './web-utils'; @@ -26,6 +27,25 @@ export class GenericOAuth2Web extends WebPlugin implements GenericOAuth2Plugin { }); } + async redirectFlowCodeListener( + options: ImplicitFlowRedirectOptions, + ): Promise { + this.webOptions = await WebUtils.buildWebOptions(options); + return new Promise((resolve, reject) => { + const urlParamObj = WebUtils.getUrlParams(options.response_url); + if (urlParamObj) { + const code = urlParamObj.code; + if (code) { + this.getAccessToken(urlParamObj, resolve, reject, code); + } else { + reject(new Error('Oauth Code parameter was not present in url.')); + } + } else { + reject(new Error('Oauth Parameters where not present in url.')); + } + }); + } + async authenticate(options: OAuth2AuthenticateOptions): Promise { const windowOptions = WebUtils.buildWindowOptions(options); @@ -109,61 +129,14 @@ export class GenericOAuth2Web extends WebPlugin implements GenericOAuth2Plugin { this.webOptions.state ) { if (this.webOptions.accessTokenEndpoint) { - const self = this; const authorizationCode = authorizationRedirectUrlParamObj.code; if (authorizationCode) { - const tokenRequest = new XMLHttpRequest(); - tokenRequest.onload = function () { - if (this.status === 200) { - const accessTokenResponse = JSON.parse(this.response); - if (self.webOptions.logsEnabled) { - self.doLog( - 'Access token response:', - accessTokenResponse, - ); - } - self.requestResource( - accessTokenResponse.access_token, - resolve, - reject, - authorizationRedirectUrlParamObj, - accessTokenResponse, - ); - } - }; - tokenRequest.onerror = function () { - // always log error because of CORS hint - self.doLog( - 'ERR_GENERAL: See client logs. It might be CORS. Status text: ' + - this.statusText, - ); - reject(new Error('ERR_GENERAL')); - }; - tokenRequest.open( - 'POST', - this.webOptions.accessTokenEndpoint, - true, - ); - tokenRequest.setRequestHeader( - 'accept', - 'application/json', - ); - if (this.webOptions.sendCacheControlHeader) { - tokenRequest.setRequestHeader( - 'cache-control', - 'no-cache', - ); - } - tokenRequest.setRequestHeader( - 'content-type', - 'application/x-www-form-urlencoded', - ); - tokenRequest.send( - WebUtils.getTokenEndpointData( - this.webOptions, - authorizationCode, - ), + this.getAccessToken( + authorizationRedirectUrlParamObj, + resolve, + reject, + authorizationCode, ); } else { reject(new Error('ERR_NO_AUTHORIZATION_CODE')); @@ -202,6 +175,53 @@ export class GenericOAuth2Web extends WebPlugin implements GenericOAuth2Plugin { private readonly MSG_RETURNED_TO_JS = 'Returned to JS:'; + private getAccessToken( + authorizationRedirectUrlParamObj: { [p: string]: string } | undefined, + resolve: (value: any) => void, + reject: (reason?: any) => void, + authorizationCode: string, + ) { + const tokenRequest = new XMLHttpRequest(); + tokenRequest.onload = () => { + WebUtils.clearCodeVerifier(); + if (tokenRequest.status === 200) { + const accessTokenResponse = JSON.parse(tokenRequest.response); + if (this.webOptions.logsEnabled) { + this.doLog('Access token response:', accessTokenResponse); + } + this.requestResource( + accessTokenResponse.access_token, + resolve, + reject, + authorizationRedirectUrlParamObj, + accessTokenResponse, + ); + } + }; + tokenRequest.onerror = () => { + this.doLog( + 'ERR_GENERAL: See client logs. It might be CORS. Status text: ' + + tokenRequest.statusText, + ); + reject(new Error('ERR_GENERAL')); + }; + tokenRequest.open('POST', this.webOptions.accessTokenEndpoint, true); + tokenRequest.setRequestHeader('accept', 'application/json'); + if (this.webOptions.sendCacheControlHeader) { + tokenRequest.setRequestHeader( + 'cache-control', + 'no-cache', + ); + } + tokenRequest.setRequestHeader( + 'content-type', + 'application/x-www-form-urlencoded', + ); + tokenRequest.send( + WebUtils.getTokenEndpointData(this.webOptions, authorizationCode), + ); + } + private requestResource( accessToken: string, resolve: any,