feat(auth): accept Android app origins for WebAuthn RP origins (#47043)

Allow `android:apk-key-hash:<base64url SHA-256>` 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.
This commit is contained in:
fadymak authored and GitHub committed 2026-06-17 18:10:30 +02:00
1 parent ee6e778380
commit 5babf1092a
4 files changed
+188 -84

No files matched your search

+4 -1
View File
@@ -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:<base64url SHA-256 of the signing certificate>`.
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.
@@ -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 = () => {
<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.'
description='Comma-separated list of allowed origins (e.g. "https://example.com"). HTTPS is required except for localhost. Android app origins are also accepted.'
>
<FormControl>
<Input {...field} placeholder="https://example.com" />
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { validateWebAuthnOrigins } from './PasskeysSettingsForm.utils'
// Android native app origin: android:apk-key-hash:<base64url-unpadded SHA-256 of signing cert>
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',
})
})
})
})
@@ -0,0 +1,93 @@
// Recognized non-web WebAuthn origin schemes, matched verbatim (no normalization).
const APP_ORIGIN_SCHEMES: ReadonlyArray<RegExp> = [
// Android native apps: android:apk-key-hash:<base64url-unpadded SHA-256 of signing cert>
/^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 }
}