Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(): enable implicit redirect flow on web #267

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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