Skip to content

Commit 4ff8c4c

Browse files
committed
fix(fraud): thread deviceTrackingToken through the login/signup IdP entry point
The maxmind fix earlier today only threaded deviceTrackingToken through sso-action.ts's runSsoAction (the SSO management/account-linking action). It missed the actual PRIMARY entry point both /login and /signup's "Sign in with Google" buttons use: login.service.ts's startIdpIntent, driven by the shared loginIdpSchema. That schema had no deviceTrackingToken field, so zod silently stripped it from the submitted form even though the hidden input was correctly populated client-side — confirmed live: the token was present in sessionStorage and the rendered form field, but never made it onto the /sso callback URL. - loginIdpSchema: add deviceTrackingToken (optional). - login.service.ts startIdpIntent: accept it, thread into idpReturnUrls (which already supported the field from the earlier fix). - routes/login/index.tsx and routes/signup/index.tsx: forward it from parsed.data into startIdpIntent. Scoped deliberately: /sso/link (linking an additional IdP to an already signed-in, already-scored account) is a GET-driven flow with a different transport mechanism and lower fraud-signal value — left out of scope. TDD: RED test confirmed the success URL was missing the param, GREEN after the fix. Full suite: 426/426 passing, tsc clean.
1 parent 806e2e2 commit 4ff8c4c

6 files changed

Lines changed: 55 additions & 26 deletions

File tree

app/modules/i18n/locales/en.po

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,11 @@ msgstr "Add your phone number as a second factor. We will send a one-time code v
6060
msgid "Additional verification is required to continue."
6161
msgstr "Additional verification is required to continue."
6262

63-
#: app/routes/signup/index.tsx:289
63+
#: app/routes/signup/index.tsx:290
6464
msgid "Already have an account?"
6565
msgstr "Already have an account?"
6666

67-
#: app/routes/login/index.tsx:269
67+
#: app/routes/login/index.tsx:270
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

@@ -169,7 +169,7 @@ msgstr "Choose how to sign in"
169169
msgid "Choose how you want to verify your identity."
170170
msgstr "Choose how you want to verify your identity."
171171

172-
#: app/routes/login/index.tsx:265
172+
#: app/routes/login/index.tsx:266
173173
msgid "Choose your login method"
174174
msgstr "Choose your login method"
175175

@@ -191,8 +191,8 @@ msgid "Connected accounts"
191191
msgstr "Connected accounts"
192192

193193
#: app/routes/device/index.tsx:73
194-
#: app/routes/login/index.tsx:346
195-
#: app/routes/signup/index.tsx:274
194+
#: app/routes/login/index.tsx:347
195+
#: app/routes/signup/index.tsx:275
196196
msgid "Continue"
197197
msgstr "Continue"
198198

@@ -212,11 +212,11 @@ msgstr "Couldn't complete sign-in. Return to your application and try again."
212212
msgid "Couldn't sign in"
213213
msgstr "Couldn't sign in"
214214

215-
#: app/routes/signup/index.tsx:201
215+
#: app/routes/signup/index.tsx:202
216216
msgid "Create a new account"
217217
msgstr "Create a new account"
218218

219-
#: app/routes/login/index.tsx:377
219+
#: app/routes/login/index.tsx:378
220220
#: app/routes/signup/password.tsx:207
221221
msgid "Create account"
222222
msgstr "Create account"
@@ -238,18 +238,18 @@ msgstr "Device code"
238238
msgid "Device denied"
239239
msgstr "Device denied"
240240

241-
#: app/routes/login/index.tsx:222
242-
#: app/routes/login/index.tsx:319
243-
#: app/routes/signup/index.tsx:235
244-
#: app/routes/signup/index.tsx:260
241+
#: app/routes/login/index.tsx:223
242+
#: app/routes/login/index.tsx:320
243+
#: app/routes/signup/index.tsx:236
244+
#: app/routes/signup/index.tsx:261
245245
msgid "Email"
246246
msgstr "Email"
247247

248248
#: app/routes/login/verify/email.tsx:72
249249
msgid "Email code"
250250
msgstr "Email code"
251251

252-
#: app/routes/login/index.tsx:355
252+
#: app/routes/login/index.tsx:356
253253
#: app/routes/login/method.tsx:117
254254
#: app/routes/signup/method.tsx:268
255255
msgid "Email me a sign-in link"
@@ -271,7 +271,7 @@ msgstr "Email OTP"
271271
msgid "Email sign-in isn't available — use your username."
272272
msgstr "Email sign-in isn't available — use your username."
273273

