Skip to content

Commit 8b21264

Browse files
committed
feat(auth): session userAgent (Device/Location) + last-used login badge
Repopulate the Zitadel session userAgent on createSession for login, signup, and SSO so cloud-portal Active Sessions shows real Device (and Location where the gateway geo-IP is enabled) instead of "-". Restore the "Last used" badge on the login page, extended to all login actions (IdP / email / passkey) and rendered with the datum-ui Badge. Part A — session userAgent: - userAgentFromRequest util: IP from x-forwarded-for using the same trust model as the rate-limit middleware (no newly-trusted spoofable header); UA header + parsed browser/OS description + fingerprintId. - SessionOpts.userAgent forwarded to the Zitadel v2 CreateSession proto. - Threaded into all 9 createSession callsites (login identifier x2, signup x6, SSO sign-in + auto-link). Part B — last-used login badge: - Signed last-used-login cookie (idp:<idpId> | email | passkey), appended as a second Set-Cookie on each successful sign-in (login password, login/signup email-link, SSO sign-in/auto-link/ auto-create, passkey verify). - Login loader reads the cookie; the page renders a datum-ui "Last used" Badge on the matching action (at most one). Tests: 127 files / 908 tests green; tsc clean. Final whole-feature review: 0 critical / 0 important.
1 parent b312a5f commit 8b21264

23 files changed

Lines changed: 1038 additions & 93 deletions

app/modules/auth/auth-provider.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ export interface SessionOpts {
5959
* the key 'maxmind/tracking-token'.
6060
*/
6161
metadata?: Record<string, string>;
62+
/**
63+
* Device/browser context — forwarded to the Zitadel CreateSession proto
64+
* `userAgent` field (zitadel.session.v2.UserAgent). Enables the cloud-portal
65+
* "Active Sessions → Device/Location" view. Built by `userAgentFromRequest()`
66+
* in `app/server/user-agent.ts`. Omit when no request context is available
67+
* (e.g. server-side-only session refresh paths).
68+
*
69+
* Shape mirrors ZitadelUserAgent from user-agent.ts:
70+
* fingerprintId? — opaque client fingerprint id
71+
* ip? — client IP (last-hop XFF)
72+
* description? — human-readable browser/device/OS string
73+
* header? — map<string, { values: string[] }> (proto HeaderValues)
74+
*/
75+
userAgent?: {
76+
fingerprintId?: string;
77+
ip?: string;
78+
description?: string;
79+
header?: Record<string, { values: string[] }>;
80+
};
6281
}
6382

