Skip to content

Commit

Permalink
feat(): enable implicit redirect flow on web (#267)
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardoRoth committed Jul 31, 2024
1 parent 159e4d0 commit a839ca1
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 53 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export interface GenericOAuth2Plugin {
* @returns {Promise<any>} the resource url response
*/
authenticate(options: OAuth2AuthenticateOptions): Promise<any>;
/**
* Listens for OAuth implicit redirect flow queryString CODE to generate an access_token
* @param {OAuth2RedirectAuthenticationOptions} options
* @returns {Promise<any>} the token endpoint response
*/
redirectFlowCodeListener(
options: ImplicitFlowRedirectOptions,
): Promise<any>;
/**
* Get a new access token based on the given refresh token.
* @param {OAuth2RefreshTokenOptions} options
Expand All @@ -23,6 +31,13 @@ export interface GenericOAuth2Plugin {
): Promise<boolean>;
}

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,...
Expand Down
91 changes: 91 additions & 0 deletions src/web-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -57,6 +72,15 @@ const oneDriveOptions: OAuth2AuthenticateOptions = {
},
};

const implicitFlowOptions: OAuth2AuthenticateOptions = {
...oneDriveOptions,
pkceEnabled: true,
web: {
...oneDriveOptions.web,
pkceEnabled: true,
},
};

const redirectUrlOptions: OAuth2AuthenticateOptions = {
appId: 'appId',
authorizationBaseUrl:
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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`,
);
});
});
25 changes: 24 additions & 1 deletion src/web-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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 => {
Expand Down
124 changes: 72 additions & 52 deletions src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
OAuth2AuthenticateOptions,
GenericOAuth2Plugin,
OAuth2RefreshTokenOptions,
ImplicitFlowRedirectOptions,
} from './definitions';
import type { WebOptions } from './web-utils';
import { WebUtils } from './web-utils';
Expand All @@ -26,6 +27,25 @@ export class GenericOAuth2Web extends WebPlugin implements GenericOAuth2Plugin {
});
}

async redirectFlowCodeListener(
options: ImplicitFlowRedirectOptions,
): Promise<any> {
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<any> {
const windowOptions = WebUtils.buildWindowOptions(options);

Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit a839ca1

Please sign in to comment.