274-
#: app/routes/login/index.tsx:221
274+
#: app/routes/login/index.tsx:222
275275
msgid "Email, phone, or username"
276276
msgstr "Email, phone, or username"
277277

@@ -328,7 +328,7 @@ msgstr "Finish creating your account"
328328
msgid "Forgot password?"
329329
msgstr "Forgot password?"
330330

331-
#: app/routes/signup/index.tsx:198
331+
#: app/routes/signup/index.tsx:199
332332
msgid "Get started"
333333
msgstr "Get started"
334334

@@ -377,7 +377,7 @@ msgstr "No sign-in method is available for this account."
377377
msgid "No signed-in accounts."
378378
msgstr "No signed-in accounts."
379379

380-
#: app/routes/login/index.tsx:375
380+
#: app/routes/login/index.tsx:376
381381
msgid "Not registered?"
382382
msgstr "Not registered?"
383383

@@ -397,7 +397,7 @@ msgstr "or"
397397
msgid "Or import this URI in your authenticator app"
398398
msgstr "Or import this URI in your authenticator app"
399399

400-
#: app/routes/login/index.tsx:300
400+
#: app/routes/login/index.tsx:301
401401
#: app/routes/login/method.tsx:102
402402
#: app/routes/setup/mfa.tsx:46
403403
msgid "Passkey"
@@ -433,7 +433,7 @@ msgstr "Password must contain an uppercase letter."
433433
msgid "Password sign-in isn't available for this account."
434434
msgstr "Password sign-in isn't available for this account."
435435

436-
#: app/routes/login/index.tsx:224
436+
#: app/routes/login/index.tsx:225
437437
msgid "Phone"
438438
msgstr "Phone"
439439

@@ -462,7 +462,7 @@ msgstr "Register a passkey using your device's biometric sensor or PIN to sign i
462462
msgid "Register passkey"
463463
msgstr "Register passkey"
464464

465-
#: app/routes/signup/index.tsx:207
465+
#: app/routes/signup/index.tsx:208
466466
msgid "Registration is currently unavailable. Please contact your administrator."
467467
msgstr "Registration is currently unavailable. Please contact your administrator."
468468

@@ -540,7 +540,7 @@ msgid "Set up SMS one-time code"
540540
msgstr "Set up SMS one-time code"
541541

542542
#: app/routes/login/password.tsx:183
543-
#: app/routes/signup/index.tsx:291
543+
#: app/routes/signup/index.tsx:292
544544
#: app/routes/sso/ldap.tsx:82
545545
msgid "Sign in"
546546
msgstr "Sign in"
@@ -570,7 +570,7 @@ msgstr "Sign in with LDAP"
570570
msgid "Sign out"
571571
msgstr "Sign out"
572572

573-
#: app/routes/login/index.tsx:365
573+
#: app/routes/login/index.tsx:366
574574
msgid "Sign-in is currently unavailable for this account. Please contact your administrator."
575575
msgstr "Sign-in is currently unavailable for this account. Please contact your administrator."
576576

@@ -706,7 +706,7 @@ msgstr "Use your passkey to verify your identity."
706706
msgid "Use your security key to verify your identity."
707707
msgstr "Use your security key to verify your identity."
708708

709-
#: app/routes/login/index.tsx:225
709+
#: app/routes/login/index.tsx:226
710710
#: app/routes/sso/ldap.tsx:73
711711
msgid "Username"
712712
msgstr "Username"
@@ -780,7 +780,7 @@ msgstr "We've sent a password reset link to <0>{0}</0>"
780780
msgid "We've sent a verification link to <0>{0}</0>"
781781
msgstr "We've sent a verification link to <0>{0}</0>"
782782

783-
#: app/routes/login/index.tsx:262
783+
#: app/routes/login/index.tsx:263
784784
msgid "Welcome"
785785
msgstr "Welcome"
786786

app/resources/login/login.schema.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const loginIdpSchema = z.object({
1919
idpId: z.string().min(1),
2020
requestId: z.string().regex(REQUEST_ID_PATTERN).optional(),
2121
organization: z.string().optional(),
22+
// MaxMind device-fingerprint token captured client-side; forwarded through the IdP
23+
// round-trip so the resulting session carries fraud-tracking metadata.
24+
deviceTrackingToken: z.string().optional(),
2225
});
2326

2427
// Client-side validation subset for the /login identifier form; advisory only

