Skip to content

Commit 651307e

Browse files
committed
fix(auth-ui): mint fingerprintId cookie so it's set for all sessions
The prior commit READ the fingerprintId cookie but nothing SET it, so a browser without the old app's cookie still got fingerprintID: null. Add getOrCreateFingerprintId(request) — mirroring getCsrfToken's [value, setCookie] seam: reuse the cookie when present, else mint crypto.randomUUID() + a Set-Cookie (httpOnly, Path=/, Max-Age=1y, SameSite=Lax, Secure in production), matching the old app's attributes with a bare-UUID value for round-trip parity. The login and SSO createSession paths pass the minted id to userAgentFromRequest and append the Set-Cookie to their response, so even the first login already carries it. datum-cloud/enhancements#755
1 parent 277d1b8 commit 651307e

8 files changed

Lines changed: 335 additions & 37 deletions

File tree

app/modules/i18n/locales/en.po

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ msgstr "{0, plural, one {# attempt remaining.} other {# attempts remaining.}}"
1919

2020
#. placeholder {0}: idp.name
2121
#. placeholder {0}: r.label
22-
#: app/routes/login/index.tsx:287
22+
#: app/routes/login/index.tsx:296
2323
#: app/routes/setup/mfa.tsx:183
2424
#: app/routes/signup/index.tsx:237
2525
msgid "{0}"
@@ -65,7 +65,7 @@ msgstr "Additional verification is required to continue."
6565
msgid "Already have an account?"
6666
msgstr "Already have an account?"
6767

68-
#: app/routes/login/index.tsx:253
68+
#: app/routes/login/index.tsx:262
6969
msgid "An account with this email already exists — sign in to continue."
7070
msgstr "An account with this email already exists — sign in to continue."
7171

@@ -157,7 +157,7 @@ msgstr "Choose how to sign in"
157157
msgid "Choose how you want to verify your identity."
158158
msgstr "Choose how you want to verify your identity."
159159

160-
#: app/routes/login/index.tsx:249
160+
#: app/routes/login/index.tsx:258
161161
msgid "Choose your login method"
162162
msgstr "Choose your login method"
163163

@@ -179,7 +179,7 @@ msgid "Connected accounts"
179179
msgstr "Connected accounts"
180180

181181
#: app/routes/device/index.tsx:74
182-
#: app/routes/login/index.tsx:381
182+
#: app/routes/login/index.tsx:390
183183
#: app/routes/signup/index.tsx:305
184184
msgid "Continue"
185185
msgstr "Continue"
@@ -204,7 +204,7 @@ msgstr "Couldn't sign in"
204204
msgid "Create a new account"
205205
msgstr "Create a new account"
206206

207-
#: app/routes/login/index.tsx:412
207+
#: app/routes/login/index.tsx:421
208208
#: app/routes/signup/password.tsx:191
209209
msgid "Create account"
210210
msgstr "Create account"
@@ -225,8 +225,8 @@ msgstr "Device code"
225225
msgid "Device denied"
226226
msgstr "Device denied"
227227

228-
#: app/routes/login/index.tsx:206
229-
#: app/routes/login/index.tsx:347
228+
#: app/routes/login/index.tsx:215
229+
#: app/routes/login/index.tsx:356
230230
#: app/routes/signup/index.tsx:266
231231
#: app/routes/signup/index.tsx:291
232232
msgid "Email"
@@ -236,7 +236,7 @@ msgstr "Email"
236236
msgid "Email code"
237237
msgstr "Email code"
238238

239-
#: app/routes/login/index.tsx:390
239+
#: app/routes/login/index.tsx:399
240240
#: app/routes/login/method.tsx:125
241241
#: app/routes/signup/method.tsx:265
242242
msgid "Email me a sign-in link"
@@ -258,7 +258,7 @@ msgstr "Email OTP"
258258
msgid "Email sign-in isn't available — use your username."
259259
msgstr "Email sign-in isn't available — use your username."
260260

261-
#: app/routes/login/index.tsx:205
261+
#: app/routes/login/index.tsx:214
262262
msgid "Email, phone, or username"
263263
msgstr "Email, phone, or username"
264264

@@ -324,9 +324,9 @@ msgid "Incorrect credentials. Please try again."
324324
msgstr "Incorrect credentials. Please try again."
325325

