UNPKG

@badgateway/oauth2-client

Version:

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

270 lines (210 loc) 7.2 kB
import { OAuth2Client } from '../client.ts'; import type { OAuth2Token } from '../token.ts'; import type { AuthorizationCodeRequest } from '../messages.ts'; import { OAuth2Error } from '../error.ts'; 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[]; /** * The resource the client intends to access. * * This is defined in RFC 8707. */ resource?: string[] | string; /** * Any parameters listed here will be added to the query string for the authorization server endpoint. */ extraParams?: Record<string, string>; /** * By default response parameters for the authorization_flow will be added * to the query string. * * Some servers let you put this in the fragment instead. This may be * benefical if your client is a browser, as embedding the authorization * code in the fragment part of the URI prevents it from being sent back * to the server. * * See: https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html */ responseMode?: 'query' | 'fragment'; } 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[]; } type GetTokenParams = { code: string; redirectUri: string; state?: string; codeVerifier?:string; /** * The resource the client intends to access. * * @see https://datatracker.ietf.org/doc/html/rfc8707 */ resource?: string[] | 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 = new URLSearchParams({ client_id: this.client.settings.clientId, response_type: 'code', redirect_uri: params.redirectUri, }); if (codeChallenge) { query.set('code_challenge_method', codeChallenge[0]); query.set('code_challenge', codeChallenge[1]); } if (params.state) { query.set('state', params.state); } if (params.scope) { query.set('scope', params.scope.join(' ')); } if (params.resource) for(const resource of [].concat(params.resource as any)) { query.append('resource', resource); } if (params.responseMode && params.responseMode!=='query') { query.append('response_mode', params.responseMode); } if (params.extraParams) for(const [k,v] of Object.entries(params.extraParams)) { if (query.has(k)) throw new Error(`Property in extraParams would overwrite standard property: ${k}`); query.set(k, v); } return authorizationEndpoint + '?' + query.toString(); } async getTokenFromCodeRedirect(url: string|URL, params: Omit<GetTokenParams, 'code'> ): Promise<OAuth2Token> { const { code } = 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. */ validateResponse(url: string|URL, params: {state?: string}): ValidateResponseResult { url = new URL(url); let queryParams = url.searchParams; if (!queryParams.has('code') && !queryParams.has('error') && url.hash.length>0) { // Try the fragment queryParams = new URLSearchParams(url.hash.slice(1)); } if (queryParams.has('error')) { throw new OAuth2Error( queryParams.get('error_description') ?? 'OAuth2 error', queryParams.get('error') as any, ); } 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: GetTokenParams): Promise<OAuth2Token> { const body:AuthorizationCodeRequest = { grant_type: 'authorization_code', code: params.code, redirect_uri: params.redirectUri, code_verifier: params.codeVerifier, resource: params.resource, }; return this.client.tokenResponseToOAuth2Token(this.client.request('tokenEndpoint', body)); } } export async function generateCodeVerifier(): Promise<string> { const webCrypto = await getWebCrypto(); const arr = new Uint8Array(32); webCrypto.getRandomValues(arr); return base64Url(arr); } export async function getCodeChallenge(codeVerifier: string): Promise<['plain' | 'S256', string]> { const webCrypto = await getWebCrypto(); return ['S256', base64Url(await webCrypto.subtle.digest('SHA-256', stringToBuffer(codeVerifier)))]; } async function getWebCrypto(): Promise<typeof window.crypto> { // Browsers if ((typeof window !== 'undefined' && window.crypto)) { if (!window.crypto.subtle?.digest) { throw new Error( "The context/environment is not secure, and does not support the 'crypto.subtle' module. See: https://developer.mozilla.org/en-US/docs/Web/API/Crypto/subtle for details" ); } 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 = await import('crypto'); return crypto.webcrypto as typeof window.crypto; } 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(/=+$/, '') ); }