@oimlsmart/platform-server 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ -- Migration 0024 — the SSO wave-A tail (TODO.identity-sso: RP-initiated
2
+ -- logout + prompt=login): the one-time code carries the consenting
3
+ -- session's authentication instant (sessions.created_at, verbatim), so
4
+ -- the token endpoint emits the ID token's auth_time — the forced
5
+ -- re-authentication's freshness proof the RP verifies. NULL = no
6
+ -- instant recorded (a code minted before this wave).
7
+ -- schema.sql carries the same end state for fresh databases —
8
+ -- src/__tests__/d1-store.test.ts pins the UNION of every migration to
9
+ -- schema.sql's CREATE set.
10
+ ALTER TABLE oidc_codes ADD COLUMN auth_time TEXT;
@@ -0,0 +1,61 @@
1
+ -- Migration 0025 — the SSO wave-C token surface (TODO.identity-sso: the
2
+ -- refresh-token grant with rotation + revocation): the OP's refresh
3
+ -- tokens. A refresh token is the offline half of the remembered consent
4
+ -- (the Relying Party keeps the authorization while the holder is away),
5
+ -- so the row carries the granting code's full provenance: the canonical
6
+ -- scope spelling, the context_org, the amr, and the ORIGINAL
7
+ -- authentication instant (auth_time never advances on a refresh — the
8
+ -- refreshed ID token proves the original authentication, never a new
9
+ -- one).
10
+ --
11
+ -- The doctrines:
12
+ -- - ONE-TIME, atomically consumed (the oidc_codes / email_change_tokens
13
+ -- posture): the exchange's UPDATE … WHERE consumed_at IS NULL flips
14
+ -- exactly once — a replay loses the race;
15
+ -- - the FAMILY: every rotation descends from the first mint and carries
16
+ -- its family_id. A presented CONSUMED token is the theft signal
17
+ -- (RFC 6819 §5.2.2.3): the whole family dies (DELETE WHERE
18
+ -- family_id), so the attacker's copy and the holder's legitimate
19
+ -- chain both end;
20
+ -- - the consumed row STAYS (the reuse detector reads it) until the
21
+ -- family's end: revocation (RFC 7009, client-bound — a client revokes
22
+ -- only its own, and revoking one refresh token revokes the family),
23
+ -- the consent's revocation, the deactivation sweep, and the account
24
+ -- erasure remove the rows outright (the personal_access_tokens
25
+ -- doctrine);
26
+ -- - expiry is per row (the consumer passes ttlMs at each mint; a
27
+ -- rotation slides the window forward on the NEW row). An expired
28
+ -- present is consumed anyway — never a second chance.
29
+ -- schema.sql carries the same end state for fresh databases —
30
+ -- test/migrations.test.ts pins the UNION of every migration to
31
+ -- schema.sql's CREATE set.
32
+
33
+ CREATE TABLE IF NOT EXISTS oidc_refresh_tokens (
34
+ token TEXT PRIMARY KEY,
35
+ user_id TEXT NOT NULL REFERENCES users(id),
36
+ client_id TEXT NOT NULL,
37
+ -- The granted scope set, the canonical space-joined spelling
38
+ -- (normalizeOidcScopeSet) — a refresh never widens it (the route's
39
+ -- math), the row is the grant's record.
40
+ scope TEXT NOT NULL,
41
+ -- The granting code's context — a refreshed access token answers the
42
+ -- SAME claims the original carried.
43
+ context_org TEXT,
44
+ -- The authorizing authentication's amr provenance (a JSON array; NULL =
45
+ -- none recorded).
46
+ amr TEXT,
47
+ -- The ORIGINAL authentication instant (verbatim from the consenting
48
+ -- session; NULL = none recorded) — carried into every refreshed ID
49
+ -- token's auth_time.
50
+ auth_time TEXT,
51
+ -- The rotation lineage: the first mint's generated id, inherited by
52
+ -- every rotation. Reuse of a consumed row kills the family.
53
+ family_id TEXT NOT NULL,
54
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
55
+ expires_at TEXT NOT NULL,
56
+ consumed_at TEXT
57
+ );
58
+ -- The deactivation/erasure sweeps + the account console's per-app read.
59
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_user ON oidc_refresh_tokens (user_id);
60
+ -- The reuse-kill + the RFC 7009 family revocation.
61
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_family ON oidc_refresh_tokens (family_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oimlsmart/platform-server",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/store/d1.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  type AuthUserPayload,
40
40
  type CompleteEmailChangeResult,
41
41
  type CompleteEnrollmentResult,
42
+ type ConsumeOidcRefreshTokenResult,
42
43
  type EmailChangeToken,
43
44
  type EnrollmentToken,
44
45
  type EntityChange,
@@ -64,6 +65,7 @@ import {
64
65
  type OidcCode,
65
66
  type OidcConsentGrant,
66
67
  type OidcKeyRow,
68
+ type OidcRefreshToken,
67
69
  consentGrantCovers,
68
70
  normalizeOidcScopeSet,
69
71
  type OpAccountErasure,
@@ -227,6 +229,7 @@ interface EnsureMemos {
227
229
  oidcColumns: Promise<void> | null
228
230
  personalAccessTokenSupport: Promise<void> | null
229
231
  consentGrantSupport: Promise<void> | null
232
+ oidcRefreshTokenSupport: Promise<void> | null
230
233
  accountEmailSupport: Promise<void> | null
231
234
  notifyDeliverySupport: Promise<void> | null
232
235
  }
@@ -243,6 +246,7 @@ function ensured(binding: D1Database, slot: keyof EnsureMemos, run: () => Promis
243
246
  orgRegistrySupport: null, holderAttributionSupport: null,
244
247
  instrumentRegistrationSupport: null, oidcColumns: null,
245
248
  personalAccessTokenSupport: null, consentGrantSupport: null,
249
+ oidcRefreshTokenSupport: null,
246
250
  accountEmailSupport: null, notifyDeliverySupport: null,
247
251
  }
248
252
  ensureMemosByBinding.set(binding, memos)
@@ -640,6 +644,11 @@ export class D1ServerStore implements ServerStore {
640
644
  if (!codeCols.results.some(c => c.name === 'amr')) {
641
645
  await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
642
646
  }
647
+ // TODO.identity-sso (the wave-A tail): the code carries the
648
+ // consenting session's authentication instant (migration 0024).
649
+ if (!codeCols.results.some(c => c.name === 'auth_time')) {
650
+ await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN auth_time TEXT').run()
651
+ }
643
652
  const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
644
653
  if (!tokenCols.results.some(c => c.name === 'amr')) {
645
654
  await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
@@ -701,6 +710,33 @@ export class D1ServerStore implements ServerStore {
701
710
  })
702
711
  }
703
712
 
713
+ // TODO.identity-sso (the wave-C token surface): the oidc_refresh_tokens
714
+ // table arrives with migration 0025 — a dev D1 migrated from before it
715
+ // lacks the table, so the refresh methods (and the deactivation sweep)
716
+ // ensure it defensively (the ensureConsentGrantSupport posture,
717
+ // memoized per (binding, chain) at module scope).
718
+ private ensureOidcRefreshTokenSupport(): Promise<void> {
719
+ return ensured(this.binding, 'oidcRefreshTokenSupport', async () => {
720
+ await this.db.prepare(
721
+ `CREATE TABLE IF NOT EXISTS oidc_refresh_tokens (
722
+ token TEXT PRIMARY KEY,
723
+ user_id TEXT NOT NULL REFERENCES users(id),
724
+ client_id TEXT NOT NULL,
725
+ scope TEXT NOT NULL,
726
+ context_org TEXT,
727
+ amr TEXT,
728
+ auth_time TEXT,
729
+ family_id TEXT NOT NULL,
730
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
731
+ expires_at TEXT NOT NULL,
732
+ consumed_at TEXT
733
+ )`,
734
+ ).run()
735
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_user ON oidc_refresh_tokens (user_id)').run()
736
+ await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_family ON oidc_refresh_tokens (family_id)').run()
737
+ })
738
+ }
739
+
704
740
  // TODO.identity-features/01 (multiple emails per account): the
705
741
  // account_emails table + the email_change_tokens.kind column arrive
706
742
  // with migration 0022 — a dev D1 migrated from before it lacks both,
@@ -862,11 +898,11 @@ export class D1ServerStore implements ServerStore {
862
898
  // reassignment takes effect on the next request; deactivation ends
863
899
  // the session at once.
864
900
  const session = await this.stmt(
865
- `SELECT s.user_id, s.active_org, s.amr, u.email, u.name, u.role, u.roles, u.org_id, u.avatar_url, u.provider, u.email_verified_at
901
+ `SELECT s.user_id, s.active_org, s.amr, s.created_at, u.email, u.name, u.role, u.roles, u.org_id, u.avatar_url, u.provider, u.email_verified_at
866
902
  FROM sessions s JOIN users u ON s.user_id = u.id
867
903
  WHERE s.token = ? AND s.expires_at > datetime('now') AND u.active = 1`,
868
904
  token,
869
- ).first<{ user_id: string; active_org: string | null; amr: string | null; email: string; name: string; role: string; roles: string | null; org_id: string | null; avatar_url: string | null; provider: string; email_verified_at: string | null }>()
905
+ ).first<{ user_id: string; active_org: string | null; amr: string | null; created_at: string; email: string; name: string; role: string; roles: string | null; org_id: string | null; avatar_url: string | null; provider: string; email_verified_at: string | null }>()
870
906
  if (!session) return null
871
907
  const amr = parseRoles(session.amr)
872
908
  const payload: AuthUserPayload = {
@@ -880,6 +916,9 @@ export class D1ServerStore implements ServerStore {
880
916
  provider: session.provider,
881
917
  emailVerifiedAt: session.email_verified_at ?? null,
882
918
  ...(amr?.length ? { amr } : {}),
919
+ // TODO.identity-sso (the wave-A tail): the authentication instant —
920
+ // the ID token's auth_time derives from it (the consumer converts).
921
+ sessionCreatedAt: session.created_at,
883
922
  }
884
923
  // TODO.identity/11: the active-org context (the membership model) —
885
924
  // the payload's org/roles follow the session's stamped context. The
@@ -1295,16 +1334,20 @@ export class D1ServerStore implements ServerStore {
1295
1334
  * (stored as JSON; the token endpoint emits it as the ID token's
1296
1335
  * amr). Absent = no provenance recorded. */
1297
1336
  amr?: string[] | null
1337
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
1338
+ * authentication instant (verbatim; absent = none recorded) — the
1339
+ * token endpoint emits it as the ID token's auth_time. */
1340
+ authTime?: string | null
1298
1341
  ttlMs: number
1299
1342
  }): Promise<void> {
1300
1343
  await this.ensureMembershipSupport()
1301
1344
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1302
1345
  await this.ensureOidcColumns()
1303
1346
  await this.stmt(
1304
- `INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, expires_at)
1305
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1347
+ `INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, auth_time, expires_at)
1348
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1306
1349
  input.code, input.clientId, input.redirectUri, input.scope, input.nonce, input.codeChallenge, input.userId, input.contextOrg ?? null,
1307
- input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt,
1350
+ input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, expiresAt,
1308
1351
  ).run()
1309
1352
  }
1310
1353
 
@@ -1330,6 +1373,7 @@ export class D1ServerStore implements ServerStore {
1330
1373
  userId: row.user_id as string,
1331
1374
  contextOrg: (row.context_org as string | null) ?? null,
1332
1375
  amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1376
+ authTime: (row.auth_time as string | null) ?? null,
1333
1377
  expiresAt: row.expires_at as string,
1334
1378
  }
1335
1379
  }
@@ -1360,7 +1404,7 @@ export class D1ServerStore implements ServerStore {
1360
1404
  async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
1361
1405
  await this.ensureMembershipSupport()
1362
1406
  const row = await this.stmt(
1363
- "SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')", token,
1407
+ "SELECT * FROM oidc_access_tokens WHERE token = ? AND datetime(expires_at) > datetime('now')", token,
1364
1408
  ).first<Record<string, unknown>>()
1365
1409
  if (!row) return null
1366
1410
  return {
@@ -1374,6 +1418,124 @@ export class D1ServerStore implements ServerStore {
1374
1418
  }
1375
1419
  }
1376
1420
 
1421
+ /** The account console's per-app read + the governance view's per-user
1422
+ * slice: the account's LIVE access tokens, newest first — created_at
1423
+ * is second-resolution, the rowid breaks the tie. */
1424
+ async listOidcAccessTokens(userId: string): Promise<OidcAccessToken[]> {
1425
+ await this.ensureMembershipSupport()
1426
+ const res = await this.stmt(
1427
+ "SELECT * FROM oidc_access_tokens WHERE user_id = ? AND datetime(expires_at) > datetime('now') ORDER BY created_at DESC, rowid DESC", userId,
1428
+ ).all<Record<string, unknown>>()
1429
+ return res.results.map(row => ({
1430
+ token: row.token as string,
1431
+ userId: row.user_id as string,
1432
+ clientId: row.client_id as string,
1433
+ scope: row.scope as string,
1434
+ contextOrg: (row.context_org as string | null) ?? null,
1435
+ amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1436
+ expiresAt: row.expires_at as string,
1437
+ }))
1438
+ }
1439
+
1440
+ /** The RFC 7009 access-token revocation: the row goes, client-bound. */
1441
+ async deleteOidcAccessToken(token: string, clientId: string): Promise<boolean> {
1442
+ await this.ensureMembershipSupport()
1443
+ const res = await this.stmt(
1444
+ 'DELETE FROM oidc_access_tokens WHERE token = ? AND client_id = ?', token, clientId,
1445
+ ).run()
1446
+ return (res.meta.changes ?? 0) > 0
1447
+ }
1448
+
1449
+ private static toRefreshToken(row: Record<string, unknown>): OidcRefreshToken {
1450
+ return {
1451
+ token: row.token as string,
1452
+ userId: row.user_id as string,
1453
+ clientId: row.client_id as string,
1454
+ scope: row.scope as string,
1455
+ contextOrg: (row.context_org as string | null) ?? null,
1456
+ amr: parseRoles((row.amr as string | null) ?? null) ?? null,
1457
+ authTime: (row.auth_time as string | null) ?? null,
1458
+ familyId: row.family_id as string,
1459
+ createdAt: row.created_at as string,
1460
+ expiresAt: row.expires_at as string,
1461
+ consumedAt: (row.consumed_at as string | null) ?? null,
1462
+ }
1463
+ }
1464
+
1465
+ async createOidcRefreshToken(input: {
1466
+ token: string
1467
+ userId: string
1468
+ clientId: string
1469
+ scope: string
1470
+ contextOrg?: string | null
1471
+ amr?: string[] | null
1472
+ authTime?: string | null
1473
+ familyId: string
1474
+ ttlMs: number
1475
+ }): Promise<OidcRefreshToken> {
1476
+ await this.ensureMembershipSupport()
1477
+ await this.ensureOidcRefreshTokenSupport()
1478
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
1479
+ await this.stmt(
1480
+ `INSERT INTO oidc_refresh_tokens (token, user_id, client_id, scope, context_org, amr, auth_time, family_id, expires_at)
1481
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
1482
+ input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
1483
+ input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, input.familyId, expiresAt,
1484
+ ).run()
1485
+ const row = await this.stmt('SELECT * FROM oidc_refresh_tokens WHERE token = ?', input.token).first<Record<string, unknown>>()
1486
+ return D1ServerStore.toRefreshToken(row as Record<string, unknown>)
1487
+ }
1488
+
1489
+ /** The refresh exchange's consume: the UPDATE flips consumed_at exactly
1490
+ * once — a replay loses the race, reads the consumed row back, and the
1491
+ * WHOLE FAMILY dies (the theft signal, RFC 6819 §5.2.2.3; a concurrent
1492
+ * double-present lands the same verdict — fail toward invalidation).
1493
+ * An expired live row is consumed anyway (never a second chance) and
1494
+ * answers 'invalid'. */
1495
+ async consumeOidcRefreshToken(token: string): Promise<ConsumeOidcRefreshTokenResult> {
1496
+ await this.ensureMembershipSupport()
1497
+ await this.ensureOidcRefreshTokenSupport()
1498
+ const res = await this.stmt(
1499
+ "UPDATE oidc_refresh_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL", token,
1500
+ ).run()
1501
+ if ((res.meta.changes ?? 0) > 0) {
1502
+ const row = await this.stmt('SELECT * FROM oidc_refresh_tokens WHERE token = ?', token).first<Record<string, unknown>>()
1503
+ if (!row) return { kind: 'invalid' }
1504
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return { kind: 'invalid' }
1505
+ return { kind: 'ok', token: D1ServerStore.toRefreshToken(row) }
1506
+ }
1507
+ const row = await this.stmt('SELECT * FROM oidc_refresh_tokens WHERE token = ?', token).first<Record<string, unknown>>()
1508
+ if (!row) return { kind: 'invalid' }
1509
+ // The reuse signal: the consumed row's family dies outright.
1510
+ const familyId = row.family_id as string
1511
+ await this.stmt('DELETE FROM oidc_refresh_tokens WHERE family_id = ?', familyId).run()
1512
+ return { kind: 'reuse', familyId, userId: row.user_id as string, clientId: row.client_id as string }
1513
+ }
1514
+
1515
+ /** The RFC 7009 refresh revocation, client-bound: the presented token's
1516
+ * family goes (never another client's rows). */
1517
+ async revokeOidcRefreshToken(token: string, clientId: string): Promise<boolean> {
1518
+ await this.ensureMembershipSupport()
1519
+ await this.ensureOidcRefreshTokenSupport()
1520
+ const row = await this.stmt(
1521
+ 'SELECT family_id FROM oidc_refresh_tokens WHERE token = ? AND client_id = ?', token, clientId,
1522
+ ).first<{ family_id: string }>()
1523
+ if (!row) return false
1524
+ await this.stmt('DELETE FROM oidc_refresh_tokens WHERE family_id = ?', row.family_id).run()
1525
+ return true
1526
+ }
1527
+
1528
+ /** The consent revocation's companion: the (account, client) pair's
1529
+ * refresh rows all go. */
1530
+ async deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number> {
1531
+ await this.ensureMembershipSupport()
1532
+ await this.ensureOidcRefreshTokenSupport()
1533
+ const res = await this.stmt(
1534
+ 'DELETE FROM oidc_refresh_tokens WHERE user_id = ? AND client_id = ?', userId, clientId,
1535
+ ).run()
1536
+ return res.meta.changes ?? 0
1537
+ }
1538
+
1377
1539
  async listOidcKeys(): Promise<OidcKeyRow[]> {
1378
1540
  const res = await this.stmt('SELECT * FROM oidc_keys ORDER BY created_at, kid').all<Record<string, unknown>>()
1379
1541
  return res.results.map(row => ({
@@ -1829,16 +1991,21 @@ export class D1ServerStore implements ServerStore {
1829
1991
  }
1830
1992
 
1831
1993
  /** The deactivation's revocation half: every live session, every issued
1832
- * access token, every unconsumed code and pending authorization goes.
1833
- * The user row STAYS (the history). */
1834
- async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }> {
1994
+ * access token, every refresh token (migration 0025 the ensure runs
1995
+ * first: a dev D1 from before it lacks the table), every unconsumed
1996
+ * code and pending authorization goes. The user row STAYS (the
1997
+ * history). */
1998
+ async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number }> {
1999
+ await this.ensureOidcRefreshTokenSupport()
1835
2000
  const sessions = await this.stmt('DELETE FROM sessions WHERE user_id = ?', userId).run()
1836
2001
  const accessTokens = await this.stmt('DELETE FROM oidc_access_tokens WHERE user_id = ?', userId).run()
2002
+ const refreshTokens = await this.stmt('DELETE FROM oidc_refresh_tokens WHERE user_id = ?', userId).run()
1837
2003
  const codes = await this.stmt('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL', userId).run()
1838
2004
  const authorizations = await this.stmt('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL', userId).run()
1839
2005
  return {
1840
2006
  sessions: sessions.meta.changes ?? 0,
1841
2007
  accessTokens: accessTokens.meta.changes ?? 0,
2008
+ refreshTokens: refreshTokens.meta.changes ?? 0,
1842
2009
  codes: codes.meta.changes ?? 0,
1843
2010
  authorizations: authorizations.meta.changes ?? 0,
1844
2011
  }
@@ -299,15 +299,18 @@ export function deleteOpClientRoles(userId: string, clientId: string): boolean {
299
299
  }
300
300
 
301
301
  /** The deactivation's revocation half: every live session, every issued
302
- * access token, every unconsumed code and pending authorization goes.
303
- * The user row STAYS (the history). */
304
- export function revokeOpUserCredentials(userId: string): { sessions: number; accessTokens: number; codes: number; authorizations: number } {
302
+ * access token, every refresh token (migration 0025 consumed or not;
303
+ * the reuse detector has no more verdicts to give), every unconsumed
304
+ * code and pending authorization goes. The user row STAYS (the
305
+ * history). */
306
+ export function revokeOpUserCredentials(userId: string): { sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number } {
305
307
  const db = getDb()
306
308
  const sessions = db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId).changes
307
309
  const accessTokens = db.prepare('DELETE FROM oidc_access_tokens WHERE user_id = ?').run(userId).changes
310
+ const refreshTokens = db.prepare('DELETE FROM oidc_refresh_tokens WHERE user_id = ?').run(userId).changes
308
311
  const codes = db.prepare('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL').run(userId).changes
309
312
  const authorizations = db.prepare('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL').run(userId).changes
310
- return { sessions, accessTokens, codes, authorizations }
313
+ return { sessions, accessTokens, refreshTokens, codes, authorizations }
311
314
  }
312
315
 
313
316
  /** The registry's edit act (name/email). The email UNIQUE conflict
@@ -351,6 +354,7 @@ export function updateOpAccount(id: string, input: { name?: string; email?: stri
351
354
  export function eraseOpAccount(userId: string): {
352
355
  sessions: number
353
356
  accessTokens: number
357
+ refreshTokens: number
354
358
  codes: number
355
359
  authorizations: number
356
360
  links: number
@@ -11,12 +11,14 @@
11
11
 
12
12
  import { getDb } from './store'
13
13
  import type {
14
+ ConsumeOidcRefreshTokenResult,
14
15
  OidcAccessToken,
15
16
  OidcAuthorization,
16
17
  OidcClient,
17
18
  OidcClientLaunch,
18
19
  OidcCode,
19
20
  OidcKeyRow,
21
+ OidcRefreshToken,
20
22
  } from '../../store'
21
23
 
22
24
  function toOidcClient(row: Record<string, unknown>): OidcClient {
@@ -174,14 +176,18 @@ export function createOidcCode(input: {
174
176
  * (stored as JSON; the token endpoint emits it as the ID token's
175
177
  * amr). Absent = no provenance recorded. */
176
178
  amr?: string[] | null
179
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
180
+ * authentication instant (verbatim; absent = none recorded) — the
181
+ * token endpoint emits it as the ID token's auth_time. */
182
+ authTime?: string | null
177
183
  ttlMs: number
178
184
  }): void {
179
185
  const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
180
186
  getDb().prepare(`
181
- INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, expires_at)
182
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
187
+ INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, auth_time, expires_at)
188
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
183
189
  `).run(input.code, input.clientId, input.redirectUri, input.scope, input.nonce, input.codeChallenge, input.userId,
184
- input.contextOrg ?? null, input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt)
190
+ input.contextOrg ?? null, input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, expiresAt)
185
191
  }
186
192
 
187
193
  /** Atomically consume the code: the UPDATE flips consumed_at exactly
@@ -204,6 +210,7 @@ export function consumeOidcCode(code: string): OidcCode | null {
204
210
  userId: row.user_id as string,
205
211
  contextOrg: (row.context_org as string | null) ?? null,
206
212
  amr: parseJsonStringList(row.amr),
213
+ authTime: (row.auth_time as string | null) ?? null,
207
214
  expiresAt: row.expires_at as string,
208
215
  }
209
216
  }
@@ -230,7 +237,7 @@ export function createOidcAccessToken(input: {
230
237
 
231
238
  export function getOidcAccessToken(token: string): OidcAccessToken | null {
232
239
  const row = getDb().prepare(
233
- "SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')",
240
+ "SELECT * FROM oidc_access_tokens WHERE token = ? AND datetime(expires_at) > datetime('now')",
234
241
  ).get(token) as Record<string, unknown> | undefined
235
242
  if (!row) return null
236
243
  return {
@@ -244,6 +251,109 @@ export function getOidcAccessToken(token: string): OidcAccessToken | null {
244
251
  }
245
252
  }
246
253
 
254
+ /** The account console's per-app read + the governance view's per-user
255
+ * slice: the account's LIVE access tokens, newest first — created_at is
256
+ * second-resolution, the rowid breaks the tie. */
257
+ export function listOidcAccessTokens(userId: string): OidcAccessToken[] {
258
+ const rows = getDb().prepare(
259
+ "SELECT * FROM oidc_access_tokens WHERE user_id = ? AND datetime(expires_at) > datetime('now') ORDER BY created_at DESC, rowid DESC",
260
+ ).all(userId) as Array<Record<string, unknown>>
261
+ return rows.map(row => ({
262
+ token: row.token as string,
263
+ userId: row.user_id as string,
264
+ clientId: row.client_id as string,
265
+ scope: row.scope as string,
266
+ contextOrg: (row.context_org as string | null) ?? null,
267
+ amr: parseJsonStringList(row.amr),
268
+ expiresAt: row.expires_at as string,
269
+ }))
270
+ }
271
+
272
+ /** The RFC 7009 access-token revocation: the row goes, client-bound. */
273
+ export function deleteOidcAccessToken(token: string, clientId: string): boolean {
274
+ const res = getDb().prepare('DELETE FROM oidc_access_tokens WHERE token = ? AND client_id = ?').run(token, clientId)
275
+ return res.changes > 0
276
+ }
277
+
278
+ function toOidcRefreshToken(row: Record<string, unknown>): OidcRefreshToken {
279
+ return {
280
+ token: row.token as string,
281
+ userId: row.user_id as string,
282
+ clientId: row.client_id as string,
283
+ scope: row.scope as string,
284
+ contextOrg: (row.context_org as string | null) ?? null,
285
+ amr: parseJsonStringList(row.amr),
286
+ authTime: (row.auth_time as string | null) ?? null,
287
+ familyId: row.family_id as string,
288
+ createdAt: row.created_at as string,
289
+ expiresAt: row.expires_at as string,
290
+ consumedAt: (row.consumed_at as string | null) ?? null,
291
+ }
292
+ }
293
+
294
+ /** The refresh mint (migration 0025): the row carries the granting code's
295
+ * provenance verbatim — a rotation re-mints the SAME truth, auth_time
296
+ * never advances. */
297
+ export function createOidcRefreshToken(input: {
298
+ token: string
299
+ userId: string
300
+ clientId: string
301
+ scope: string
302
+ contextOrg?: string | null
303
+ amr?: string[] | null
304
+ authTime?: string | null
305
+ familyId: string
306
+ ttlMs: number
307
+ }): OidcRefreshToken {
308
+ const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
309
+ getDb().prepare(`
310
+ INSERT INTO oidc_refresh_tokens (token, user_id, client_id, scope, context_org, amr, auth_time, family_id, expires_at)
311
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
312
+ `).run(input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
313
+ input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, input.familyId, expiresAt)
314
+ return toOidcRefreshToken(
315
+ getDb().prepare('SELECT * FROM oidc_refresh_tokens WHERE token = ?').get(input.token) as Record<string, unknown>,
316
+ )
317
+ }
318
+
319
+ /** The refresh exchange's consume: the UPDATE flips consumed_at exactly
320
+ * once — a replay loses the race, reads the consumed row back, and the
321
+ * WHOLE FAMILY dies (the theft signal, RFC 6819 §5.2.2.3; a concurrent
322
+ * double-present lands the same verdict — fail toward invalidation). An
323
+ * expired live row is consumed anyway (never a second chance) and
324
+ * answers 'invalid'. */
325
+ export function consumeOidcRefreshToken(token: string): ConsumeOidcRefreshTokenResult {
326
+ const db = getDb()
327
+ const res = db.prepare("UPDATE oidc_refresh_tokens SET consumed_at = datetime('now') WHERE token = ? AND consumed_at IS NULL").run(token)
328
+ if (res.changes > 0) {
329
+ const row = db.prepare('SELECT * FROM oidc_refresh_tokens WHERE token = ?').get(token) as Record<string, unknown>
330
+ if (new Date(row.expires_at as string).getTime() <= Date.now()) return { kind: 'invalid' }
331
+ return { kind: 'ok', token: toOidcRefreshToken(row) }
332
+ }
333
+ const row = db.prepare('SELECT * FROM oidc_refresh_tokens WHERE token = ?').get(token) as Record<string, unknown> | undefined
334
+ if (!row) return { kind: 'invalid' }
335
+ // The reuse signal: the consumed row's family dies outright.
336
+ const familyId = row.family_id as string
337
+ db.prepare('DELETE FROM oidc_refresh_tokens WHERE family_id = ?').run(familyId)
338
+ return { kind: 'reuse', familyId, userId: row.user_id as string, clientId: row.client_id as string }
339
+ }
340
+
341
+ /** The RFC 7009 refresh revocation, client-bound: the presented token's
342
+ * family goes (never another client's rows). */
343
+ export function revokeOidcRefreshToken(token: string, clientId: string): boolean {
344
+ const db = getDb()
345
+ const row = db.prepare('SELECT family_id FROM oidc_refresh_tokens WHERE token = ? AND client_id = ?').get(token, clientId) as { family_id: string } | undefined
346
+ if (!row) return false
347
+ db.prepare('DELETE FROM oidc_refresh_tokens WHERE family_id = ?').run(row.family_id)
348
+ return true
349
+ }
350
+
351
+ /** The consent revocation's companion: the (account, client) pair's
352
+ * refresh rows all go. */
353
+ export function deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): number {
354
+ return getDb().prepare('DELETE FROM oidc_refresh_tokens WHERE user_id = ? AND client_id = ?').run(userId, clientId).changes
355
+ }
356
+
247
357
  /** The amr column's honest parse (a JSON array of strings, else null —
248
358
  * the provenance is absent on rows that predate the wave). */
249
359
  function parseJsonStringList(raw: unknown): string[] | null {
@@ -339,6 +339,10 @@ CREATE TABLE IF NOT EXISTS oidc_codes (
339
339
  -- TODO.identity-sso/02+03: the consenting session's amr provenance (a
340
340
  -- JSON array; NULL = none recorded), carried into the ID token.
341
341
  amr TEXT,
342
+ -- TODO.identity-sso (the wave-A tail): the consenting session's
343
+ -- authentication instant (sessions.created_at, verbatim; NULL = none
344
+ -- recorded), carried into the ID token's auth_time.
345
+ auth_time TEXT,
342
346
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
343
347
  expires_at TEXT NOT NULL,
344
348
  consumed_at TEXT
@@ -360,6 +364,31 @@ CREATE TABLE IF NOT EXISTS oidc_access_tokens (
360
364
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
361
365
  );
362
366
 
367
+ -- The refresh tokens (migration 0025 — the SSO wave-C token surface): the
368
+ -- offline half of the remembered consent. One-time, atomically consumed
369
+ -- (WHERE consumed_at IS NULL); every rotation inherits the first mint's
370
+ -- family_id, and a presented CONSUMED token is the theft signal that kills
371
+ -- the whole family. The row carries the granting code's provenance
372
+ -- (canonical scope, context_org, amr, the ORIGINAL auth_time — a refresh
373
+ -- never advances it). Consumed rows STAY for the reuse detector;
374
+ -- revocation (client-bound, per family), the consent's revocation, the
375
+ -- deactivation sweep and the erasure remove rows outright.
376
+ CREATE TABLE IF NOT EXISTS oidc_refresh_tokens (
377
+ token TEXT PRIMARY KEY,
378
+ user_id TEXT NOT NULL REFERENCES users(id),
379
+ client_id TEXT NOT NULL,
380
+ scope TEXT NOT NULL,
381
+ context_org TEXT,
382
+ amr TEXT,
383
+ auth_time TEXT,
384
+ family_id TEXT NOT NULL,
385
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
386
+ expires_at TEXT NOT NULL,
387
+ consumed_at TEXT
388
+ );
389
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_user ON oidc_refresh_tokens (user_id);
390
+ CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_family ON oidc_refresh_tokens (family_id);
391
+
363
392
  -- The OP's signing-key rotation history: the PUBLIC halves. JWKS serves
364
393
  -- every row not retired beyond the token lifetime, so a rotation never
365
394
  -- strands an in-flight ID token. The private half rides the
@@ -323,6 +323,10 @@ export function getSessionUser(token: string): AuthUserPayload | null {
323
323
  ).run(token)
324
324
  // s.* carries the SESSION's id — the payload's id is the USER's.
325
325
  const payload = toAuthPayload({ ...session, id: session.user_id })
326
+ // TODO.identity-sso (the wave-A tail): the session's authentication
327
+ // instant (sessions.created_at, verbatim) — the ID token's auth_time
328
+ // derives from it (the consumer converts to the OIDC NumericDate).
329
+ payload.sessionCreatedAt = session.created_at as string
326
330
  // TODO.identity/11: the active-org context (the membership model) —
327
331
  // the payload's org/roles follow the session's stamped context.
328
332
  return applySessionOrgContext(session.active_org ?? null, payload)
@@ -117,16 +117,22 @@ import {
117
117
  } from './sqlite/notify'
118
118
  import {
119
119
  consumeOidcCode,
120
+ consumeOidcRefreshToken,
120
121
  createOidcAccessToken,
121
122
  createOidcAuthorization,
122
123
  createOidcCode,
124
+ createOidcRefreshToken,
123
125
  decideOidcAuthorization,
126
+ deleteOidcAccessToken,
127
+ deleteOidcRefreshTokensForUserClient,
124
128
  getOidcAccessToken,
125
129
  getOidcAuthorization,
126
130
  getOidcClient,
131
+ listOidcAccessTokens,
127
132
  listOidcClients,
128
133
  listOidcKeys,
129
134
  retireOidcKey,
135
+ revokeOidcRefreshToken,
130
136
  setOidcClientStatus,
131
137
  setOidcClientLaunch,
132
138
  upsertOidcClient,
@@ -179,7 +185,7 @@ import {
179
185
  updateOpAccount,
180
186
  updateUserName,
181
187
  } from './sqlite/op-accounts-store'
182
- import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventEntityKey, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
188
+ import { installStore, type AccountEmail, type AddAccountEmailResult, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type ConsumeOidcRefreshTokenResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, type EventEntityKey, type EventKeyFilter, type FederationPeer, type IdentityApproval, type IdentityLink, type IdentityProvider, type InstrumentRegistration, type InstrumentRegistrationLifecycle, type InstrumentRegistrationScopeStatus, type NotifyDelivery, type NotifyDeliveryStatus, type NotifyEntityMute, type NotifyInboxState, type NotifyPreferences, type NotifyRule, type OAuthInitialAssignment, type OidcAccessToken, type OidcAuthorization, type OidcClient, type OidcClientLaunch, type OidcCode, type OidcConsentGrant, type OidcKeyRow, type OidcRefreshToken, type OpAccountErasure, type OpClientRoleAssignment, type OpLiveSession, type OrgJoinRequest, type OrgMembership, type OrgMembershipState, type OrgRegistryContact, type OrgRegistryOrg, type OrgRegistryState, type PersonalAccessToken, type PlatformEvent, type ServerStore, type SessionView, type SsoSignInState, type UserAdminRow, type AdvanceCounterResult, type MfaPending, type RecoveryCodeState, type TotpSecret, type WebauthnChallenge, type WebauthnCredential } from '../store'
183
189
  import {
184
190
  advanceWebauthnCounter,
185
191
  consumeMfaPending,
@@ -658,6 +664,34 @@ export function createSqliteServerStore(): ServerStore {
658
664
  async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
659
665
  return getOidcAccessToken(token)
660
666
  },
667
+ async listOidcAccessTokens(userId: string): Promise<OidcAccessToken[]> {
668
+ return listOidcAccessTokens(userId)
669
+ },
670
+ async deleteOidcAccessToken(token: string, clientId: string): Promise<boolean> {
671
+ return deleteOidcAccessToken(token, clientId)
672
+ },
673
+ async createOidcRefreshToken(input: {
674
+ token: string
675
+ userId: string
676
+ clientId: string
677
+ scope: string
678
+ contextOrg?: string | null
679
+ amr?: string[] | null
680
+ authTime?: string | null
681
+ familyId: string
682
+ ttlMs: number
683
+ }): Promise<OidcRefreshToken> {
684
+ return createOidcRefreshToken(input)
685
+ },
686
+ async consumeOidcRefreshToken(token: string): Promise<ConsumeOidcRefreshTokenResult> {
687
+ return consumeOidcRefreshToken(token)
688
+ },
689
+ async revokeOidcRefreshToken(token: string, clientId: string): Promise<boolean> {
690
+ return revokeOidcRefreshToken(token, clientId)
691
+ },
692
+ async deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number> {
693
+ return deleteOidcRefreshTokensForUserClient(userId, clientId)
694
+ },
661
695
  async listOidcKeys(): Promise<OidcKeyRow[]> {
662
696
  return listOidcKeys()
663
697
  },
@@ -956,7 +990,7 @@ export function createSqliteServerStore(): ServerStore {
956
990
  async deleteOpClientRoles(userId: string, clientId: string): Promise<boolean> {
957
991
  return deleteOpClientRoles(userId, clientId)
958
992
  },
959
- async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }> {
993
+ async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number }> {
960
994
  return revokeOpUserCredentials(userId)
961
995
  },
962
996
  async eraseOpAccount(userId: string): Promise<OpAccountErasure | null> {
package/src/store.ts CHANGED
@@ -65,6 +65,13 @@ export interface AuthUserPayload {
65
65
  * read (getSessionUser) only; ABSENT = no OP-side credential event
66
66
  * recorded (an upstream-provider sign-in, a legacy row). */
67
67
  amr?: string[]
68
+ /** TODO.identity-sso (the wave-A tail): the SESSION's authentication
69
+ * instant (sessions.created_at, the column verbatim — the sign-in's
70
+ * wall-clock stamp). Projected by the session-backed read
71
+ * (getSessionUser) only. The ID token's auth_time derives from it (the
72
+ * OIDC NumericDate conversion is the consumer's — the column's storage
73
+ * format is the store's own). */
74
+ sessionCreatedAt?: string
68
75
  }
69
76
 
70
77
  // ── identity federation (TODO.federation/10) ─────────────────────────
@@ -425,6 +432,10 @@ export interface OidcCode {
425
432
  /** TODO.identity-sso/02+03: the consenting session's amr provenance
426
433
  * (parsed from the row's JSON; null = none recorded). */
427
434
  amr: string[] | null
435
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
436
+ * authentication instant (sessions.created_at, verbatim; null = none
437
+ * recorded) — the token endpoint emits it as the ID token's auth_time. */
438
+ authTime: string | null
428
439
  expiresAt: string
429
440
  }
430
441
 
@@ -443,6 +454,41 @@ export interface OidcAccessToken {
443
454
  expiresAt: string
444
455
  }
445
456
 
457
+ /** An issued refresh token (migration 0025 — the SSO wave-C token
458
+ * surface): the offline half of the remembered consent. The row carries
459
+ * the granting code's full provenance — the canonical scope spelling, the
460
+ * context_org, the amr, and the ORIGINAL authentication instant (authTime
461
+ * never advances on a refresh: the refreshed ID token proves the original
462
+ * authentication). familyId is the rotation lineage: every rotation
463
+ * inherits the first mint's id. consumedAt flips atomically at the
464
+ * exchange; the consumed row STAYS so a re-present detects the reuse. */
465
+ export interface OidcRefreshToken {
466
+ token: string
467
+ userId: string
468
+ clientId: string
469
+ scope: string
470
+ contextOrg: string | null
471
+ amr: string[] | null
472
+ authTime: string | null
473
+ familyId: string
474
+ createdAt: string
475
+ expiresAt: string
476
+ consumedAt: string | null
477
+ }
478
+
479
+ /** The refresh exchange's honest outcomes. 'ok' carries the freshly
480
+ * consumed row (the route rotates from its provenance). 'reuse' is the
481
+ * theft signal (RFC 6819 §5.2.2.3): a presented CONSUMED token — the
482
+ * store already killed the whole family (the attacker's copy and the
483
+ * legitimate chain both end); a concurrent double-present loses the
484
+ * atomic race to the same verdict, the fail-toward-invalidation posture.
485
+ * 'invalid' covers the never-existed, the revoked (the row is gone), and
486
+ * the expired (consumed anyway — never a second chance). */
487
+ export type ConsumeOidcRefreshTokenResult =
488
+ | { kind: 'ok'; token: OidcRefreshToken }
489
+ | { kind: 'reuse'; familyId: string; userId: string; clientId: string }
490
+ | { kind: 'invalid' }
491
+
446
492
  /** The OP key rotation history — the PUBLIC half only. */
447
493
  export interface OidcKeyRow {
448
494
  kid: string
@@ -598,7 +644,7 @@ export interface OpClientRoleAssignment {
598
644
  }
599
645
 
600
646
  /** The erasure act's removal counts (the audit event's metadata):
601
- * revokeOpUserCredentials' four plus the links, the per-client
647
+ * revokeOpUserCredentials' five plus the links, the per-client
602
648
  * assignments, the org memberships (TODO.identity/11), and the
603
649
  * credential/token rows (passwords, enrollment tokens, email-change
604
650
  * tokens). TODO.identity-sso/02+03 adds `factors`: the factor-registry
@@ -613,6 +659,7 @@ export interface OpClientRoleAssignment {
613
659
  export interface OpAccountErasure {
614
660
  sessions: number
615
661
  accessTokens: number
662
+ refreshTokens: number
616
663
  codes: number
617
664
  authorizations: number
618
665
  links: number
@@ -1580,6 +1627,11 @@ export interface ServerStore {
1580
1627
  * (stored as JSON; the token endpoint emits it as the ID token's
1581
1628
  * amr). Absent = no provenance recorded. */
1582
1629
  amr?: string[] | null
1630
+ /** TODO.identity-sso (the wave-A tail): the consenting session's
1631
+ * authentication instant (sessions.created_at, verbatim; absent =
1632
+ * none recorded) — the token endpoint emits it as the ID token's
1633
+ * auth_time. */
1634
+ authTime?: string | null
1583
1635
  ttlMs: number
1584
1636
  }): Promise<void>
1585
1637
  /** Atomically consume the code: answers the row exactly once (a
@@ -1601,6 +1653,50 @@ export interface ServerStore {
1601
1653
  ttlMs: number
1602
1654
  }): Promise<void>
1603
1655
  getOidcAccessToken(token: string): Promise<OidcAccessToken | null>
1656
+ /** The account's LIVE access tokens (unexpired, never revoked — the
1657
+ * row's absence IS the revocation), newest first. The account console's
1658
+ * per-app read and the client-registry governance view's per-user
1659
+ * slice; the introspection endpoint never lists (it resolves ONE
1660
+ * presented token). */
1661
+ listOidcAccessTokens(userId: string): Promise<OidcAccessToken[]>
1662
+ /** The RFC 7009 access-token revocation: delete the row, client-bound —
1663
+ * a client revokes only its OWN tokens (a token minted for another
1664
+ * client answers false). An absent row answers false too (the endpoint
1665
+ * masks both behind its 200). */
1666
+ deleteOidcAccessToken(token: string, clientId: string): Promise<boolean>
1667
+ /** The refresh tokens (migration 0025 — the SSO wave-C token surface).
1668
+ * The mint: token is the opaque value the route generated, familyId the
1669
+ * rotation lineage (a fresh id at the code exchange's first mint, the
1670
+ * consumed row's familyId at every rotation). scope is the CANONICAL
1671
+ * spelling of the granted set (a refresh never widens it). authTime is
1672
+ * the ORIGINAL authentication instant — it never advances. */
1673
+ createOidcRefreshToken(input: {
1674
+ token: string
1675
+ userId: string
1676
+ clientId: string
1677
+ scope: string
1678
+ contextOrg?: string | null
1679
+ amr?: string[] | null
1680
+ authTime?: string | null
1681
+ familyId: string
1682
+ ttlMs: number
1683
+ }): Promise<OidcRefreshToken>
1684
+ /** The refresh exchange's consume: the UPDATE … WHERE consumed_at IS
1685
+ * NULL flips exactly once (the oidc_codes doctrine). A presented
1686
+ * CONSUMED token is the reuse signal — the whole family is deleted
1687
+ * before the verdict answers. An expired live row is consumed anyway
1688
+ * and answers 'invalid'. */
1689
+ consumeOidcRefreshToken(token: string): Promise<ConsumeOidcRefreshTokenResult>
1690
+ /** The RFC 7009 refresh-token revocation, client-bound: the presented
1691
+ * token's WHOLE FAMILY goes (the grant lineage ends), never another
1692
+ * client's rows. Answers false when the token never existed, was
1693
+ * already gone, or belongs to another client (the endpoint masks all
1694
+ * three behind its 200). */
1695
+ revokeOidcRefreshToken(token: string, clientId: string): Promise<boolean>
1696
+ /** The consent revocation's companion: every refresh row of the
1697
+ * (account, client) pair goes — the "Revoke access" act ends the
1698
+ * offline half with the remembered consent. Answers the count. */
1699
+ deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number>
1604
1700
  /** The key rotation history (public halves). */
1605
1701
  listOidcKeys(): Promise<OidcKeyRow[]>
1606
1702
  upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
@@ -1747,10 +1843,12 @@ export interface ServerStore {
1747
1843
  /** Clear the per-client assignment (the account default is restored). */
1748
1844
  deleteOpClientRoles(userId: string, clientId: string): Promise<boolean>
1749
1845
  /** The deactivation's revocation half: delete EVERY live session, every
1750
- * issued OIDC access token, every unconsumed authorization code and
1751
- * every pending authorization of the account. Answers the counts (the
1752
- * audit event's metadata). The user row STAYS (the history). */
1753
- revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }>
1846
+ * issued OIDC access token, every refresh token (consumed or not — the
1847
+ * reuse detector has no more verdicts to give), every unconsumed
1848
+ * authorization code and every pending authorization of the account.
1849
+ * Answers the counts (the audit event's metadata). The user row STAYS
1850
+ * (the history). */
1851
+ revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number }>
1754
1852
  /** The offboarding runbook's DELETE path (the erasure): every credential,
1755
1853
  * token, link and per-client assignment removed, the user row anonymized
1756
1854
  * in place (provider 'erased' — it drops out of every account surface;