Files
supabase/packages/common/gotrue.ts
T
Gildas Garcia 63bedef77f MFA Recovery codes: allow users to download their recovery codes (#50267)
## What kind of change does this PR introduce?

After users have set up a new MFA (first or not), we must:

- check whether recovery codes have already been generated
- if there are none, generate recovery codes and display them, "forcing"
users to copy them
- if already generated, show them how many are still available

> [!NOTE]
> The _Delete my recovery codes_ button in last screenshot only appear
on local and staging environments

## How to test

- On an account that doesn't have recovery codes generated yet and has
an MFA added
- You should see an admonition suggesting to generate the codes

## Screenshots

<img width="729" height="306" alt="image"
src="https://github.com/user-attachments/assets/79ba3870-4ef8-4571-9fd6-36eed20c9c24"
/>

<img width="550" height="356" alt="image"
src="https://github.com/user-attachments/assets/1632611a-996a-470d-b6cd-a4693b0f4602"
/>

<img width="719" height="205" alt="image"
src="https://github.com/user-attachments/assets/73cef611-05cf-4fac-bbd2-243f9b28e48d"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added support for generating, copying, and confirming MFA recovery
codes.
- Added recovery-code status visibility, including remaining and
exhausted codes.
  - Added the ability to delete recovery codes with confirmation.
- Added clear loading, success, and error states for recovery-code
actions.
  - Recovery-code status refreshes after codes are generated or deleted.

- **Bug Fixes**
- Recovery-code notices now remain visible when all codes have been
used.
  - Recovery-code dialogs can now be closed after generation errors.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-09-15 11:16:47 +02:00

197 lines
5.3 KiB
TypeScript

import { AuthClient, navigatorLock, User } from '@supabase/auth-js'
import { isBrowser } from './helpers'
export const STORAGE_KEY = process.env.NEXT_PUBLIC_STORAGE_KEY || 'supabase.dashboard.auth.token'
export const AUTH_DEBUG_KEY =
process.env.NEXT_PUBLIC_AUTH_DEBUG_KEY || 'supabase.dashboard.auth.debug'
export const AUTH_DEBUG_PERSISTED_KEY =
process.env.NEXT_PUBLIC_AUTH_DEBUG_PERSISTED_KEY || 'supabase.dashboard.auth.debug.persist'
export const AUTH_NAVIGATOR_LOCK_DISABLED_KEY =
process.env.NEXT_PUBLIC_AUTH_NAVIGATOR_LOCK_KEY ||
'supabase.dashboard.auth.navigatorLock.disabled'
/**
* Catches errors thrown when accessing localStorage. Safari with certain
* security settings throws when localStorage is accessed.
*/
function safeGetLocalStorage(key: string) {
try {
return globalThis?.localStorage?.getItem(key)
} catch {
return null
}
}
const debug =
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' && safeGetLocalStorage(AUTH_DEBUG_KEY) === 'true'
const persistedDebug =
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' &&
safeGetLocalStorage(AUTH_DEBUG_PERSISTED_KEY) === 'true'
const shouldEnableNavigatorLock =
process.env.NEXT_PUBLIC_IS_PLATFORM === 'true' &&
!(safeGetLocalStorage(AUTH_NAVIGATOR_LOCK_DISABLED_KEY) === 'true')
const shouldDetectSessionInUrl = process.env.NEXT_PUBLIC_AUTH_DETECT_SESSION_IN_URL
? process.env.NEXT_PUBLIC_AUTH_DETECT_SESSION_IN_URL === 'true'
: true
const navigatorLockEnabled = !!(shouldEnableNavigatorLock && globalThis?.navigator?.locks)
if (isBrowser && shouldEnableNavigatorLock && !globalThis?.navigator?.locks) {
console.warn('This browser does not support the Navigator Locks API. Please update it.')
}
const tabId = Math.random().toString(16).substring(2)
let dbHandle = new Promise<IDBDatabase | null>((accept, _) => {
if (!persistedDebug) {
accept(null)
return
}
const request = indexedDB.open('auth-debug-log', 1)
request.onupgradeneeded = (event: any) => {
const db = event?.target?.result
if (!db) {
return
}
db.createObjectStore('events', { autoIncrement: true })
}
request.onsuccess = (event: any) => {
console.log('Opened persisted auth debug log IndexedDB database', tabId)
accept(event.target.result)
}
request.onerror = (event: any) => {
console.error('Failed to open persisted auth debug log IndexedDB database', event)
accept(null)
}
})
const logIndexedDB = (message: string, ...args: any[]) => {
console.log(message, ...args)
const copyArgs = structuredClone(args)
copyArgs.forEach((value) => {
if (typeof value === 'object' && value !== null) {
delete value.user
delete value.access_token
delete value.token_type
delete value.provider_token
}
})
;(async () => {
try {
const db = await dbHandle
if (!db) {
return
}
const tx = db.transaction(['events'], 'readwrite')
tx.onerror = (event: any) => {
console.error('Failed to write to persisted auth debug log IndexedDB database', event)
dbHandle = Promise.resolve(null)
}
const events = tx.objectStore('events')
events.add({
m: message.replace(/^GoTrueClient@/i, ''),
a: copyArgs,
l: window.location.pathname,
t: tabId,
})
} catch (e: any) {
console.error('Failed to log to persisted auth debug log IndexedDB database', e)
dbHandle = Promise.resolve(null)
}
})()
}
/**
* Reference to a function that captures exceptions for debugging purposes to be sent to Sentry.
*/
let captureException: ((e: any) => any) | null = null
export function setCaptureException(fn: typeof captureException) {
captureException = fn
}
async function debuggableNavigatorLock<R>(
name: string,
acquireTimeout: number,
fn: () => Promise<R>
): Promise<R> {
let stackException: any
try {
throw new Error('Lock is being held for over 10s here')
} catch (e: any) {
stackException = e
}
const debugTimeout = setTimeout(() => {
;(async () => {
const bc = new BroadcastChannel('who-is-holding-the-lock')
try {
bc.postMessage({})
} finally {
bc.close()
}
console.error(
`Waited for over 10s to acquire an Auth client lock`,
await navigator.locks.query(),
stackException
)
})()
}, 10000)
try {
return await navigatorLock(name, acquireTimeout, async () => {
clearTimeout(debugTimeout)
const bc = new BroadcastChannel('who-is-holding-the-lock')
bc.addEventListener('message', () => {
console.error('Lock is held here', stackException)
if (captureException) {
captureException(stackException)
}
})
try {
return await fn()
} finally {
bc.close()
}
})
} finally {
clearTimeout(debugTimeout)
}
}
export const gotrueClient = new AuthClient({
url: process.env.NEXT_PUBLIC_GOTRUE_URL,
storageKey: STORAGE_KEY,
detectSessionInUrl: shouldDetectSessionInUrl,
debug: debug ? (persistedDebug ? logIndexedDB : true) : false,
lock: navigatorLockEnabled ? debuggableNavigatorLock : undefined,
// TODO: Should this be enabled with a feature flag?
experimental: { recoveryCodes: true },
...('localStorage' in globalThis
? { storage: globalThis.localStorage, userStorage: globalThis.localStorage }
: null),
})
export type { User }