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
64 changes: 64 additions & 0 deletions app/modules/auth/session/idp-autostart.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<string | null> {
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();
}
44 changes: 22 additions & 22 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: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."

Expand Down Expand Up @@ -172,15 +172,15 @@ 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"

#: app/routes/login/mfa.tsx:125
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"

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:546
#: app/routes/login/index.tsx:556
#: 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:591
#: app/routes/login/index.tsx:601
#: 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: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"
Expand All @@ -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"
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:376
#: app/routes/login/index.tsx:386
msgid "Email, phone, or username"
msgstr "Email, phone, or username"

Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
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:379
#: app/routes/login/index.tsx:394
#: app/routes/login/index.tsx:389
#: app/routes/login/index.tsx:404
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: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."

Expand All @@ -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>."

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: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"
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:417
#: app/routes/login/index.tsx:427
msgid "Welcome"
msgstr "Welcome"

Expand Down
15 changes: 5 additions & 10 deletions app/resources/login/login-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,6 @@ export type Decision =
| { kind: 'redirect'; path: string; params?: Record<string, string> }
| { kind: 'error'; error: string };

const SINGLE_TARGET: Record<string, string> = {
passkey: '/login/passkey',
idp: '/sso',
password: '/login/password',
otp_email: '/login/verify/email',
};

export function decideAfterIdentifier({
methods,
settings,
Expand All @@ -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) {
Expand Down
Loading
Loading