diff --git a/app/modules/auth/session/idp-autostart.ts b/app/modules/auth/session/idp-autostart.ts
new file mode 100644
index 0000000000..354ad0801b
--- /dev/null
+++ b/app/modules/auth/session/idp-autostart.ts
@@ -0,0 +1,64 @@
+import { env } from '@/server/infra/env.server';
+import { createCookie } from 'react-router';
+
+/**
+ * ONE-SHOT marker for /login/method's sole-linked-IdP auto-start: the loginName whose IdP
+ * intent this browser has ALREADY been sent to the provider for.
+ *
+ * The auto-start lives in a LOADER (a server redirect, so the chooser never flashes), and a
+ * loader re-runs on every arrival at the URL — including the one the browser makes when the
+ * user presses Back at the provider. Without this marker that Back mints a brand-new Zitadel
+ * intent and bounces them straight forward again: the user can never return to the app. With
+ * it, the second arrival for the SAME loginName falls through and renders the chooser, whose
+ * IdP button re-starts the ceremony deliberately (a POST, where Back does not re-fire).
+ *
+ * NEVER an auth signal and never rendered — it only suppresses one automatic redirect.
+ * Same protection class as the `sessions` / `reauth-intent` cookies (which already store a
+ * loginName): httpOnly, sameSite lax, scoped to `/id`, signed with SESSION_SECRET.
+ * SHORT maxAge (10 min, matching reauth-intent): a sign-in completes promptly, and a stale
+ * marker must not keep suppressing the no-flash fast path for a later, unrelated visit.
+ */
+export const idpAutostartCookie = createCookie('idp-autostart', {
+ httpOnly: true,
+ sameSite: 'lax',
+ path: '/id',
+ secure: env.NODE_ENV === 'production',
+ secrets: [env.SESSION_SECRET],
+ maxAge: 60 * 10, // 10 minutes
+});
+
+/** Serialize the marker (the loginName just auto-started) to a Set-Cookie string. */
+export async function serializeIdpAutostart(loginName: string): Promise {
+ return idpAutostartCookie.serialize(loginName);
+}
+
+/**
+ * Expire the marker — emitted by the IDENTIFIER submit, which is the moment a NEW sign-in
+ * ceremony begins.
+ *
+ * The marker only ever needs to survive one ceremony: it exists to stop the Back-from-the-
+ * provider arrival re-minting an intent, and Back does not re-POST the identifier. Left to its
+ * own 10-minute maxAge it outlived the ceremony that wrote it, so a user who signed in, signed
+ * out, and signed straight back in got the one-button chooser instead of the auto-start the
+ * whole feature exists to give them. Clearing it here scopes the one-shot to exactly one
+ * ceremony without weakening the Back guard at all.
+ */
+export async function clearIdpAutostart(): Promise {
+ return idpAutostartCookie.serialize('', { maxAge: 0 });
+}
+
+/** Read the marked loginName. Returns null when the cookie is absent, invalid, or empty. */
+export async function readIdpAutostart(request: Request): Promise {
+ const value = await idpAutostartCookie.parse(request.headers.get('cookie'));
+ return typeof value === 'string' && value.length > 0 ? value : null;
+}
+
+/**
+ * True when this browser has already been auto-started into an IdP for `loginName`.
+ * Case-insensitive: the marker is written from the URL's loginName while the arrival that
+ * reads it may carry a different casing of the same account (IdPs / SAML round-trips and
+ * hand-typed identifiers both do this) — an exact compare would silently re-arm the trap.
+ */
+export function idpAutostartMatches(marker: string | null, loginName: string): boolean {
+ return marker !== null && marker.trim().toLowerCase() === loginName.trim().toLowerCase();
+}
diff --git a/app/modules/i18n/locales/en.po b/app/modules/i18n/locales/en.po
index 4bc049f5f2..34c67eb47b 100644
--- a/app/modules/i18n/locales/en.po
+++ b/app/modules/i18n/locales/en.po
@@ -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:424
+#: app/routes/login/index.tsx:434
msgid "An account with this email already exists — sign in to continue."
msgstr "An account with this email already exists — sign in to continue."
@@ -172,7 +172,7 @@ msgstr "Choose a new password"
msgid "Choose an account"
msgstr "Choose an account"
-#: app/routes/login/method.tsx:184
+#: app/routes/login/method.tsx:341
msgid "Choose how to sign in"
msgstr "Choose how to sign in"
@@ -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:420
+#: app/routes/login/index.tsx:430
msgid "Choose your login method"
msgstr "Choose your login method"
@@ -211,7 +211,7 @@ msgid "Connected accounts"
msgstr "Connected accounts"
#: app/routes/device/index.tsx:73
-#: app/routes/login/index.tsx:546
+#: app/routes/login/index.tsx:556
#: app/routes/signup/index.tsx:291
msgid "Continue"
msgstr "Continue"
@@ -241,7 +241,7 @@ msgstr "Couldn't verify"
msgid "Create a new account"
msgstr "Create a new account"
-#: app/routes/login/index.tsx:591
+#: app/routes/login/index.tsx:601
#: app/routes/signup/password.tsx:237
msgid "Create account"
msgstr "Create account"
@@ -268,8 +268,8 @@ msgstr "Device code"
msgid "Device denied"
msgstr "Device denied"
-#: app/routes/login/index.tsx:377
-#: app/routes/login/index.tsx:392
+#: app/routes/login/index.tsx:387
+#: app/routes/login/index.tsx:402
#: app/routes/signup/index.tsx:250
#: app/routes/signup/index.tsx:275
msgid "Email"
@@ -283,9 +283,9 @@ msgstr "Email code"
msgid "Email me a code"
msgstr "Email me a code"
-#: app/routes/login/index.tsx:557
#: app/routes/login/index.tsx:567
-#: app/routes/login/method.tsx:238
+#: app/routes/login/index.tsx:577
+#: app/routes/login/method.tsx:411
#: app/routes/signup/method.tsx:339
msgid "Email me a sign-in link"
msgstr "Email me a sign-in link"
@@ -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:376
+#: app/routes/login/index.tsx:386
msgid "Email, phone, or username"
msgstr "Email, phone, or username"
@@ -446,13 +446,13 @@ msgstr "No signed-in accounts."
msgid "Not now"
msgstr "Not now"
-#: app/routes/login/index.tsx:589
+#: app/routes/login/index.tsx:599
msgid "Not registered?"
msgstr "Not registered?"
#: app/components/identity-badge/identity-badge.tsx:30
#: app/routes/device/authorize.tsx:122
-#: app/routes/login/method.tsx:193
+#: app/routes/login/method.tsx:350
#: app/routes/passkeys.tsx:249
#: app/routes/reauth.tsx:280
#: app/routes/signed-in.tsx:49
@@ -472,8 +472,8 @@ 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:487
-#: app/routes/login/method.tsx:221
+#: app/routes/login/index.tsx:497
+#: app/routes/login/method.tsx:394
#: app/routes/reauth.tsx:310
#: app/routes/setup/mfa.tsx:46
msgid "Passkey"
@@ -511,7 +511,7 @@ msgstr "Passkeys"
msgid "Passkeys let you sign in with your fingerprint, face, or device PIN."
msgstr "Passkeys let you sign in with your fingerprint, face, or device PIN."
-#: app/routes/login/method.tsx:255
+#: app/routes/login/method.tsx:428
#: app/routes/reauth.tsx:339
#: app/routes/reauth.tsx:377
#: app/routes/signup/password.tsx:229
@@ -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:379
-#: app/routes/login/index.tsx:394
+#: app/routes/login/index.tsx:389
+#: app/routes/login/index.tsx:404
msgid "Phone"
msgstr "Phone"
@@ -702,7 +702,7 @@ msgstr "Sign out of"
msgid "Sign out other sessions"
msgstr "Sign out other sessions"
-#: app/routes/login/index.tsx:579
+#: app/routes/login/index.tsx:589
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."
@@ -715,7 +715,7 @@ msgstr "Signed-in sessions on other devices can still be active. Sign out your o
msgid "Signing in as"
msgstr "Signing in as"
-#: app/routes/login/method.tsx:187
+#: app/routes/login/method.tsx:344
msgid "Signing in as <0>{loginName}0>."
msgstr "Signing in as <0>{loginName}0>."
@@ -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:380
-#: app/routes/login/index.tsx:395
+#: app/routes/login/index.tsx:390
+#: app/routes/login/index.tsx:405
#: app/routes/sso/ldap.tsx:76
msgid "Username"
msgstr "Username"
@@ -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:417
+#: app/routes/login/index.tsx:427
msgid "Welcome"
msgstr "Welcome"
diff --git a/app/resources/login/login-decision.ts b/app/resources/login/login-decision.ts
index 66b0fcea4f..18cfaf5b96 100644
--- a/app/resources/login/login-decision.ts
+++ b/app/resources/login/login-decision.ts
@@ -8,13 +8,6 @@ export type Decision =
| { kind: 'redirect'; path: string; params?: Record }
| { kind: 'error'; error: string };
-const SINGLE_TARGET: Record = {
- passkey: '/login/passkey',
- idp: '/sso',
- password: '/login/password',
- otp_email: '/login/verify/email',
-};
-
export function decideAfterIdentifier({
methods,
settings,
@@ -41,9 +34,11 @@ export function decideAfterIdentifier({
if (methods.includes('password') && settings.allowPassword) available.push('password');
if (methods.includes('otp_email') && emailDeliveryEnabled) available.push('otp_email');
- if (available.length >= 2) return { kind: 'redirect', path: '/login/method' };
-
- if (available.length === 1) return { kind: 'redirect', path: SINGLE_TARGET[available[0]] };
+ // Every account with at least one usable method goes to the chooser, which renders
+ // exactly the methods it has (and auto-starts the ones that need no form). Routing
+ // per-method from here is what sent sole-IdP users to a bare /sso page: this function
+ // is pure over method KINDS, so it has no idpId and could only name a static path.
+ if (available.length >= 1) return { kind: 'redirect', path: '/login/method' };
// available.length === 0: surface the most actionable error.
if (methods.includes('password') && !settings.allowPassword) {
diff --git a/app/resources/login/login.service.ts b/app/resources/login/login.service.ts
index 4f45d02a97..42f70d0e7e 100644
--- a/app/resources/login/login.service.ts
+++ b/app/resources/login/login.service.ts
@@ -25,8 +25,13 @@ import type { LoginSettings, ProviderErrorCode } from '@/modules/auth/types';
import { ProviderError } from '@/modules/auth/types';
import { decideAfterIdentifier } from '@/resources/login/login-decision';
import { isEmailLike } from '@/resources/login/login.schema';
+import {
+ GHOST_METHODS,
+ emailDomain,
+ resolveGhostPolicyOrg,
+} from '@/resources/login/method-options';
import { nextStepFromSession, threadParams } from '@/resources/shared/next-step-params';
-import { resolveOrg } from '@/resources/shared/resolve-org';
+import { resolveOrg, resolvePolicyOrg } from '@/resources/shared/resolve-org';
import { idpReturnUrls } from '@/resources/sso/idp-return-urls';
import { paths } from '@/routes/paths';
import { logAuthEvent, hashActor } from '@/server/observability';
@@ -69,6 +74,12 @@ export interface StartIdpInput {
* is the real guard, this only improves the picker UX.
*/
reauthHint?: string;
+ /**
+ * The POLICY org this intent was decided under, when it differs from `organization`.
+ * Rides on the success URL so the callback gates `allowRegister` (and auto-creates) in the
+ * SAME org whose IdP list the caller picked from. See IdpReturnOpts.policyOrg.
+ */
+ policyOrg?: string;
/** MaxMind device-fingerprint token captured client-side; attached as session metadata. */
deviceTrackingToken?: string;
}
@@ -85,12 +96,21 @@ export type StartIdpResult = { ok: true; authUrl: string } | { ok: false; error:
*/
export async function startIdpIntent(
provider: AuthProvider,
- { idpId, origin, requestId, organization, reauthHint, deviceTrackingToken }: StartIdpInput
+ {
+ idpId,
+ origin,
+ requestId,
+ organization,
+ reauthHint,
+ policyOrg,
+ deviceTrackingToken,
+ }: StartIdpInput
): Promise {
const slug = idpTypeToSlug(idpId) ?? idpId;
const { success, failure } = idpReturnUrls(origin, slug, {
requestId,
organization,
+ policyOrg,
deviceTrackingToken,
});
// Guard the provider call: a ProviderError (Zitadel unavailable / IdP intent rejected) is the
@@ -183,13 +203,6 @@ export type ResolveIdentifierResult =
| ResolveIdentifierStartIdp
| { ok: false; error: ResolveIdentifierError };
-/** Lowercased domain of an email-style identifier, or null when it is not an email. */
-function emailDomain(loginName: string): string | null {
- const at = loginName.lastIndexOf('@');
- if (at <= 0 || at === loginName.length - 1) return null;
- return loginName.slice(at + 1).toLowerCase();
-}
-
/**
* Identifier step: resolve the user, create the ceremony session, decide the next
* factor screen, and shape the ceremony-session entry + threaded query params.
@@ -247,16 +260,30 @@ export async function resolveIdentifier(
}
}
+ // The caller's already-fetched settings describe `organization`, so they are reusable only
+ // while the resolved org still equals it (domain discovery above may have moved `org`).
+ // Shared by BOTH branches below: the known path saves an RPC with it, and the unknown path
+ // must save exactly the same one — an extra provider round-trip on one side only is a timing
+ // difference between "account exists" and "account does not", which is what this whole flag
+ // exists to erase.
+ const reusableSettings =
+ threadedSettings && org !== undefined && org === organization ? threadedSettings : undefined;
+
const user = await provider.findUser(loginName, org);
if (!user) {
// ignoreUnknownUsernames (settings-gated, DEFAULT-OFF): with the flag off this is
- // byte-identical to before (USER_NOT_FOUND). With it on, proceed to the password
- // step bound to the typed loginName but with NO user attached — the credential check
- // then fails generically, identical to a wrong password for a real account.
- // Org-first: resolve the org (explicit wins; else default org) before reading the policy that
- // gates ignoreUnknownUsernames / disableLoginWithEmail. Reading raw `org` (often undefined
- // here) returned INSTANCE settings, so these gates could diverge from the default org's policy.
- const settings = await provider.getLoginSettings(await resolveOrg(provider, org));
+ // byte-identical to before (USER_NOT_FOUND). With it on, proceed bound to the typed loginName
+ // but with NO user attached — the credential check then fails generically, identical to a
+ // wrong password for a real account.
+ // Resolve the policy org from the identifier's DOMAIN, not the default org — see
+ // resolveGhostPolicyOrg. Reading the default org here judged `nobody@acme.test` under a
+ // different policy than `mia@acme.test`, and when the two disagreed on `allowPassword` the
+ // redirect targets split (known → /login/method, ghost → /error): the exact account-existence
+ // oracle this flag exists to close. It also gates ignoreUnknownUsernames /
+ // disableLoginWithEmail, which now answer for the org that actually claims the address.
+ const settings =
+ reusableSettings ??
+ (await provider.getLoginSettings(await resolveGhostPolicyOrg(provider, org, loginName)));
if (settings.ignoreUnknownUsernames !== true) {
// disableLoginWithEmail (settings-gated, DEFAULT-OFF): an email is never a valid loginname
// under this policy, so the lookup already failed. Refine the generic not-found into a
@@ -280,7 +307,28 @@ export async function resolveIdentifier(
sessionEntryFromSession(ghostSession, { loginName, organization: org, requestId })
);
const ghostParams = new URLSearchParams(threadParams(loginName, requestId, org));
- return { ok: true, target: '/login/password', params: ghostParams, sessions: ghostSessions };
+ // ROUTE THE GHOST THROUGH THE REAL DECISION, never a target of its own.
+ //
+ // This branch's ONLY job is to be indistinguishable from a real account, and a hardcoded
+ // '/login/password' stopped being that the moment every known account with >= 1 usable method
+ // started resolving to /login/method: known → /login/method, unknown → /login/password turns
+ // the redirect target into a perfect account-existence oracle and silently disables the very
+ // setting that put us here. The collision was the protection, so re-establish it structurally —
+ // ask decideAfterIdentifier the same question a PASSWORD-ONLY account asks (GHOST_METHODS),
+ // under the same settings, and take the same answer. An org that forbids passwords sends both
+ // to /error; every other org sends both to /login/method, whose loader serves the ghost the
+ // identical chooser (see routes/login/method.tsx's ghost-subject note).
+ const ghostDecision = decideAfterIdentifier({
+ methods: [...GHOST_METHODS],
+ settings,
+ emailDeliveryEnabled,
+ context: { role: 'primary' },
+ });
+ if (ghostDecision.kind === 'redirect') {
+ Object.entries(ghostDecision.params ?? {}).forEach(([k, v]) => ghostParams.set(k, v));
+ return { ok: true, target: ghostDecision.path, params: ghostParams, sessions: ghostSessions };
+ }
+ return { ok: true, target: paths.error(), params: ghostParams, sessions: ghostSessions };
}
logAuthEvent('identifier', 'success', { actor: hashActor(loginName) });
@@ -294,13 +342,18 @@ export async function resolveIdentifier(
// pinned and domain-discovery didn't run (org === undefined), the user may live in a
// different org than the default. Use user.orgId as the authoritative org so the
// settings (allowPassword, passkeysType, …) match the user's actual policies.
- // The caller's threadedSettings are only reused when both the caller pinned an explicit
- // org AND the user's org matches it (org !== undefined && org === organization).
- const userOrg = org ?? user.orgId;
- const settings =
- threadedSettings && org !== undefined && org === organization
- ? threadedSettings
- : await provider.getLoginSettings(userOrg);
+ //
+ // resolvePolicyOrg, NOT a bare `org ?? user.orgId`: `User.orgId` is OPTIONAL, and when it was
+ // absent this read fell through to INSTANCE settings while /login/method's loader — which
+ // re-computes the very availability decided here — fell through to the DEFAULT ORG's. Those
+ // two policies can disagree, and when they do this function approves a method the chooser then
+ // computes away, bouncing the user to /error. Both sides now share the one helper.
+ // `reusableSettings` (computed above, shared with the unknown-identifier branch) already
+ // encodes the only condition under which the caller's threaded settings describe this org:
+ // an explicit `organization` that survived discovery — in which case resolvePolicyOrg is the
+ // identity on `org` anyway.
+ const userOrg = await resolvePolicyOrg(provider, org, user.orgId);
+ const settings = reusableSettings ?? (await provider.getLoginSettings(userOrg));
const decision = decideAfterIdentifier({
methods,
settings,
diff --git a/app/resources/login/method-options.ts b/app/resources/login/method-options.ts
new file mode 100644
index 0000000000..8773907b27
--- /dev/null
+++ b/app/resources/login/method-options.ts
@@ -0,0 +1,159 @@
+// app/resources/login/method-options.ts
+//
+// "What can this identified account actually sign in with RIGHT NOW?" — the resolution behind
+// the /login/method chooser, extracted from the route loader so that loader stays a thin
+// request/response shell (session gate → reads → decide → render).
+//
+// Applies the SAME policy gates decideAfterIdentifier (login-decision.ts) applies, plus the one
+// thing that function structurally cannot know: whether the account's enrolled 'idp' method
+// resolves to any actually-usable linked provider. decideAfterIdentifier is pure over method
+// KINDS, so it counts 'idp' from `methods.includes('idp') && settings.allowExternalIdp` alone;
+// only this resolution sees that the links may all be missing, deactivated, or LDAP-only.
+import type { AuthProvider } from '@/modules/auth/auth-provider';
+import type { AuthMethod, IdProvider, LoginSettings } from '@/modules/auth/types';
+import { resolveOrg } from '@/resources/shared/resolve-org';
+import { getActiveIdPs } from '@/resources/sso/idp-providers';
+import { joinLinkedIdps, type LinkedIdpView } from '@/resources/sso/sso-management';
+
+/** The four primary sign-in methods the chooser can offer. Closed set. */
+export type ChooserMethod = 'passkey' | 'password' | 'otp_email' | 'idp';
+
+/**
+ * The enrolment the chooser SYNTHESIZES for a GHOST subject — an identifier that cleared
+ * /login/method's session gate but resolves to no account.
+ *
+ * `ignoreUnknownUsernames` is a tenant-facing anti-enumeration setting: with it on, /login's
+ * identifier step plants a real ceremony session for an UNKNOWN identifier and routes it to the
+ * SAME target a known account gets, so the response reveals nothing. That protection is ENTIRELY
+ * the indistinguishability, so the chooser must serve the ghost the very screen a real
+ * PASSWORD-ONLY account gets.
+ *
+ * Password, and only password, because it is the one method an unknown identifier can plausibly
+ * own end-to-end: the credential check at /login/password already fails generically for a ghost
+ * session (that is the whole design of the flag), whereas a passkey or IdP button would be an
+ * offer the ceremony cannot honour — and failing differently is exactly the leak we are closing.
+ *
+ * Fed through {@link resolveMethodOptions} like any other enrolment rather than short-circuiting
+ * the render, so an org that forbids passwords dead-ends the ghost at the same place it dead-ends
+ * a real password-only account instead of offering a button policy would refuse.
+ */
+export const GHOST_METHODS: readonly AuthMethod[] = Object.freeze(['password']);
+
+/** Lowercased domain of an email-style identifier, or null when it is not an email. */
+export function emailDomain(loginName: string): string | null {
+ const at = loginName.lastIndexOf('@');
+ if (at <= 0 || at === loginName.length - 1) return null;
+ return loginName.slice(at + 1).toLowerCase();
+}
+
+/**
+ * The POLICY org a GHOST subject is judged under — the counterpart to {@link resolvePolicyOrg}
+ * for a subject that resolves to no account.
+ *
+ * WHY THIS EXISTS. A ghost has no `user.orgId`, so `resolvePolicyOrg`'s middle rung has nothing
+ * to bite on and the read fell through to the DEFAULT org — while a real account at the same
+ * address was judged by its OWN org. When those two policies disagreed the two branches took
+ * different routes (known → /login/method, ghost → /error for a default org with
+ * `allowPassword: false`), which is precisely the account-existence oracle
+ * `ignoreUnknownUsernames` exists to prevent. Both hops read this — the identifier decision
+ * (login.service.ts) and the chooser loader (routes/login/method.tsx) — so they cannot drift.
+ *
+ * Resolve from the identifier's DOMAIN instead: `nobody@acme.test` is then judged by the very
+ * org `mia@acme.test` belongs to.
+ *
+ * DELIBERATELY NOT GATED on `allowDomainDiscovery`. That flag governs whether a domain may ROUTE
+ * a ceremony (map an org and skip screens); this is a POLICY READ only — it never changes where
+ * anyone lands, only which org's settings decide it. Gating it would leave the oracle open for
+ * every tenant with discovery off, which is the default.
+ *
+ * ACCEPTED TRADE: an org whose domain is registered becomes distinguishable from one whose is
+ * not. That is ORG-existence, not ACCOUNT-existence — it says nothing about whether any
+ * particular person has an account, which is the property the flag protects.
+ *
+ * SIDE BENEFIT: the extra read balances the known branch's `listAuthMethods`, so the two
+ * branches now spend the same number of provider calls for an email-style identifier — closing
+ * the coarse timing channel they otherwise had. A NON-EMAIL ghost has no domain to look up and
+ * still spends one call fewer; that residual is unavoidable without a wasted round-trip.
+ *
+ * NOT error-guarded, on purpose: the known branch's `listAuthMethods` is not either, so a
+ * provider outage must surface identically on both paths rather than degrading one of them.
+ */
+export async function resolveGhostPolicyOrg(
+ provider: AuthProvider,
+ urlOrg: string | undefined,
+ loginName: string
+): Promise {
+ // An explicit (or already domain-discovered) org wins outright — same precedence
+ // resolvePolicyOrg gives it, and the caller's threaded settings already describe it.
+ if (urlOrg !== undefined) return resolveOrg(provider, urlOrg);
+ const domain = emailDomain(loginName);
+ const hit = domain ? await provider.findOrgByDomain(domain) : null;
+ return resolveOrg(provider, hit?.orgId);
+}
+
+export interface MethodOptions {
+ /** Policy-permitted, actually-usable primary methods, in chooser display order. */
+ available: ChooserMethod[];
+ /** This user's linked, active, non-LDAP providers — the buttons behind `available: ['idp']`. */
+ idps: IdProvider[];
+}
+
+export interface MethodOptionsInput {
+ userId: string;
+ /**
+ * Enrolled methods (provider.listAuthMethods) — the caller already fetched them, or
+ * {@link GHOST_METHODS} for a subject that resolves to no account.
+ */
+ methods: readonly AuthMethod[];
+ /** Login settings read for the POLICY org (see resolvePolicyOrg). */
+ settings: LoginSettings;
+ /** The POLICY org — the same one `settings` was read for. */
+ policyOrg?: string;
+ /** env.AUTH_EMAIL_DELIVERY_ENABLED, threaded so this stays a pure-input decision. */
+ emailDeliveryEnabled: boolean;
+}
+
+/**
+ * Resolve the chooser's offer for one identified user. The only provider I/O is the linked-IdP
+ * join, and only when 'idp' is both enrolled and policy-allowed — a password-only account costs
+ * nothing extra.
+ */
+export async function resolveMethodOptions(
+ provider: AuthProvider,
+ { userId, methods, settings, policyOrg, emailDeliveryEnabled }: MethodOptionsInput
+): Promise {
+ const available: ChooserMethod[] = [];
+ if (methods.includes('passkey') && settings.passkeysType !== 'not_allowed') {
+ available.push('passkey');
+ }
+
+ // Resolve the user's linked (redirect-based) IdPs directly into IdpButtonList's IdProvider
+ // shape — mirrors reauth.service.ts's loadReauth idp-resolution, including the LDAP exclusion
+ // (LDAP needs its own credential form, not an OAuth round-trip). A link whose provider is no
+ // longer active has no name/type to join — filtered out, since there is no sign-in button to
+ // offer for a dead provider.
+ let idps: IdProvider[] = [];
+ if (methods.includes('idp') && settings.allowExternalIdp) {
+ const [links, active] = await Promise.all([
+ provider.listIdpLinks(userId),
+ getActiveIdPs(provider, policyOrg),
+ ]);
+ idps = joinLinkedIdps(links, active)
+ .filter(
+ (l): l is LinkedIdpView & { name: string; type: string } =>
+ l.name !== undefined && l.type !== undefined && l.type !== 'LDAP'
+ )
+ .map((l) => ({ id: l.idpId, name: l.name, type: l.type, logoUrl: l.logoUrl }));
+ if (idps.length > 0) available.push('idp');
+ }
+
+ if (methods.includes('password') && settings.allowPassword) available.push('password');
+ if (methods.includes('otp_email') && emailDeliveryEnabled) available.push('otp_email');
+
+ return { available, idps };
+}
+
+/** True when the account's ONLY usable method is exactly one linked IdP (the auto-start case). */
+export function isSoleLinkedIdp({ available, idps }: MethodOptions): boolean {
+ return available.length === 1 && available[0] === 'idp' && idps.length === 1;
+}
diff --git a/app/resources/shared/resolve-org.ts b/app/resources/shared/resolve-org.ts
index a0d2d00801..03f00bafce 100644
--- a/app/resources/shared/resolve-org.ts
+++ b/app/resources/shared/resolve-org.ts
@@ -63,3 +63,42 @@ export async function resolveOrg(
): Promise {
return urlOrg ?? env.ZITADEL_DEFAULT_ORG_ID ?? (await getCachedDefaultOrg(provider)) ?? undefined;
}
+
+/**
+ * The POLICY org for a POST-IDENTIFIER decision — i.e. every read (`getLoginSettings`,
+ * `getActiveIdPs`) that decides which sign-in methods an IDENTIFIED user may use.
+ *
+ * Precedence: the ceremony's explicit `?organization=` wins, else the org the FOUND USER
+ * actually belongs to, else {@link resolveOrg}'s default-org fallback.
+ *
+ * SHARED ON PURPOSE. `resolveIdentifier` (login.service.ts) decides a user has a usable method
+ * and routes them to /login/method; that route's loader then RE-computes the same availability.
+ * If the two resolve a different org they read different policies, and the loader can compute
+ * `available: []` for a user the decision just approved — a bounce to /error on the product's
+ * most travelled path. They previously agreed only for users WITH an `orgId`: `User.orgId` is
+ * optional, and when it was absent the decision fell through to INSTANCE settings while the
+ * loader fell through to the DEFAULT ORG's. Routing both through this one helper makes the
+ * agreement structural instead of coincidental.
+ *
+ * KNOWN, PRE-EXISTING: `urlOrg` is the raw `?organization=` query param and it WINS here, so
+ * whoever composes a URL — not only the ceremony that minted it — picks which org's policy
+ * decides an identified user's available methods. A permissive org named there can re-enable a
+ * method the user's own org disabled (`allowPassword`, `passkeysType`); a restrictive one can
+ * collapse their options to a single IdP. That is the trust level `?organization=` has always
+ * carried (every per-method screen was directly reachable with it long before this chooser
+ * existed) and the credential checks downstream are enforced by Zitadel, not by these settings —
+ * but note the chooser LOADER is a STATE-CHANGING consumer of it, since a collapsed option set
+ * can trigger the sole-IdP auto-start, which the per-method screens never did. Closing it means
+ * authenticating the param the way sso's `policyOrg` now is (see server/signed-param.ts);
+ * deliberately out of scope here because it predates and outlives this flow.
+ *
+ * A subject that resolves to NO account has no `userOrgId` for the middle rung and must not
+ * silently fall through to the default org — see `resolveGhostPolicyOrg` (login/method-options).
+ */
+export async function resolvePolicyOrg(
+ provider: AuthProvider,
+ urlOrg: string | undefined,
+ userOrgId: string | undefined
+): Promise {
+ return resolveOrg(provider, urlOrg ?? userOrgId);
+}
diff --git a/app/resources/sso/idp-return-urls.ts b/app/resources/sso/idp-return-urls.ts
index bd1160749a..6e9f8eb2f4 100644
--- a/app/resources/sso/idp-return-urls.ts
+++ b/app/resources/sso/idp-return-urls.ts
@@ -14,14 +14,34 @@
// APP_BASENAME is defined once in resources/shared; re-exported here for back-compat
// (the sso barrel and this module's URL builder both consume it).
import { APP_BASENAME } from '@/resources/shared/app-basename';
+import { signParam } from '@/server/signed-param';
export { APP_BASENAME };
+/**
+ * HMAC purpose tag for `policyOrg`. Shared with the callback that verifies it — a mismatch here
+ * is a fail-closed (the callback falls back to `organization`), never an accepted forgery.
+ */
+export const POLICY_ORG_PURPOSE = 'sso.policyOrg';
+
export interface IdpReturnOpts {
/** Original OIDC/SAML request to resume after the IdP round-trip (e.g. `oidc_…`). */
requestId?: string;
/** Org scope to carry through the ceremony. */
organization?: string;
+ /**
+ * The POLICY org this intent was decided under, when it differs from the ceremony
+ * `organization` (which is the RAW `?organization=` the user arrived with, often absent).
+ *
+ * The callback resolves the org it reads `allowRegister` from — and auto-creates into — from
+ * `organization`, which on a bare flow falls back to the DEFAULT org. A caller that picked
+ * this IdP out of a list resolved for a DIFFERENT org (e.g. /login/method resolving the found
+ * user's own org) would otherwise have its start decided under one policy and its landing
+ * decided under another. Carrying it separately fixes that divergence WITHOUT widening
+ * `organization`, whose absence is what keeps the callback's `findUser` lookups instance-wide
+ * (see the note at sso-callback.ts's `callbackOrg`).
+ */
+ policyOrg?: string;
/** Account-linking flow (sets `?link=true` for the callback). */
link?: boolean;
/**
@@ -45,6 +65,22 @@ export function idpReturnUrls(
if (opts?.link) query.set('link', 'true');
if (opts?.requestId) query.set('requestId', opts.requestId);
if (opts?.organization) query.set('organization', opts.organization);
+ // Only when it actually differs — an identical value would be pure URL noise, and omitting it
+ // keeps every existing caller's success URL byte-identical.
+ //
+ // SIGNED, because this param is the one thing on the URL that is NOT already coupled to
+ // something else. `organization` drags the callback's same-email findUser scope, the created
+ // session's orgId and signInWithIdpIntent along with it, so naming a foreign org there costs an
+ // attacker the whole flow (an accidental fail-closed). `policyOrg` drags nothing: it feeds
+ // `callbackOrg` alone, which is what gates `allowRegister` and picks the auto-create org. A
+ // hand-written `?policyOrg=` would therefore borrow that org's
+ // registration policy while leaving the same-email lookup instance-wide — with
+ // ALLOW_IDP_AUTO_LINK on, a path into a victim's account in ANY org. Length validation cannot
+ // help: decoupling those two orgs is the param's whole purpose, so only provenance can.
+ if (opts?.policyOrg && opts.policyOrg !== opts.organization) {
+ query.set('policyOrg', opts.policyOrg);
+ query.set('policyOrgSig', signParam(POLICY_ORG_PURPOSE, opts.policyOrg));
+ }
if (opts?.deviceTrackingToken) query.set('deviceTrackingToken', opts.deviceTrackingToken);
const qs = query.toString();
// The failure URL MUST carry requestId/organization too — the IdP broker redirects here
diff --git a/app/resources/sso/sso-callback.ts b/app/resources/sso/sso-callback.ts
index 8e3720b313..faa62f7be6 100644
--- a/app/resources/sso/sso-callback.ts
+++ b/app/resources/sso/sso-callback.ts
@@ -23,10 +23,12 @@ import { registerAndLinkIdp } from '@/resources/signup';
import { MAXMIND_TRACKING_TOKEN_METADATA_KEY } from '@/resources/signup/signup.service';
import { deriveIdpProfileName } from '@/resources/sso/derive-idp-name';
import { decideIdpCallback } from '@/resources/sso/idp-callback';
+import { POLICY_ORG_PURPOSE } from '@/resources/sso/idp-return-urls';
import { signInWithIdpIntent, requestScopedProviderReads } from '@/resources/sso/idp-session';
import type { SsoOutcome } from '@/resources/sso/sso-outcome';
import { env } from '@/server/infra/env.server';
import { logAuthEvent } from '@/server/observability';
+import { verifyParam } from '@/server/signed-param';
import { getOrCreateFingerprintId, userAgentFromRequest } from '@/server/user-agent';
import { providerErrorCode } from '@/utils/errors/auth-error';
import { z } from 'zod';
@@ -48,6 +50,19 @@ export const CallbackQuery = z.object({
// Attacker-controllable from the callback URL; POSTURE B2 is fail-closed against a wrong org, but
// cap the length as defense-in-depth (Zitadel org ids are short numeric strings).
organization: z.string().max(64).optional(),
+ // The POLICY org the START side decided this intent under (idp-return-urls.ts). Feeds
+ // `callbackOrg` ONLY — never the findUser lookups below, which stay on raw `organization`.
+ //
+ // That asymmetry is exactly why it must be AUTHENTICATED and not merely capped. `organization`
+ // is attacker-controllable too, but naming a foreign org there also narrows the same-email
+ // lookup and poisons createSession's orgId — it fails closed. `policyOrg` is coupled to none of
+ // that: it decides `allowRegister` and the auto-create org and nothing else, so an unsigned one
+ // is a way to borrow a registration-open org's policy while keeping the lookup instance-wide.
+ // Verified against `policyOrgSig` below; anything that fails falls back to `organization`.
+ policyOrg: z.string().max(64).optional(),
+ // Detached HMAC over `policyOrg`, minted by idpReturnUrls with SESSION_SECRET. base64url
+ // SHA-256 is 43 chars; the cap is slack, not a check — verifyParam is the check.
+ policyOrgSig: z.string().max(128).optional(),
// MaxMind device-fingerprint token captured client-side and threaded through the OAuth
// round-trip (see idp-return-urls.ts) so it can be attached to the resulting session's metadata.
deviceTrackingToken: z.string().optional(),
@@ -94,7 +109,28 @@ export async function processIdpCallback(
};
}
- const { id, token, link, requestId, organization, deviceTrackingToken } = parsed.data;
+ const { id, token, link, requestId, organization, policyOrg, policyOrgSig, deviceTrackingToken } =
+ parsed.data;
+
+ // AUTHENTICATE the policy org before it is allowed to influence anything. An unsigned or
+ // mis-signed value is treated as absent — the callback falls back to `organization`, i.e. to
+ // exactly the behavior it had before the param existed. Logged (org ids are not PII) because a
+ // signature failure here is either tampering or a SESSION_SECRET rotation mid-flight, and both
+ // are worth seeing.
+ let verifiedPolicyOrg: string | undefined;
+ if (policyOrg) {
+ if (verifyParam(POLICY_ORG_PURPOSE, policyOrg, policyOrgSig)) {
+ verifiedPolicyOrg = policyOrg;
+ } else {
+ // snake_case per the P5+ naming convention, and the same `invalid_signature` reason the
+ // `sessions` cookie's tamper signal uses — one vocabulary for "we did not mint this".
+ logAuthEvent('idp_policy_org', 'failure', {
+ reason: 'invalid_signature',
+ requestId,
+ policyOrg,
+ });
+ }
+ }
// Ensure the fingerprintId cookie exists for this browser. The SAME minted id feeds
// every createSession userAgent below (no first-session gap); fingerprintCookie is
@@ -115,14 +151,22 @@ export async function processIdpCallback(
let intent: IdpIntentResult;
let entries: Awaited>;
let decision: ReturnType;
- // Resolve the effective org for this callback ceremony — org-first (URL ?organization=),
- // then ZITADEL_DEFAULT_ORG_ID env pin, then the provider's instance Default Organization.
+ // Resolve the effective org for this callback ceremony — policy-org-first (the org the START
+ // side decided the intent under, see idp-return-urls.ts), then the URL `?organization=`, then
+ // the ZITADEL_DEFAULT_ORG_ID env pin, then the provider's instance Default Organization.
// Hoisted before the try block so both the decision try and the auto-create switch case
// can reference it. This resolved org feeds the allowRegister gate (getLoginSettings) and
// the auto-create register call so a bare (no ?organization=) flow always has a concrete
// org, preventing Zitadel's FAILED_PRECONDITION on addHumanUser with no org.
- // NOTE: findUser calls below deliberately stay on raw `organization` (instance-wide lookup).
- const callbackOrg = await resolveOrg(provider, organization);
+ //
+ // `policyOrg` participates HERE and nowhere else. Without it, a start that resolved its IdP
+ // list under the found user's own org (e.g. /login/method's sole-linked-IdP auto-start) landed
+ // here with `organization` still undefined and had allowRegister decided by the DEFAULT org —
+ // a different policy from the one that offered the provider in the first place.
+ // NOTE: findUser calls below deliberately stay on raw `organization` (instance-wide lookup),
+ // which is exactly why the policy org rides in its OWN param instead of widening this one —
+ // and why only the SIGNATURE-VERIFIED value may be used here.
+ const callbackOrg = await resolveOrg(provider, verifiedPolicyOrg ?? organization);
try {
intent = await doRetrieveIdpIntent(id, token);
diff --git a/app/routes/login/index.tsx b/app/routes/login/index.tsx
index 6760436b2a..14590ab742 100644
--- a/app/routes/login/index.tsx
+++ b/app/routes/login/index.tsx
@@ -15,6 +15,7 @@ import { idpTypeToSlug } from '@/modules/auth/idp-slug';
// 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 } from '@/modules/auth/session/cookie';
+import { clearIdpAutostart } from '@/modules/auth/session/idp-autostart';
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';
@@ -253,6 +254,13 @@ export async function action({ request }: ActionFunctionArgs) {
const headers = new Headers();
headers.append('set-cookie', await serializeSessions(result.sessions));
if (fpCookie) headers.append('set-cookie', fpCookie);
+ // A fresh ceremony starts here, so retire any one-shot IdP auto-start marker the previous one
+ // left behind — otherwise a sign-out/sign-in inside its 10-minute window suppressed the very
+ // auto-start the marker exists to protect. Unconditional, and identical on both the known and
+ // ghost branches: expiring an already-absent cookie is a no-op for the browser, and branching
+ // on its presence would both cost a read and make the two branches' headers differ. See
+ // clearIdpAutostart.
+ headers.append('set-cookie', await clearIdpAutostart());
// Domain-discovery single-IdP org: start the IdP intent directly. A plain redirect to /sso
// (the old behavior) dead-ends session-less users in a /login ↔ /sso bounce loop.
if ('startIdp' in result) {
@@ -266,10 +274,12 @@ export async function action({ request }: ActionFunctionArgs) {
if (!idpResult.ok) return data({ error: idpResult.error }, { status: 502 });
return redirect(idpResult.authUrl, { headers });
}
- // Sole-passkey: redirect to /login/passkey like every other post-identifier path
- // (password, OTP) —
- // /login renders the chooser and nothing else; no route inlines an identity-bound,
- // single-method screen except /login/method and /reauth, which are out of scope here.
+ // Every account with >= 1 usable method now resolves to the same target — /login/method —
+ // which renders exactly what that account has and auto-starts what needs no form (a sole
+ // linked IdP server-side in its loader, a sole passkey client-side on mount). This route
+ // deliberately picks no per-method screen: it is pure over method KINDS, so it has no idpId
+ // and could only ever name a static path (that is what sent sole-IdP users to a bare /sso).
+ // The zero-method legs (/verify, /error) are unchanged.
return redirect(`${result.target}?${result.params}`, { headers });
}
diff --git a/app/routes/login/method.tsx b/app/routes/login/method.tsx
index ed93291411..710bb9aacd 100644
--- a/app/routes/login/method.tsx
+++ b/app/routes/login/method.tsx
@@ -5,26 +5,36 @@ import { useAuthActionError } from '@/hooks/use-auth-action-error';
import { useLoginContext } from '@/hooks/use-login-context';
import { usePasskeyLoginCeremony } from '@/hooks/use-passkey-login-ceremony';
import SplitLayout from '@/layouts/split.layout';
-import type { IdProvider } from '@/modules/auth/types';
+import { listSessions, readSessions } from '@/modules/auth/session/cookie';
+import {
+ idpAutostartMatches,
+ readIdpAutostart,
+ serializeIdpAutostart,
+} from '@/modules/auth/session/idp-autostart';
import { startIdpIntent } from '@/resources/login';
-import { decideAfterIdentifier } from '@/resources/login/login-decision';
import { loginIdpSchema } from '@/resources/login/login.schema';
+import {
+ GHOST_METHODS,
+ isSoleLinkedIdp,
+ resolveGhostPolicyOrg,
+ resolveMethodOptions,
+} from '@/resources/login/method-options';
import { readCeremonyParams } from '@/resources/shared/ceremony-params';
-import { resolveOrg } from '@/resources/shared/resolve-org';
+import { resolveOrg, resolvePolicyOrg } from '@/resources/shared/resolve-org';
import { joinLinkedIdps } from '@/resources/sso';
import { getActiveIdPs } from '@/resources/sso/idp-providers';
-import type { LinkedIdpView } from '@/resources/sso/sso-management';
import { redirectToLogin } from '@/routes/login-bounce';
import { paths } from '@/routes/paths';
import { providerForRequest } from '@/server/auth-context.server';
import { loaderCsrf, assertCsrf } from '@/server/csrf';
import { trustedAppOrigin } from '@/server/infra/app-origin.server';
import { env } from '@/server/infra/env.server';
-import { Button, LinkButton } from '@datum-cloud/datum-ui/button';
+import { LinkButton } from '@datum-cloud/datum-ui/button';
import { Icon } from '@datum-cloud/datum-ui/icons';
import { cn } from '@datum-cloud/datum-ui/utils';
import { Trans } from '@lingui/react/macro';
import { Key, Lock, Mail } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
import {
data,
redirect,
@@ -41,69 +51,164 @@ export const meta: MetaFunction = () => [{ title: 'Choose how to sign in' }];
export async function loader({ request }: LoaderFunctionArgs) {
const provider = providerForRequest(request);
const { loginName, requestId, organization } = readCeremonyParams(new URL(request.url));
+ const bounce = () => redirect(redirectToLogin(requestId, organization));
- if (!loginName) return redirect(redirectToLogin(requestId, organization));
+ if (!loginName) return bounce();
+
+ // ── SESSION GATE ────────────────────────────────────────────────────────────────────────
+ // This loader is state-changing (it mints a real Zitadel IdP intent for a sole-linked-IdP
+ // account and 302s to the provider) and its outcomes are distinguishable from outside:
+ // 302-to-a-named-provider (sole IdP) vs 200 (several methods) vs 302-to-/verify|/error (none).
+ // Before the gate an unknown identifier added a fourth, 302-to-/login. Reachable by URL alone
+ // that made
+ // `GET /id/login/method?loginName=X` an account-existence AND identity-provider oracle, a
+ // login-CSRF vector (no token on a GET), and a bypass of ignoreUnknownUsernames — which is
+ // honoured only in resolveIdentifier (login.service.ts).
+ //
+ // Requiring the ceremony session the identifier step ALREADY planted for this loginName costs
+ // legitimate arrivals nothing: /login's identifier action serializes `result.sessions` into the
+ // cookie on the same response that redirects here, and every other path to this URL (the e2e
+ // helper's chooser hop, a Back from a later step, a reload) carries that same cookie.
+ //
+ // WHAT THIS GATE IS AND IS NOT. It is NOT an authorization boundary: the cookie it demands is
+ // SELF-ISSUABLE — any party can POST /id/login (CSRF-protected, so not forceable cross-site,
+ // but self-driven probing only needs to fetch the token first) and be handed one. So the gate
+ // raises the cost of reaching this loader by exactly one request; what actually bounds
+ // enumeration BREADTH is the two-tier GET limiter in server/middleware/rate-limit.ts, now
+ // backed by an ip ceiling on the identifier POST itself. What the gate genuinely buys is that
+ // `GET /id/login/method?loginName=X` alone — with no cookie at all — mints nothing and tells
+ // nobody anything, which is what makes the URL safe to hand out, bookmark and reload.
+ //
+ // NOT-KNOWN-EXPIRED, not "live": listSessions is the codebase's expiry-aware filter (the same
+ // idiom arm-login-passkey.ts uses) but it deliberately KEEPS entries whose expirationTs is
+ // empty or unparseable (session.ts) — and Zitadel Session-API sessions minted without an
+ // explicit lifetime, which is exactly what the ceremony createSession produces, carry no
+ // expirationDate. So a known-expired entry cannot pass, but an unknown-expiry one can.
+ // Case-insensitive: the URL carries the provider's canonical loginName while a hand-typed or
+ // IdP-returned identifier may differ in case.
+ const sessions = listSessions(await readSessions(request), Date.now());
+ if (!sessions.some((s) => s.loginName.toLowerCase() === loginName.toLowerCase())) {
+ return bounce();
+ }
+ // ── GHOST SUBJECT (anti-enumeration) ────────────────────────────────────────────────────
+ // A findUser MISS here does NOT mean "no such account, bounce". `ignoreUnknownUsernames` makes
+ // /login's identifier step plant a real ceremony session for an UNKNOWN identifier and route it
+ // to THIS url — the same one a real password-only account is routed to — precisely so the two
+ // are indistinguishable. Bouncing on the miss would hand that difference straight back: a
+ // 302-to-/login for the unknown vs a 200 chooser for the known is a perfect existence oracle,
+ // and it would silently disable a security setting the tenant switched on.
+ //
+ // So a miss that CLEARED THE SESSION GATE above is served the password-only chooser (see
+ // GHOST_METHODS): same status, same single control, same branding resolution, same rotated CSRF
+ // token, same headers. Its Password link leads to /login/password, where the ghost session's
+ // credential check fails generically — exactly as it did before this route existed.
+ //
+ // The gate is what makes this safe to serve at all: the ONLY way to reach it is a live ceremony
+ // session this browser was handed for this loginName, which /login mints only under the flag.
const user = await provider.findUser(loginName, organization);
- if (!user) return redirect(redirectToLogin(requestId, organization));
// The method chooser is a branded screen — thread getBranding through so SplitLayout
// renders the org logo, mirroring /login and /signup. Fetched in parallel with the rest.
// Org-first: an explicit org wins, else the default org (matches the old app's
// `organization ?? getDefaultOrg()`). findUser above stays instance-wide by design.
- const settingsOrg = await resolveOrg(provider, organization);
+ const brandingOrg = await resolveOrg(provider, organization);
+ // POLICY reads (login settings + the org's active IdPs) must resolve to the SAME org
+ // resolveIdentifier used when it routed the user here. Both sides now call resolvePolicyOrg,
+ // so that agreement is structural rather than two look-alike expressions kept in sync by hand
+ // (they previously diverged for a user with NO orgId: instance settings there, default-org
+ // settings here). Branding is deliberately left org-first — the logo follows the ceremony's
+ // org, not the user's. Sequenced after brandingOrg so the memoized default-org lookup is
+ // already warm and this costs no second provider round-trip.
+ // A GHOST has no orgId for resolvePolicyOrg's middle rung to bite on, so this used to fall
+ // through to the DEFAULT org while a real account at the same address was judged by its own.
+ // That split `available` — and with it the render-vs-/error outcome — between the two branches,
+ // re-opening the existence oracle one hop after resolveIdentifier closed it. Both hops now
+ // resolve a ghost's policy org from the identifier's domain (see resolveGhostPolicyOrg), so the
+ // decision that routed the subject here and the one recomputed here read the same org either way.
+ const settingsOrg = user
+ ? await resolvePolicyOrg(provider, organization, user.orgId)
+ : await resolveGhostPolicyOrg(provider, organization, loginName);
const [methods, settings, branding, { csrfToken, headers }] = await Promise.all([
- provider.listAuthMethods(user.id),
+ // A ghost has no id to enumerate methods for; it carries the synthesized password-only
+ // enrolment instead, and every read below is the one a real account makes.
+ user ? provider.listAuthMethods(user.id) : Promise.resolve([...GHOST_METHODS]),
provider.getLoginSettings(settingsOrg),
- provider.getBranding(settingsOrg),
+ provider.getBranding(brandingOrg),
loaderCsrf(request),
]);
- // Compute available primary sign-in methods using the same policy gates as
- // decideAfterIdentifier (login-decision.ts). This screen only appears when
- // available.length >= 2; we surface exactly those available methods.
- const available: Array<'passkey' | 'password' | 'otp_email' | 'idp'> = [];
- if (methods.includes('passkey') && settings.passkeysType !== 'not_allowed')
- available.push('passkey');
+ // Which methods this account can actually use right now (same policy gates as
+ // decideAfterIdentifier, plus the linked-IdP resolution that function cannot see).
+ // This screen is the destination for EVERY account with >= 1 available method.
+ const { available, idps } = await resolveMethodOptions(provider, {
+ // Never read for a ghost: GHOST_METHODS carries no 'idp', so the linked-provider join
+ // (the only place userId is used) does not run.
+ userId: user?.id ?? '',
+ methods,
+ settings,
+ policyOrg: settingsOrg,
+ emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED,
+ });
- // Resolve the user's linked (redirect-based) IdPs directly into IdpButtonList's
- // IdProvider shape — mirrors reauth.service.ts's loadReauth idp-resolution, including
- // the LDAP exclusion (LDAP needs its own credential form, not an OAuth round-trip). A
- // link whose provider is no longer active has no name/type to join — filtered out,
- // since there's no sign-in button to offer for a dead provider.
- let idps: IdProvider[] = [];
- if (methods.includes('idp') && settings.allowExternalIdp) {
- const [links, active] = await Promise.all([
- provider.listIdpLinks(user.id),
- getActiveIdPs(provider, settingsOrg),
- ]);
- idps = joinLinkedIdps(links, active)
- .filter(
- (l): l is LinkedIdpView & { name: string; type: string } =>
- l.name !== undefined && l.type !== undefined && l.type !== 'LDAP'
- )
- .map((l) => ({ id: l.idpId, name: l.name, type: l.type, logoUrl: l.logoUrl }));
- if (idps.length > 0) available.push('idp');
+ // Sole linked IdP: start the intent here rather than rendering a one-button screen.
+ // Server-side, so the chooser never renders and the user sees no intermediate page.
+ //
+ // ONE-SHOT. Being in a LOADER is what buys the no-flash redirect, and it is also what makes
+ // this dangerous: a loader re-runs on every arrival at the URL, including the one the browser
+ // makes when the user presses Back at the provider. Unguarded, that Back mints a NEW Zitadel
+ // intent and bounces them forward again — they can never get back into the app. The marker
+ // cookie makes the second arrival for the same loginName fall through and RENDER the chooser,
+ // whose IdP button restarts the ceremony deliberately (a POST to this route's action, where
+ // Back does not re-fire — which is also why /login's domain-discovery branch, an action, never
+ // needed a guard like this one).
+ if (isSoleLinkedIdp({ available, idps })) {
+ const alreadyAutoStarted = idpAutostartMatches(await readIdpAutostart(request), loginName);
+ if (!alreadyAutoStarted) {
+ const idpResult = await startIdpIntent(provider, {
+ idpId: idps[0].id,
+ origin: trustedAppOrigin(request),
+ requestId,
+ organization,
+ // The IdP was picked out of a list resolved for settingsOrg; the callback must gate
+ // allowRegister in that same org rather than in whatever `organization` resolves to.
+ policyOrg: settingsOrg,
+ reauthHint: loginName,
+ });
+ // On failure fall through and render the button, so the user keeps a way forward.
+ // The marker is written only on the redirect that actually mints — a failed start has
+ // nothing to come Back from.
+ if (idpResult.ok) {
+ return redirect(idpResult.authUrl, {
+ headers: { 'set-cookie': await serializeIdpAutostart(loginName) },
+ });
+ }
+ }
}
- if (methods.includes('password') && settings.allowPassword) available.push('password');
- if (methods.includes('otp_email') && env.AUTH_EMAIL_DELIVERY_ENABLED) available.push('otp_email');
-
- // Defensive: if for some reason < 2 methods are available, run decideAfterIdentifier
- // and redirect to the single target (or /error). Consume the Decision union by
- // `kind` — 'redirect' → its path, 'error' → /error.
- if (available.length < 2) {
- const decision = decideAfterIdentifier({
- methods,
- settings,
- emailDeliveryEnabled: env.AUTH_EMAIL_DELIVERY_ENABLED,
- context: { role: 'primary' }, // post-identifier decision is the primary flow
- });
- const params = new URLSearchParams({ loginName });
- if (requestId) params.set('requestId', requestId);
- if (organization) params.set('organization', organization);
- const target = decision.kind === 'redirect' ? decision.path : paths.error();
- return redirect(`${target}?${params.toString()}`);
+ // Nothing usable. Exactly two outcomes are reachable from here, so name them directly
+ // rather than asking decideAfterIdentifier and filtering its answer: no enrolled methods
+ // at all is the invite path (/verify); anything else is a policy dead end (/error — the
+ // PASSWORD_NOT_ALLOWED / NO_SUPPORTED_METHOD legs, which carry no params of their own).
+ //
+ // A GHOST reaches this only where a real password-only account would: its synthesized
+ // enrolment is non-empty, so an org that forbids passwords sends both to /error, and neither
+ // can reach the /verify leg. (In practice resolveIdentifier already sent both there — this is
+ // the direct-URL arrival with a still-live session.)
+ //
+ // Deciding it here is what makes a self-redirect loop UNREPRESENTABLE. decideAfterIdentifier
+ // counts 'idp' as available from methods.includes('idp') && settings.allowExternalIdp alone
+ // — it has no visibility into the resolved `idps` list above, which is the ONLY source of
+ // truth this route has for actually-usable linked IdPs. A user whose sole enrolled method is
+ // 'idp' but whose links are all missing, inactive, or LDAP-only lands here (our own
+ // `available` is []) while that function still names THIS route. Following it would 302 into
+ // ourselves forever (same user, same inputs, same empty `available` every time); neither
+ // destination below can name /login/method, so there is nothing left to guard against.
+ if (available.length === 0) {
+ // Same query the manual URLSearchParams built (loginName, then requestId, then
+ // organization; undefined values skipped) — paths.* threads the ceremony for us.
+ const query = { loginName, requestId, organization };
+ return redirect(methods.length === 0 ? paths.verify.index(query) : paths.error(query));
}
return data(
@@ -136,7 +241,11 @@ export async function action({ request }: ActionFunctionArgs) {
// resolves to one of THIS identified user's own linked, active, non-LDAP providers
// before starting the round-trip. Mirrors the same check reauth.tsx's idp-reauth
// action uses for its own linked-IdP chooser.
- const settingsOrg = await resolveOrg(provider, organization);
+ //
+ // Resolved through the SAME helper the loader uses, for the same reason: this check must
+ // accept precisely the set of buttons the loader rendered. Reading a different org's active
+ // IdPs here would 400 a legitimate click on a provider the loader itself just offered.
+ const settingsOrg = await resolvePolicyOrg(provider, organization, user.orgId);
const [links, active] = await Promise.all([
provider.listIdpLinks(user.id),
getActiveIdPs(provider, settingsOrg),
@@ -151,12 +260,32 @@ export async function action({ request }: ActionFunctionArgs) {
origin: trustedAppOrigin(request),
requestId,
organization,
+ // Same divergence the loader's auto-start closes: the button was offered from settingsOrg's
+ // IdP list, so the callback must gate allowRegister in settingsOrg too.
+ policyOrg: settingsOrg,
reauthHint: loginName,
});
if (!result.ok) return data({ error: result.error }, { status: 502 });
return redirect(result.authUrl);
}
+// This loader MINTS CEREMONY STATE — a Zitadel IdP intent for a sole-linked-IdP account, a
+// rotated loaderCsrf token, the one-shot auto-start marker — and costs ~5 provider reads plus a
+// rate-limit unit per run. The in-place passkey ceremony below submits its assertion to
+// /login/passkey while THIS route stays mounted, and RR's default post-submit revalidation would
+// re-run all of that on every ceremony step. Same guard, same marker, and the same WEBAU-3M9si
+// class of hazard as login/index.tsx:284 and /login/passkey:40. Anything else revalidates normally.
+export function shouldRevalidate({
+ formData,
+ defaultShouldRevalidate,
+}: {
+ formData?: FormData;
+ defaultShouldRevalidate: boolean;
+}) {
+ if (formData?.get('passkeyCeremony') === '1') return false;
+ return defaultShouldRevalidate;
+}
+
export default function LoginMethod() {
const { methods, branding, idps, csrfToken } = useLoaderData();
const { loginName, requestId, organization } = useLoginContext();
@@ -169,6 +298,34 @@ export default function LoginMethod() {
const serverError = useAuthActionError(ceremony.actionData);
const passkeyBusy = ceremony.phase !== 'idle';
+ // Sole passkey: the identifier step already captured intent, so run the ceremony on
+ // arrival instead of rendering a one-button screen. One-shot — a cancelled ceremony
+ // must leave the button usable rather than re-prompting in a loop.
+ const solePasskey = methods.length === 1 && methods[0] === 'passkey';
+ const autoBegunRef = useRef(false);
+ // The auto-begun attempt has no transient user activation behind it (no click preceded the
+ // mount), and WebKit REQUIRES activation for navigator.credentials.get() — so on Safari it
+ // reliably rejects with NotAllowedError, which the ceremony classifies as 'not-allowed'.
+ // That reason's copy tells the user their passkey sign-in "was cancelled", accusing someone
+ // who has done nothing but arrive. Suppress the copy for THIS attempt only; the Passkey
+ // button below stays as the way forward, and a ceremony the user starts by clicking reports
+ // its failure exactly as it does on every other surface.
+ const [suppressAutoBeginReason, setSuppressAutoBeginReason] = useState(false);
+ useEffect(() => {
+ if (!solePasskey || autoBegunRef.current) return;
+ autoBegunRef.current = true;
+ setSuppressAutoBeginReason(true);
+ ceremony.begin();
+ }, [solePasskey, ceremony]);
+
+ // Any user-initiated ceremony re-arms the copy — begin() clears the previous reason, so the
+ // next one to land belongs to an attempt the user asked for.
+ const beginFromClick = () => {
+ setSuppressAutoBeginReason(false);
+ ceremony.begin();
+ };
+ const ceremonyReason = suppressAutoBeginReason ? null : ceremony.reason;
+
// Mirrors login/index.tsx's own submittingIdpId computation — drives IdpButtonList's
// per-row loading state while its POST to this route's own action is in flight.
const navigation = useNavigation();
@@ -193,9 +350,9 @@ export default function LoginMethod() {
Not you?
{methods.includes('passkey') ? (
- // Button (not LinkButton) — fires the ceremony IN PLACE (lazy challenge;
- // a password pick never spends a session) instead of navigating to
- // /login/passkey. The list stays visible as the fallback on failure.
-