auth-device-tracking.ts 5.5 KB

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