326326
#: app/components/auth-form/last-used-badge.tsx:14
327-
#: app/routes/login/index.tsx:293
328-
#: app/routes/login/index.tsx:319
329-
#: app/routes/login/index.tsx:353
327+
#: app/routes/login/index.tsx:302
328+
#: app/routes/login/index.tsx:328
329+
#: app/routes/login/index.tsx:362
330330
msgid "Last used"
331331
msgstr "Last used"
332332

@@ -367,7 +367,7 @@ msgstr "No sign-in method is available for this account."
367367
msgid "No signed-in accounts."
368368
msgstr "No signed-in accounts."
369369

370-
#: app/routes/login/index.tsx:410
370+
#: app/routes/login/index.tsx:419
371371
msgid "Not registered?"
372372
msgstr "Not registered?"
373373

@@ -379,7 +379,7 @@ msgstr "Not you?"
379379
msgid "Open your authenticator app and enter the 6-digit code."
380380
msgstr "Open your authenticator app and enter the 6-digit code."
381381

382-
#: app/routes/login/index.tsx:329
382+
#: app/routes/login/index.tsx:338
383383
#: app/routes/signup/index.tsx:248
384384
msgid "or"
385385
msgstr "or"
@@ -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:313
391+
#: app/routes/login/index.tsx:322
392392
#: app/routes/login/method.tsx:110
393393
#: app/routes/setup/mfa.tsx:45
394394
msgid "Passkey"
@@ -424,7 +424,7 @@ 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:208
427+
#: app/routes/login/index.tsx:217
428428
msgid "Phone"
429429
msgstr "Phone"
430430

@@ -557,7 +557,7 @@ msgstr "Sign in with LDAP"
557557
msgid "Sign out"
558558
msgstr "Sign out"
559559

560-
#: app/routes/login/index.tsx:400
560+
#: app/routes/login/index.tsx:409
561561
msgid "Sign-in is currently unavailable for this account. Please contact your administrator."
562562
msgstr "Sign-in is currently unavailable for this account. Please contact your administrator."
563563

@@ -675,7 +675,7 @@ msgstr "Use your passkey to verify your identity."
675675
msgid "Use your security key to verify your identity."
676676
msgstr "Use your security key to verify your identity."
677677

678-
#: app/routes/login/index.tsx:209
678+
#: app/routes/login/index.tsx:218
679679
#: app/routes/sso/ldap.tsx:75
680680
msgid "Username"
681681
msgstr "Username"
@@ -749,7 +749,7 @@ msgstr "We've sent a password reset link to <0>{0}</0>"
749749
msgid "We've sent a verification link to <0>{0}</0>"
750750
msgstr "We've sent a verification link to <0>{0}</0>"
751751

