mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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:
1 parent
ee6e778380
commit
5babf1092a
4 files changed
+188
-84
No files matched your search
@@ -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 }
|
||||
}
|
||||
Reference in new issue
Block a user