@supabase/auth-js
Version:
Official SDK for Supabase Auth
417 lines (360 loc) • 12.1 kB
text/typescript
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))
}