feat: passkeys config page (#44718)

## Summary by CodeRabbit

* **New Features**
* Added Passkeys configuration page to manage WebAuthn relying-party
settings and enable/disable passkey auth.
  * Added a Beta "Passkeys" item to the Auth settings menu.
  * Enabled saving passkey-related authentication parameters.

* **Tests**
* Added test coverage to ensure the Passkeys menu appears or is omitted
based on feature flags.

* **Chores**
  * Removed an unused import to tidy the code.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: fadymak <dev@fadymak.com>
Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-13 16:37:57 +02:00
1 parent f7a686ee87
commit 31794b83f2
7 files changed
+534 -1

No files matched your search

@@ -11,7 +11,6 @@ import { toast } from 'sonner'
import {
Button,
Form_Shadcn_,
FormControl_Shadcn_,
Sheet,
SheetContent,
SheetFooter,
@@ -0,0 +1,406 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Card,
CardContent,
CardFooter,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
Input_Shadcn_,
Switch,
useWatch_Shadcn_,
} from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import * as z from 'zod'
import { InlineLink } from '@/components/ui/InlineLink'
import NoPermission from '@/components/ui/NoPermission'
import type { components } from '@/data/api'
import { useAuthConfigQuery } from '@/data/auth/auth-config-query'
import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
type GoTrueConfig = components['schemas']['GoTrueConfigResponse']
function isLocalhost(hostname: string): boolean {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]'
}
function validateRpId(rpId: string): string | null {
const trimmed = rpId.trim().toLowerCase()
if (!trimmed) return null
try {
const url = new URL('https://' + trimmed)
if (url.hostname !== trimmed) return null
return trimmed
} catch {
return null
}
}
function validateWebAuthnOrigins(
value: string,
rpId: string | null
): { valid: true } | { valid: false; message: string } {
const origins = value
.split(',')
.map((o) => o.trim())
.filter(Boolean)
if (origins.length === 0) {
return { valid: false, message: 'At least one origin is required' }
}
if (origins.length > 5) {
return { valid: false, message: 'A maximum of 5 origins is allowed' }
}
for (const origin of origins) {
let url: URL
try {
url = new URL(origin)
} catch {
return { valid: false, message: `"${origin}" is not a valid URL` }
}
if (url.protocol === 'http:') {
if (!isLocalhost(url.hostname)) {
return {
valid: false,
message: `"${origin}" must use HTTPS unless it is a localhost origin`,
}
}
} else if (url.protocol !== 'https:') {
return {
valid: false,
message: `"${origin}" must use HTTPS unless it is a localhost origin`,
}
}
if (url.href !== url.origin + '/') {
return {
valid: false,
message: `"${origin}" must be a plain origin without path, query, or fragment (e.g. "${url.origin}")`,
}
}
if (rpId && !isOriginCompatibleWithRpId(url.hostname, rpId)) {
return {
valid: false,
message: `"${origin}" is not compatible with Relying Party ID "${rpId}". The origin's hostname must match or be a subdomain of the RP ID.`,
}
}
}
return { valid: true }
}
function isOriginCompatibleWithRpId(originHostname: string, rpId: string): boolean {
const host = originHostname.toLowerCase()
const id = rpId.toLowerCase()
if (isLocalhost(host) && isLocalhost(id)) return true
if (host === id) return true
if (host.endsWith('.' + id)) return true
return false
}
const schema = z
.object({
PASSKEY_ENABLED: z.boolean(),
WEBAUTHN_RP_ID: z.string().trim(),
WEBAUTHN_RP_DISPLAY_NAME: z.string().trim(),
WEBAUTHN_RP_ORIGINS: z.string().trim(),
})
.superRefine((data, ctx) => {
if (!data.PASSKEY_ENABLED) return
if (!data.WEBAUTHN_RP_DISPLAY_NAME) {
ctx.addIssue({
path: ['WEBAUTHN_RP_DISPLAY_NAME'],
code: z.ZodIssueCode.custom,
message: 'Relying Party Display Name is required when Passkey is enabled',
})
}
let validatedRpId: string | null = null
if (!data.WEBAUTHN_RP_ID) {
ctx.addIssue({
path: ['WEBAUTHN_RP_ID'],
code: z.ZodIssueCode.custom,
message: 'Relying Party ID is required when Passkey is enabled',
})
} else {
validatedRpId = validateRpId(data.WEBAUTHN_RP_ID)
if (validatedRpId === null) {
ctx.addIssue({
path: ['WEBAUTHN_RP_ID'],
code: z.ZodIssueCode.custom,
message:
'Relying Party ID must be a bare domain (e.g. "example.com"). Do not include a scheme, port, or path.',
})
}
}
const origins = data.WEBAUTHN_RP_ORIGINS
if (!origins) {
ctx.addIssue({
path: ['WEBAUTHN_RP_ORIGINS'],
code: z.ZodIssueCode.custom,
message: 'Relying Party Origins is required when Passkey is enabled',
})
return
}
const result = validateWebAuthnOrigins(origins, validatedRpId)
if (!result.valid) {
ctx.addIssue({
path: ['WEBAUTHN_RP_ORIGINS'],
code: z.ZodIssueCode.custom,
message: result.message,
})
}
})
type PasskeysSettings = z.infer<typeof schema>
function getPasskeyDefault(
key: keyof Pick<
PasskeysSettings,
'WEBAUTHN_RP_ID' | 'WEBAUTHN_RP_ORIGINS' | 'WEBAUTHN_RP_DISPLAY_NAME'
>,
config: GoTrueConfig,
project: { name: string } | undefined
): string {
const siteUrl = config.SITE_URL
switch (key) {
case 'WEBAUTHN_RP_ID': {
if (!siteUrl) return ''
try {
return new URL(siteUrl).hostname
} catch {
return ''
}
}
case 'WEBAUTHN_RP_ORIGINS': {
if (!siteUrl) return ''
try {
return new URL(siteUrl).origin
} catch {
return ''
}
}
case 'WEBAUTHN_RP_DISPLAY_NAME': {
return project?.name ?? ''
}
default:
return ''
}
}
function buildPasskeysFormValues(
config: GoTrueConfig,
project: { name: string } | undefined
): PasskeysSettings {
const values: PasskeysSettings = {
PASSKEY_ENABLED: config.PASSKEY_ENABLED ?? false,
WEBAUTHN_RP_ID: config.WEBAUTHN_RP_ID || getPasskeyDefault('WEBAUTHN_RP_ID', config, project),
WEBAUTHN_RP_DISPLAY_NAME:
config.WEBAUTHN_RP_DISPLAY_NAME ||
getPasskeyDefault('WEBAUTHN_RP_DISPLAY_NAME', config, project),
WEBAUTHN_RP_ORIGINS:
config.WEBAUTHN_RP_ORIGINS || getPasskeyDefault('WEBAUTHN_RP_ORIGINS', config, project),
}
return values
}
export const PasskeysSettingsForm = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const {
data: authConfig,
isPending: isAuthConfigLoading,
isSuccess,
} = useAuthConfigQuery({ projectRef })
const { mutate: updateAuthConfig, isPending } = useAuthConfigUpdateMutation({
onSuccess: () => {
toast.success('Passkey settings updated successfully')
},
onError: (error) => {
toast.error(`Failed to update passkey settings: ${error?.message}`)
},
})
const {
can: canReadConfig,
isLoading: isLoadingPermissions,
isSuccess: isPermissionsLoaded,
} = useAsyncCheckPermissions(PermissionAction.READ, 'custom_config_gotrue')
const { can: canUpdateConfig } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'custom_config_gotrue'
)
const form = useForm<PasskeysSettings>({
resolver: zodResolver(schema),
defaultValues: {
PASSKEY_ENABLED: false,
WEBAUTHN_RP_ID: '',
WEBAUTHN_RP_DISPLAY_NAME: '',
WEBAUTHN_RP_ORIGINS: '',
},
})
useEffect(() => {
if (isSuccess && authConfig) {
form.reset(buildPasskeysFormValues(authConfig, project))
}
// form.reset is stable; authConfig/project drive when to sync from server
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSuccess, authConfig, project])
const onSubmit = (values: PasskeysSettings) => {
if (!projectRef) return
const payload: Record<string, string | boolean | null> = {
PASSKEY_ENABLED: values.PASSKEY_ENABLED,
WEBAUTHN_RP_ID: values.WEBAUTHN_RP_ID.trim() || null,
WEBAUTHN_RP_DISPLAY_NAME: values.WEBAUTHN_RP_DISPLAY_NAME.trim() || null,
WEBAUTHN_RP_ORIGINS: values.WEBAUTHN_RP_ORIGINS.trim() || null,
}
updateAuthConfig({ projectRef, config: payload })
}
const passKeysEnabled = useWatch_Shadcn_({ control: form.control, name: 'PASSKEY_ENABLED' })
if (isPermissionsLoaded && !canReadConfig) {
return <NoPermission resourceText="view passkey settings" />
}
if (isAuthConfigLoading || isLoadingPermissions || !authConfig) {
return <GenericSkeletonLoader />
}
return (
<Form_Shadcn_ {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="PASSKEY_ENABLED"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Enable Passkey authentication"
description={
<>
Allow users to sign in using passkeys (WebAuthn) with biometrics, security
keys, or platform authenticators.{' '}
<InlineLink href={`${DOCS_URL}/guides/auth/passkeys`}>Learn more</InlineLink>
</>
}
>
<FormControl_Shadcn_>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={!canUpdateConfig}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
{passKeysEnabled && (
<>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="WEBAUTHN_RP_DISPLAY_NAME"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Relying Party Display Name"
description="A human-readable name for your application shown during passkey registration."
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="My project" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="WEBAUTHN_RP_ID"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Relying Party ID"
description='The domain name for your application (e.g. "example.com"). This determines which passkeys can be used.'
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="example.com" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
<CardContent>
<FormField_Shadcn_
control={form.control}
name="WEBAUTHN_RP_ORIGINS"
render={({ field }) => (
<FormItemLayout
layout="flex-row-reverse"
label="Relying Party Origins"
description='Comma-separated list of allowed origins (e.g. "https://example.com"). HTTPS is required except for localhost.'
>
<FormControl_Shadcn_>
<Input_Shadcn_ {...field} placeholder="https://example.com" />
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</CardContent>
</>
)}
<CardFooter className="justify-end space-x-2">
<Button
type="default"
onClick={() => form.reset(buildPasskeysFormValues(authConfig, project))}
disabled={isPending}
>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={!canUpdateConfig || !form.formState.isDirty}
loading={isPending}
>
Save changes
</Button>
</CardFooter>
</Card>
</form>
</Form_Shadcn_>
)
}
@@ -13,6 +13,7 @@ const allFeaturesEnabled: GenerateAuthMenuOptions = {
multiFactor: true,
attackProtection: true,
performance: true,
passkeys: true,
},
}
@@ -27,6 +28,7 @@ const allFeaturesDisabled: GenerateAuthMenuOptions = {
multiFactor: false,
attackProtection: false,
performance: false,
passkeys: true,
},
}
@@ -53,6 +55,7 @@ describe('generateAuthMenu', () => {
expect(names).toContain('Email')
expect(names).toContain('Sign In / Providers')
expect(names).toContain('OAuth Server')
expect(names).toContain('Passkeys')
expect(names).toContain('Sessions')
expect(names).toContain('Rate Limits')
expect(names).toContain('Multi-Factor')
@@ -71,6 +74,7 @@ describe('generateAuthMenu', () => {
expect(names).toContain('OAuth Apps')
expect(names).toContain('Policies')
expect(names).toContain('OAuth Server')
expect(names).toContain('Passkeys')
expect(names).toContain('Sessions')
expect(names).toContain('URL Configuration')
expect(names).toContain('Auth Hooks')
@@ -141,4 +145,12 @@ describe('generateAuthMenu', () => {
expect(users?.url).toBe('/project/my-project/auth/users')
expect(oauthApps?.url).toBe('/project/my-project/auth/oauth-apps')
})
it('hides Passkeys when passkeys feature is false', () => {
const menu = generateAuthMenu({
...allFeaturesEnabled,
features: { ...allFeaturesEnabled.features, passkeys: false },
})
expect(flatItemNames(menu)).not.toContain('Passkeys')
})
})
@@ -15,11 +15,13 @@ export interface GenerateAuthMenuOptions {
multiFactor: boolean
attackProtection: boolean
performance: boolean
passkeys?: boolean
}
}
export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuGroup[] {
const { ref, isPlatform, showOverview, features } = options
const passkeysInMenu = Boolean(features.passkeys)
const baseUrl = `/project/${ref}/auth`
return [
@@ -84,6 +86,16 @@ export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuG
},
]
: []),
...(passkeysInMenu
? [
{
name: 'Passkeys',
key: 'passkeys',
url: `${baseUrl}/passkeys`,
label: 'Beta',
},
]
: []),
{
name: 'OAuth Server',
key: 'oauth-server',
@@ -165,6 +177,7 @@ export function generateAuthMenu(options: GenerateAuthMenuOptions): ProductMenuG
export const useGenerateAuthMenu = (): ProductMenuGroup[] => {
const { ref } = useParams()
const showOverview = useFlag('authOverviewPage')
const enablePasskeyAuth = useFlag('enablePasskeyAuth')
const {
authenticationSignInProviders,
@@ -193,6 +206,7 @@ export const useGenerateAuthMenu = (): ProductMenuGroup[] => {
multiFactor: authenticationMultiFactor,
attackProtection: authenticationAttackProtection,
performance: authenticationPerformance,
passkeys: enablePasskeyAuth,
},
})
}
@@ -0,0 +1,93 @@
import { IS_PLATFORM, useFeatureFlags, useFlag } from 'common'
import { PageContainer } from 'ui-patterns/PageContainer'
import {
PageHeader,
PageHeaderDescription,
PageHeaderMeta,
PageHeaderSummary,
PageHeaderTitle,
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { PasskeysSettingsForm } from '@/components/interfaces/Auth/Passkeys/PasskeysSettingsForm'
import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
import DefaultLayout from '@/components/layouts/DefaultLayout'
import type { NextPageWithLayout } from '@/types'
const PasskeysPage: NextPageWithLayout = () => {
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const isPasskeyAuthEnabled = useFlag('enablePasskeyAuth')
const isResolvingPasskeyFlag = IS_PLATFORM && !flagsLoaded
if (isResolvingPasskeyFlag) {
return (
<>
<PageHeader size="default">
<PageHeaderMeta>
<HeaderSummary />
</PageHeaderMeta>
</PageHeader>
<PageContainer size="default">
<PageSection>
<PageSectionContent>
<GenericSkeletonLoader />
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
if (!isPasskeyAuthEnabled) {
return (
<PageContainer size="default">
<PageSection>
<PageSectionContent>
<p className="text-sm text-foreground-light">
Passkey authentication is not available for this project.
</p>
</PageSectionContent>
</PageSection>
</PageContainer>
)
}
return (
<>
<PageHeader size="default">
<PageHeaderMeta>
<HeaderSummary />
</PageHeaderMeta>
</PageHeader>
<PageContainer size="default">
<PageSection>
<PageSectionContent>
<PasskeysSettingsForm />
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
const HeaderSummary = () => {
return (
<PageHeaderSummary>
<PageHeaderTitle>Passkeys</PageHeaderTitle>
<PageHeaderDescription>
Configure WebAuthn passkeys so users can sign in with biometrics, security keys, or platform
authenticators
</PageHeaderDescription>
</PageHeaderSummary>
)
}
PasskeysPage.getLayout = (page) => (
<DefaultLayout>
<AuthLayout title="Passkeys">{page}</AuthLayout>
</DefaultLayout>
)
export default PasskeysPage
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="gray" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-round-key-icon lucide-user-round-key"><path d="M19 11v6"/><path d="M19 13h2"/><path d="M2 21a8 8 0 0 1 12.868-6.349"/><circle cx="10" cy="8" r="5"/><circle cx="19" cy="19" r="2"/></svg>

After

Width:  |  Height:  |  Size: 386 B

+8
View File
@@ -7001,6 +7001,10 @@ export interface components {
MFA_WEB_AUTHN_ENROLL_ENABLED: boolean
MFA_WEB_AUTHN_VERIFY_ENABLED: boolean
NIMBUS_OAUTH_CLIENT_ID: string | null
PASSKEY_ENABLED: boolean
WEBAUTHN_RP_DISPLAY_NAME: string
WEBAUTHN_RP_ID: string
WEBAUTHN_RP_ORIGINS: string
NIMBUS_OAUTH_CLIENT_SECRET: string | null
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION: boolean
OAUTH_SERVER_AUTHORIZATION_PATH: string | null
@@ -10330,6 +10334,10 @@ export interface components {
MFA_WEB_AUTHN_ENROLL_ENABLED?: boolean | null
MFA_WEB_AUTHN_VERIFY_ENABLED?: boolean | null
NIMBUS_OAUTH_CLIENT_ID?: string | null
PASSKEY_ENABLED?: boolean | null
WEBAUTHN_RP_DISPLAY_NAME?: string | null
WEBAUTHN_RP_ID?: string | null
WEBAUTHN_RP_ORIGINS?: string | null
NIMBUS_OAUTH_CLIENT_SECRET?: string | null
OAUTH_SERVER_ALLOW_DYNAMIC_REGISTRATION?: boolean | null
OAUTH_SERVER_AUTHORIZATION_PATH?: string | null