Skip to content

Commit

Permalink
Merge pull request #55 from bpedroza/2.x
Browse files Browse the repository at this point in the history
Make sure state and code verifier are unique per request
  • Loading branch information
bpedroza authored Dec 22, 2024
2 parents 5773af1 + 4682510 commit ba03365
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 176 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
node_modules
/dist
.DS_Store
.vscode
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
![Build Status](https://github.com/bpedroza/js-pkce/actions/workflows/run-tests.yml/badge.svg)

# js-pkce
A package that makes using the OAuth2 PKCE flow easier
A package that makes using the OAuth2 PKCE flow easier.

This package is implemented according to [the specification: rfc7636](https://datatracker.ietf.org/doc/html/rfc7636).

## Use from CDN
Since version 1.3.0 this package is available to be used from a CDN:

https://cdn.jsdelivr.net/npm/js-pkce/dist/browser.js

Explicit version example:

https://cdn.jsdelivr.net/npm/js-pkce@1.5/dist/browser.js

## Installation
`npm i js-pkce`
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "js-pkce",
"version": "1.5.1",
"version": "2.0.0",
"description": "A package that makes using the OAuth2 PKCE flow easier",
"main": "dist/PKCE.js",
"types": "dist/PKCE.d.ts",
Expand Down
209 changes: 117 additions & 92 deletions src/PKCE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import ITokenResponse from './ITokenResponse';
import ICorsOptions from './ICorsOptions';

export default class PKCE {
private readonly STATE_KEY: string = 'pkce_state';
private readonly CODE_VERIFIER_KEY: string = 'pkce_code_verifier';

private config: IConfig;
private state: string = '';
private codeVerifier: string = '';
private corsRequestOptions: ICorsOptions = {};

/**
Expand All @@ -21,6 +22,30 @@ export default class PKCE {
this.config = config;
}

/**
* Generate the authorize url
* @param {object} additionalParams include additional parameters in the query
* @return Promise<string>
*/
public authorizeUrl(additionalParams: IObject = {}): string {
this.setCodeVerifier();
this.setState(additionalParams.state || null);
const codeChallenge = this.pkceChallengeFromVerifier();

const queryString = new URLSearchParams({
response_type: 'code',
client_id: this.config.client_id,
state: this.getState(),
scope: this.config.requested_scopes,
redirect_uri: this.config.redirect_uri,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
...additionalParams,
}).toString();

return `${this.config.authorization_endpoint}?${queryString}`;
}

/**
* Allow the user to enable cross domain cors requests
* @param enable turn the cross domain request options on.
Expand All @@ -37,69 +62,68 @@ export default class PKCE {
}

/**
* Generate the authorize url
* @param {object} additionalParams include additional parameters in the query
* @return Promise<string>
* Given the return url, get a token from the oauth server
* @param url current urlwith params from server
* @param {object} additionalParams include additional parameters in the request body
* @return {Promise<ITokenResponse>}
*/
public authorizeUrl(additionalParams: IObject = {}): string {
const codeChallenge = this.pkceChallengeFromVerifier();
public async exchangeForAccessToken(url: string, additionalParams: IObject = {}): Promise<ITokenResponse> {
const { code } = await this.parseAuthResponseUrl(url);
const response = await fetch(this.config.token_endpoint, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: this.config.client_id,
redirect_uri: this.config.redirect_uri,
code_verifier: this.getCodeVerifier(),
...additionalParams,
}),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
...this.corsRequestOptions,
});

const queryString = new URLSearchParams(
Object.assign(
{
response_type: 'code',
client_id: this.config.client_id,
state: this.getState(additionalParams.state || null),
scope: this.config.requested_scopes,
redirect_uri: this.config.redirect_uri,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
},
additionalParams,
),
).toString();
return await response.json();
}

return `${this.config.authorization_endpoint}?${queryString}`;
/**
* Get the current codeVerifier
* @return {string}
*/
public getCodeVerifier(): string {
const codeVerifier = this.getStore().getItem(this.CODE_VERIFIER_KEY);

if (null === codeVerifier) {
throw new Error('Code Verifier not set.');
}

return codeVerifier;
}

/**
* Given the return url, get a token from the oauth server
* @param url current urlwith params from server
* @param {object} additionalParams include additional parameters in the request body
* @return {Promise<ITokenResponse>}
* Get the current state
* @return {string}
*/
public exchangeForAccessToken(url: string, additionalParams: IObject = {}): Promise<ITokenResponse> {
return this.parseAuthResponseUrl(url).then((q) => {
return fetch(this.config.token_endpoint, {
method: 'POST',
body: new URLSearchParams(
Object.assign(
{
grant_type: 'authorization_code',
code: q.code,
client_id: this.config.client_id,
redirect_uri: this.config.redirect_uri,
code_verifier: this.getCodeVerifier(),
},
additionalParams,
),
),
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
...this.corsRequestOptions,
}).then((response) => response.json());
});
public getState(): string {
const state = this.getStore().getItem(this.STATE_KEY);

if (null === state) {
throw new Error('State not set.');
}

return state;
}

/**
* Given a refresh token, return a new token from the oauth server
* @param refreshTokens current refresh token from server
* @return {Promise<ITokenResponse>}
*/
public refreshAccessToken(refreshToken: string): Promise<ITokenResponse> {
return fetch(this.config.token_endpoint, {
public async refreshAccessToken(refreshToken: string): Promise<ITokenResponse> {
const response = await fetch(this.config.token_endpoint, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
Expand All @@ -110,29 +134,46 @@ export default class PKCE {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
}).then((response) => response.json());
});

return await response.json();
}

public revokeToken(tokenToExpire: string, hint: string = ''): Promise<boolean> {
/**
* Revoke an existing token.
* Optionally send a token_type_hint as second parameter
* @param {string} tokenToExpire the token to be expired
* @param {string} hint when not empty, token_type_hint will be sent with request
* @returns
*/
public async revokeToken(tokenToExpire: string, hint: string = ''): Promise<boolean> {
this.checkEndpoint('revoke_endpoint');

const params = new URLSearchParams({
token: tokenToExpire,
client_id: this.config.client_id,
});

if (hint.length) {
params.append('token_type_hint', hint);
}
return fetch(this.config.revoke_endpoint, {

const response = await fetch(this.config.revoke_endpoint, {
method: 'POST',
body: params,
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
},
})
.then((response) => response.ok)
.catch(() => false);
});

return response.ok;
}

/**
* Check if an endpoint from configuration is set and using https protocol
* Allow http on localhost
* @param {string} propertyName the key of the item in configuration to check
*/
private checkEndpoint(propertyName: string) {
if (!this.config.hasOwnProperty(propertyName)) {
throw new Error(`${propertyName} not configured.`);
Expand All @@ -146,33 +187,11 @@ export default class PKCE {
}

/**
* Get the current codeVerifier or generate a new one
* Generate a random string
* @return {string}
*/
private getCodeVerifier(): string {
if (this.codeVerifier === '') {
this.codeVerifier = this.randomStringFromStorage('pkce_code_verifier');
}

return this.codeVerifier;
}

/**
* Get the current state or generate a new one
* @return {string}
*/
private getState(explicit: string = null): string {
const stateKey = 'pkce_state';

if (explicit !== null) {
this.getStore().setItem(stateKey, explicit);
}

if (this.state === '') {
this.state = this.randomStringFromStorage(stateKey);
}

return this.state;
private generateRandomString(): string {
return WordArray.random(64);
}

/**
Expand Down Expand Up @@ -201,17 +220,22 @@ export default class PKCE {
}

/**
* Get a random string from storage or store a new one and return it's value
* @param {string} key
* @return {string}
* Set the code verifier in storage to a random string
* @return {void}
*/
private randomStringFromStorage(key: string): string {
const fromStorage = this.getStore().getItem(key);
if (fromStorage === null) {
this.getStore().setItem(key, WordArray.random(64));
}
private setCodeVerifier(): void {
this.getStore().setItem(this.CODE_VERIFIER_KEY, this.generateRandomString());
}

return this.getStore().getItem(key) || '';
/**
* Set the state in storage to a random string.
* Optionally set an explicit state
* @param {string | null} explicit when set, we will use this value for the state value
* @return {void}
*/
private setState(explicit: string | null = null): void {
const value = explicit !== null ? explicit : this.generateRandomString();
this.getStore().setItem(this.STATE_KEY, value);
}

/**
Expand All @@ -234,7 +258,8 @@ export default class PKCE {
}

/**
* Get the storage (sessionStorage / localStorage) to use, defaults to sessionStorage
* Get the instance of Storage interface to use.
* Defaults to sessionStorage.
* @return {Storage}
*/
private getStore(): Storage {
Expand Down
Loading

0 comments on commit ba03365

Please # to comment.