UNPKG

@bc-koenro/oauth2-client

Version:

OAuth2 client for browsers and Node.js. Tiny footprint, PKCE support

231 lines (178 loc) 6.22 kB
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(/=+$/, '') ); }