@oimlsmart/platform-server 0.2.5 → 0.2.7
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.
- package/migrations/0025_oidc_refresh_tokens.sql +61 -0
- package/migrations/0026_entity_changes_store_seq.sql +15 -0
- package/package.json +1 -1
- package/src/store/d1.ts +168 -4
- package/src/store/sqlite/entities.ts +11 -0
- package/src/store/sqlite/op-accounts-store.ts +8 -4
- package/src/store/sqlite/op-store.ts +106 -1
- package/src/store/sqlite/schema.sql +31 -0
- package/src/store/sqlite.ts +40 -2
- package/src/store.ts +100 -18
|
@@ -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);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- The per-store journal high-water (the ServerStore seam's
|
|
2
|
+
-- latestChangeSeqFor(store), the 2026-09-07 performance audit's G1):
|
|
3
|
+
-- the seam's DOC-ONLY contract since 0.2.2 ("the (store, seq) walk —
|
|
4
|
+
-- the index rides the same commit") lands. The read answers MAX(seq)
|
|
5
|
+
-- over one store's slice of the ONE global journal — the seq stays
|
|
6
|
+
-- global and monotone, this is a projection, never a second sequence.
|
|
7
|
+
-- Without the index the projection scans the journal's rowid walk and
|
|
8
|
+
-- filters; with it the read is a single indexed probe, which is the
|
|
9
|
+
-- contract's whole point (per-store conditional revalidation / SSE
|
|
10
|
+
-- resume probes paying one read, never a payload fetch).
|
|
11
|
+
--
|
|
12
|
+
-- CREATE INDEX IF NOT EXISTS is the idempotent guard (the migration
|
|
13
|
+
-- contract's expand-only discipline; a consumer that already carries
|
|
14
|
+
-- the index converges). schema.sql's mirror lands in the same commit.
|
|
15
|
+
CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store, seq);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.7",
|
|
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)
|
|
@@ -706,6 +710,33 @@ export class D1ServerStore implements ServerStore {
|
|
|
706
710
|
})
|
|
707
711
|
}
|
|
708
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
|
+
|
|
709
740
|
// TODO.identity-features/01 (multiple emails per account): the
|
|
710
741
|
// account_emails table + the email_change_tokens.kind column arrive
|
|
711
742
|
// with migration 0022 — a dev D1 migrated from before it lacks both,
|
|
@@ -1373,7 +1404,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
1373
1404
|
async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
|
|
1374
1405
|
await this.ensureMembershipSupport()
|
|
1375
1406
|
const row = await this.stmt(
|
|
1376
|
-
"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,
|
|
1377
1408
|
).first<Record<string, unknown>>()
|
|
1378
1409
|
if (!row) return null
|
|
1379
1410
|
return {
|
|
@@ -1387,6 +1418,124 @@ export class D1ServerStore implements ServerStore {
|
|
|
1387
1418
|
}
|
|
1388
1419
|
}
|
|
1389
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
|
+
|
|
1390
1539
|
async listOidcKeys(): Promise<OidcKeyRow[]> {
|
|
1391
1540
|
const res = await this.stmt('SELECT * FROM oidc_keys ORDER BY created_at, kid').all<Record<string, unknown>>()
|
|
1392
1541
|
return res.results.map(row => ({
|
|
@@ -1842,16 +1991,21 @@ export class D1ServerStore implements ServerStore {
|
|
|
1842
1991
|
}
|
|
1843
1992
|
|
|
1844
1993
|
/** The deactivation's revocation half: every live session, every issued
|
|
1845
|
-
* access token, every
|
|
1846
|
-
*
|
|
1847
|
-
|
|
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()
|
|
1848
2000
|
const sessions = await this.stmt('DELETE FROM sessions WHERE user_id = ?', userId).run()
|
|
1849
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()
|
|
1850
2003
|
const codes = await this.stmt('DELETE FROM oidc_codes WHERE user_id = ? AND consumed_at IS NULL', userId).run()
|
|
1851
2004
|
const authorizations = await this.stmt('DELETE FROM oidc_authorizations WHERE user_id = ? AND decision IS NULL', userId).run()
|
|
1852
2005
|
return {
|
|
1853
2006
|
sessions: sessions.meta.changes ?? 0,
|
|
1854
2007
|
accessTokens: accessTokens.meta.changes ?? 0,
|
|
2008
|
+
refreshTokens: refreshTokens.meta.changes ?? 0,
|
|
1855
2009
|
codes: codes.meta.changes ?? 0,
|
|
1856
2010
|
authorizations: authorizations.meta.changes ?? 0,
|
|
1857
2011
|
}
|
|
@@ -3326,6 +3480,16 @@ export class D1ServerStore implements ServerStore {
|
|
|
3326
3480
|
return row?.seq ?? 0
|
|
3327
3481
|
}
|
|
3328
3482
|
|
|
3483
|
+
async latestChangeSeqFor(store: string): Promise<number> {
|
|
3484
|
+
// One indexed probe over idx_entity_changes_store_seq (migration
|
|
3485
|
+
// 0026) — the per-store projection of the one journal; 0 when the
|
|
3486
|
+
// store carries no rows (MAX answers NULL on the empty set).
|
|
3487
|
+
const row = await this.stmt(
|
|
3488
|
+
'SELECT MAX(seq) AS seq FROM entity_changes WHERE store = ?', store,
|
|
3489
|
+
).first<{ seq: number | null }>()
|
|
3490
|
+
return row?.seq ?? 0
|
|
3491
|
+
}
|
|
3492
|
+
|
|
3329
3493
|
// ── the platform event store (TODO.notify/01) ─────────────────────
|
|
3330
3494
|
// The event rows port directly (D1 is SQLite) — the same statements
|
|
3331
3495
|
// as sqlite/events.ts's sync half.
|
|
@@ -74,3 +74,14 @@ export function latestChangeSeq(): number {
|
|
|
74
74
|
const row = getDb().prepare('SELECT MAX(seq) AS seq FROM entity_changes').get() as { seq: number | null }
|
|
75
75
|
return row.seq ?? 0
|
|
76
76
|
}
|
|
77
|
+
|
|
78
|
+
/** The per-store projection of the one journal (the seam's contract):
|
|
79
|
+
* MAX(seq) over the store's own slice, 0 on a store with no writes.
|
|
80
|
+
* Walks idx_entity_changes_store_seq — one indexed probe, never a
|
|
81
|
+
* journal scan. */
|
|
82
|
+
export function latestChangeSeqFor(store: string): number {
|
|
83
|
+
const row = getDb()
|
|
84
|
+
.prepare('SELECT MAX(seq) AS seq FROM entity_changes WHERE store = ?')
|
|
85
|
+
.get(store) as { seq: number | null }
|
|
86
|
+
return row.seq ?? 0
|
|
87
|
+
}
|
|
@@ -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
|
|
303
|
-
*
|
|
304
|
-
|
|
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 {
|
|
@@ -235,7 +237,7 @@ export function createOidcAccessToken(input: {
|
|
|
235
237
|
|
|
236
238
|
export function getOidcAccessToken(token: string): OidcAccessToken | null {
|
|
237
239
|
const row = getDb().prepare(
|
|
238
|
-
"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')",
|
|
239
241
|
).get(token) as Record<string, unknown> | undefined
|
|
240
242
|
if (!row) return null
|
|
241
243
|
return {
|
|
@@ -249,6 +251,109 @@ export function getOidcAccessToken(token: string): OidcAccessToken | null {
|
|
|
249
251
|
}
|
|
250
252
|
}
|
|
251
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
|
+
|
|
252
357
|
/** The amr column's honest parse (a JSON array of strings, else null —
|
|
253
358
|
* the provenance is absent on rows that predate the wave). */
|
|
254
359
|
function parseJsonStringList(raw: unknown): string[] | null {
|
|
@@ -132,6 +132,12 @@ CREATE TABLE IF NOT EXISTS entity_changes (
|
|
|
132
132
|
id TEXT NOT NULL,
|
|
133
133
|
at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
134
134
|
);
|
|
135
|
+
-- The per-store high-water's walk (migration 0026, the seam's
|
|
136
|
+
-- latestChangeSeqFor(store)): MAX(seq) over one store's slice of the
|
|
137
|
+
-- one global journal costs a single indexed probe, never a journal
|
|
138
|
+
-- scan. The seq stays global and monotone — a projection, never a
|
|
139
|
+
-- second sequence.
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_entity_changes_store_seq ON entity_changes (store, seq);
|
|
135
141
|
|
|
136
142
|
-- TODO.notify/01 — the platform event store (the notification system's
|
|
137
143
|
-- source of truth): one row per DECLARED notifiable act (the catalog,
|
|
@@ -364,6 +370,31 @@ CREATE TABLE IF NOT EXISTS oidc_access_tokens (
|
|
|
364
370
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
365
371
|
);
|
|
366
372
|
|
|
373
|
+
-- The refresh tokens (migration 0025 — the SSO wave-C token surface): the
|
|
374
|
+
-- offline half of the remembered consent. One-time, atomically consumed
|
|
375
|
+
-- (WHERE consumed_at IS NULL); every rotation inherits the first mint's
|
|
376
|
+
-- family_id, and a presented CONSUMED token is the theft signal that kills
|
|
377
|
+
-- the whole family. The row carries the granting code's provenance
|
|
378
|
+
-- (canonical scope, context_org, amr, the ORIGINAL auth_time — a refresh
|
|
379
|
+
-- never advances it). Consumed rows STAY for the reuse detector;
|
|
380
|
+
-- revocation (client-bound, per family), the consent's revocation, the
|
|
381
|
+
-- deactivation sweep and the erasure remove rows outright.
|
|
382
|
+
CREATE TABLE IF NOT EXISTS oidc_refresh_tokens (
|
|
383
|
+
token TEXT PRIMARY KEY,
|
|
384
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
385
|
+
client_id TEXT NOT NULL,
|
|
386
|
+
scope TEXT NOT NULL,
|
|
387
|
+
context_org TEXT,
|
|
388
|
+
amr TEXT,
|
|
389
|
+
auth_time TEXT,
|
|
390
|
+
family_id TEXT NOT NULL,
|
|
391
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
392
|
+
expires_at TEXT NOT NULL,
|
|
393
|
+
consumed_at TEXT
|
|
394
|
+
);
|
|
395
|
+
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_user ON oidc_refresh_tokens (user_id);
|
|
396
|
+
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_family ON oidc_refresh_tokens (family_id);
|
|
397
|
+
|
|
367
398
|
-- The OP's signing-key rotation history: the PUBLIC halves. JWKS serves
|
|
368
399
|
-- every row not retired beyond the token lifetime, so a rotation never
|
|
369
400
|
-- strands an in-flight ID token. The private half rides the
|
package/src/store/sqlite.ts
CHANGED
|
@@ -84,6 +84,7 @@ import {
|
|
|
84
84
|
deleteEntity,
|
|
85
85
|
getEntity,
|
|
86
86
|
latestChangeSeq,
|
|
87
|
+
latestChangeSeqFor,
|
|
87
88
|
listEntities,
|
|
88
89
|
putEntity,
|
|
89
90
|
} from './sqlite/entities'
|
|
@@ -117,16 +118,22 @@ import {
|
|
|
117
118
|
} from './sqlite/notify'
|
|
118
119
|
import {
|
|
119
120
|
consumeOidcCode,
|
|
121
|
+
consumeOidcRefreshToken,
|
|
120
122
|
createOidcAccessToken,
|
|
121
123
|
createOidcAuthorization,
|
|
122
124
|
createOidcCode,
|
|
125
|
+
createOidcRefreshToken,
|
|
123
126
|
decideOidcAuthorization,
|
|
127
|
+
deleteOidcAccessToken,
|
|
128
|
+
deleteOidcRefreshTokensForUserClient,
|
|
124
129
|
getOidcAccessToken,
|
|
125
130
|
getOidcAuthorization,
|
|
126
131
|
getOidcClient,
|
|
132
|
+
listOidcAccessTokens,
|
|
127
133
|
listOidcClients,
|
|
128
134
|
listOidcKeys,
|
|
129
135
|
retireOidcKey,
|
|
136
|
+
revokeOidcRefreshToken,
|
|
130
137
|
setOidcClientStatus,
|
|
131
138
|
setOidcClientLaunch,
|
|
132
139
|
upsertOidcClient,
|
|
@@ -179,7 +186,7 @@ import {
|
|
|
179
186
|
updateOpAccount,
|
|
180
187
|
updateUserName,
|
|
181
188
|
} 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'
|
|
189
|
+
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
190
|
import {
|
|
184
191
|
advanceWebauthnCounter,
|
|
185
192
|
consumeMfaPending,
|
|
@@ -658,6 +665,34 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
658
665
|
async getOidcAccessToken(token: string): Promise<OidcAccessToken | null> {
|
|
659
666
|
return getOidcAccessToken(token)
|
|
660
667
|
},
|
|
668
|
+
async listOidcAccessTokens(userId: string): Promise<OidcAccessToken[]> {
|
|
669
|
+
return listOidcAccessTokens(userId)
|
|
670
|
+
},
|
|
671
|
+
async deleteOidcAccessToken(token: string, clientId: string): Promise<boolean> {
|
|
672
|
+
return deleteOidcAccessToken(token, clientId)
|
|
673
|
+
},
|
|
674
|
+
async createOidcRefreshToken(input: {
|
|
675
|
+
token: string
|
|
676
|
+
userId: string
|
|
677
|
+
clientId: string
|
|
678
|
+
scope: string
|
|
679
|
+
contextOrg?: string | null
|
|
680
|
+
amr?: string[] | null
|
|
681
|
+
authTime?: string | null
|
|
682
|
+
familyId: string
|
|
683
|
+
ttlMs: number
|
|
684
|
+
}): Promise<OidcRefreshToken> {
|
|
685
|
+
return createOidcRefreshToken(input)
|
|
686
|
+
},
|
|
687
|
+
async consumeOidcRefreshToken(token: string): Promise<ConsumeOidcRefreshTokenResult> {
|
|
688
|
+
return consumeOidcRefreshToken(token)
|
|
689
|
+
},
|
|
690
|
+
async revokeOidcRefreshToken(token: string, clientId: string): Promise<boolean> {
|
|
691
|
+
return revokeOidcRefreshToken(token, clientId)
|
|
692
|
+
},
|
|
693
|
+
async deleteOidcRefreshTokensForUserClient(userId: string, clientId: string): Promise<number> {
|
|
694
|
+
return deleteOidcRefreshTokensForUserClient(userId, clientId)
|
|
695
|
+
},
|
|
661
696
|
async listOidcKeys(): Promise<OidcKeyRow[]> {
|
|
662
697
|
return listOidcKeys()
|
|
663
698
|
},
|
|
@@ -956,7 +991,7 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
956
991
|
async deleteOpClientRoles(userId: string, clientId: string): Promise<boolean> {
|
|
957
992
|
return deleteOpClientRoles(userId, clientId)
|
|
958
993
|
},
|
|
959
|
-
async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; codes: number; authorizations: number }> {
|
|
994
|
+
async revokeOpUserCredentials(userId: string): Promise<{ sessions: number; accessTokens: number; refreshTokens: number; codes: number; authorizations: number }> {
|
|
960
995
|
return revokeOpUserCredentials(userId)
|
|
961
996
|
},
|
|
962
997
|
async eraseOpAccount(userId: string): Promise<OpAccountErasure | null> {
|
|
@@ -988,6 +1023,9 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
988
1023
|
async latestChangeSeq(): Promise<number> {
|
|
989
1024
|
return latestChangeSeq()
|
|
990
1025
|
},
|
|
1026
|
+
async latestChangeSeqFor(store: string): Promise<number> {
|
|
1027
|
+
return latestChangeSeqFor(store)
|
|
1028
|
+
},
|
|
991
1029
|
|
|
992
1030
|
// ── the platform event store (TODO.notify/01) ──
|
|
993
1031
|
async appendEvent(input: {
|
package/src/store.ts
CHANGED
|
@@ -454,6 +454,41 @@ export interface OidcAccessToken {
|
|
|
454
454
|
expiresAt: string
|
|
455
455
|
}
|
|
456
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
|
+
|
|
457
492
|
/** The OP key rotation history — the PUBLIC half only. */
|
|
458
493
|
export interface OidcKeyRow {
|
|
459
494
|
kid: string
|
|
@@ -609,7 +644,7 @@ export interface OpClientRoleAssignment {
|
|
|
609
644
|
}
|
|
610
645
|
|
|
611
646
|
/** The erasure act's removal counts (the audit event's metadata):
|
|
612
|
-
* revokeOpUserCredentials'
|
|
647
|
+
* revokeOpUserCredentials' five plus the links, the per-client
|
|
613
648
|
* assignments, the org memberships (TODO.identity/11), and the
|
|
614
649
|
* credential/token rows (passwords, enrollment tokens, email-change
|
|
615
650
|
* tokens). TODO.identity-sso/02+03 adds `factors`: the factor-registry
|
|
@@ -624,6 +659,7 @@ export interface OpClientRoleAssignment {
|
|
|
624
659
|
export interface OpAccountErasure {
|
|
625
660
|
sessions: number
|
|
626
661
|
accessTokens: number
|
|
662
|
+
refreshTokens: number
|
|
627
663
|
codes: number
|
|
628
664
|
authorizations: number
|
|
629
665
|
links: number
|
|
@@ -1617,6 +1653,50 @@ export interface ServerStore {
|
|
|
1617
1653
|
ttlMs: number
|
|
1618
1654
|
}): Promise<void>
|
|
1619
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>
|
|
1620
1700
|
/** The key rotation history (public halves). */
|
|
1621
1701
|
listOidcKeys(): Promise<OidcKeyRow[]>
|
|
1622
1702
|
upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
|
|
@@ -1763,10 +1843,12 @@ export interface ServerStore {
|
|
|
1763
1843
|
/** Clear the per-client assignment (the account default is restored). */
|
|
1764
1844
|
deleteOpClientRoles(userId: string, clientId: string): Promise<boolean>
|
|
1765
1845
|
/** The deactivation's revocation half: delete EVERY live session, every
|
|
1766
|
-
* issued OIDC access token, every
|
|
1767
|
-
*
|
|
1768
|
-
*
|
|
1769
|
-
|
|
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 }>
|
|
1770
1852
|
/** The offboarding runbook's DELETE path (the erasure): every credential,
|
|
1771
1853
|
* token, link and per-client assignment removed, the user row anonymized
|
|
1772
1854
|
* in place (provider 'erased' — it drops out of every account surface;
|
|
@@ -2271,20 +2353,20 @@ export interface ServerStore {
|
|
|
2271
2353
|
changesAfter(seq: number, limit?: number): Promise<EntityChange[]>
|
|
2272
2354
|
/** The GLOBAL journal high-water: the bootstrap snapshot's ETag leg
|
|
2273
2355
|
* (any write anywhere bumps the one seq — conservative, never
|
|
2274
|
-
* stale).
|
|
2275
|
-
*
|
|
2276
|
-
* NAMED FOLLOW-UP, DOC ONLY (the smart side references it from
|
|
2277
|
-
* server/routes/bootstrap.ts: the per-store `seqs` snapshot carries
|
|
2278
|
-
* the journal position a delta-loading client would resume from):
|
|
2279
|
-
* the PER-STORE high-water — `latestChangeSeqFor(store)` — answering
|
|
2280
|
-
* MAX(seq) over one store's slice of the SAME journal. The contract
|
|
2281
|
-
* when it lands: the seq stays global and monotone (the per-store
|
|
2282
|
-
* read is a PROJECTION of the one journal, never a second
|
|
2283
|
-
* sequence); it exists so a per-store conditional revalidation /
|
|
2284
|
-
* SSE resume probe costs one indexed read instead of a payload
|
|
2285
|
-
* fetch; and its query wants the (store, seq) walk — the index
|
|
2286
|
-
* rides the same commit, expand-only per the migration contract. */
|
|
2356
|
+
* stale). */
|
|
2287
2357
|
latestChangeSeq(): Promise<number>
|
|
2358
|
+
/** The PER-STORE high-water: MAX(seq) over one store's slice of the
|
|
2359
|
+
* SAME journal. The seq stays global and monotone — the per-store
|
|
2360
|
+
* read is a PROJECTION of the one journal, never a second sequence.
|
|
2361
|
+
* A store with no writes answers 0 (the empty journal's floor, the
|
|
2362
|
+
* same as the global form's). The read is one indexed probe over
|
|
2363
|
+
* idx_entity_changes_store_seq (migration 0026 — the (store, seq)
|
|
2364
|
+
* walk rode that commit, expand-only per the migration contract),
|
|
2365
|
+
* so a per-store conditional revalidation / SSE resume probe never
|
|
2366
|
+
* pays a journal scan. Landed for the 2026-09-07 performance
|
|
2367
|
+
* audit's G1: the smart side's bootstrap composite ETag switches
|
|
2368
|
+
* from the global seq to the per-set composite of these. */
|
|
2369
|
+
latestChangeSeqFor(store: string): Promise<number>
|
|
2288
2370
|
|
|
2289
2371
|
// ── the platform event store (TODO.notify/01) ──
|
|
2290
2372
|
/** Append one declared event (the emitter's write; one row inside the
|