752-
#: app/routes/login/index.tsx:246
752+
#: app/routes/login/index.tsx:255
753753
msgid "Welcome"
754754
msgstr "Welcome"
755755

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// End-to-end thread test: the fingerprintId minted/ensured in processIdpCallback must
2+
// (a) ride out on the finalizing redirect's Set-Cookie when the browser lacked the
3+
// cookie (closing the first-session gap), and
4+
// (b) be the SAME id handed to createSession's userAgent — so the very first session
5+
// in cloud-portal Active Sessions carries a non-null fingerprintID.
6+
// When the cookie is already present it is reused: no new fingerprintId Set-Cookie and
7+
// the session's userAgent carries the EXISTING id.
8+
//
9+
// @vitest-environment node
10+
// node env: happy-dom forbids setting the Cookie request header.
11+
import { FakeAuthProvider } from '@/modules/auth/providers/fake/fake-provider';
12+
import type { IdpIntentResult } from '@/modules/auth/types';
13+
import { processIdpCallback, outcomeToResponse } from '@/resources/sso';
14+
import { describe, it, expect } from 'vitest';
15+
16+
const REGISTER_INTENT_VERIFIED: IdpIntentResult = {
17+
userId: null,
18+
information: { idpId: 'idp-g', idpUserId: 'g-1', idpUserName: 'you@gmail.com' },
19+
draft: { email: 'you@gmail.com', firstName: 'You', lastName: 'User', emailVerified: true },
20+
};
21+
22+
/** All set-cookie header values as a proper array (undici exposes getSetCookie). */
23+
function setCookies(res: Response): string[] {
24+
return res.headers.getSetCookie();
25+
}
26+
27+
/** The fingerprintId cookie's value, or null if no fingerprintId Set-Cookie was emitted. */
28+
function fingerprintCookieValue(res: Response): string | null {
29+
for (const c of setCookies(res)) {
30+
const first = c.split(';')[0];
31+
if (first.startsWith('fingerprintId=')) {
32+
return decodeURIComponent(first.slice('fingerprintId='.length));
33+
}
34+
}
35+
return null;
36+
}
37+
38+
async function runAutoCreate(cookieHeader?: string): Promise<{
39+
res: Response;
40+
provider: FakeAuthProvider;
41+
}> {
42+
const provider = new FakeAuthProvider({});
43+
const headers: Record<string, string> = {};
44+
if (cookieHeader) headers['cookie'] = cookieHeader;
45+
const request = new Request(
46+
'https://auth.localtest.me/sso/google/callback?id=intent-1&token=tok-1',
47+
{ headers }
48+
);
49+
const outcome = await processIdpCallback(provider, request, 'google', {
50+
retrieveIdpIntent: async () => REGISTER_INTENT_VERIFIED,
51+
onAuthEvent: () => {},
52+
});
53+
return { res: outcomeToResponse(outcome) as Response, provider };
54+
}
55+
56+
describe('fingerprintId end-to-end through the SSO auto-create createSession', () => {
57+
it('cookie ABSENT: mints + Set-Cookie, and the SAME id reaches createSession userAgent', async () => {
58+
const { res, provider } = await runAutoCreate();
59+
60+
// The finalizing redirect carries a fingerprintId Set-Cookie...
61+
const minted = fingerprintCookieValue(res);
62+
expect(minted).toBeTruthy();
63+
expect(minted).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
64+
65+
// ...with the OLD-app attributes preserved.
66+
const fpCookie = setCookies(res).find((c) => c.startsWith('fingerprintId='))!;
67+
expect(fpCookie).toContain('Max-Age=31536000');
68+
expect(fpCookie).toContain('Path=/');
69+
expect(fpCookie).toContain('HttpOnly');
70+
71+
// ...and the created session's userAgent carries that EXACT id (no first-session gap).
72+
expect(provider.lastCreateSessionOpts?.userAgent?.fingerprintId).toBe(minted);
73+
74+
// The session cookie is still set alongside it (multiple Set-Cookie headers).
75+
expect(setCookies(res).some((c) => c.startsWith('sessions='))).toBe(true);
76+
});
77+
78+
it('cookie PRESENT: reused — no new fingerprintId Set-Cookie, session carries the existing id', async () => {
79+
const { res, provider } = await runAutoCreate('fingerprintId=existing-fp-xyz');
80+
81+
// No fresh fingerprintId Set-Cookie is emitted on reuse.
82+
expect(fingerprintCookieValue(res)).toBeNull();
83+
84+
// The created session's userAgent carries the EXISTING cookie value.
85+
expect(provider.lastCreateSessionOpts?.userAgent?.fingerprintId).toBe('existing-fp-xyz');
86+
});
87+
});

app/resources/sso/sso-callback.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { decideIdpCallback } from '@/resources/sso/idp-callback';
1919
import { signInWithIdpIntent, requestScopedProviderReads } from '@/resources/sso/idp-session';
2020
import type { SsoOutcome } from '@/resources/sso/sso-outcome';
2121
import { logAuthEvent } from '@/server/observability';
22-
import { userAgentFromRequest } from '@/server/user-agent';
22+
import { getOrCreateFingerprintId, userAgentFromRequest } from '@/server/user-agent';
2323
import { providerErrorCode } from '@/utils/errors/auth-error';
2424
import { z } from 'zod';
2525

