Files
supabase/apps/studio/components/interfaces/Auth/Passkeys/PasskeysSettingsForm.utils.ts
fadymak 5babf1092a 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.
2026-06-17 18:10:30 +02:00

94 lines
2.7 KiB
TypeScript

// 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 }
}