Skip to content

Commit 231183e

Browse files
committed
feat(auth): re-auth identity guard — verify the account that signs back in
A 'Needs re-authentication' account sent the user to /login, but the login surface let them authenticate as ANY identity (e.g. 'Sign in with Google' → a different Google account). Nothing checked the result, the dead entry was pruned up-front, and the ceremony silently completed as the wrong identity. Now re-auth is identity-scoped end to end: - reauthRedirect records the account being re-authenticated in a short-lived, signed reauth-intent cookie (mirrors the last-used-login cookie) and KEEPS the dead entry until re-auth actually succeeds (no up-front prune). - Soft block (IdP): startIdpIntent appends login_hint=<loginName> to the authorize URL so Google/OIDC pre-select that account (GitHub & others ignore the unknown param — best-effort only). - Hard block (our area): both completion points verify the authenticated identity matches the intent — • IdP callback (signInWithIdpIntent): compares the IdP-vouched loginName. • password step: compares the typed loginName. On MISMATCH: keep BOTH accounts and bounce to /accounts?reauthMismatch=1 (carrying the live requestId) with an explanatory banner — never silently continue the ceremony as the unintended identity. On MATCH: continue and prune the stale entry. The intent cookie is cleared either way. - resolveIdentifier supersedes a prior same-identity (loginName+org) entry so a password re-auth doesn't leave the kept dead entry shadowing the fresh one. Tests: IdP guard (none/match/mismatch), login_hint append/omit, reauth-intent cookie set + no prune, and the /accounts mismatch banner.
1 parent 4204ef2 commit 231183e

15 files changed

Lines changed: 342 additions & 59 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { env } from '@/server/infra/env.server';
2+
import { createCookie } from 'react-router';
3+
4+
/**
5+
* Short-lived RE-AUTHENTICATION intent: records the loginName of the account a user clicked to
6+
* "re-authenticate" (a "Needs re-authentication" entry on /accounts). It rides through the
7+
* login / IdP round-trip so the completion point (IdP callback, password step) can verify the
8+
* identity that actually authenticated MATCHES the one being re-authenticated — and, if not,
9+
* keep both accounts and bounce to the picker instead of silently continuing the ceremony as the
10+
* wrong identity. It is ALSO used as a best-effort `login_hint` to pre-select the account at the
11+
* IdP. Never an auth signal — purely an intent marker.
12+
*
13+
* httpOnly (never script-readable), sameSite: lax, scoped to `/id` (mirrors the session cookie),
14+
* signed with SESSION_SECRET. SHORT maxAge (10 min): a re-auth completes promptly, and a stale
15+
* marker must not gate a later, unrelated login.
16+
*/
17+
export const reauthIntentCookie = createCookie('reauth-intent', {
18+
httpOnly: true,
19+
sameSite: 'lax',
20+
path: '/id',
21+
secure: env.NODE_ENV === 'production',
22+
secrets: [env.SESSION_SECRET],
23+
maxAge: 60 * 10, // 10 minutes
24+
});
25+
26+
/** Serialize the re-auth intent (the loginName being re-authenticated) to a Set-Cookie string. */
27+
export async function serializeReauthIntent(loginName: string): Promise<string> {
28+
return reauthIntentCookie.serialize(loginName);
29+
}
30+
31+
/** Read the re-auth intent loginName from a request. Returns null when absent or invalid. */
32+
export async function readReauthIntent(request: Request): Promise<string | null> {
33+
const cookieHeader = request.headers.get('cookie');
34+
const value = await reauthIntentCookie.parse(cookieHeader);
35+
return typeof value === 'string' && value.length > 0 ? value : null;
36+
}
37+
38+
/** Clear the re-auth intent cookie (Set-Cookie that expires it immediately). */
39+
export async function clearReauthIntent(): Promise<string> {
40+
return reauthIntentCookie.serialize('', { maxAge: 0 });
41+
}

app/modules/i18n/locales/en.po

Lines changed: 35 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ msgstr ""
1313
"Plural-Forms: \n"
1414

1515
#. placeholder {0}: attempts.count
16-
#: app/routes/login/password.tsx:142
16+
#: app/routes/login/password.tsx:158
1717
msgid "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}"
1818
msgstr "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}"
1919

@@ -36,15 +36,15 @@ msgstr "A new code has been sent to your email."
3636
msgid "Activate your device"
3737
msgstr "Activate your device"
3838

