auth-device-tracking.ts183 lines · main
1/**
2 * Device tracking — Gap Fix #6 / Sprint S2.
3 *
4 * Detects new devices on sign-in by hashing the user-agent string.
5 * No raw IPs are stored (privacy — CLAUDE.md §5.1).
6 * When a previously-unseen device signs in, a "new device" email is sent.
7 */
8
9import { createHash } from 'node:crypto';
10
11import { runInProjectDatabase } from '../db/data-plane.js';
12import { sendBrivenAuthNewDeviceLogin } from './auth-mailer.js';
13
14/** sha-256 of the user-agent (capped). Stable key for "seen before?" checks. */
15export function deviceFingerprint(userAgent: string | null | undefined): string {
16 const ua = (userAgent ?? 'unknown').trim().slice(0, 256) || 'unknown';
17 return createHash('sha256').update(ua).digest('hex');
18}
19
20/** Human-readable browser + OS hint for the new-device email (no raw UA). */
21export function deviceHint(userAgent: string | null | undefined): string {
22 const ua = userAgent ?? 'unknown device';
23 const browser = /Firefox\//i.test(ua)
24 ? 'Firefox'
25 : /Edg\//i.test(ua)
26 ? 'Edge'
27 : /Chrome\//i.test(ua) && /Safari\//i.test(ua)
28 ? 'Chrome'
29 : /Safari\//i.test(ua)
30 ? 'Safari'
31 : 'browser';
32 // iPhone/iPad before Mac OS — mobile Safari UAs often contain both.
33 const os = /iPhone|iPad/i.test(ua)
34 ? 'iOS'
35 : /Android/i.test(ua)
36 ? 'Android'
37 : /Mac OS/i.test(ua)
38 ? 'macOS'
39 : /Windows/i.test(ua)
40 ? 'Windows'
41 : /Linux/i.test(ua)
42 ? 'Linux'
43 : 'unknown OS';
44 return `${browser} on ${os}`;
45}
46
47export interface AuthDeviceRow {
48 id: string;
49 fingerprint: string;
50 userAgent: string | null;
51 /** Human hint only — never store as-is for display without recompute. */
52 hint: string;
53 createdAt: string;
54 updatedAt: string;
55}
56
57/**
58 * Check whether this user-agent has been seen before for the given user.
59 * If not, record it and send a new-device email (fire-and-forget).
60 * Known devices get `updated_at` bumped (last seen).
61 */
62export async function maybeAlertNewDevice(
63 projectId: string,
64 userId: string,
65 email: string,
66 userAgent: string | null | undefined,
67): Promise<{ isNew: boolean }> {
68 const fp = deviceFingerprint(userAgent);
69 const hint = deviceHint(userAgent);
70
71 const isNew = await runInProjectDatabase<boolean>(projectId, async (tx) => {
72 const existing = (await tx.unsafe(
73 `SELECT id FROM "_briven_auth_devices" WHERE user_id = $1 AND fingerprint = $2 LIMIT 1`,
74 [userId, fp] as never,
75 )) as Array<{ id: string }>;
76 if (existing.length > 0) {
77 await tx.unsafe(
78 `UPDATE "_briven_auth_devices" SET updated_at = now() WHERE id = $1`,
79 [existing[0]!.id] as never,
80 );
81 return false;
82 }
83
84 await tx.unsafe(
85 `INSERT INTO "_briven_auth_devices" (id, user_id, fingerprint, user_agent, created_at, updated_at)
86 VALUES (gen_random_uuid()::text, $1, $2, $3, now(), now())`,
87 [userId, fp, userAgent ?? null] as never,
88 );
89 return true;
90 });
91
92 if (isNew) {
93 void sendBrivenAuthNewDeviceLogin(projectId, email, {
94 deviceHint: hint,
95 whenIso: new Date().toISOString(),
96 manageUrl: `${process.env.BRIVEN_API_ORIGIN ?? 'https://api.briven.tech'}/v1/auth-tenant/get-session?briven_project_id=${projectId}`,
97 }).catch(() => {
98 // Swallow — email failure must not break sign-in.
99 });
100 }
101
102 return { isNew };
103}
104
105/** List devices for a user (newest first). For admin user drawer / self profile. */
106export async function listDevicesForUser(
107 projectId: string,
108 userId: string,
109): Promise<AuthDeviceRow[]> {
110 const rows = await runInProjectDatabase<
111 Array<{
112 id: string;
113 fingerprint: string;
114 user_agent: string | null;
115 created_at: Date | string;
116 updated_at: Date | string;
117 }>
118 >(projectId, async (tx) =>
119 tx.unsafe(
120 `SELECT id, fingerprint, user_agent, created_at, updated_at
121 FROM "_briven_auth_devices"
122 WHERE user_id = $1
123 ORDER BY updated_at DESC
124 LIMIT 50`,
125 [userId] as never,
126 ) as never,
127 );
128
129 return rows.map((r) => ({
130 id: r.id,
131 fingerprint: r.fingerprint,
132 userAgent: r.user_agent,
133 hint: deviceHint(r.user_agent),
134 createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
135 updatedAt: r.updated_at instanceof Date ? r.updated_at.toISOString() : String(r.updated_at),
136 }));
137}
138
139export interface AuthSessionRow {
140 id: string;
141 createdAt: string;
142 expiresAt: string | null;
143 userAgent: string | null;
144 hint: string;
145}
146
147/** List live sessions for a user (admin). Tokens never returned. */
148export async function listSessionsForUser(
149 projectId: string,
150 userId: string,
151): Promise<AuthSessionRow[]> {
152 const rows = await runInProjectDatabase<
153 Array<{
154 id: string;
155 created_at: Date | string;
156 expires_at: Date | string | null;
157 user_agent: string | null;
158 }>
159 >(projectId, async (tx) =>
160 tx.unsafe(
161 `SELECT id, created_at, expires_at, user_agent
162 FROM "_briven_auth_sessions"
163 WHERE user_id = $1
164 AND (expires_at IS NULL OR expires_at > now())
165 ORDER BY created_at DESC
166 LIMIT 50`,
167 [userId] as never,
168 ) as never,
169 );
170
171 return rows.map((r) => ({
172 id: r.id,
173 createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at),
174 expiresAt:
175 r.expires_at == null
176 ? null
177 : r.expires_at instanceof Date
178 ? r.expires_at.toISOString()
179 : String(r.expires_at),
180 userAgent: r.user_agent,
181 hint: deviceHint(r.user_agent),
182 }));
183}