@@ -71,6 +71,11 @@ export async function processIdpCallback(
7171

7272
const { id, token, link, requestId, organization } = parsed.data;
7373

74+
// Ensure the fingerprintId cookie exists for this browser. The SAME minted id feeds
75+
// every createSession userAgent below (no first-session gap); fingerprintCookie is
76+
// null on reuse and rides out on the redirect that finalizes the sign-in.
77+
const [fingerprintId, fingerprintCookie] = getOrCreateFingerprintId(request);
78+
7479
// Wrap the intent-fetch + session-resolution + decision block so a transient
7580
// ProviderError redirects to the branded error page and emits a failure audit event instead
7681
// of producing a raw 500. Unknown errors (non-ProviderError) re-throw so the root
@@ -158,7 +163,7 @@ export async function processIdpCallback(
158163
requestId,
159164
organization,
160165
fallbackLoginName: intent.information.idpUserName,
161-
userAgent: userAgentFromRequest(request),
166+
userAgent: userAgentFromRequest(request, fingerprintId),
162167
}));
163168
} catch (err) {
164169
const reason = err instanceof Error ? err.message : 'unknown';
@@ -176,7 +181,13 @@ export async function processIdpCallback(
176181
requestId,
177182
});
178183
const lastUsedCookie = await serializeLastUsedLogin(`idp:${intent.information.idpId}`);
179-
return { kind: 'redirect', location: target, setCookie, lastUsedCookie };
184+
return {
185+
kind: 'redirect',
186+
location: target,
187+
setCookie,
188+
lastUsedCookie,
189+
fingerprintCookie: fingerprintCookie ?? undefined,
190+
};
180191
}
181192

182193
case 'link':
@@ -204,7 +215,7 @@ export async function processIdpCallback(
204215
requestId,
205216
orgId: organization,
206217
userId: decision.userId,
207-
userAgent: userAgentFromRequest(request),
218+
userAgent: userAgentFromRequest(request, fingerprintId),
208219
}
209220
);
210221
// Elide the post-create getUser(decision.userId) lookup — the cookie's
@@ -236,6 +247,7 @@ export async function processIdpCallback(
236247
location: target,
237248
setCookie: await serializeSessions(next),
238249
lastUsedCookie,
250+
fingerprintCookie: fingerprintCookie ?? undefined,
239251
};
240252
} catch (err) {
241253
if (err instanceof ProviderError) {
@@ -287,14 +299,15 @@ export async function processIdpCallback(
287299
idpIntentId: id,
288300
idpIntentToken: token,
289301
emailVerified: intent.draft?.emailVerified ?? false,
290-
userAgent: userAgentFromRequest(request),
302+
userAgent: userAgentFromRequest(request, fingerprintId),
291303
});
292304
const lastUsedCookie = await serializeLastUsedLogin(`idp:${decision.link.idpId}`);
293305
return {
294306
kind: 'redirect',
295307
location: result.target,
296308
setCookie: await serializeSessions(result.sessions),
297309
lastUsedCookie,
310+
fingerprintCookie: fingerprintCookie ?? undefined,
298311
};
299312
} catch (err) {
300313
if (err instanceof ProviderError) {

app/resources/sso/sso-outcome.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ import { data, redirect } from 'react-router';
1212
// (used for plain 400 Bad Request and 502 data responses).
1313

1414
export type SsoOutcome =
15-
| { kind: 'redirect'; location: string; setCookie?: string; lastUsedCookie?: string }
15+
| {
16+
kind: 'redirect';
17+
location: string;
18+
setCookie?: string;
19+
lastUsedCookie?: string;
20+
// fingerprintId Set-Cookie minted for a browser that lacked it (null/absent on reuse).
21+
fingerprintCookie?: string;
22+
}
1623
| { kind: 'data'; payload: unknown; status?: number; headers?: Record<string, string> }
1724
| { kind: 'response'; response: Response };
1825

@@ -24,10 +31,11 @@ export type SsoOutcome =
2431
export function outcomeToResponse(outcome: SsoOutcome): Response | ReturnType<typeof data> {
2532
switch (outcome.kind) {
2633
case 'redirect':
27-
if (outcome.setCookie || outcome.lastUsedCookie) {
34+
if (outcome.setCookie || outcome.lastUsedCookie || outcome.fingerprintCookie) {
2835
const h = new Headers();
2936
if (outcome.setCookie) h.append('set-cookie', outcome.setCookie);
3037
if (outcome.lastUsedCookie) h.append('set-cookie', outcome.lastUsedCookie);
38+
if (outcome.fingerprintCookie) h.append('set-cookie', outcome.fingerprintCookie);
3139
return redirect(outcome.location, { headers: h });
3240
}
3341
return redirect(outcome.location);

0 commit comments

Comments
 (0)