39-
#: app/routes/accounts.tsx:101
39+
#: app/routes/accounts.tsx:116
4040
msgid "Add an account"
4141
msgstr "Add an account"
4242

4343
#: app/routes/setup/mfa.tsx:160
4444
msgid "Add an extra layer of security to your account by setting up a second factor."
4545
msgstr "Add an extra layer of security to your account by setting up a second factor."
4646

47-
#: app/routes/accounts.tsx:183
47+
#: app/routes/accounts.tsx:198
4848
msgid "Add another account"
4949
msgstr "Add another account"
5050

@@ -64,7 +64,7 @@ msgstr "Additional verification is required to continue."
6464
msgid "Already have an account?"
6565
msgstr "Already have an account?"
6666

67-
#: app/routes/login/index.tsx:252
67+
#: app/routes/login/index.tsx:257
6868
msgid "An account with this email already exists — sign in to continue."
6969
msgstr "An account with this email already exists — sign in to continue."
7070

@@ -148,7 +148,7 @@ msgstr "Check your email"
148148
msgid "Choose a new password"
149149
msgstr "Choose a new password"
150150

151-
#: app/routes/accounts.tsx:85
151+
#: app/routes/accounts.tsx:90
152152
msgid "Choose an account"
153153
msgstr "Choose an account"
154154

@@ -160,7 +160,7 @@ msgstr "Choose how to sign in"
160160
msgid "Choose how you want to verify your identity."
161161
msgstr "Choose how you want to verify your identity."
162162

163-
#: app/routes/login/index.tsx:248
163+
#: app/routes/login/index.tsx:253
164164
msgid "Choose your login method"
165165
msgstr "Choose your login method"
166166

@@ -182,7 +182,7 @@ msgid "Connected accounts"
182182
msgstr "Connected accounts"
183183

184184
#: app/routes/device/index.tsx:73
185-
#: app/routes/login/index.tsx:329
185+
#: app/routes/login/index.tsx:334
186186
#: app/routes/signup/index.tsx:269
187187
msgid "Continue"
188188
msgstr "Continue"
@@ -191,7 +191,7 @@ msgstr "Continue"
191191
msgid "Continue with your provider"
192192
msgstr "Continue with your provider"
193193

194-
#: app/routes/login/password.tsx:90
194+
#: app/routes/login/password.tsx:106
195195
msgid "Could not verify password"
196196
msgstr "Could not verify password"
197197

@@ -207,7 +207,7 @@ msgstr "Couldn't sign in"
207207
msgid "Create a new account"
208208
msgstr "Create a new account"
209209

210-
#: app/routes/login/index.tsx:360
210+
#: app/routes/login/index.tsx:365
211211
#: app/routes/signup/password.tsx:189
212212
msgid "Create account"
213213
msgstr "Create account"
@@ -229,8 +229,8 @@ msgstr "Device code"
229229
msgid "Device denied"
230230
msgstr "Device denied"
231231

232-
#: app/routes/login/index.tsx:205
233-
#: app/routes/login/index.tsx:302
232+
#: app/routes/login/index.tsx:210
233+
#: app/routes/login/index.tsx:307
234234
#: app/routes/signup/index.tsx:230
235235
#: app/routes/signup/index.tsx:255
236236
msgid "Email"
@@ -240,7 +240,7 @@ msgstr "Email"
240240
msgid "Email code"
241241
msgstr "Email code"
242242

243-
#: app/routes/login/index.tsx:338
243+
#: app/routes/login/index.tsx:343
244244
#: app/routes/login/method.tsx:113
245245
#: app/routes/signup/method.tsx:263
246246
msgid "Email me a sign-in link"
@@ -262,7 +262,7 @@ msgstr "Email OTP"
262262
msgid "Email sign-in isn't available — use your username."
263263
msgstr "Email sign-in isn't available — use your username."
264264

265-
#: app/routes/login/index.tsx:204
265+
#: app/routes/login/index.tsx:209
266266
msgid "Email, phone, or username"
267267
msgstr "Email, phone, or username"
268268

@@ -302,8 +302,8 @@ msgstr "Enter your authenticator code"
302302
msgid "Enter your email code"
303303
msgstr "Enter your email code"
304304

