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