@oimlsmart/platform-server 0.2.5 → 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.
- package/migrations/0025_oidc_refresh_tokens.sql +61 -0
- package/package.json +1 -1
- package/src/store/d1.ts +158 -4
- 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 +25 -0
- package/src/store/sqlite.ts +36 -2
- package/src/store.ts +87 -5
|
@@ -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.
|
|
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)
|
|
@@ -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
|
}
|
|
@@ -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 {
|
|
@@ -364,6 +364,31 @@ CREATE TABLE IF NOT EXISTS oidc_access_tokens (
|
|
|
364
364
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
365
365
|
);
|
|
366
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
|
+
|
|
367
392
|
-- The OP's signing-key rotation history: the PUBLIC halves. JWKS serves
|
|
368
393
|
-- every row not retired beyond the token lifetime, so a rotation never
|
|
369
394
|
-- strands an in-flight ID token. The private half rides the
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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
|
@@ -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;
|