305-
#: app/routes/login/password.tsx:110
306-
#: app/routes/login/password.tsx:127
305+
#: app/routes/login/password.tsx:126
306+
#: app/routes/login/password.tsx:143
307307
msgid "Enter your password"
308308
msgstr "Enter your password"
309309

@@ -315,7 +315,7 @@ msgstr "Enter your SMS code"
315315
msgid "Finish creating your account"
316316
msgstr "Finish creating your account"
317317

318-
#: app/routes/login/password.tsx:173
318+
#: app/routes/login/password.tsx:189
319319
msgid "Forgot password?"
320320
msgstr "Forgot password?"
321321

@@ -347,7 +347,7 @@ msgstr "Linked accounts"
347347
msgid "Manual setup key"
348348
msgstr "Manual setup key"
349349

350-
#: app/routes/accounts.tsx:146
350+
#: app/routes/accounts.tsx:161
351351
msgid "Needs re-authentication"
352352
msgstr "Needs re-authentication"
353353

@@ -364,11 +364,11 @@ msgstr "No account was found and sign-up is not available."
364364
msgid "No sign-in method is available for this account."
365365
msgstr "No sign-in method is available for this account."
366366

367-
#: app/routes/accounts.tsx:93
367+
#: app/routes/accounts.tsx:108
368368
msgid "No signed-in accounts."
369369
msgstr "No signed-in accounts."
370370

371-
#: app/routes/login/index.tsx:358
371+
#: app/routes/login/index.tsx:363
372372
msgid "Not registered?"
373373
msgstr "Not registered?"
374374

@@ -388,7 +388,7 @@ msgstr "or"
388388
msgid "Or import this URI in your authenticator app"
389389
msgstr "Or import this URI in your authenticator app"
390390

391-
#: app/routes/login/index.tsx:283
391+
#: app/routes/login/index.tsx:288
392392
#: app/routes/login/method.tsx:98
393393
#: app/routes/setup/mfa.tsx:46
394394
msgid "Passkey"
@@ -424,15 +424,15 @@ msgstr "Password must contain an uppercase letter."
424424
msgid "Password sign-in isn't available for this account."
425425
msgstr "Password sign-in isn't available for this account."
426426

427-
#: app/routes/login/index.tsx:207
427+
#: app/routes/login/index.tsx:212
428428
msgid "Phone"
429429
msgstr "Phone"
430430

431431
#: app/utils/errors/auth-error-messages.tsx:31
432432
msgid "Phone sign-in isn't available — use your email or username."
433433
msgstr "Phone sign-in isn't available — use your email or username."
434434

435-
#: app/routes/login/password.tsx:161
435+
#: app/routes/login/password.tsx:177
436436
#: app/utils/errors/auth-error-messages.tsx:27
437437
msgid "Please check your input and try again."
438438
msgstr "Please check your input and try again."
@@ -476,7 +476,7 @@ msgstr "Scan the QR code below with your authenticator app, then enter the 6-dig
476476
msgid "Security key"
477477
msgstr "Security key"
478478

479-
#: app/routes/accounts.tsx:86
479+
#: app/routes/accounts.tsx:91
480480
msgid "Select an account to continue or add a new one."
481481
msgstr "Select an account to continue or add a new one."
482482

@@ -492,7 +492,7 @@ msgstr "Send reset link"
492492
msgid "Service temporarily unavailable. Please try again."
493493
msgstr "Service temporarily unavailable. Please try again."
494494

495-
#: app/routes/accounts.tsx:144
495+
#: app/routes/accounts.tsx:159
496496
msgid "Session active"
497497
msgstr "Session active"
498498

@@ -530,13 +530,13 @@ msgstr "Set up security key"
530530
msgid "Set up SMS one-time code"
531531
msgstr "Set up SMS one-time code"
532532

533-
#: app/routes/login/password.tsx:166
533+
#: app/routes/login/password.tsx:182
534534
#: app/routes/signup/index.tsx:286
535535
#: app/routes/sso/ldap.tsx:82
536536
msgid "Sign in"
537537
msgstr "Sign in"
538538

539-
#: app/routes/login/password.tsx:155
539+
#: app/routes/login/password.tsx:171
540540
#: app/routes/logout/success.tsx:22
541541
#: app/utils/errors/auth-error-recovery.tsx:47
542542
msgid "Sign in again"
@@ -561,7 +561,7 @@ msgstr "Sign in with LDAP"
561561
msgid "Sign out"
562562
msgstr "Sign out"
563563

