From 2fc278106c1b2b4f4d45d75c9ad3f02588af1cbe Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Wed, 29 Mar 2023 13:57:47 +0800 Subject: [PATCH] feat: add pkce (#591) The corresponding client side implementation for: https://github.com/supabase/gotrue/pull/891 Points of note: - We don't support `plain` as a method on the client side for now. Users can make use of `plain` on the server side endpoints if they wish. TODO: - [x] Add support for devices without a `window` After PR: - Update reference spec with example on how to use PKCE --------- Co-authored-by: joel@joellee.org Co-authored-by: Stojan Dimitrovski Co-authored-by: Kang Ming --- package-lock.json | 26 +++++++++++++++++++- package.json | 4 ++- src/GoTrueClient.ts | 59 ++++++++++++++++++++++++++++++++++++++++++--- src/lib/helpers.ts | 41 ++++++++++++++++++++++++++++++- src/lib/types.ts | 3 +++ 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 71e748c8..97097bc1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "cross-fetch": "^3.1.5" + "cross-fetch": "^3.1.5", + "crypto-js": "^4.1.1" }, "devDependencies": { + "@types/crypto-js": "^4.1.1", "@types/faker": "^5.1.6", "@types/jest": "^28.1.6", "@types/jsonwebtoken": "^8.5.6", @@ -1248,6 +1250,12 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==", + "dev": true + }, "node_modules/@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -2299,6 +2307,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -7955,6 +7968,12 @@ "@types/node": "*" } }, + "@types/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-BG7fQKZ689HIoc5h+6D2Dgq1fABRa0RbBWKBd9SP/MVRVXROflpm5fhwyATX5duFmbStzyzyycPB8qUYKDH3NA==", + "dev": true + }, "@types/express": { "version": "4.17.13", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.13.tgz", @@ -8751,6 +8770,11 @@ "which": "^2.0.1" } }, + "crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index 29637c2f..9fc55b9c 100644 --- a/package.json +++ b/package.json @@ -36,9 +36,11 @@ "docs:json": "typedoc --json docs/v2/spec.json --excludeExternals --excludePrivate --excludeProtected src/index.ts" }, "dependencies": { - "cross-fetch": "^3.1.5" + "cross-fetch": "^3.1.5", + "crypto-js": "^4.1.1" }, "devDependencies": { + "@types/crypto-js": "^4.1.1", "@types/faker": "^5.1.6", "@types/jest": "^28.1.6", "@types/jsonwebtoken": "^8.5.6", diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 4b43ec22..06b50d15 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -23,9 +23,12 @@ import { uuid, retryable, sleep, + generatePKCEVerifier, + generatePKCEChallenge, } from './lib/helpers' import localStorageAdapter from './lib/local-storage' import { polyfillGlobalThis } from './lib/polyfills' + import type { AuthChangeEvent, AuthResponse, @@ -63,6 +66,7 @@ import type { AuthenticatorAssuranceLevels, Factor, MFAChallengeAndVerifyParams, + OAuthFlowType, } from './lib/types' polyfillGlobalThis() // Make "globalThis" available @@ -378,14 +382,43 @@ export default class GoTrueClient { */ async signInWithOAuth(credentials: SignInWithOAuthCredentials): Promise { await this._removeSession() - return this._handleProviderSignIn(credentials.provider, { + + return await this._handleProviderSignIn(credentials.provider, { redirectTo: credentials.options?.redirectTo, scopes: credentials.options?.scopes, queryParams: credentials.options?.queryParams, skipBrowserRedirect: credentials.options?.skipBrowserRedirect, + flowType: credentials.options?.flowType ?? 'implicit', }) } + /** + * Log in an existing user via a third-party provider. + */ + async exchangeCodeForSession(authCode: string): Promise { + const codeVerifier = await getItemAsync(this.storage, `${this.storageKey}-oauth-code-verifier`) + const { data, error } = await _request( + this.fetch, + 'POST', + `${this.url}/token?grant_type=oauth_pkce`, + { + headers: this.headers, + body: { + auth_code: authCode, + code_verifier: codeVerifier, + }, + xform: _sessionResponse, + } + ) + await removeItemAsync(this.storage, `${this.storageKey}-oauth-code-verifier`) + if (error || !data) return { data: { user: null, session: null }, error } + if (data.session) { + await this._saveSession(data.session) + this._notifyAllSubscribers('SIGNED_IN', data.session) + } + return { data, error } + } + /** * Allows signing in with an ID token issued by certain supported providers. * The ID token is verified for validity and a new session is established. @@ -1040,24 +1073,28 @@ export default class GoTrueClient { return isValidSession } - private _handleProviderSignIn( + private async _handleProviderSignIn( provider: Provider, options: { redirectTo?: string scopes?: string queryParams?: { [key: string]: string } skipBrowserRedirect?: boolean + flowType?: OAuthFlowType } = {} ) { - const url: string = this._getUrlForProvider(provider, { + + const url: string = await this._getUrlForProvider(provider, { redirectTo: options.redirectTo, scopes: options.scopes, queryParams: options.queryParams, + flowType: options.flowType, }) // try to open on the browser if (isBrowser() && !options.skipBrowserRedirect) { window.location.assign(url) } + return { data: { provider, url }, error: null } } @@ -1355,13 +1392,15 @@ export default class GoTrueClient { * @param options.redirectTo A URL or mobile address to send the user to after they are confirmed. * @param options.scopes A space-separated list of scopes granted to the OAuth application. * @param options.queryParams An object of key-value pairs containing query parameters granted to the OAuth application. + * @param options.flowType OAuth flow to use - defaults to implicit flow. PKCE is recommended for mobile and server-side applications. */ - private _getUrlForProvider( + private async _getUrlForProvider( provider: Provider, options: { redirectTo?: string scopes?: string queryParams?: { [key: string]: string } + flowType: OAuthFlowType } ) { const urlParams: string[] = [`provider=${encodeURIComponent(provider)}`] @@ -1371,10 +1410,22 @@ export default class GoTrueClient { if (options?.scopes) { urlParams.push(`scopes=${encodeURIComponent(options.scopes)}`) } + if (options?.flowType === 'pkce') { + const codeVerifier = await generatePKCEVerifier() + await setItemAsync(this.storage, `${this.storageKey}-oauth-code-verifier`, codeVerifier) + const codeChallenge = await generatePKCEChallenge(codeVerifier) + const flowParams = new URLSearchParams({ + flow_type: `${encodeURIComponent(options.flowType)}`, + code_challenge: `${encodeURIComponent(codeChallenge)}`, + code_challenge_method: `${encodeURIComponent('s256')}`, + }) + urlParams.push(flowParams.toString()) + } if (options?.queryParams) { const query = new URLSearchParams(options.queryParams) urlParams.push(query.toString()) } + return `${this.url}/authorize?${urlParams.join('&')}` } diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 6cc0a10c..9517ff3a 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,5 +1,5 @@ import { SupportedStorage } from './types' - +import sha256CryptoJS from 'crypto-js/sha256' export function expiresAt(expiresIn: number) { const timeNow = Math.round(Date.now() / 1000) return timeNow + expiresIn @@ -236,3 +236,42 @@ export function retryable( return promise } + +function dec2hex(dec: number) { + return ('0' + dec.toString(16)).substr(-2) +} + +// Functions below taken from: https://stackoverflow.com/questions/63309409/creating-a-code-verifier-and-challenge-for-pkce-auth-on-spotify-api-in-reactjs +export async function generatePKCEVerifier() { + const verifierLength = 56 + const array = new Uint32Array(verifierLength) + if (!isBrowser()) { + for (let i = 0; i < verifierLength; i++) { + array[i] = Math.floor(Math.random() * 256) + } + } + return Array.from(array, dec2hex).join('') +} + +async function sha256(randomString: string) { + const encoder = new TextEncoder() + const encodedData = encoder.encode(randomString) + if (!isBrowser()) { + return sha256CryptoJS(randomString).toString() + } + const hash = await window.crypto.subtle.digest('SHA-256', encodedData) + const bytes = new Uint8Array(hash) + + return Array.from(bytes) + .map((c) => String.fromCharCode(c)) + .join('') +} + +function base64urlencode(str: string) { + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export async function generatePKCEChallenge(verifier: string) { + const hashed = await sha256(verifier) + return base64urlencode(hashed) +} diff --git a/src/lib/types.ts b/src/lib/types.ts index b9ae1ea5..bf87fd56 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -429,6 +429,7 @@ export type SignInWithPasswordlessCredentials = } } +export type OAuthFlowType = 'implicit' | 'pkce' export type SignInWithOAuthCredentials = { /** One of the providers supported by GoTrue. */ provider: Provider @@ -441,6 +442,8 @@ export type SignInWithOAuthCredentials = { queryParams?: { [key: string]: string } /** If set to true does not immediately redirect the current browser context to visit the OAuth authorization page for the provider. */ skipBrowserRedirect?: boolean + /** If set to 'pkce' PKCE flow. Defaults to the 'implicit' flow otherwise */ + flowType?: OAuthFlowType } }