fix(auth): passkey_configured stops advertising the phantom localhost config (#2147)

Without any configuration getAppUrl() invents http://localhost as the relying party, and the UI advertised passkeys that every click answered with not configured. A localhost RP only counts as configured now when the operator declared one. The origin check also binds to the expected origins again instead of accepting every subdomain of the RP id; that widening never shipped in a release.
This commit is contained in:
Maurice
2026-09-02 19:01:03 +02:00
committed by Maurice
parent a5d87d295a
commit c026c5b1c9
4 changed files with 101 additions and 8 deletions
+21 -1
View File
@@ -230,6 +230,26 @@ export class AuthService {
// App config (public)
// -------------------------------------------------------------------------
/**
* Whether a passkey ceremony can actually complete on this deployment. With
* nothing configured at all, getAppUrl() invents `http://localhost:{PORT}` and
* the resolver returns a localhost RP for it: a phantom config that every real
* browser is turned away from at the options step (#2147), so advertising it
* as configured only produces a button whose every click 400s. A localhost RP
* the operator pointed here themselves (APP_URL, ALLOWED_ORIGINS or the
* webauthn settings) is a real single-machine install and still counts.
*/
private passkeyConfigured(): boolean {
const cfg = this.webauthn.resolve();
if (!cfg) return false;
if (cfg.rpID !== 'localhost' || cfg.explicitOrigins) return true;
const env = readEnv();
const declaredRpId = (
env.webauthn.rpId || this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'webauthn_rp_id'")?.value
)?.trim();
return !!(declaredRpId || env.app.appUrl || env.http.allowedOriginsRaw);
}
getAppConfig(authenticatedUser: User | undefined | null) {
const userCount = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const isDemo = readEnv().demo.enabled;
@@ -281,7 +301,7 @@ export class AuthService {
// are true. `passkey_configured` stays a pure boolean — it never leaks the
// resolved RP ID / origin / APP_URL on this unauthenticated endpoint.
passkey_login: toggles.passkey_login,
passkey_configured: this.webauthn.isConfigured(),
passkey_configured: this.passkeyConfigured(),
env_override_oidc_only: readEnv().oidc.only,
has_users: userCount > 0,
setup_complete: setupComplete,
+11 -4
View File
@@ -146,10 +146,15 @@ export class PasskeyService {
* The origins handed to the verifier for this ceremony. An explicit operator
* list is honored verbatim. A derived config (APP_URL fallback) often carries
* the wrong scheme or port (TLS-terminating proxy, first ALLOWED_ORIGINS
* entry), so the browser-asserted origin is added IF it falls within the RP
* ID's WebAuthn scope — the same suffix rule the browser enforces before it
* runs the ceremony at all. rpIdHash, challenge and signature checks are
* untouched, so this never accepts an origin the RP ID doesn't cover.
* entry), so the browser-asserted origin is added when it is the RP ID's own
* host and only the scheme or port drifted.
*
* The HOST is never widened, even though the browser's rule would run a
* ceremony from any subdomain of the RP ID: on an apex RP ID that exact-origin
* list is the only thing between a taken-over sibling subdomain and an
* assertion this server would accept, and the browser cannot supply it. A
* deployment that really spans several hosts under one RP ID lists them in
* webauthn_origins / WEBAUTHN_ORIGINS, which is honored verbatim above.
*/
private expectedOrigins(cfg: WebauthnConfig, resp: unknown): string[] {
if (cfg.explicitOrigins) return cfg.origins;
@@ -157,6 +162,8 @@ export class PasskeyService {
if (!clientOrigin || cfg.origins.includes(clientOrigin) || !originWithinRpScope(clientOrigin, cfg.rpID)) {
return cfg.origins;
}
// Parsing cannot throw here: originWithinRpScope only says yes on a URL it parsed.
if (new URL(clientOrigin).hostname !== cfg.rpID) return cfg.origins;
return [...cfg.origins, clientOrigin];
}