Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/hooks/use-conditional-passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,38 @@ export function useConditionalPasskey(input: ConditionalPasskeyInput) {
body: new URLSearchParams({ csrf: csrfToken, credential: JSON.stringify(credential) }),
});
if (abortedRef.current) return;
// 409 = the tapped credential belongs to an account this browser is ALREADY
// signed into. Not a failure: send the user there rather than showing ceremony
// error copy for a session that already exists. Full navigation, not RR
// routing — the destination resolves the session server-side.
if (res.status === 409) {
const body = (await res.json().catch(() => null)) as { loginName?: string } | null;
if (abortedRef.current) return;
setPhase('done');
if (typeof body?.loginName === 'string' && body.loginName.length > 0) {
// Land on the ACCOUNTS PICKER, not /signed-in. /signed-in resolves
// mostRecent(sessions) — the most-recently-active account, not necessarily the
// one whose passkey was just tapped — so with two live sessions this could sign
// the user into the WRONG one. The picker's switchAccount (session.service.ts)
// validates the tapped session provider-side, resolves the continuation via
// resolveNextPath(..., { requestId }), and promotes it to most-recent — the
// existing, correct idiom for "continue as a specific already-live account".
// Threading requestId/organization (rather than dropping them, as /signed-in
// would) keeps a mid-ceremony ?add=1 arrival able to hand back to the
// OIDC/SAML/device callback the picker resolves; with none, it resolves the
// same default destination /signed-in would have.
window.location.assign(
`${APP_BASENAME}${paths.accounts({
requestId: input.requestId,
organization: input.organization,
})}`
);
return;
}
// Contract violation — never leave an explicit click with no outcome.
if (explicitRef.current) setReason('unknown');
return;
}
if (!res.ok) {
// Opaque 400 (unknown user, no passkey, mint failure). Ambient flow: designed
// non-event. Explicit (button) flow: the user acted, so say something — the
Expand Down
34 changes: 17 additions & 17 deletions app/modules/i18n/locales/en.po
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ msgstr "Additional verification is required to continue."
msgid "Already have an account?"
msgstr "Already have an account?"

#: app/routes/login/index.tsx:474
#: app/routes/login/index.tsx:424
msgid "An account with this email already exists — sign in to continue."
msgstr "An account with this email already exists — sign in to continue."

Expand Down Expand Up @@ -180,7 +180,7 @@ msgstr "Choose how to sign in"
msgid "Choose how you want to verify your identity."
msgstr "Choose how you want to verify your identity."

#: app/routes/login/index.tsx:470
#: app/routes/login/index.tsx:420
msgid "Choose your login method"
msgstr "Choose your login method"

Expand Down Expand Up @@ -211,7 +211,7 @@ msgid "Connected accounts"
msgstr "Connected accounts"

#: app/routes/device/index.tsx:73
#: app/routes/login/index.tsx:594
#: app/routes/login/index.tsx:546
#: app/routes/signup/index.tsx:291
msgid "Continue"
msgstr "Continue"
Expand Down Expand Up @@ -241,7 +241,7 @@ msgstr "Couldn't verify"
msgid "Create a new account"
msgstr "Create a new account"

#: app/routes/login/index.tsx:639
#: app/routes/login/index.tsx:591
#: app/routes/signup/password.tsx:237
msgid "Create account"
msgstr "Create account"
Expand All @@ -268,8 +268,8 @@ msgstr "Device code"
msgid "Device denied"
msgstr "Device denied"

#: app/routes/login/index.tsx:427
#: app/routes/login/index.tsx:442
#: app/routes/login/index.tsx:377
#: app/routes/login/index.tsx:392
#: app/routes/signup/index.tsx:250
#: app/routes/signup/index.tsx:275
msgid "Email"
Expand All @@ -283,8 +283,8 @@ msgstr "Email code"
msgid "Email me a code"
msgstr "Email me a code"

#: app/routes/login/index.tsx:605
#: app/routes/login/index.tsx:615
#: app/routes/login/index.tsx:557
#: app/routes/login/index.tsx:567
#: app/routes/login/method.tsx:238
#: app/routes/signup/method.tsx:339
msgid "Email me a sign-in link"
Expand All @@ -306,7 +306,7 @@ msgstr "Email OTP"
msgid "Email sign-in isn't available — use your username."
msgstr "Email sign-in isn't available — use your username."

#: app/routes/login/index.tsx:426
#: app/routes/login/index.tsx:376
msgid "Email, phone, or username"
msgstr "Email, phone, or username"

Expand Down Expand Up @@ -446,7 +446,7 @@ msgstr "No signed-in accounts."
msgid "Not now"
msgstr "Not now"

#: app/routes/login/index.tsx:637
#: app/routes/login/index.tsx:589
msgid "Not registered?"
msgstr "Not registered?"

Expand All @@ -472,7 +472,7 @@ msgstr "or"
msgid "Or import this URI in your authenticator app"
msgstr "Or import this URI in your authenticator app"

#: app/routes/login/index.tsx:535
#: app/routes/login/index.tsx:487
#: app/routes/login/method.tsx:221
#: app/routes/reauth.tsx:310
#: app/routes/setup/mfa.tsx:46
Expand Down Expand Up @@ -543,8 +543,8 @@ msgstr "Password must contain an uppercase letter."
msgid "Password sign-in isn't available for this account."
msgstr "Password sign-in isn't available for this account."

#: app/routes/login/index.tsx:429
#: app/routes/login/index.tsx:444
#: app/routes/login/index.tsx:379
#: app/routes/login/index.tsx:394
msgid "Phone"
msgstr "Phone"

Expand Down Expand Up @@ -702,7 +702,7 @@ msgstr "Sign out of"
msgid "Sign out other sessions"
msgstr "Sign out other sessions"

#: app/routes/login/index.tsx:627
#: app/routes/login/index.tsx:579
msgid "Sign-in is currently unavailable for this account. Please contact your administrator."
msgstr "Sign-in is currently unavailable for this account. Please contact your administrator."

Expand Down Expand Up @@ -871,8 +871,8 @@ msgstr "Use your passkey to verify your identity."
msgid "Use your security key to verify your identity."
msgstr "Use your security key to verify your identity."

#: app/routes/login/index.tsx:430
#: app/routes/login/index.tsx:445
#: app/routes/login/index.tsx:380
#: app/routes/login/index.tsx:395
#: app/routes/sso/ldap.tsx:76
msgid "Username"
msgstr "Username"
Expand Down Expand Up @@ -943,7 +943,7 @@ msgstr "We've sent a password reset link to <0>{0}</0>"
msgid "We've sent a verification link to <0>{0}</0>"
msgstr "We've sent a verification link to <0>{0}</0>"

#: app/routes/login/index.tsx:467
#: app/routes/login/index.tsx:417
msgid "Welcome"
msgstr "Welcome"

Expand Down
96 changes: 96 additions & 0 deletions app/resources/webauthn/arm-login-passkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// app/resources/webauthn/arm-login-passkey.ts
//
// The /login loader's passkey arming decision, extracted whole.
//
// Two arms, tried in order rather than as alternatives. The user-bound arm needs a
// resolvable identity and a session the provider will accept; the discovery arm needs
// neither (mintIdentityChallenge is SELF-issued — no provider round-trip, nothing
// persisted), so it can catch every decline the first arm produces.
//
// They were previously mutually exclusive (`if (hint) … else if (!hint) …`), which meant a
// declined hint arm dead-ended on the email field even though discovery would have worked.
// Combined with a zero-live-sessions guard on discovery, that made the Passkey button
// unusable for the entire add-another-account population.
import { mintIdentityChallenge } from './identity-challenge';
import { armUserBoundChallenge } from './webauthn.service';
import type { AuthProvider } from '@/modules/auth/auth-provider';
import { listSessions, type SessionEntry } from '@/modules/auth/session/cookie';

export interface LoginPasskeyArming {
conditionalPasskey: { loginName: string; publicKeyCredentialRequestOptions: unknown } | null;
identityDiscovery: { publicKeyCredentialRequestOptions: unknown } | null;
/** Set-Cookie values the caller must append (ceremony session + fingerprint). */
setCookies: string[];
/** The hint named a user that can never fire — caller should clear the cookie. */
clearHint: boolean;
}

export interface ArmLoginPasskeyInput {
/** Signed passkey-hint cookie value, or null. */
hint: string | null;
/** `?add=1` — the user explicitly wants a DIFFERENT identity. */
isAddAccount: boolean;
sessions: SessionEntry[];
/** Request hostname — the FIDO2 relying-party id. */
hostname: string;
/** AUTH_PASSKEY_DISCOVERY_ENABLED — operational kill switch, default ON. */
discoveryEnabled: boolean;
}

export async function armLoginPasskey(
provider: AuthProvider,
request: Request,
{ hint, isAddAccount, sessions, hostname, discoveryEnabled }: ArmLoginPasskeyInput
): Promise<LoginPasskeyArming> {
let conditionalPasskey: LoginPasskeyArming['conditionalPasskey'] = null;
const setCookies: string[] = [];
let clearHint = false;

// ── Arm 1: user-bound, from the hint ────────────────────────────────────────
// Skipped under ?add=1: the hint names the account the user ALREADY holds, so arming
// it would sign them back into it — the opposite of "add another account".
if (hint && !isAddAccount) {
// LIVE session, not merely a cookie entry: armUserBoundChallenge's caller contract
// requires this, because its same-loginName supersede is safe only against dead
// entries. listSessions is the codebase's expiry-aware filter.
const hasLiveSession = listSessions(sessions, Date.now()).some(
(s) => s.loginName.toLowerCase() === hint.toLowerCase()
);
if (!hasLiveSession) {
const user = await provider.findUser(hint);
if (!user) {
// Deleted/renamed user — the hint can never fire; drop it.
clearHint = true;
} else if ((await provider.listAuthMethods(user.id)).includes('passkey')) {
try {
const armed = await armUserBoundChallenge(provider, request, sessions, user, hostname);
if (armed) {
setCookies.push(...armed.setCookies);
conditionalPasskey = {
loginName: armed.loginName,
publicKeyCredentialRequestOptions: armed.publicKeyCredentialRequestOptions,
};
}
} catch {
// Session creation failed (deactivated user, provider hiccup) — clear the hint
// and let the discovery arm below catch it.
clearHint = true;
}
}
}
}

// ── Arm 2: discovery, catching every decline above ──────────────────────────
// Free by design: self-minted options, no provider call, nothing persisted. Zitadel
// enters only at /login/passkey-discover, after a credential has actually been tapped.
// Deliberately NOT gated on live sessions: a signed-in visitor adding a second account
// is exactly the population this serves. /login/passkey-discover keeps its own
// per-user guard, so the loader decides what to OFFER and the action enforces what is
// ALLOWED.
const identityDiscovery =
!conditionalPasskey && discoveryEnabled
? { publicKeyCredentialRequestOptions: mintIdentityChallenge(hostname) }
: null;

return { conditionalPasskey, identityDiscovery, setCookies, clearHint };
}
98 changes: 25 additions & 73 deletions app/routes/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { idpTypeToSlug } from '@/modules/auth/idp-slug';
// ADAPTATION (plan-drift fix): readSessions + serializeSessions live in @/modules/auth/session/cookie.
// The locked plan block incorrectly listed them as coming from @/modules/auth/session/session
// (that module only has pure helpers, no cookie I/O).
import { readSessions, serializeSessions, listSessions } from '@/modules/auth/session/cookie';
import { readSessions, serializeSessions } from '@/modules/auth/session/cookie';
import { readLastUsedLogin } from '@/modules/auth/session/last-used-login';
import { readPasskeyHint, clearPasskeyHint } from '@/modules/auth/session/passkey-hint';
import { readReauthIntent } from '@/modules/auth/session/reauth-intent';
Expand All @@ -28,8 +28,7 @@ import {
} from '@/resources/login/login.schema';
import { resolveOrg } from '@/resources/shared/resolve-org';
import { getActiveIdPs } from '@/resources/sso/idp-providers';
import { mintIdentityChallenge } from '@/resources/webauthn/identity-challenge';
import { armUserBoundChallenge } from '@/resources/webauthn/webauthn.service';
import { armLoginPasskey } from '@/resources/webauthn/arm-login-passkey';
import { paths } from '@/routes/paths';
import { providerForRequest } from '@/server/auth-context.server';
import { loaderCsrf, assertCsrf } from '@/server/csrf';
Expand Down Expand Up @@ -86,76 +85,27 @@ export async function loader({ request }: LoaderFunctionArgs) {
const notice = url.searchParams.get('notice') ?? undefined;

// ── Usernameless fast path: arm a conditional-mediation passkey ceremony ────
// A hint is an inference; arm ONLY when nothing more specific is known. Explicit
// suppression list: ?add=1 (user asked for a different
// account), hinted user already live (nothing to log in), unresolvable user (clear the
// stale hint), no passkey method. Every suppression — and every mint failure — renders
// the ordinary page; arming is invisible either way.
// Two arms tried as a CASCADE (armLoginPasskey), not mutually-exclusive alternatives.
// The first arm binds a pre-minted, user-bound challenge to the passkey hint — but only
// when nothing more specific is known: it declines under ?add=1 (user asked for a
// different account), a hinted user already live (nothing to log in), an unresolvable
// user (the hint is cleared), or no passkey method. Every decline of the first arm falls
// through to the second: usernameless identity discovery, which serves ?add=1 and a live
// session just as well as a cold browser (gated only by the AUTH_PASSKEY_DISCOVERY_ENABLED
// kill switch). Every failure of both arms — and every mint failure — renders the
// ordinary page; arming is invisible either way.
const responseHeaders = new Headers(headers);
let conditionalPasskey: {
loginName: string;
publicKeyCredentialRequestOptions: unknown;
} | null = null;
let identityDiscovery: { publicKeyCredentialRequestOptions: unknown } | null = null;
const hint = await readPasskeyHint(request);
const isAddAccount = url.searchParams.get('add') === '1';
if (hint && !isAddAccount) {
const sessions = await readSessions(request);
// LIVE session, not just any cookie entry: raw readSessions() output can carry stale
// (expired) entries, and a stale entry must not suppress the fast path — the
// suppression criterion is a LIVE session. listSessions is the codebase's expiry-aware
// filter (same usage as session.service.ts); unknown expiry counts as live.
const hasLiveSession = listSessions(sessions, Date.now()).some(
(s) => s.loginName.toLowerCase() === hint.toLowerCase()
);
if (!hasLiveSession) {
const user = await provider.findUser(hint);
if (!user) {
// Deleted/renamed user — the hint can never fire; drop it so we stop re-checking.
responseHeaders.append('set-cookie', await clearPasskeyHint());
} else if ((await provider.listAuthMethods(user.id)).includes('passkey')) {
try {
// Mirror resolveIdentifier's known-user session mint, then persist the entry so
// the /login/passkey verify action can resolve it by loginName. The loader-side
// Set-Cookie is the accepted side effect.
// `hasLiveSession` above satisfies armUserBoundChallenge's caller contract
// (its same-loginName supersede is only safe against dead entries).
const armed = await armUserBoundChallenge(
provider,
request,
sessions,
user,
url.hostname
);
if (armed) {
for (const cookie of armed.setCookies) responseHeaders.append('set-cookie', cookie);
conditionalPasskey = {
loginName: armed.loginName,
publicKeyCredentialRequestOptions: armed.publicKeyCredentialRequestOptions,
};
}
} catch {
// Session creation failed (deactivated user, provider hiccup) —
// clear the hint, render normally.
responseHeaders.append('set-cookie', await clearPasskeyHint());
}
}
}
} else if (!hint && !isAddAccount) {
// ── Discovery arm ─────────────────────────────────────────────────────────
// Hintless visitors get a SELF-MINTED challenge: no Zitadel call, nothing
// persisted — the identity tap posts to /login/passkey-discover, which mints
// the real user-bound challenge only after a passkey was actually tapped.
// Suppressed when ANY live session exists (arming is inference; a logged-in
// visitor is better served by the ordinary page) and
// by the operational kill switch (env, default ON — incident mitigation).
const sessions = await readSessions(request);
if (env.AUTH_PASSKEY_DISCOVERY_ENABLED && listSessions(sessions, Date.now()).length === 0) {
identityDiscovery = {
publicKeyCredentialRequestOptions: mintIdentityChallenge(url.hostname),
};
}
}
const arming = await armLoginPasskey(provider, request, {
hint,
isAddAccount: url.searchParams.get('add') === '1',
sessions: await readSessions(request),
hostname: url.hostname,
discoveryEnabled: env.AUTH_PASSKEY_DISCOVERY_ENABLED,
});
for (const cookie of arming.setCookies) responseHeaders.append('set-cookie', cookie);
if (arming.clearHint) responseHeaders.append('set-cookie', await clearPasskeyHint());
const { conditionalPasskey, identityDiscovery } = arming;

return data(
{
Expand Down Expand Up @@ -521,8 +471,10 @@ export default function Login() {
onClick={() => {
// No resolvable identity — run the discovery ceremony MODALLY: the browser's native picker over the loader's
// self-minted challenge, then the discover → verify pipeline. Fall back to
// the identifier step only when discovery can't start (not armed — e.g.
// ?add=1 or a live session — or WebAuthn unsupported).
// the identifier step only when discovery can't start — the
// AUTH_PASSKEY_DISCOVERY_ENABLED kill switch is off, or WebAuthn is
// unsupported (?add=1 and a live session no longer suppress discovery; the
// loader's cascade arms it for both).
if (!passkeyIdentity) {
if (!conditional.beginDiscovery()) setShowEmailField(true);
return;
Expand Down
Loading
Loading