app/resources/login/login.service.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export interface StartIdpInput {
6868
* is the real guard, this only improves the picker UX.
6969
*/
7070
reauthHint?: string;
71+
/** MaxMind device-fingerprint token captured client-side; attached as session metadata. */
72+
deviceTrackingToken?: string;
7173
}
7274

7375
export type StartIdpError = 'IDP_UNAVAILABLE';
@@ -82,10 +84,14 @@ export type StartIdpResult = { ok: true; authUrl: string } | { ok: false; error:
8284
*/
8385
export async function startIdpIntent(
8486
provider: AuthProvider,
85-
{ idpId, origin, requestId, organization, reauthHint }: StartIdpInput
87+
{ idpId, origin, requestId, organization, reauthHint, deviceTrackingToken }: StartIdpInput
8688
): Promise<StartIdpResult> {
8789
const slug = idpTypeToSlug(idpId) ?? idpId;
88-
const { success, failure } = idpReturnUrls(origin, slug, { requestId, organization });
90+
const { success, failure } = idpReturnUrls(origin, slug, {
91+
requestId,
92+
organization,
93+
deviceTrackingToken,
94+
});
8995
const result = await provider.startIdpIntent(idpId, { success, failure });
9096
if (!result.authUrl) {
9197
logAuthEvent('idp_start', 'failure', { idpId, reason: 'no_auth_url' });

app/routes/login/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ export async function action({ request }: ActionFunctionArgs) {
100100
if (form.get('intent') === 'idp') {
101101
const parsed = loginIdpSchema.safeParse(Object.fromEntries(form));
102102
if (!parsed.success) return data({ error: 'INVALID_INPUT' }, { status: 400 });
103-
const { idpId, requestId, organization } = parsed.data;
103+
const { idpId, requestId, organization, deviceTrackingToken } = parsed.data;
104104
// RE-AUTH: when this login is re-authenticating a specific account, pass its loginName as a
105105
// best-effort login_hint so the IdP pre-selects it. The callback's identity check is the guard.
106106
const reauthHint = await readReauthIntent(request);
@@ -110,6 +110,7 @@ export async function action({ request }: ActionFunctionArgs) {
110110
requestId,
111111
organization,
112112
reauthHint: reauthHint ?? undefined,
113+
deviceTrackingToken,
113114
});
114115
if (!result.ok) return data({ error: result.error }, { status: 502 });
115116
// The authUrl is the provider's IdP-start URL; requestId + organization are threaded

app/routes/signup/index.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,14 @@ export async function action({ request }: ActionFunctionArgs) {
101101
if (form.get('intent') === 'idp') {
102102
const parsed = loginIdpSchema.safeParse(Object.fromEntries(form));
103103
if (!parsed.success) return data({ error: 'INVALID_INPUT' as const }, { status: 400 });
104-
const { idpId, requestId, organization } = parsed.data;
104+
const { idpId, requestId, organization, deviceTrackingToken } = parsed.data;
105105
try {
106106
const result = await startIdpIntent(provider, {
107107
idpId,
108108
origin: trustedAppOrigin(request),
109109
requestId,
110110
organization,
111+
deviceTrackingToken,
111112
});
112113
// The pure decider maps the service result onto the Decision union.
113114
const decision = decideSignupIdpIntent(result);

cypress/component/resources/login/idp-origin.cy.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,22 @@ describe('login IdP start: return URLs must use PUBLIC_ORIGIN, not request Host'
3333
expect(successUrl.startsWith(`${PUBLIC_ORIGIN}/id/sso/`)).to.equal(true);
3434
expect(successUrl).not.to.contain('evil.example');
3535
});
36+
37+
// Both /login and /signup's "Sign in with Google" buttons call this same startIdpIntent —
38+
// deviceTrackingToken must ride the success URL exactly like requestId/organization already do,
39+
// or the MaxMind fraud-tracking token captured client-side never reaches the /sso callback.
40+
it('threads deviceTrackingToken onto the success URL when provided', async () => {
41+
const fake = makeProvider();
42+
const spy = cy.spy(fake, 'startIdpIntent');
43+
44+
await startIdpIntent(fake, {
45+
idpId: GOOGLE_IDP_ID,
46+
origin: PUBLIC_ORIGIN,
47+
deviceTrackingToken: 'mm-token-xyz',
48+
});
49+
50+
const [, urls] = spy.args[0];
51+
const successUrl: string = (urls as { success: string; failure: string }).success;
52+
expect(successUrl).to.contain('deviceTrackingToken=mm-token-xyz');
53+
});
3654
});

0 commit comments

Comments
 (0)