564-
#: app/routes/login/index.tsx:348
564+
#: app/routes/login/index.tsx:353
565565
msgid "Sign-in is currently unavailable for this account. Please contact your administrator."
566566
msgstr "Sign-in is currently unavailable for this account. Please contact your administrator."
567567

@@ -683,7 +683,7 @@ msgstr "Use your passkey to verify your identity."
683683
msgid "Use your security key to verify your identity."
684684
msgstr "Use your security key to verify your identity."
685685

686-
#: app/routes/login/index.tsx:208
686+
#: app/routes/login/index.tsx:213
687687
#: app/routes/sso/ldap.tsx:73
688688
msgid "Username"
689689
msgstr "Username"
@@ -757,7 +757,7 @@ msgstr "We've sent a password reset link to <0>{0}</0>"
757757
msgid "We've sent a verification link to <0>{0}</0>"
758758
msgstr "We've sent a verification link to <0>{0}</0>"
759759

760-
#: app/routes/login/index.tsx:245
760+
#: app/routes/login/index.tsx:250
761761
msgid "Welcome"
762762
msgstr "Welcome"
763763

@@ -795,6 +795,10 @@ msgstr "You may return to your device."
795795
msgid "You must be signed in to link an external account."
796796
msgstr "You must be signed in to link an external account."
797797

798+
#: app/routes/accounts.tsx:98
799+
msgid "You signed in as a different account than the one you were re-authenticating. Both are kept — choose an account to continue."
800+
msgstr "You signed in as a different account than the one you were re-authenticating. Both are kept — choose an account to continue."
801+
798802
#: app/routes/device/authorize.tsx:134
799803
msgid "You'll be asked to sign in before authorizing."
800804
msgstr "You'll be asked to sign in before authorizing."
@@ -811,7 +815,7 @@ msgstr "You're almost done! <0>{loginName}</0>"
811815
msgid "You've been signed out"
812816
msgstr "You've been signed out"
813817

814-
#: app/routes/login/password.tsx:137
818+
#: app/routes/login/password.tsx:153
815819
msgid "Your account is temporarily locked after too many attempts."
816820
msgstr "Your account is temporarily locked after too many attempts."
817821

@@ -835,7 +839,7 @@ msgstr "Your password has expired. Please reset it to continue."
835839
msgid "Your session has ended and you've been securely signed out of Datum. You can safely close this tab, or sign back in any time to pick up where you left off."
836840
msgstr "Your session has ended and you've been securely signed out of Datum. You can safely close this tab, or sign back in any time to pick up where you left off."
837841

838-
#: app/routes/login/password.tsx:153
842+
#: app/routes/login/password.tsx:169
839843
#: app/utils/errors/auth-error-messages.tsx:56
840844
msgid "Your session has expired."
841845
msgstr "Your session has expired."

app/resources/login/__tests__/idp-origin.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,25 @@ describe('login IdP start: return URLs must use PUBLIC_ORIGIN, not request Host'
4444
expect(successUrl).not.toContain('evil.example');
4545
});
4646
});
47+
48+
describe('login IdP start: re-auth login_hint (best-effort IdP pre-selection)', () => {
49+
it('appends login_hint to the authorize URL when re-authenticating a specific account', async () => {
50+
const fake = getAuthProvider({ AUTH_PROVIDER: 'fake' }) as FakeAuthProvider;
51+
const result = await startIdpIntent(fake, {
52+
idpId: GOOGLE_IDP_ID,
53+
origin: PUBLIC_ORIGIN,
54+
reauthHint: 'alice@acme.test',
55+
});
56+
expect(result.ok).toBe(true);
57+
if (!result.ok) throw new Error('expected ok');
58+
expect(result.authUrl).toContain('login_hint=alice%40acme.test');
59+
});
60+
61+
it('omits login_hint for a normal (non-re-auth) IdP start', async () => {
62+
const fake = getAuthProvider({ AUTH_PROVIDER: 'fake' }) as FakeAuthProvider;
63+
const result = await startIdpIntent(fake, { idpId: GOOGLE_IDP_ID, origin: PUBLIC_ORIGIN });
64+
expect(result.ok).toBe(true);
65+
if (!result.ok) throw new Error('expected ok');
66+
expect(result.authUrl).not.toContain('login_hint');
67+
});
68+
});

0 commit comments

Comments
 (0)