6483
export interface RegisterInput {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { FakeAuthProvider } from './fake-provider';
2+
import { describe, it, expect } from 'vitest';
3+
4+
describe('FakeAuthProvider — userAgent forwarding', () => {
5+
it('records userAgent on lastCreateSessionOpts when passed', async () => {
6+
const p = new FakeAuthProvider();
7+
const ua = {
8+
fingerprintId: 'fp-abc',
9+
ip: '1.2.3.4',
10+
description: 'Chrome, , , , Blink, , macOS, , ',
11+
header: { 'user-agent': { values: ['Mozilla/5.0'] } },
12+
};
13+
14+
await p.createSession({}, { userAgent: ua });
15+
16+
expect(p.lastCreateSessionOpts?.userAgent).toEqual(ua);
17+
});
18+
19+
it('leaves userAgent undefined on lastCreateSessionOpts when not passed', async () => {
20+
const p = new FakeAuthProvider();
21+
22+
await p.createSession({});
23+
24+
expect(p.lastCreateSessionOpts?.userAgent).toBeUndefined();
25+
});
26+
27+
it('accepts a partial userAgent with only fingerprintId', async () => {
28+
const p = new FakeAuthProvider();
29+
const ua = { fingerprintId: 'fp-only' };
30+
31+
await p.createSession({}, { userAgent: ua });
32+
33+
expect(p.lastCreateSessionOpts?.userAgent).toEqual({ fingerprintId: 'fp-only' });
34+
});
35+
});

app/modules/auth/providers/zitadel/index.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ import {
6767
SAMLService,
6868
SessionSchema as SamlSessionSchema,
6969
} from '@zitadel/proto/zitadel/saml/v2/saml_service_pb';
70-
import { SearchQuerySchema as SessionSearchQuerySchema } from '@zitadel/proto/zitadel/session/v2/session_pb';
70+
import {
71+
SearchQuerySchema as SessionSearchQuerySchema,
72+
UserAgentSchema,
73+
UserAgent_HeaderValuesSchema,
74+
} from '@zitadel/proto/zitadel/session/v2/session_pb';
7175
import {
7276
ChecksSchema,
7377
SessionService,
@@ -444,11 +448,36 @@ export class ZitadelAuthProvider implements AuthProvider {
444448
const metadata = opts?.metadata
445449
? Object.fromEntries(Object.entries(opts.metadata).map(([k, v]) => [k, encoder.encode(v)]))
446450
: undefined;
451+
// Forward Zitadel UserAgent so cloud-portal shows Device/Location on active sessions.
452+
// Map our ZitadelUserAgent shape onto the proto UserAgent message.
453+
// header values are repeated strings wrapped in a HeaderValues sub-message.
454+
const userAgent = opts?.userAgent
455+
? create(UserAgentSchema, {
456+
...(opts.userAgent.fingerprintId !== undefined
457+
? { fingerprintId: opts.userAgent.fingerprintId }
458+
: {}),
459+
...(opts.userAgent.ip !== undefined ? { ip: opts.userAgent.ip } : {}),
460+
...(opts.userAgent.description !== undefined
461+
? { description: opts.userAgent.description }
462+
: {}),
463+
...(opts.userAgent.header !== undefined
464+
? {
465+
header: Object.fromEntries(
466+
Object.entries(opts.userAgent.header).map(([k, v]) => [
467+
k,
468+
create(UserAgent_HeaderValuesSchema, { values: v.values }),
469+
])
470+
),
471+
}
472+
: {}),
473+
})
474+
: undefined;
447475
const created = await sessions.createSession(
448476
{
449477
checks: builtChecks,
450478
lifetime: { seconds: BigInt(lifetimeSeconds) },
451479
...(metadata ? { metadata } : {}),
480+
...(userAgent ? { userAgent } : {}),
452481
},
453482
{}
454483
);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
serializeLastUsedLogin,
3+
lastUsedLoginCookie,
4+
} from '@/modules/auth/session/last-used-login';
5+
import { describe, it, expect } from 'vitest';
6+
7+
/**
8+
* Extract the raw `name=value` pair from a Set-Cookie header string so it can be
9+
* passed directly to `lastUsedLoginCookie.parse()` as a Cookie request header.
10+
* (happy-dom blocks `cookie` as a forbidden header on `new Request()`, so we parse
11+
* via `lastUsedLoginCookie.parse(cookieHeader)` directly instead of via `readLastUsedLogin`.)
12+
*/
13+
function setCookieToCookieHeader(setCookieHeader: string): string {
14+
return setCookieHeader.split(';')[0].trim();
15+
}
16+
17+
describe('last-used-login cookie', () => {
18+
it('round-trips a token through serialize → parse', async () => {
19+
const token = 'idp:g';
20+
const setCookieValue = await serializeLastUsedLogin(token);
21+
const cookieHeader = setCookieToCookieHeader(setCookieValue);
22+
const parsed = await lastUsedLoginCookie.parse(cookieHeader);
23+
expect(parsed).toBe(token);
24+
});
25+
26+
it('returns null when the cookie is absent', async () => {
27+
const parsed = await lastUsedLoginCookie.parse(null);
28+
expect(parsed).toBeNull();
29+
});
30+
31+
it('round-trips email token', async () => {
32+
const token = 'email';
33+
const setCookieValue = await serializeLastUsedLogin(token);
34+
const cookieHeader = setCookieToCookieHeader(setCookieValue);
35+
const parsed = await lastUsedLoginCookie.parse(cookieHeader);
36+
expect(parsed).toBe(token);
37+
});
38+
39+
it('round-trips passkey token', async () => {
40+
const token = 'passkey';
41+
const setCookieValue = await serializeLastUsedLogin(token);
42+
const cookieHeader = setCookieToCookieHeader(setCookieValue);
43+
const parsed = await lastUsedLoginCookie.parse(cookieHeader);
44+
expect(parsed).toBe(token);
45+
});
46+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { env } from '@/utils/env/env.server';
2+
import { createCookie } from 'react-router';
3+
4+
/**
5+
* UX hint: records the last-used login method (idp:<idpId> | email | passkey).
6+
* Never an auth signal — purely per-browser preference tracking.
7+
* httpOnly: true (never accessible to scripts), sameSite: lax, 1-year maxAge.
8+
* Signed with SESSION_SECRET to prevent tampering.
9+
*/
10+
export const lastUsedLoginCookie = createCookie('last-used-login', {
11+
httpOnly: true,
12+
sameSite: 'lax',
13+
path: '/',
14+
secure: env.NODE_ENV === 'production',
15+
secrets: [env.SESSION_SECRET],
16+
maxAge: 60 * 60 * 24 * 365, // 1 year
17+
});
18+
19+
/**
20+
* Serialize a last-used login token to a Set-Cookie string.
21+
* Token ∈ 'idp:<idpId>' | 'email' | 'passkey'
22+
*/
23+
export async function serializeLastUsedLogin(token: string): Promise<string> {
24+
return lastUsedLoginCookie.serialize(token);
25+
}
26+
27+
/**
28+
* Read the last-used login token from a request.
29+
* Returns null if the cookie is absent or invalid.
30+
*/
31+
export async function readLastUsedLogin(request: Request): Promise<string | null> {
32+
const cookieHeader = request.headers.get('cookie');
33+
const token = await lastUsedLoginCookie.parse(cookieHeader);
34+
return token ?? null;
35+
}

0 commit comments

Comments
 (0)