From daa266949b336d2e78f2a7b9c9837b70abeab7a6 Mon Sep 17 00:00:00 2001 From: Kang Ming Date: Mon, 10 Feb 2025 08:24:05 -0800 Subject: [PATCH] feat: introduce getClaims method to verify asymmetric JWTs (#1030) ## What kind of change does this PR introduce? * `getClaims` supports verifying JWTs (both asymmetric and symmetric) and returns the entire set of claims in the JWT payload --------- Co-authored-by: Stojan Dimitrovski --- infra/docker-compose.yml | 45 +++++- src/GoTrueClient.ts | 156 +++++++++++++++++--- src/lib/base64url.ts | 290 ++++++++++++++++++++++++++++++++++++++ src/lib/constants.ts | 2 + src/lib/errors.ts | 6 + src/lib/helpers.ts | 105 ++++++++------ src/lib/types.ts | 29 ++++ test/GoTrueClient.test.ts | 69 +++++++++ test/base64url.test.ts | 77 ++++++++++ test/helpers.test.ts | 156 +++++++++++++++++++- test/lib/clients.ts | 9 ++ 11 files changed, 873 insertions(+), 71 deletions(-) create mode 100644 src/lib/base64url.ts create mode 100644 test/base64url.test.ts diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 099ca28a8..29d4d1ed6 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -32,12 +32,12 @@ services: GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com GOTRUE_MAILER_SUBJECTS_CONFIRMATION: 'Please confirm' GOTRUE_EXTERNAL_PHONE_ENABLED: 'true' - GOTRUE_SMS_PROVIDER: "twilio" - GOTRUE_SMS_TWILIO_ACCOUNT_SID: "${GOTRUE_SMS_TWILIO_ACCOUNT_SID}" - GOTRUE_SMS_TWILIO_AUTH_TOKEN: "${GOTRUE_SMS_TWILIO_AUTH_TOKEN}" - GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: "${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}" + GOTRUE_SMS_PROVIDER: 'twilio' + GOTRUE_SMS_TWILIO_ACCOUNT_SID: '${GOTRUE_SMS_TWILIO_ACCOUNT_SID}' + GOTRUE_SMS_TWILIO_AUTH_TOKEN: '${GOTRUE_SMS_TWILIO_AUTH_TOKEN}' + GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: '${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}' GOTRUE_SMS_AUTOCONFIRM: 'false' - GOTRUE_COOKIE_KEY: "sb" + GOTRUE_COOKIE_KEY: 'sb' depends_on: - db restart: on-failure @@ -47,6 +47,7 @@ services: - '9998:9998' environment: GOTRUE_JWT_SECRET: '37c304f8-51aa-419a-a1af-06154e63707a' + GOTRUE_JWT_KEYS: '[{"kty":"oct","k":"Z7-AyPyChGNcQsX16cPBV-pPBo4q-zckDxkq1VZjATo","kid":"12580317-221c-49b6-894a-f4473b8afe39","key_ops":["sign", "verify"],"alg":"HS256"},{"kty":"RSA","n":"y3KQnIXK6wkPQ5m0XWp7z54BNZzXJk4IxXy81zFophdBBqz6u5OCMqWkC6i3WB7rlax4xjmxxyGyYRODooqCQTGahmpXryAAKc3g-gDIAq2MqVwlpmvXDavCVRK4hK7DZ6wK4MHrliSNHCuCkwIH3ofxTxgUwpSkOT58iU1ZOua5E1Y6R_Ozt3gLHha0Xa7a4V23pkP7n0xBvJPzIqiS3MZ4CQ_pz-buXYRgCPQkUJvXFFcuxmyqoYzorwQ1YVBOmH2XMx26RrCIxgj7geo9eVQ9u5qCPpQCGV5biqYMC4_m1kurOGf62URGRzXtmVzrW1PZJAeGoqMz5Fcfr8hiwQ","e":"AQAB","d":"C4XxquvpEmbw9mM-VAwz9w58Aw1fIkxJMuZdy9KAmue2RyqFCRrRxQycvgxQVi1qKpAaRx_9ccn20IjKa-psdkTY-8QKM2EcoUGH_KEOsxghX3ZYq5RwGdYgq7DjwqAjcTvNYe2Z6mcnlvDf9HOo_nG0uUYj5uGEa7meVCiNZUiSVdNGs-vOTUD8yB5pbZ4ute8ebuUzCWGQ3YwSNoWLa-dbECSO7jeobCapdB52MjEwE3_Ii8BWoySeDP-DEFX_5RTM2Zeh81zXAgmOxpZYTkjMsrznyxxBbXn7CdT8WMEXrreGZwIt3Mu6XpsLF5mwmTQ_ZyoM6tJpn5LeAhnCAQ","p":"6xy1skrnlrGUWtZFSHixn_eRA_O3GXKNBE4wziWodGZaFYsmFijZHbuQT0WFqc0epvLHNdNPvubFrVfV-U7ZIarfSSq6qBwBzDrDQS060MvjJIjrI16pKlx2X727FR1ZuwxT27dNg-wRTgKcZqXEalkvFOTEYBlCtw2-vzI0aRs","q":"3YWwOAs4GRZ9eq_fqNujACWJFyUO9QgEDPDOMg0EZhY7WkAlehTxxVXg65spWnfx_0GSc72I5N5qdbY-yDh2Dl7zIxvwnqZaKMJn4PEFkeAfyg62XlJlkHIwOVSj6vLNUDdDmG7bO2k6MyQ59jeuAemIljf9WhALNy8c9R0K3VM","dp":"KJ4LHcQnAjeng5Hk4kJHnXUtjls6VKEfj5DaiaKj2YgdI_-oEsf3ylUu9yLxloYjN4BVvgzFiBtiJzI3exyOEmzsqj1Bhe1guiGkvcvMj2nJ0fP9e1zNKM5UfPHQMjOh3tigXCLst0-_JZT55BnbNuw1YAytiFSU2_755xoLR-U","dq":"dCP7V-bJ6p1X_FLpOGau9wy262OKi_0_4mj-Mk-Q1tUhGRg4jeEdQRDdc6lN7Rilz-ZZGkVs2FGkD0MVd3PisXYmk2m6pfMhoe0K-WxkNy8Ce7Vq99jLVwgHMIenyS6zZjMTRYAZgPSShu2fVe-rU2VVLyz7r5RpzOzuibRIVfE","qi":"i7ND2teiVLkbaAs6rHfo5DiD1nlsORNYnn8Y_FjF6utb5OUljZ6-5WyEDJN9oIUX8o_Il9E6js-z7nhvPfFZHQN7ZWuYI0rO5qmsCDS9jWJ4GR61SgzZuLT7Jpp_KtwjW70x5wZ1Y-GugOP1Wct1YZWHn5YyLhvO6X_vttSmcS0","kid":"638c54b8-28c2-4b12-9598-ba12ef610a29","key_ops":["verify"],"alg":"RS256"}]' GOTRUE_JWT_EXP: 3600 GOTRUE_DB_DRIVER: postgres DB_NAMESPACE: auth @@ -66,7 +67,37 @@ services: GOTRUE_SMTP_USER: GOTRUE_SMTP_USER GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com - GOTRUE_COOKIE_KEY: "sb" + GOTRUE_COOKIE_KEY: 'sb' + depends_on: + - db + restart: on-failure + autoconfirm_with_asymmetric_keys: # Signup enabled, autoconfirm on + image: supabase/auth:v2.169.0 + ports: + - '9996:9996' + environment: + GOTRUE_JWT_SECRET: 'Z7-AyPyChGNcQsX16cPBV-pPBo4q-zckDxkq1VZjATo' + GOTRUE_JWT_KEYS: '[{"kty":"oct","k":"Z7-AyPyChGNcQsX16cPBV-pPBo4q-zckDxkq1VZjATo","kid":"12580317-221c-49b6-894a-f4473b8afe39","key_ops":["verify"],"alg":"HS256"},{"kty":"RSA","n":"y3KQnIXK6wkPQ5m0XWp7z54BNZzXJk4IxXy81zFophdBBqz6u5OCMqWkC6i3WB7rlax4xjmxxyGyYRODooqCQTGahmpXryAAKc3g-gDIAq2MqVwlpmvXDavCVRK4hK7DZ6wK4MHrliSNHCuCkwIH3ofxTxgUwpSkOT58iU1ZOua5E1Y6R_Ozt3gLHha0Xa7a4V23pkP7n0xBvJPzIqiS3MZ4CQ_pz-buXYRgCPQkUJvXFFcuxmyqoYzorwQ1YVBOmH2XMx26RrCIxgj7geo9eVQ9u5qCPpQCGV5biqYMC4_m1kurOGf62URGRzXtmVzrW1PZJAeGoqMz5Fcfr8hiwQ","e":"AQAB","d":"C4XxquvpEmbw9mM-VAwz9w58Aw1fIkxJMuZdy9KAmue2RyqFCRrRxQycvgxQVi1qKpAaRx_9ccn20IjKa-psdkTY-8QKM2EcoUGH_KEOsxghX3ZYq5RwGdYgq7DjwqAjcTvNYe2Z6mcnlvDf9HOo_nG0uUYj5uGEa7meVCiNZUiSVdNGs-vOTUD8yB5pbZ4ute8ebuUzCWGQ3YwSNoWLa-dbECSO7jeobCapdB52MjEwE3_Ii8BWoySeDP-DEFX_5RTM2Zeh81zXAgmOxpZYTkjMsrznyxxBbXn7CdT8WMEXrreGZwIt3Mu6XpsLF5mwmTQ_ZyoM6tJpn5LeAhnCAQ","p":"6xy1skrnlrGUWtZFSHixn_eRA_O3GXKNBE4wziWodGZaFYsmFijZHbuQT0WFqc0epvLHNdNPvubFrVfV-U7ZIarfSSq6qBwBzDrDQS060MvjJIjrI16pKlx2X727FR1ZuwxT27dNg-wRTgKcZqXEalkvFOTEYBlCtw2-vzI0aRs","q":"3YWwOAs4GRZ9eq_fqNujACWJFyUO9QgEDPDOMg0EZhY7WkAlehTxxVXg65spWnfx_0GSc72I5N5qdbY-yDh2Dl7zIxvwnqZaKMJn4PEFkeAfyg62XlJlkHIwOVSj6vLNUDdDmG7bO2k6MyQ59jeuAemIljf9WhALNy8c9R0K3VM","dp":"KJ4LHcQnAjeng5Hk4kJHnXUtjls6VKEfj5DaiaKj2YgdI_-oEsf3ylUu9yLxloYjN4BVvgzFiBtiJzI3exyOEmzsqj1Bhe1guiGkvcvMj2nJ0fP9e1zNKM5UfPHQMjOh3tigXCLst0-_JZT55BnbNuw1YAytiFSU2_755xoLR-U","dq":"dCP7V-bJ6p1X_FLpOGau9wy262OKi_0_4mj-Mk-Q1tUhGRg4jeEdQRDdc6lN7Rilz-ZZGkVs2FGkD0MVd3PisXYmk2m6pfMhoe0K-WxkNy8Ce7Vq99jLVwgHMIenyS6zZjMTRYAZgPSShu2fVe-rU2VVLyz7r5RpzOzuibRIVfE","qi":"i7ND2teiVLkbaAs6rHfo5DiD1nlsORNYnn8Y_FjF6utb5OUljZ6-5WyEDJN9oIUX8o_Il9E6js-z7nhvPfFZHQN7ZWuYI0rO5qmsCDS9jWJ4GR61SgzZuLT7Jpp_KtwjW70x5wZ1Y-GugOP1Wct1YZWHn5YyLhvO6X_vttSmcS0","kid":"638c54b8-28c2-4b12-9598-ba12ef610a29","key_ops":["sign","verify"],"alg":"RS256"}]' + GOTRUE_JWT_EXP: 3600 + GOTRUE_DB_DRIVER: postgres + DB_NAMESPACE: auth + GOTRUE_API_HOST: 0.0.0.0 + PORT: 9996 + GOTRUE_DISABLE_SIGNUP: 'false' + API_EXTERNAL_URL: http://localhost:9996 + GOTRUE_SITE_URL: http://localhost:9996 + GOTRUE_MAILER_AUTOCONFIRM: 'true' + GOTRUE_SMS_AUTOCONFIRM: 'true' + GOTRUE_LOG_LEVEL: DEBUG + GOTRUE_OPERATOR_TOKEN: super-secret-operator-token + DATABASE_URL: 'postgres://postgres:postgres@db:5432/postgres?sslmode=disable' + GOTRUE_EXTERNAL_PHONE_ENABLED: 'true' + GOTRUE_SMTP_HOST: mail + GOTRUE_SMTP_PORT: 2500 + GOTRUE_SMTP_USER: GOTRUE_SMTP_USER + GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS + GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com + GOTRUE_COOKIE_KEY: 'sb' depends_on: - db restart: on-failure @@ -95,7 +126,7 @@ services: GOTRUE_SMTP_USER: GOTRUE_SMTP_USER GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com - GOTRUE_COOKIE_KEY: "sb" + GOTRUE_COOKIE_KEY: 'sb' depends_on: - db restart: on-failure diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 74f5f20a3..c85000196 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -20,6 +20,7 @@ import { isAuthRetryableFetchError, isAuthSessionMissingError, isAuthImplicitGrantRedirectError, + AuthInvalidJwtError, } from './lib/errors' import { Fetch, @@ -30,7 +31,6 @@ import { _ssoResponse, } from './lib/fetch' import { - decodeJWTPayload, Deferred, getItemAsync, isBrowser, @@ -43,6 +43,9 @@ import { supportsLocalStorage, parseParametersFromURL, getCodeChallengeAndMethod, + getAlgorithm, + validateExp, + decodeJWT, } from './lib/helpers' import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage' import { polyfillGlobalThis } from './lib/polyfills' @@ -86,7 +89,6 @@ import type { MFAVerifyParams, AuthMFAVerifyResponse, AuthMFAListFactorsResponse, - AMREntry, AuthMFAGetAuthenticatorAssuranceLevelResponse, AuthenticatorAssuranceLevels, Factor, @@ -100,7 +102,11 @@ import type { MFAEnrollPhoneParams, AuthMFAEnrollTOTPResponse, AuthMFAEnrollPhoneResponse, + JWK, + JwtPayload, + JwtHeader, } from './lib/types' +import { stringToUint8Array } from './lib/base64url' polyfillGlobalThis() // Make "globalThis" available @@ -140,7 +146,10 @@ export default class GoTrueClient { protected storageKey: string protected flowType: AuthFlowType - + /** + * The JWKS used for verifying asymmetric JWTs + */ + protected jwks: { keys: JWK[] } protected autoRefreshToken: boolean protected persistSession: boolean protected storage: SupportedStorage @@ -220,7 +229,7 @@ export default class GoTrueClient { } else { this.lock = lockNoOp } - + this.jwks = { keys: [] } this.mfa = { verify: this._verify.bind(this), enroll: this._enroll.bind(this), @@ -1288,17 +1297,6 @@ export default class GoTrueClient { } } - /** - * Decodes a JWT (without performing any validation). - */ - private _decodeJWT(jwt: string): { - exp?: number - aal?: AuthenticatorAssuranceLevels | null - amr?: AMREntry[] | null - } { - return decodeJWTPayload(jwt) - } - /** * Sets the session data from the current session. If the current session is expired, setSession will take care of refreshing it to obtain a new session. * If the refresh token or access token in the current session is invalid, an error will be thrown. @@ -1328,7 +1326,7 @@ export default class GoTrueClient { let expiresAt = timeNow let hasExpired = true let session: Session | null = null - const payload = decodeJWTPayload(currentSession.access_token) + const { payload } = decodeJWT(currentSession.access_token) if (payload.exp) { expiresAt = payload.exp hasExpired = expiresAt <= timeNow @@ -2576,7 +2574,7 @@ export default class GoTrueClient { } } - const payload = this._decodeJWT(session.access_token) + const { payload } = decodeJWT(session.access_token) let currentLevel: AuthenticatorAssuranceLevels | null = null @@ -2599,4 +2597,128 @@ export default class GoTrueClient { }) }) } + + private async fetchJwk(kid: string, jwks: { keys: JWK[] } = { keys: [] }): Promise { + // try fetching from the supplied jwks + let jwk = jwks.keys.find((key) => key.kid === kid) + if (jwk) { + return jwk + } + + // try fetching from cache + jwk = this.jwks.keys.find((key) => key.kid === kid) + if (jwk) { + return jwk + } + // jwk isn't cached in memory so we need to fetch it from the well-known endpoint + const { data, error } = await _request(this.fetch, 'GET', `${this.url}/.well-known/jwks.json`, { + headers: this.headers, + }) + if (error) { + throw error + } + if (!data.keys || data.keys.length === 0) { + throw new AuthInvalidJwtError('JWKS is empty') + } + this.jwks = data + // Find the signing key + jwk = data.keys.find((key: any) => key.kid === kid) + if (!jwk) { + throw new AuthInvalidJwtError('No matching signing key found in JWKS') + } + return jwk + } + + /** + * @experimental This method may change in future versions. + * @description Gets the claims from a JWT. If the JWT is symmetric JWTs, it will call getUser() to verify against the server. If the JWT is asymmetric, it will be verified against the JWKS using the WebCrypto API. + */ + async getClaims( + jwt?: string, + jwks: { keys: JWK[] } = { keys: [] } + ): Promise< + | { + data: { claims: JwtPayload; header: JwtHeader; signature: Uint8Array } + error: null + } + | { data: null; error: AuthError } + | { data: null; error: null } + > { + try { + let token = jwt + if (!token) { + const { data, error } = await this.getSession() + if (error || !data.session) { + return { data: null, error } + } + token = data.session.access_token + } + + const { + header, + payload, + signature, + raw: { header: rawHeader, payload: rawPayload }, + } = decodeJWT(token) + + // Reject expired JWTs + validateExp(payload.exp) + + // If symmetric algorithm or WebCrypto API is unavailable, fallback to getUser() + if ( + !header.kid || + header.alg === 'HS256' || + !('crypto' in globalThis && 'subtle' in globalThis.crypto) + ) { + const { error } = await this.getUser(token) + if (error) { + throw error + } + // getUser succeeds so the claims in the JWT can be trusted + return { + data: { + claims: payload, + header, + signature, + }, + error: null, + } + } + + const algorithm = getAlgorithm(header.alg) + const signingKey = await this.fetchJwk(header.kid, jwks) + + // Convert JWK to CryptoKey + const publicKey = await crypto.subtle.importKey('jwk', signingKey, algorithm, true, [ + 'verify', + ]) + + // Verify the signature + const isValid = await crypto.subtle.verify( + algorithm, + publicKey, + signature, + stringToUint8Array(`${rawHeader}.${rawPayload}`) + ) + + if (!isValid) { + throw new AuthInvalidJwtError('Invalid JWT signature') + } + + // If verification succeeds, decode and return claims + return { + data: { + claims: payload, + header, + signature, + }, + error: null, + } + } catch (error) { + if (isAuthError(error)) { + return { data: null, error } + } + throw error + } + } } diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts new file mode 100644 index 000000000..3a5f2617a --- /dev/null +++ b/src/lib/base64url.ts @@ -0,0 +1,290 @@ +/** + * Avoid modifying this file. It's part of + * https://github.com/supabase-community/base64url-js. Submit all fixes on + * that repo! + */ + +/** + * An array of characters that encode 6 bits into a Base64-URL alphabet + * character. + */ +const TO_BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split('') + +/** + * An array of characters that can appear in a Base64-URL encoded string but + * should be ignored. + */ +const IGNORE_BASE64URL = ' \t\n\r='.split('') + +/** + * An array of 128 numbers that map a Base64-URL character to 6 bits, or if -2 + * used to skip the character, or if -1 used to error out. + */ +const FROM_BASE64URL = (() => { + const charMap: number[] = new Array(128) + + for (let i = 0; i < charMap.length; i += 1) { + charMap[i] = -1 + } + + for (let i = 0; i < IGNORE_BASE64URL.length; i += 1) { + charMap[IGNORE_BASE64URL[i].charCodeAt(0)] = -2 + } + + for (let i = 0; i < TO_BASE64URL.length; i += 1) { + charMap[TO_BASE64URL[i].charCodeAt(0)] = i + } + + return charMap +})() + +/** + * Converts a byte to a Base64-URL string. + * + * @param byte The byte to convert, or null to flush at the end of the byte sequence. + * @param state The Base64 conversion state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. + * @param emit A function called with the next Base64 character when ready. + */ +export function byteToBase64URL( + byte: number | null, + state: { queue: number; queuedBits: number }, + emit: (char: string) => void +) { + if (byte !== null) { + state.queue = (state.queue << 8) | byte + state.queuedBits += 8 + + while (state.queuedBits >= 6) { + const pos = (state.queue >> (state.queuedBits - 6)) & 63 + emit(TO_BASE64URL[pos]) + state.queuedBits -= 6 + } + } else if (state.queuedBits > 0) { + state.queue = state.queue << (6 - state.queuedBits) + state.queuedBits = 6 + + while (state.queuedBits >= 6) { + const pos = (state.queue >> (state.queuedBits - 6)) & 63 + emit(TO_BASE64URL[pos]) + state.queuedBits -= 6 + } + } +} + +/** + * Converts a String char code (extracted using `string.charCodeAt(position)`) to a sequence of Base64-URL characters. + * + * @param charCode The char code of the JavaScript string. + * @param state The Base64 state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. + * @param emit A function called with the next byte. + */ +export function byteFromBase64URL( + charCode: number, + state: { queue: number; queuedBits: number }, + emit: (byte: number) => void +) { + const bits = FROM_BASE64URL[charCode] + + if (bits > -1) { + // valid Base64-URL character + state.queue = (state.queue << 6) | bits + state.queuedBits += 6 + + while (state.queuedBits >= 8) { + emit((state.queue >> (state.queuedBits - 8)) & 0xff) + state.queuedBits -= 8 + } + } else if (bits === -2) { + // ignore spaces, tabs, newlines, = + return + } else { + throw new Error(`Invalid Base64-URL character "${String.fromCharCode(charCode)}"`) + } +} + +/** + * Converts a JavaScript string (which may include any valid character) into a + * Base64-URL encoded string. The string is first encoded in UTF-8 which is + * then encoded as Base64-URL. + * + * @param str The string to convert. + */ +export function stringToBase64URL(str: string) { + const base64: string[] = [] + + const emitter = (char: string) => { + base64.push(char) + } + + const state = { queue: 0, queuedBits: 0 } + + stringToUTF8(str, (byte: number) => { + byteToBase64URL(byte, state, emitter) + }) + + byteToBase64URL(null, state, emitter) + + return base64.join('') +} + +/** + * Converts a Base64-URL encoded string into a JavaScript string. It is assumed + * that the underlying string has been encoded as UTF-8. + * + * @param str The Base64-URL encoded string. + */ +export function stringFromBase64URL(str: string) { + const conv: string[] = [] + + const utf8Emit = (codepoint: number) => { + conv.push(String.fromCodePoint(codepoint)) + } + + const utf8State = { + utf8seq: 0, + codepoint: 0, + } + + const b64State = { queue: 0, queuedBits: 0 } + + const byteEmit = (byte: number) => { + stringFromUTF8(byte, utf8State, utf8Emit) + } + + for (let i = 0; i < str.length; i += 1) { + byteFromBase64URL(str.charCodeAt(i), b64State, byteEmit) + } + + return conv.join('') +} + +/** + * Converts a Unicode codepoint to a multi-byte UTF-8 sequence. + * + * @param codepoint The Unicode codepoint. + * @param emit Function which will be called for each UTF-8 byte that represents the codepoint. + */ +export function codepointToUTF8(codepoint: number, emit: (byte: number) => void) { + if (codepoint <= 0x7f) { + emit(codepoint) + return + } else if (codepoint <= 0x7ff) { + emit(0xc0 | (codepoint >> 6)) + emit(0x80 | (codepoint & 0x3f)) + return + } else if (codepoint <= 0xffff) { + emit(0xe0 | (codepoint >> 12)) + emit(0x80 | ((codepoint >> 6) & 0x3f)) + emit(0x80 | (codepoint & 0x3f)) + return + } else if (codepoint <= 0x10ffff) { + emit(0xf0 | (codepoint >> 18)) + emit(0x80 | ((codepoint >> 12) & 0x3f)) + emit(0x80 | ((codepoint >> 6) & 0x3f)) + emit(0x80 | (codepoint & 0x3f)) + return + } + + throw new Error(`Unrecognized Unicode codepoint: ${codepoint.toString(16)}`) +} + +/** + * Converts a JavaScript string to a sequence of UTF-8 bytes. + * + * @param str The string to convert to UTF-8. + * @param emit Function which will be called for each UTF-8 byte of the string. + */ +export function stringToUTF8(str: string, emit: (byte: number) => void) { + for (let i = 0; i < str.length; i += 1) { + let codepoint = str.charCodeAt(i) + + if (codepoint > 0xd7ff && codepoint <= 0xdbff) { + // most UTF-16 codepoints are Unicode codepoints, except values in this + // range where the next UTF-16 codepoint needs to be combined with the + // current one to get the Unicode codepoint + const highSurrogate = ((codepoint - 0xd800) * 0x400) & 0xffff + const lowSurrogate = (str.charCodeAt(i + 1) - 0xdc00) & 0xffff + codepoint = (lowSurrogate | highSurrogate) + 0x10000 + i += 1 + } + + codepointToUTF8(codepoint, emit) + } +} + +/** + * Converts a UTF-8 byte to a Unicode codepoint. + * + * @param byte The UTF-8 byte next in the sequence. + * @param state The shared state between consecutive UTF-8 bytes in the + * sequence, an object with the shape `{ utf8seq: 0, codepoint: 0 }`. + * @param emit Function which will be called for each codepoint. + */ +export function stringFromUTF8( + byte: number, + state: { utf8seq: number; codepoint: number }, + emit: (codepoint: number) => void +) { + if (state.utf8seq === 0) { + if (byte <= 0x7f) { + emit(byte) + return + } + + // count the number of 1 leading bits until you reach 0 + for (let leadingBit = 1; leadingBit < 6; leadingBit += 1) { + if (((byte >> (7 - leadingBit)) & 1) === 0) { + state.utf8seq = leadingBit + break + } + } + + if (state.utf8seq === 2) { + state.codepoint = byte & 31 + } else if (state.utf8seq === 3) { + state.codepoint = byte & 15 + } else if (state.utf8seq === 4) { + state.codepoint = byte & 7 + } else { + throw new Error('Invalid UTF-8 sequence') + } + + state.utf8seq -= 1 + } else if (state.utf8seq > 0) { + if (byte <= 0x7f) { + throw new Error('Invalid UTF-8 sequence') + } + + state.codepoint = (state.codepoint << 6) | (byte & 63) + state.utf8seq -= 1 + + if (state.utf8seq === 0) { + emit(state.codepoint) + } + } +} + +/** + * Helper functions to convert different types of strings to Uint8Array + */ + +export function base64UrlToUint8Array(str: string): Uint8Array { + const result: number[] = [] + const state = { queue: 0, queuedBits: 0 } + + const onByte = (byte: number) => { + result.push(byte) + } + + for (let i = 0; i < str.length; i += 1) { + byteFromBase64URL(str.charCodeAt(i), state, onByte) + } + + return new Uint8Array(result) +} + +export function stringToUint8Array(str: string): Uint8Array { + const result: number[] = [] + stringToUTF8(str, (byte: number) => result.push(byte)) + return new Uint8Array(result) +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 92f285cb5..8136de5ad 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -28,3 +28,5 @@ export const API_VERSIONS = { name: '2024-01-01', }, } + +export const BASE64URL_REGEX = /^([a-z0-9_-]{4})*($|[a-z0-9_-]{3}$|[a-z0-9_-]{2}$)$/i diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 90ba19368..48bc50d96 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -157,3 +157,9 @@ export class AuthWeakPasswordError extends CustomAuthError { export function isAuthWeakPasswordError(error: unknown): error is AuthWeakPasswordError { return isAuthError(error) && error.name === 'AuthWeakPasswordError' } + +export class AuthInvalidJwtError extends CustomAuthError { + constructor(message: string) { + super(message, 'AuthInvalidJwtError', 400, 'invalid_jwt') + } +} diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index c0a948fca..8cd44ed85 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,5 +1,7 @@ -import { API_VERSION_HEADER_NAME } from './constants' -import { SupportedStorage } from './types' +import { API_VERSION_HEADER_NAME, BASE64URL_REGEX } from './constants' +import { AuthInvalidJwtError } from './errors' +import { base64UrlToUint8Array, stringFromBase64URL, stringToBase64URL } from './base64url' +import { JwtHeader, JwtPayload, SupportedStorage } from './types' export function expiresAt(expiresIn: number) { const timeNow = Math.round(Date.now() / 1000) @@ -141,34 +143,6 @@ export const removeItemAsync = async (storage: SupportedStorage, key: string): P await storage.removeItem(key) } -export function decodeBase64URL(value: string): string { - const key = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=' - let base64 = '' - let chr1, chr2, chr3 - let enc1, enc2, enc3, enc4 - let i = 0 - value = value.replace('-', '+').replace('_', '/') - - while (i < value.length) { - enc1 = key.indexOf(value.charAt(i++)) - enc2 = key.indexOf(value.charAt(i++)) - enc3 = key.indexOf(value.charAt(i++)) - enc4 = key.indexOf(value.charAt(i++)) - chr1 = (enc1 << 2) | (enc2 >> 4) - chr2 = ((enc2 & 15) << 4) | (enc3 >> 2) - chr3 = ((enc3 & 3) << 6) | enc4 - base64 = base64 + String.fromCharCode(chr1) - - if (enc3 != 64 && chr2 != 0) { - base64 = base64 + String.fromCharCode(chr2) - } - if (enc4 != 64 && chr3 != 0) { - base64 = base64 + String.fromCharCode(chr3) - } - } - return base64 -} - /** * A deferred represents some asynchronous work that is not yet finished, which * may or may not culminate in a value. @@ -194,23 +168,38 @@ export class Deferred { } } -// Taken from: https://stackoverflow.com/questions/38552003/how-to-decode-jwt-token-in-javascript-without-using-a-library -export function decodeJWTPayload(token: string) { - // Regex checks for base64url format - const base64UrlRegex = /^([a-z0-9_-]{4})*($|[a-z0-9_-]{3}=?$|[a-z0-9_-]{2}(==)?$)$/i - +export function decodeJWT(token: string): { + header: JwtHeader + payload: JwtPayload + signature: Uint8Array + raw: { + header: string + payload: string + } +} { const parts = token.split('.') if (parts.length !== 3) { - throw new Error('JWT is not valid: not a JWT structure') + throw new AuthInvalidJwtError('Invalid JWT structure') } - if (!base64UrlRegex.test(parts[1] as string)) { - throw new Error('JWT is not valid: payload is not in base64url format') + // Regex checks for base64url format + for (let i = 0; i < parts.length; i++) { + if (!BASE64URL_REGEX.test(parts[i] as string)) { + throw new AuthInvalidJwtError('JWT not in base64url format') + } } - - const base64Url = parts[1] as string - return JSON.parse(decodeBase64URL(base64Url)) + const data = { + // using base64url lib + header: JSON.parse(stringFromBase64URL(parts[0])), + payload: JSON.parse(stringFromBase64URL(parts[1])), + signature: base64UrlToUint8Array(parts[2]), + raw: { + header: parts[0], + payload: parts[1], + }, + } + return data } /** @@ -287,10 +276,6 @@ async function sha256(randomString: string) { .join('') } -function base64urlencode(str: string) { - return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - export async function generatePKCEChallenge(verifier: string) { const hasCryptoSupport = typeof crypto !== 'undefined' && @@ -304,7 +289,7 @@ export async function generatePKCEChallenge(verifier: string) { return verifier } const hashed = await sha256(verifier) - return base64urlencode(hashed) + return stringToBase64URL(hashed) } export async function getCodeChallengeAndMethod( @@ -344,3 +329,31 @@ export function parseResponseAPIVersion(response: Response) { return null } } + +export function validateExp(exp: number) { + if (!exp) { + throw new Error('Missing exp claim') + } + const timeNow = Math.floor(Date.now() / 1000) + if (exp <= timeNow) { + throw new Error('JWT has expired') + } +} + +export function getAlgorithm(alg: 'RS256' | 'ES256'): RsaHashedImportParams | EcKeyImportParams { + switch (alg) { + case 'RS256': + return { + name: 'RSASSA-PKCS1-v1_5', + hash: { name: 'SHA-256' }, + } + case 'ES256': + return { + name: 'ECDSA', + namedCurve: 'P-256', + hash: { name: 'SHA-256' }, + } + default: + throw new Error('Invalid alg claim') + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 31b117ff8..338793043 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1199,3 +1199,32 @@ export type AuthMFAEnrollPhoneResponse = data: null error: AuthError } + +export type JwtHeader = { + alg: 'RS256' | 'ES256' | 'HS256' + kid: string + typ: string +} + +export type RequiredClaims = { + iss: string + sub: string + aud: string | string[] + exp: number + iat: number + role: string + aal: AuthenticatorAssuranceLevels + session_id: string +} + +export type JwtPayload = RequiredClaims & { + [key: string]: any +} + +export interface JWK { + kty: 'RSA' | 'EC' | 'oct' + key_ops: string[] + alg?: string + kid?: string + [key: string]: any +} diff --git a/test/GoTrueClient.test.ts b/test/GoTrueClient.test.ts index 7d426513d..68a6398ef 100644 --- a/test/GoTrueClient.test.ts +++ b/test/GoTrueClient.test.ts @@ -5,12 +5,16 @@ import GoTrueClient from '../src/GoTrueClient' import { authClient as auth, authClientWithSession as authWithSession, + authClientWithAsymmetricSession as authWithAsymmetricSession, authSubscriptionClient, clientApiAutoConfirmOffSignupsEnabledClient as phoneClient, clientApiAutoConfirmDisabledClient as signUpDisabledClient, clientApiAutoConfirmEnabledClient as signUpEnabledClient, authAdminApiAutoConfirmEnabledClient, GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + authClient, + authClientWithAsymmetricSession, + GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON, } from './lib/clients' import { mockUserCredentials } from './lib/utils' import { Session } from '../src' @@ -918,6 +922,71 @@ describe('MFA', () => { }) }) +describe('getClaims', () => { + test('getClaims returns nothing if there is no session present', async () => { + const { data, error } = await authClient.getClaims() + expect(data).toBeNull() + expect(error).toBeNull() + }) + + test('getClaims calls getUser if symmetric jwt is present', async () => { + const { email, password } = mockUserCredentials() + jest.spyOn(authWithSession, 'getUser') + const { + data: { user }, + error: initialError, + } = await authWithSession.signUp({ + email, + password, + }) + expect(initialError).toBeNull() + expect(user).not.toBeNull() + + const { data, error } = await authWithSession.getClaims() + expect(error).toBeNull() + expect(data?.claims.email).toEqual(user?.email) + expect(authWithSession.getUser).toHaveBeenCalled() + }) + + test('getClaims fetches JWKS to verify asymmetric jwt', async () => { + const fetchedUrls: any[] = [] + const fetchedResponse: any[] = [] + + // override fetch to inspect fetchJwk called within getClaims + authWithAsymmetricSession['fetch'] = async (url: RequestInfo | URL, options = {}) => { + fetchedUrls.push(url) + const response = await globalThis.fetch(url, options) + const clonedResponse = response.clone() + fetchedResponse.push(await clonedResponse.json()) + return response + } + const { email, password } = mockUserCredentials() + const { + data: { user }, + error: initialError, + } = await authWithAsymmetricSession.signUp({ + email, + password, + }) + expect(initialError).toBeNull() + expect(user).not.toBeNull() + + const { data, error } = await authWithAsymmetricSession.getClaims() + expect(error).toBeNull() + expect(data?.claims.email).toEqual(user?.email) + + // node 18 doesn't support crypto.subtle API by default unless built with the experimental-global-webcrypto flag + if (parseInt(process.version.slice(1).split('.')[0]) === 20) { + expect(fetchedUrls).toContain( + GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON + '/.well-known/jwks.json' + ) + } + + // contains the response for getSession and fetchJwk + expect(fetchedResponse).toHaveLength(2) + }) +}) + describe('GoTrueClient with storageisServer = true', () => { const originalWarn = console.warn let warnings: any[][] = [] diff --git a/test/base64url.test.ts b/test/base64url.test.ts new file mode 100644 index 000000000..93fc71f57 --- /dev/null +++ b/test/base64url.test.ts @@ -0,0 +1,77 @@ +import { + codepointToUTF8, + stringFromBase64URL, + stringFromUTF8, + stringToBase64URL, +} from '../src/lib/base64url' + +const EXAMPLES = [ + 'a', + 'ab', + 'abc', + 'abcd', + 'hello world', + 'нешто на кирилица', + 'something with emojis 🤙🏾 ', + 'Supabaseは、オープンソースの Firebase 代替製品です。エンタープライズグレードのオープンソースツールを使って、Firebase の機能を構築しています。', +] + +describe('stringToBase64URL', () => { + EXAMPLES.forEach((example) => { + test(`encode "${example}"`, () => { + expect(stringToBase64URL(example)).toEqual(Buffer.from(example).toString('base64url')) + }) + }) +}) + +describe('stringFromBase64URL', () => { + EXAMPLES.forEach((example) => { + test(`decode "${example}"`, () => { + expect(stringFromBase64URL('\r\t\n ' + Buffer.from(example).toString('base64url'))).toEqual( + example + ) + }) + }) + + test('decode with invalid Base64-URL character', () => { + expect(() => { + stringFromBase64URL('*') + }).toThrow(new Error(`Invalid Base64-URL character "*"`)) + }) +}) + +const BAD_UTF8 = [ + [0xf8], // 11111000 + [0xff], // 11111111 + [0x80], // 10000000 + [0xf8, 1], // 11110000 00000001 + [0xe0, 1], // 11100000 00000001 + [0xc0, 1], // 11100000 00000001 +] + +describe('stringFromUTF8', () => { + BAD_UTF8.forEach((example) => { + test(`should recognize bad UTF-8 sequence ${example + .map((x) => x.toString(16)) + .join(' ')}`, () => { + expect(() => { + const state = { utf8seq: 0, codepoint: 0 } + example.forEach((byte) => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + stringFromUTF8(byte, state, () => {}) + }) + }).toThrow(new Error('Invalid UTF-8 sequence')) + }) + }) +}) + +describe('codepointToUTF8', () => { + test('invalid codepoints above 0x10ffff', () => { + const invalidCodepoint = 0x10ffff + 1 + expect(() => { + codepointToUTF8(invalidCodepoint, () => { + throw new Error('Should not becalled') + }) + }).toThrow(new Error(`Unrecognized Unicode codepoint: ${invalidCodepoint.toString(16)}`)) + }) +}) diff --git a/test/helpers.test.ts b/test/helpers.test.ts index 4cc0cfc83..e78b56f2a 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -1,4 +1,10 @@ -import { parseParametersFromURL, parseResponseAPIVersion } from '../src/lib/helpers' +import { AuthInvalidJwtError } from '../src' +import { + decodeJWT, + getAlgorithm, + parseParametersFromURL, + parseResponseAPIVersion, +} from '../src/lib/helpers' describe('parseParametersFromURL', () => { it('should parse parameters from a URL with query params only', () => { @@ -71,3 +77,151 @@ describe('parseResponseAPIVersion', () => { }) }) }) + +describe('decodeJWT', () => { + it('should reject non-JWT strings', () => { + expect(() => decodeJWT('non-jwt')).toThrowError( + new AuthInvalidJwtError('Invalid JWT structure') + ) + expect(() => decodeJWT('aHR0.cDovL.2V4YW1wbGUuY29t')).toThrowError( + new AuthInvalidJwtError('JWT not in base64url format') + ) + }) + + it('should decode JWT successfully', () => { + expect( + decodeJWT( + 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImZhM2ZmYzk5LTQ2MzUtNGIxOS1iNWMwLTZkNmE4ZDMwYzRlYiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3Byb2plY3RyZWYuc3VwYWJhc2UuY28iLCJzdWIiOiI2OTAxMTJlNi04NThiLTQwYzctODBlNi05NmRiNjk3MTkyYjUiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxODM4MDk5NjcwLCJpYXQiOjE3MzgwOTk2NzAsImVtYWlsIjoiIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnt9LCJ1c2VyX21ldGFkYXRhIjp7ImNvbG9yIjoiYmx1ZSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoiYW5vbnltb3VzIiwidGltZXN0YW1wIjoxNzM4MDk5NjcwfV0sInNlc3Npb25faWQiOiI0YzZiMjg5NC00M2I0LTQ2YzQtYmQyZi0zNWM1OWVjNDRmZWYiLCJpc19hbm9ueW1vdXMiOnRydWV9.JcWCW3u4F9iFo1yV3OlxnosP7jLnOa2Q7LoPTxyFmvZc1_Kziimw8jD95EpXyTMEwKFt2dPSmWGkqdoJu6FV0Q' + ) + ).toMatchInlineSnapshot(` +Object { + "header": Object { + "alg": "ES256", + "kid": "fa3ffc99-4635-4b19-b5c0-6d6a8d30c4eb", + "typ": "JWT", + }, + "payload": Object { + "aal": "aal1", + "amr": Array [ + Object { + "method": "anonymous", + "timestamp": 1738099670, + }, + ], + "app_metadata": Object {}, + "aud": "authenticated", + "email": "", + "exp": 1838099670, + "iat": 1738099670, + "is_anonymous": true, + "iss": "https://projectref.supabase.co", + "phone": "", + "role": "", + "session_id": "4c6b2894-43b4-46c4-bd2f-35c59ec44fef", + "sub": "690112e6-858b-40c7-80e6-96db697192b5", + "user_metadata": Object { + "color": "blue", + }, + }, + "raw": Object { + "header": "eyJhbGciOiJFUzI1NiIsImtpZCI6ImZhM2ZmYzk5LTQ2MzUtNGIxOS1iNWMwLTZkNmE4ZDMwYzRlYiIsInR5cCI6IkpXVCJ9", + "payload": "eyJpc3MiOiJodHRwczovL3Byb2plY3RyZWYuc3VwYWJhc2UuY28iLCJzdWIiOiI2OTAxMTJlNi04NThiLTQwYzctODBlNi05NmRiNjk3MTkyYjUiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxODM4MDk5NjcwLCJpYXQiOjE3MzgwOTk2NzAsImVtYWlsIjoiIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnt9LCJ1c2VyX21ldGFkYXRhIjp7ImNvbG9yIjoiYmx1ZSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoiYW5vbnltb3VzIiwidGltZXN0YW1wIjoxNzM4MDk5NjcwfV0sInNlc3Npb25faWQiOiI0YzZiMjg5NC00M2I0LTQ2YzQtYmQyZi0zNWM1OWVjNDRmZWYiLCJpc19hbm9ueW1vdXMiOnRydWV9", + }, + "signature": Uint8Array [ + 37, + 197, + 130, + 91, + 123, + 184, + 23, + 216, + 133, + 163, + 92, + 149, + 220, + 233, + 113, + 158, + 139, + 15, + 238, + 50, + 231, + 57, + 173, + 144, + 236, + 186, + 15, + 79, + 28, + 133, + 154, + 246, + 92, + 215, + 242, + 179, + 138, + 41, + 176, + 242, + 48, + 253, + 228, + 74, + 87, + 201, + 51, + 4, + 192, + 161, + 109, + 217, + 211, + 210, + 153, + 97, + 164, + 169, + 218, + 9, + 187, + 161, + 85, + 209, + ], +} +`) + }) +}) + +describe('getAlgorithm', () => { + const cases = [ + { + name: 'RS256', + expected: { + name: 'RSASSA-PKCS1-v1_5', + hash: { name: 'SHA-256' }, + }, + }, + { + name: 'ES256', + expected: { + name: 'ECDSA', + namedCurve: 'P-256', + hash: { name: 'SHA-256' }, + }, + }, + ] + it('should return correct algorithm object', () => { + cases.forEach((c) => { + expect(getAlgorithm(c.name as any)).toEqual(c.expected) + }) + }) + it('should throw if invalid alg claim', () => { + expect(() => getAlgorithm('EdDSA' as any)).toThrowError(new Error('Invalid alg claim')) + }) +}) diff --git a/test/lib/clients.ts b/test/lib/clients.ts index e042b7212..23738bb07 100644 --- a/test/lib/clients.ts +++ b/test/lib/clients.ts @@ -5,9 +5,11 @@ export const SIGNUP_ENABLED_AUTO_CONFIRM_OFF_PORT = 9999 export const SIGNUP_ENABLED_AUTO_CONFIRM_ON_PORT = 9998 export const SIGNUP_DISABLED_AUTO_CONFIRM_OFF_PORT = 9997 +export const SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON_PORT = 9996 export const GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_OFF = `http://localhost:${SIGNUP_ENABLED_AUTO_CONFIRM_OFF_PORT}` export const GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON = `http://localhost:${SIGNUP_ENABLED_AUTO_CONFIRM_ON_PORT}` +export const GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON = `http://localhost:${SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON_PORT}` export const GOTRUE_URL_SIGNUP_DISABLED_AUTO_CONFIRM_OFF = `http://localhost:${SIGNUP_DISABLED_AUTO_CONFIRM_OFF_PORT}` export const GOTRUE_JWT_SECRET = '37c304f8-51aa-419a-a1af-06154e63707a' @@ -50,6 +52,13 @@ export const authClientWithSession = new GoTrueClient({ storage: new MemoryStorage(), }) +export const authClientWithAsymmetricSession = new GoTrueClient({ + url: GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON, + autoRefreshToken: false, + persistSession: true, + storage: new MemoryStorage(), +}) + export const authSubscriptionClient = new GoTrueClient({ url: GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, autoRefreshToken: false,