@bc-koenro/oauth2-client
Version:
OAuth2 client for browsers and Node.js. Tiny footprint, PKCE support
231 lines (178 loc) • 6.22 kB
text/typescript
import { OAuth2Client, tokenResponseToOAuth2Token, generateQueryString } from '../client';
import { OAuth2Token } from '../token';
import { AuthorizationCodeRequest, AuthorizationQueryParams } from '../messages';
import { OAuth2Error } from '../error';
type GetAuthorizeUrlParams = {
/**
* Where to redirect the user back to after authentication.
*/
redirectUri: string;
/**
* The 'state' is a string that can be sent to the authentication server,
* and back to the redirectUri.
*/
state?: string;
/**
* Code verifier for PKCE support. If you used this in the redirect
* to the authorization endpoint, you also need to use this again
* when getting the access_token on the token endpoint.
*/
codeVerifier?: string;
/**
* List of scopes.
*/
scope?: string[];
}
type ValidateResponseResult = {
/**
* The authorization code. This code should be used to obtain an access token.
*/
code: string;
/**
* List of scopes that the client requested.
*/
scope?: string[];
}
export class OAuth2AuthorizationCodeClient {
client: OAuth2Client;
constructor(client: OAuth2Client) {
this.client = client;
}
/**
* Returns the URi that the user should open in a browser to initiate the
* authorization_code flow.
*/
async getAuthorizeUri(params: GetAuthorizeUrlParams): Promise<string> {
const [
codeChallenge,
authorizationEndpoint
] = await Promise.all([
params.codeVerifier ? getCodeChallenge(params.codeVerifier) : undefined,
this.client.getEndpoint('authorizationEndpoint')
]);
const query: AuthorizationQueryParams = {
client_id: this.client.settings.clientId,
response_type: 'code',
redirect_uri: params.redirectUri,
code_challenge_method: codeChallenge?.[0],
code_challenge: codeChallenge?.[1],
};
if (params.state) {
query.state = params.state;
}
if (params.scope) {
query.scope = params.scope.join(' ');
}
return authorizationEndpoint + '?' + generateQueryString(query);
}
async getTokenFromCodeRedirect(url: string|URL, params: {redirectUri: string; state?: string; codeVerifier?:string} ): Promise<OAuth2Token> {
const { code } = await this.validateResponse(url, {
state: params.state
});
return this.getToken({
code,
redirectUri: params.redirectUri,
codeVerifier: params.codeVerifier,
});
}
/**
* After the user redirected back from the authorization endpoint, the
* url will contain a 'code' and other information.
*
* This function takes the url and validate the response. If the user
* redirected back with an error, an error will be thrown.
*/
async validateResponse(url: string|URL, params: {state?: string}): Promise<ValidateResponseResult> {
const queryParams = new URL(url).searchParams;
if (queryParams.has('error')) {
throw new OAuth2Error(
queryParams.get('error_description') ?? 'OAuth2 error',
queryParams.get('error')!,
0,
);
}
if (!queryParams.has('code')) throw new Error(`The url did not contain a code parameter ${url}`);
if (params.state && params.state !== queryParams.get('state')) {
throw new Error(`The "state" parameter in the url did not match the expected value of ${params.state}`);
}
return {
code: queryParams.get('code')!,
scope: queryParams.has('scope') ? queryParams.get('scope')!.split(' ') : undefined,
};
}
/**
* Receives an OAuth2 token using 'authorization_code' grant
*/
async getToken(params: { code: string; redirectUri: string; codeVerifier?: string }): Promise<OAuth2Token> {
const body:AuthorizationCodeRequest = {
grant_type: 'authorization_code',
code: params.code,
redirect_uri: params.redirectUri,
code_verifier: params.codeVerifier,
};
return tokenResponseToOAuth2Token(this.client.request('tokenEndpoint', body));
}
}
export async function generateCodeVerifier(): Promise<string> {
const webCrypto = getWebCrypto();
if (webCrypto) {
const arr = new Uint8Array(32);
webCrypto.getRandomValues(arr);
return base64Url(arr);
} else {
// Old node doesn't have 'webcrypto', so this is a fallback
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodeCrypto = require('crypto');
return new Promise<string>((res, rej) => {
nodeCrypto.randomBytes(32, (err:Error, buf: Buffer) => {
if (err) rej(err);
res(buf.toString('base64url'));
});
});
}
}
export async function getCodeChallenge(codeVerifier: string): Promise<['plain' | 'S256', string]> {
const webCrypto = getWebCrypto();
if (webCrypto?.subtle) {
return ['S256', base64Url(await webCrypto.subtle.digest('SHA-256', stringToBuffer(codeVerifier)))];
} else {
// Node 14.x fallback
// eslint-disable-next-line @typescript-eslint/no-var-requires
const nodeCrypto = require('crypto');
const hash = nodeCrypto.createHash('sha256');
hash.update(stringToBuffer(codeVerifier));
return ['S256', hash.digest('base64url')];
}
}
function getWebCrypto() {
// Browsers
if ((typeof window !== 'undefined' && window.crypto)) {
return window.crypto;
}
// Web workers possibly
if ((typeof self !== 'undefined' && self.crypto)) {
return self.crypto;
}
// Node
// eslint-disable-next-line @typescript-eslint/no-var-requires
const crypto = require('crypto');
if (crypto.webcrypto) {
return crypto.webcrypto;
}
return null;
}
function stringToBuffer(input: string): ArrayBuffer {
const buf = new Uint8Array(input.length);
for(let i=0; i<input.length;i++) {
buf[i] = input.charCodeAt(i) & 0xFF;
}
return buf;
}
function base64Url(buf: ArrayBuffer) {
return (
btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
);
}