UNPKG

@supabase/auth-js

Version:
417 lines (360 loc) 12.1 kB
import { API_VERSION_HEADER_NAME, BASE64URL_REGEX } from './constants' import { AuthInvalidJwtError } from './errors' import { base64UrlToUint8Array, stringFromBase64URL } from './base64url' import { JwtHeader, JwtPayload, SupportedStorage, User } from './types' import { Uint8Array_ } from './webauthn.dom' export function expiresAt(expiresIn: number) { const timeNow = Math.round(Date.now() / 1000) return timeNow + expiresIn } export function uuid() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { const r = (Math.random() * 16) | 0, v = c == 'x' ? r : (r & 0x3) | 0x8 return v.toString(16) }) } export const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined' const localStorageWriteTests = { tested: false, writable: false, } /** * Checks whether localStorage is supported on this browser. */ export const supportsLocalStorage = () => { if (!isBrowser()) { return false } try { if (typeof globalThis.localStorage !== 'object') { return false } } catch (e) { // DOM exception when accessing `localStorage` return false } if (localStorageWriteTests.tested) { return localStorageWriteTests.writable } const randomKey = `lswt-${Math.random()}${Math.random()}` try { globalThis.localStorage.setItem(randomKey, randomKey) globalThis.localStorage.removeItem(randomKey) localStorageWriteTests.tested = true localStorageWriteTests.writable = true } catch (e) { // localStorage can't be written to // https://www.chromium.org/for-testers/bug-reporting-guidelines/uncaught-securityerror-failed-to-read-the-localstorage-property-from-window-access-is-denied-for-this-document localStorageWriteTests.tested = true localStorageWriteTests.writable = false } return localStorageWriteTests.writable } /** * Extracts parameters encoded in the URL both in the query and fragment. */ export function parseParametersFromURL(href: string) { const result: { [parameter: string]: string } = {} const url = new URL(href) if (url.hash && url.hash[0] === '#') { try { const hashSearchParams = new URLSearchParams(url.hash.substring(1)) hashSearchParams.forEach((value, key) => { result[key] = value }) } catch (e: any) { // hash is not a query string } } // search parameters take precedence over hash parameters url.searchParams.forEach((value, key) => { result[key] = value }) return result } type Fetch = typeof fetch export const resolveFetch = (customFetch?: Fetch): Fetch => { let _fetch: Fetch if (customFetch) { _fetch = customFetch } else if (typeof fetch === 'undefined') { _fetch = (...args) => import('@supabase/node-fetch' as any).then(({ default: fetch }) => fetch(...args)) } else { _fetch = fetch } return (...args) => _fetch(...args) } export const looksLikeFetchResponse = (maybeResponse: unknown): maybeResponse is Response => { return ( typeof maybeResponse === 'object' && maybeResponse !== null && 'status' in maybeResponse && 'ok' in maybeResponse && 'json' in maybeResponse && typeof (maybeResponse as any).json === 'function' ) } // Storage helpers export const setItemAsync = async ( storage: SupportedStorage, key: string, data: any ): Promise<void> => { await storage.setItem(key, JSON.stringify(data)) } export const getItemAsync = async (storage: SupportedStorage, key: string): Promise<unknown> => { const value = await storage.getItem(key) if (!value) { return null } try { return JSON.parse(value) } catch { return value } } export const removeItemAsync = async (storage: SupportedStorage, key: string): Promise<void> => { await storage.removeItem(key) } /** * A deferred represents some asynchronous work that is not yet finished, which * may or may not culminate in a value. * Taken from: https://github.com/mike-north/types/blob/master/src/async.ts */ export class Deferred<T = any> { public static promiseConstructor: PromiseConstructor = Promise public readonly promise!: PromiseLike<T> public readonly resolve!: (value?: T | PromiseLike<T>) => void public readonly reject!: (reason?: any) => any public constructor() { // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(this as any).promise = new Deferred.promiseConstructor((res, rej) => { // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(this as any).resolve = res // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(this as any).reject = rej }) } } 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 AuthInvalidJwtError('Invalid JWT structure') } // 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 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 } /** * Creates a promise that resolves to null after some time. */ export async function sleep(time: number): Promise<null> { return await new Promise((accept) => { setTimeout(() => accept(null), time) }) } /** * Converts the provided async function into a retryable function. Each result * or thrown error is sent to the isRetryable function which should return true * if the function should run again. */ export function retryable<T>( fn: (attempt: number) => Promise<T>, isRetryable: (attempt: number, error: any | null, result?: T) => boolean ): Promise<T> { const promise = new Promise<T>((accept, reject) => { // eslint-disable-next-line @typescript-eslint/no-extra-semi ;(async () => { for (let attempt = 0; attempt < Infinity; attempt++) { try { const result = await fn(attempt) if (!isRetryable(attempt, null, result)) { accept(result) return } } catch (e: any) { if (!isRetryable(attempt, e)) { reject(e) return } } } })() }) 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 function generatePKCEVerifier() { const verifierLength = 56 const array = new Uint32Array(verifierLength) if (typeof crypto === 'undefined') { const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~' const charSetLen = charSet.length let verifier = '' for (let i = 0; i < verifierLength; i++) { verifier += charSet.charAt(Math.floor(Math.random() * charSetLen)) } return verifier } crypto.getRandomValues(array) return Array.from(array, dec2hex).join('') } async function sha256(randomString: string) { const encoder = new TextEncoder() const encodedData = encoder.encode(randomString) const hash = await crypto.subtle.digest('SHA-256', encodedData) const bytes = new Uint8Array(hash) return Array.from(bytes) .map((c) => String.fromCharCode(c)) .join('') } export async function generatePKCEChallenge(verifier: string) { const hasCryptoSupport = typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined' && typeof TextEncoder !== 'undefined' if (!hasCryptoSupport) { console.warn( 'WebCrypto API is not supported. Code challenge method will default to use plain instead of sha256.' ) return verifier } const hashed = await sha256(verifier) return btoa(hashed).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } export async function getCodeChallengeAndMethod( storage: SupportedStorage, storageKey: string, isPasswordRecovery = false ) { const codeVerifier = generatePKCEVerifier() let storedCodeVerifier = codeVerifier if (isPasswordRecovery) { storedCodeVerifier += '/PASSWORD_RECOVERY' } await setItemAsync(storage, `${storageKey}-code-verifier`, storedCodeVerifier) const codeChallenge = await generatePKCEChallenge(codeVerifier) const codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256' return [codeChallenge, codeChallengeMethod] } /** Parses the API version which is 2YYY-MM-DD. */ const API_VERSION_REGEX = /^2[0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|1[0-9]|2[0-9]|3[0-1])$/i export function parseResponseAPIVersion(response: Response) { const apiVersion = response.headers.get(API_VERSION_HEADER_NAME) if (!apiVersion) { return null } if (!apiVersion.match(API_VERSION_REGEX)) { return null } try { const date = new Date(`${apiVersion}T00:00:00.0Z`) return date } catch (e: any) { 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: 'HS256' | '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') } } const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ export function validateUUID(str: string) { if (!UUID_REGEX.test(str)) { throw new Error('@supabase/auth-js: Expected parameter to be UUID but is not') } } export function userNotAvailableProxy(): User { const proxyTarget = {} as User return new Proxy(proxyTarget, { get: (target: any, prop: string) => { if (prop === '__isUserNotAvailableProxy') { return true } // Preventative check for common problematic symbols during cloning/inspection // These symbols might be accessed by structuredClone or other internal mechanisms. if (typeof prop === 'symbol') { const sProp = (prop as symbol).toString() if ( sProp === 'Symbol(Symbol.toPrimitive)' || sProp === 'Symbol(Symbol.toStringTag)' || sProp === 'Symbol(util.inspect.custom)' ) { // Node.js util.inspect return undefined } } throw new Error( `@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Accessing the "${prop}" property of the session object is not supported. Please use getUser() instead.` ) }, set: (_target: any, prop: string) => { throw new Error( `@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Setting the "${prop}" property of the session object is not supported. Please use getUser() to fetch a user object you can manipulate.` ) }, deleteProperty: (_target: any, prop: string) => { throw new Error( `@supabase/auth-js: client was created with userStorage option and there was no user stored in the user storage. Deleting the "${prop}" property of the session object is not supported. Please use getUser() to fetch a user object you can manipulate.` ) }, }) } /** * Deep clones a JSON-serializable object using JSON.parse(JSON.stringify(obj)). * Note: Only works for JSON-safe data. */ export function deepClone<T>(obj: T): T { return JSON.parse(JSON.stringify(obj)) }