From 5babf1092a078cdf7215e17f0f23aed842e0d633 Mon Sep 17 00:00:00 2001 From: fadymak Date: Wed, 17 Jun 2026 18:10:30 +0200 Subject: [PATCH] feat(auth): accept Android app origins for WebAuthn RP origins (#47043) Allow `android:apk-key-hash:` entries in `WEBAUTHN_RP_ORIGINS` alongside https/localhost-http origins. Non-http(s) origins are matched as-is downstream and Android is responsible to binding the origin to the RP ID via digital asset links. --- apps/docs/content/guides/auth/passkeys.mdx | 5 +- .../Auth/Passkeys/PasskeysSettingsForm.tsx | 85 +---------------- .../PasskeysSettingsForm.utils.test.ts | 89 ++++++++++++++++++ .../Passkeys/PasskeysSettingsForm.utils.ts | 93 +++++++++++++++++++ 4 files changed, 188 insertions(+), 84 deletions(-) create mode 100644 apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.test.ts create mode 100644 apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.ts diff --git a/apps/docs/content/guides/auth/passkeys.mdx b/apps/docs/content/guides/auth/passkeys.mdx index d9d872bb3cd..1a732f7cfa7 100644 --- a/apps/docs/content/guides/auth/passkeys.mdx +++ b/apps/docs/content/guides/auth/passkeys.mdx @@ -39,7 +39,10 @@ Open the [Passkeys settings](/dashboard/project/_/auth/passkeys) from the **Auth - **Relying Party Display Name**: a human-readable name for your application shown during the passkey prompt (for example, "My App"). - **Relying Party ID**: the bare domain name for your application (for example, "example.com"). Do not include a scheme, port, or path. This determines which passkeys can be used. -- **Relying Party Origins**: comma-separated list of allowed origins (for example "https://example.com,https://app.example.com"). HTTPS is required except for loopback addresses ("localhost", "127.0.0.1", "[::1]"). Each origin's hostname must match or be a subdomain of the Relying Party ID. Up to 5 origins. +- **Relying Party Origins**: comma-separated list of allowed origins (for example "https://example.com,https://app.example.com"). Up to 5 origins. + - HTTPS is required except for loopback addresses ("localhost", "127.0.0.1", "[::1]"). + - Each origin's hostname must match or be a subdomain of the Relying Party ID. + - Android native apps can use an app origin of the form `android:apk-key-hash:`. The dashboard pre-fills these from your project's Site URL and project name. Adjust them if your production app is served from a different domain. diff --git a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx index 571222901c7..2a3cb1234a5 100644 --- a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx +++ b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.tsx @@ -19,6 +19,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' +import { validateRpId, validateWebAuthnOrigins } from './PasskeysSettingsForm.utils' import { InlineLink } from '@/components/ui/InlineLink' import NoPermission from '@/components/ui/NoPermission' import type { components } from '@/data/api' @@ -30,88 +31,6 @@ 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(), @@ -365,7 +284,7 @@ export const PasskeysSettingsForm = () => { diff --git a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.test.ts b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.test.ts new file mode 100644 index 00000000000..05bad26216c --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import { validateWebAuthnOrigins } from './PasskeysSettingsForm.utils' + +// Android native app origin: android:apk-key-hash: +const ANDROID_ORIGIN = 'android:apk-key-hash:9PVlcDFXh3IFumGfp4D08m6IcVAJYmF7gJqXdMzVwSY' + +describe('validateWebAuthnOrigins', () => { + describe('app origins', () => { + it('accepts an Android app origin alone (no rpId)', () => { + expect(validateWebAuthnOrigins(ANDROID_ORIGIN, null)).toEqual({ valid: true }) + }) + + it('accepts an Android app origin mixed with an https origin', () => { + expect( + validateWebAuthnOrigins(`https://example.com,${ANDROID_ORIGIN}`, 'example.com') + ).toEqual({ valid: true }) + }) + + it('accepts an Android app origin when rpId is set (no hostname-compatibility check)', () => { + expect(validateWebAuthnOrigins(ANDROID_ORIGIN, 'example.com')).toEqual({ valid: true }) + }) + + it('accepts a mixed-case Android app origin (no normalization)', () => { + const mixedCase = 'android:apk-key-hash:AbCdEfGh_-9PVlcDFXh3IFumGfp4D08m6IcVAJYmF7gJq' + expect(validateWebAuthnOrigins(mixedCase, null)).toEqual({ valid: true }) + }) + + it('accepts an http://localhost origin mixed with an Android app origin', () => { + expect(validateWebAuthnOrigins(`http://localhost,${ANDROID_ORIGIN}`, null)).toEqual({ + valid: true, + }) + }) + + it('counts Android app origins toward the 5-origin limit', () => { + const origins = [ + ...Array.from({ length: 4 }, (_, i) => `https://app${i}.example.com`), + ANDROID_ORIGIN, + ].join(',') + expect(validateWebAuthnOrigins(origins, 'example.com')).toEqual({ valid: true }) + }) + + it('rejects when origins exceed 5 counting an Android app origin', () => { + const origins = [ + ...Array.from({ length: 5 }, (_, i) => `https://app${i}.example.com`), + ANDROID_ORIGIN, + ].join(',') + const result = validateWebAuthnOrigins(origins, 'example.com') + expect(result).toEqual({ valid: false, message: 'A maximum of 5 origins is allowed' }) + }) + + it.each([ + ['andoid:apk-key-hash:9PVlcDFXh3IFumGfp4D08m6IcVAJYmF7gJqXdMzVwSY', 'misspelled scheme'], + ['android:foo', 'wrong android format'], + ['android:apk-key-hash:', 'empty hash'], + ['android:apk-key-hash:abc+def/ghi=', 'standard-base64 chars'], + ['chrome-extension://abc', 'not-yet-supported scheme'], + ])('rejects unsupported app origin %s (%s)', (origin) => { + const result = validateWebAuthnOrigins(origin, 'example.com') + expect(result).toEqual({ + valid: false, + message: `"${origin}" must use HTTPS or be a supported app origin`, + }) + }) + }) + + describe('web origins', () => { + it('accepts an https origin compatible with the rpId', () => { + expect(validateWebAuthnOrigins('https://app.example.com', 'example.com')).toEqual({ + valid: true, + }) + }) + + it('rejects a non-localhost http origin', () => { + const result = validateWebAuthnOrigins('http://example.com', null) + expect(result).toEqual({ + valid: false, + message: '"http://example.com" must use HTTPS unless it is a localhost origin', + }) + }) + + it('rejects an empty list', () => { + expect(validateWebAuthnOrigins('', null)).toEqual({ + valid: false, + message: 'At least one origin is required', + }) + }) + }) +}) diff --git a/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.ts b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.ts new file mode 100644 index 00000000000..28494970fa2 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.ts @@ -0,0 +1,93 @@ +// Recognized non-web WebAuthn origin schemes, matched verbatim (no normalization). +const APP_ORIGIN_SCHEMES: ReadonlyArray = [ + // Android native apps: android:apk-key-hash: + /^android:apk-key-hash:[A-Za-z0-9_-]+$/, +] + +export function isLocalhost(hostname: string): boolean { + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' +} + +export 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 + } +} + +export 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 +} + +export 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) { + // App-origin schemes (e.g. Android apk-key-hash) have no hostname to validate or + // normalize against the RP ID, so they're accepted verbatim and matched downstream as-is. + if (APP_ORIGIN_SCHEMES.some((pattern) => pattern.test(origin))) { + continue + } + + 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 or be a supported app 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 } +}