@oimlsmart/platform-server 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0021_oidc_consent_grants.sql +44 -0
- package/package.json +1 -1
- package/src/store/d1.ts +109 -0
- package/src/store/sqlite/consent-grants-store.ts +85 -0
- package/src/store/sqlite/op-accounts-store.ts +5 -1
- package/src/store/sqlite/schema.sql +24 -0
- package/src/store/sqlite.ts +21 -1
- package/src/store.ts +63 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Migration 0021 — the remembered consent grants (TODO.identity-features/12):
|
|
2
|
+
-- the OP remembers the account holder's "Allow" per (user, client, scope
|
|
3
|
+
-- set), so a repeat authorization the grant COVERS skips the consent page
|
|
4
|
+
-- (the OIDC-correct behavior — the page shows again only when the request
|
|
5
|
+
-- carries prompt=consent, when the granted set no longer covers the asked
|
|
6
|
+
-- scopes, or when the holder revoked the access from the account console).
|
|
7
|
+
--
|
|
8
|
+
-- The doctrines:
|
|
9
|
+
-- - ONE LIVE grant per (user_id, client_id, scope): the partial unique
|
|
10
|
+
-- index keys the live rows only (a live grant = revoked_at IS NULL) —
|
|
11
|
+
-- a revoked triple's re-allow lands a FRESH row and the history keeps
|
|
12
|
+
-- the revoked one;
|
|
13
|
+
-- - scope is the CANONICAL spelling (the scope SET, space-joined,
|
|
14
|
+
-- deduped, sorted — store.ts's normalizeOidcScopeSet): 'profile openid'
|
|
15
|
+
-- and 'openid profile' are the same grant, so the unique triple holds
|
|
16
|
+
-- honestly;
|
|
17
|
+
-- - the skip check's COVERAGE math (the granted set ⊇ the requested set)
|
|
18
|
+
-- is the store's (consentGrantCovers over the live rows), never a LIKE
|
|
19
|
+
-- scan in SQL;
|
|
20
|
+
-- - revocation flips revoked_at (the row STAYS — the audit chain carries
|
|
21
|
+
-- the grant + the revoke, the row is their resolvable record); the
|
|
22
|
+
-- account erasure removes the rows outright (a dead account's grants
|
|
23
|
+
-- die with it — the personal_access_tokens doctrine, migration 0020).
|
|
24
|
+
-- schema.sql carries the same end state for fresh databases —
|
|
25
|
+
-- test/migrations.test.ts pins the UNION of every migration to
|
|
26
|
+
-- schema.sql's CREATE set.
|
|
27
|
+
|
|
28
|
+
CREATE TABLE IF NOT EXISTS oidc_consent_grants (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
31
|
+
client_id TEXT NOT NULL,
|
|
32
|
+
-- The granted scope set, the canonical space-joined spelling
|
|
33
|
+
-- (normalizeOidcScopeSet).
|
|
34
|
+
scope TEXT NOT NULL,
|
|
35
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
36
|
+
revoked_at TEXT
|
|
37
|
+
);
|
|
38
|
+
-- One LIVE grant per (user, client, scope set) — the predicate keeps the
|
|
39
|
+
-- revoked rows out of the index, so the re-allow after a revoke inserts
|
|
40
|
+
-- cleanly.
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live
|
|
42
|
+
ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL;
|
|
43
|
+
-- The account console's "apps they can access" read.
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "The OIML SMART platform server kernel: the store seam (ServerStore + the D1 and SQLite implementations), the canonical D1 migration set both deployments apply, the instance profile, the mailer, the RBAC map, the OIDC/OAuth client cones, and the shared role/permission vocabulary. Consumed by the smart monorepo (browser/) and the identity service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
package/src/store/d1.ts
CHANGED
|
@@ -47,7 +47,10 @@ import {
|
|
|
47
47
|
type OidcClient,
|
|
48
48
|
type OidcClientLaunch,
|
|
49
49
|
type OidcCode,
|
|
50
|
+
type OidcConsentGrant,
|
|
50
51
|
type OidcKeyRow,
|
|
52
|
+
consentGrantCovers,
|
|
53
|
+
normalizeOidcScopeSet,
|
|
51
54
|
type OpAccountErasure,
|
|
52
55
|
type OpClientRoleAssignment,
|
|
53
56
|
type OpLiveSession,
|
|
@@ -478,6 +481,35 @@ export class D1ServerStore implements ServerStore {
|
|
|
478
481
|
return this.personalAccessTokenSupportEnsured
|
|
479
482
|
}
|
|
480
483
|
|
|
484
|
+
// TODO.identity-features/12 (the remembered consent grants): the
|
|
485
|
+
// oidc_consent_grants table arrives with migration 0021 — a dev D1
|
|
486
|
+
// migrated from before it lacks the table, so the grant methods ensure
|
|
487
|
+
// it defensively (the ensurePersonalAccessTokenSupport posture,
|
|
488
|
+
// memoized per store).
|
|
489
|
+
private consentGrantSupportEnsured: Promise<void> | null = null
|
|
490
|
+
|
|
491
|
+
private ensureConsentGrantSupport(): Promise<void> {
|
|
492
|
+
if (!this.consentGrantSupportEnsured) {
|
|
493
|
+
this.consentGrantSupportEnsured = (async () => {
|
|
494
|
+
await this.db.prepare(
|
|
495
|
+
`CREATE TABLE IF NOT EXISTS oidc_consent_grants (
|
|
496
|
+
id TEXT PRIMARY KEY,
|
|
497
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
498
|
+
client_id TEXT NOT NULL,
|
|
499
|
+
scope TEXT NOT NULL,
|
|
500
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
501
|
+
revoked_at TEXT
|
|
502
|
+
)`,
|
|
503
|
+
).run()
|
|
504
|
+
await this.db.prepare(
|
|
505
|
+
'CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL',
|
|
506
|
+
).run()
|
|
507
|
+
await this.db.prepare('CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id)').run()
|
|
508
|
+
})()
|
|
509
|
+
}
|
|
510
|
+
return this.consentGrantSupportEnsured
|
|
511
|
+
}
|
|
512
|
+
|
|
481
513
|
// ── users / sessions ─────────────────────────────────────────────
|
|
482
514
|
|
|
483
515
|
async seedDemoAccounts(): Promise<void> {
|
|
@@ -1125,6 +1157,78 @@ export class D1ServerStore implements ServerStore {
|
|
|
1125
1157
|
).run()
|
|
1126
1158
|
}
|
|
1127
1159
|
|
|
1160
|
+
// ── the remembered consent grants (TODO.identity-features/12) ─────
|
|
1161
|
+
|
|
1162
|
+
private static toConsentGrant(row: Record<string, unknown>): OidcConsentGrant {
|
|
1163
|
+
return {
|
|
1164
|
+
id: row.id as string,
|
|
1165
|
+
userId: row.user_id as string,
|
|
1166
|
+
clientId: row.client_id as string,
|
|
1167
|
+
scope: row.scope as string,
|
|
1168
|
+
createdAt: D1ServerStore.storeTimeToIso(row.created_at as string)!,
|
|
1169
|
+
revokedAt: D1ServerStore.storeTimeToIso((row.revoked_at as string | null) ?? null),
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null> {
|
|
1174
|
+
await this.ensureConsentGrantSupport()
|
|
1175
|
+
// The skip check's coverage math is the store.ts helper's, never a
|
|
1176
|
+
// LIKE scan: the account's live rows for the client, the freshest
|
|
1177
|
+
// covering grant wins.
|
|
1178
|
+
const res = await this.stmt(
|
|
1179
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
1180
|
+
userId, clientId,
|
|
1181
|
+
).all<Record<string, unknown>>()
|
|
1182
|
+
for (const row of res.results) {
|
|
1183
|
+
if (consentGrantCovers(row.scope as string, scope)) return D1ServerStore.toConsentGrant(row)
|
|
1184
|
+
}
|
|
1185
|
+
return null
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant> {
|
|
1189
|
+
await this.ensureConsentGrantSupport()
|
|
1190
|
+
const scope = normalizeOidcScopeSet(input.scope)
|
|
1191
|
+
// The upsert targets the partial unique index: a live triple's row
|
|
1192
|
+
// refreshes its stamp (the re-affirmed consent); a REVOKED triple's
|
|
1193
|
+
// re-allow falls out of the index's predicate and inserts FRESH —
|
|
1194
|
+
// the history survives.
|
|
1195
|
+
await this.stmt(
|
|
1196
|
+
`INSERT INTO oidc_consent_grants (id, user_id, client_id, scope)
|
|
1197
|
+
VALUES (?, ?, ?, ?)
|
|
1198
|
+
ON CONFLICT (user_id, client_id, scope) WHERE revoked_at IS NULL
|
|
1199
|
+
DO UPDATE SET created_at = datetime('now')`,
|
|
1200
|
+
crypto.randomUUID(), input.userId, input.clientId, scope,
|
|
1201
|
+
).run()
|
|
1202
|
+
const row = await this.stmt(
|
|
1203
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND scope = ? AND revoked_at IS NULL',
|
|
1204
|
+
input.userId, input.clientId, scope,
|
|
1205
|
+
).first<Record<string, unknown>>()
|
|
1206
|
+
if (!row) throw new Error('recordConsentGrant: the upsert left no live row')
|
|
1207
|
+
return D1ServerStore.toConsentGrant(row)
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
async listConsentGrants(userId: string): Promise<OidcConsentGrant[]> {
|
|
1211
|
+
await this.ensureConsentGrantSupport()
|
|
1212
|
+
// The console's list: the LIVE grants only (the revoked rows ride the
|
|
1213
|
+
// audit chain), newest first — created_at is second-resolution, the
|
|
1214
|
+
// rowid breaks the tie.
|
|
1215
|
+
const res = await this.stmt(
|
|
1216
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
1217
|
+
userId,
|
|
1218
|
+
).all<Record<string, unknown>>()
|
|
1219
|
+
return res.results.map(D1ServerStore.toConsentGrant)
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** The guarded revoke: the owner's LIVE row flips, once. */
|
|
1223
|
+
async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
|
|
1224
|
+
await this.ensureConsentGrantSupport()
|
|
1225
|
+
const res = await this.stmt(
|
|
1226
|
+
"UPDATE oidc_consent_grants SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
|
1227
|
+
id, userId,
|
|
1228
|
+
).run()
|
|
1229
|
+
return (res.meta.changes ?? 0) > 0
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1128
1232
|
// ── the upstream providers (TODO.identity/08) ─────────────────────
|
|
1129
1233
|
// The provider + link rows port directly (D1 is SQLite).
|
|
1130
1234
|
|
|
@@ -1512,6 +1616,10 @@ export class D1ServerStore implements ServerStore {
|
|
|
1512
1616
|
// again).
|
|
1513
1617
|
await this.ensurePersonalAccessTokenSupport()
|
|
1514
1618
|
const personalAccessTokens = await this.stmt('DELETE FROM personal_access_tokens WHERE user_id = ?', userId).run()
|
|
1619
|
+
// TODO.identity-features/12: the remembered consent grants die with
|
|
1620
|
+
// the account (a tombstone never skips a consent page again).
|
|
1621
|
+
await this.ensureConsentGrantSupport()
|
|
1622
|
+
const consentGrants = await this.stmt('DELETE FROM oidc_consent_grants WHERE user_id = ?', userId).run()
|
|
1515
1623
|
await this.stmt(
|
|
1516
1624
|
`UPDATE users SET
|
|
1517
1625
|
email = ?, name = 'Deleted account', provider = 'erased',
|
|
@@ -1529,6 +1637,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
1529
1637
|
factors: (passkeys.meta.changes ?? 0) + (totp.meta.changes ?? 0) + (recovery.meta.changes ?? 0)
|
|
1530
1638
|
+ (challenges.meta.changes ?? 0) + (mfa.meta.changes ?? 0),
|
|
1531
1639
|
personalAccessTokens: personalAccessTokens.meta.changes ?? 0,
|
|
1640
|
+
consentGrants: consentGrants.meta.changes ?? 0,
|
|
1532
1641
|
}
|
|
1533
1642
|
}
|
|
1534
1643
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The remembered consent grants' SQLite half (TODO.identity-features/12)
|
|
3
|
+
// — the sync implementations behind the ServerStore consent-grant
|
|
4
|
+
// methods (sqlite-server-store.ts delegates here one-for-one, the
|
|
5
|
+
// pat-store.ts pattern). The D1 store implements the same surface in
|
|
6
|
+
// d1.ts.
|
|
7
|
+
//
|
|
8
|
+
// The doctrines carried:
|
|
9
|
+
// - ONE LIVE grant per (user, client, scope set): the partial unique
|
|
10
|
+
// index (migration 0021) is the backstop; the record path's upsert
|
|
11
|
+
// targets it, so a repeat allow refreshes the live row's stamp and a
|
|
12
|
+
// REVOKED triple's re-allow lands a fresh row (the history survives);
|
|
13
|
+
// - the scope cell is the CANONICAL spelling (normalizeOidcScopeSet) —
|
|
14
|
+
// written normalized, read defensively (a hand-edited row still reads
|
|
15
|
+
// as a set, never trusted as a string match);
|
|
16
|
+
// - the revoke is a GUARDED update (the owner's live row flips, once);
|
|
17
|
+
// - the erasure (op-accounts-store.ts's eraseOpAccount) removes the
|
|
18
|
+
// rows outright — a dead account's grants die with it.
|
|
19
|
+
//
|
|
20
|
+
// NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
|
|
21
|
+
// never sees this module.
|
|
22
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
23
|
+
|
|
24
|
+
import { randomUUID } from 'crypto'
|
|
25
|
+
import { getDb } from './store'
|
|
26
|
+
import { storeTimeToIso } from './factors-store'
|
|
27
|
+
import { consentGrantCovers, normalizeOidcScopeSet, type OidcConsentGrant } from '../../store'
|
|
28
|
+
|
|
29
|
+
function toConsentGrant(row: Record<string, unknown>): OidcConsentGrant {
|
|
30
|
+
return {
|
|
31
|
+
id: row.id as string,
|
|
32
|
+
userId: row.user_id as string,
|
|
33
|
+
clientId: row.client_id as string,
|
|
34
|
+
scope: row.scope as string,
|
|
35
|
+
createdAt: storeTimeToIso(row.created_at as string)!,
|
|
36
|
+
revokedAt: storeTimeToIso((row.revoked_at as string | null) ?? null),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The authorize endpoint's remembered-consent read: the account's LIVE
|
|
41
|
+
* grant for this client whose scope set COVERS the requested set (the
|
|
42
|
+
* freshest first, when several cover). */
|
|
43
|
+
export function getConsentGrant(userId: string, clientId: string, scope: string): OidcConsentGrant | null {
|
|
44
|
+
const rows = getDb().prepare(
|
|
45
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
46
|
+
).all(userId, clientId) as Array<Record<string, unknown>>
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
if (consentGrantCovers(row.scope as string, scope)) return toConsentGrant(row)
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The consent decision's remember (the allow): the upsert on the live
|
|
54
|
+
* triple refreshes the stamp; a revoked triple's re-allow inserts fresh
|
|
55
|
+
* (the partial unique index's predicate keeps the revoked row out of the
|
|
56
|
+
* collision). Answers the live row. */
|
|
57
|
+
export function recordConsentGrant(input: { userId: string; clientId: string; scope: string }): OidcConsentGrant {
|
|
58
|
+
const scope = normalizeOidcScopeSet(input.scope)
|
|
59
|
+
getDb().prepare(
|
|
60
|
+
`INSERT INTO oidc_consent_grants (id, user_id, client_id, scope)
|
|
61
|
+
VALUES (?, ?, ?, ?)
|
|
62
|
+
ON CONFLICT (user_id, client_id, scope) WHERE revoked_at IS NULL
|
|
63
|
+
DO UPDATE SET created_at = datetime('now')`,
|
|
64
|
+
).run(randomUUID(), input.userId, input.clientId, scope)
|
|
65
|
+
const row = getDb().prepare(
|
|
66
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND client_id = ? AND scope = ? AND revoked_at IS NULL',
|
|
67
|
+
).get(input.userId, input.clientId, scope) as Record<string, unknown> | undefined
|
|
68
|
+
if (!row) throw new Error('recordConsentGrant: the upsert left no live row')
|
|
69
|
+
return toConsentGrant(row)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The console's list: the account's LIVE grants, newest first. */
|
|
73
|
+
export function listConsentGrants(userId: string): OidcConsentGrant[] {
|
|
74
|
+
const rows = getDb().prepare(
|
|
75
|
+
'SELECT * FROM oidc_consent_grants WHERE user_id = ? AND revoked_at IS NULL ORDER BY created_at DESC, rowid DESC',
|
|
76
|
+
).all(userId) as Array<Record<string, unknown>>
|
|
77
|
+
return rows.map(toConsentGrant)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The guarded revoke: the owner's LIVE row flips, once. */
|
|
81
|
+
export function revokeConsentGrant(id: string, userId: string): boolean {
|
|
82
|
+
return getDb().prepare(
|
|
83
|
+
"UPDATE oidc_consent_grants SET revoked_at = datetime('now') WHERE id = ? AND user_id = ? AND revoked_at IS NULL",
|
|
84
|
+
).run(id, userId).changes > 0
|
|
85
|
+
}
|
|
@@ -313,6 +313,7 @@ export function eraseOpAccount(userId: string): {
|
|
|
313
313
|
tokens: number
|
|
314
314
|
factors: number
|
|
315
315
|
personalAccessTokens: number
|
|
316
|
+
consentGrants: number
|
|
316
317
|
} | null {
|
|
317
318
|
const db = getDb()
|
|
318
319
|
const row = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(userId)
|
|
@@ -339,6 +340,9 @@ export function eraseOpAccount(userId: string): {
|
|
|
339
340
|
// TODO.identity-features/08: the developer tokens die with the account
|
|
340
341
|
// (the hashed rows go — a tombstone's tokens never exchange again).
|
|
341
342
|
const personalAccessTokens = db.prepare('DELETE FROM personal_access_tokens WHERE user_id = ?').run(userId).changes
|
|
343
|
+
// TODO.identity-features/12: the remembered consent grants die with the
|
|
344
|
+
// account (a tombstone never skips a consent page again).
|
|
345
|
+
const consentGrants = db.prepare('DELETE FROM oidc_consent_grants WHERE user_id = ?').run(userId).changes
|
|
342
346
|
db.prepare(
|
|
343
347
|
`UPDATE users SET
|
|
344
348
|
email = ?, name = 'Deleted account', provider = 'erased',
|
|
@@ -346,7 +350,7 @@ export function eraseOpAccount(userId: string): {
|
|
|
346
350
|
avatar_url = NULL, email_verified_at = NULL, active = 0
|
|
347
351
|
WHERE id = ?`,
|
|
348
352
|
).run(`deleted-${userId}@erased.invalid`, userId)
|
|
349
|
-
return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens }
|
|
353
|
+
return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens, consentGrants }
|
|
350
354
|
}
|
|
351
355
|
|
|
352
356
|
/** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
|
|
@@ -825,3 +825,27 @@ CREATE TABLE IF NOT EXISTS personal_access_tokens (
|
|
|
825
825
|
UNIQUE (token_hash)
|
|
826
826
|
);
|
|
827
827
|
CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id);
|
|
828
|
+
|
|
829
|
+
-- ═══════════════════════════════════════════════════════════════════
|
|
830
|
+
-- The remembered consent grants (TODO.identity-features/12): the OP
|
|
831
|
+
-- remembers the account holder's "Allow" per (user, client, scope set) —
|
|
832
|
+
-- a repeat authorization the grant COVERS skips the consent page (unless
|
|
833
|
+
-- the request carries prompt=consent). scope is the canonical space-joined
|
|
834
|
+
-- set spelling (normalizeOidcScopeSet); the partial unique index keys ONE
|
|
835
|
+
-- LIVE grant per (user_id, client_id, scope) — revocation flips
|
|
836
|
+
-- revoked_at (the row stays for the audit + the history), a revoked
|
|
837
|
+
-- triple's re-allow lands a fresh row, and the account erasure removes
|
|
838
|
+
-- the rows outright. The D1 migration set carries the identical end state
|
|
839
|
+
-- (0021_oidc_consent_grants.sql).
|
|
840
|
+
-- ═══════════════════════════════════════════════════════════════════
|
|
841
|
+
CREATE TABLE IF NOT EXISTS oidc_consent_grants (
|
|
842
|
+
id TEXT PRIMARY KEY,
|
|
843
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
844
|
+
client_id TEXT NOT NULL,
|
|
845
|
+
scope TEXT NOT NULL,
|
|
846
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
847
|
+
revoked_at TEXT
|
|
848
|
+
);
|
|
849
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live
|
|
850
|
+
ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL;
|
|
851
|
+
CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id);
|
package/src/store/sqlite.ts
CHANGED
|
@@ -172,7 +172,7 @@ import {
|
|
|
172
172
|
updateOpAccount,
|
|
173
173
|
updateUserName,
|
|
174
174
|
} from './sqlite/op-accounts-store'
|
|
175
|
-
import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, 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 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'
|
|
175
|
+
import { installStore, type AuthUserPayload, type CertificateHolderClaim, type CertificateHolderOrg, type CompleteEmailChangeResult, type CompleteEnrollmentResult, type EmailChangeToken, type EnrollmentToken, type EntityChange, type EntityRow, 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'
|
|
176
176
|
import {
|
|
177
177
|
advanceWebauthnCounter,
|
|
178
178
|
consumeMfaPending,
|
|
@@ -205,6 +205,12 @@ import {
|
|
|
205
205
|
revokePersonalAccessToken,
|
|
206
206
|
stampPersonalAccessTokenUse,
|
|
207
207
|
} from './sqlite/pat-store'
|
|
208
|
+
import {
|
|
209
|
+
getConsentGrant,
|
|
210
|
+
listConsentGrants,
|
|
211
|
+
recordConsentGrant,
|
|
212
|
+
revokeConsentGrant,
|
|
213
|
+
} from './sqlite/consent-grants-store'
|
|
208
214
|
|
|
209
215
|
|
|
210
216
|
/** The workflow tables the reset wipe covers (the D1 store's
|
|
@@ -652,6 +658,20 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
652
658
|
retireOidcKey(kid)
|
|
653
659
|
},
|
|
654
660
|
|
|
661
|
+
// ── the remembered consent grants (TODO.identity-features/12) ──
|
|
662
|
+
async getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null> {
|
|
663
|
+
return getConsentGrant(userId, clientId, scope)
|
|
664
|
+
},
|
|
665
|
+
async recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant> {
|
|
666
|
+
return recordConsentGrant(input)
|
|
667
|
+
},
|
|
668
|
+
async listConsentGrants(userId: string): Promise<OidcConsentGrant[]> {
|
|
669
|
+
return listConsentGrants(userId)
|
|
670
|
+
},
|
|
671
|
+
async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
|
|
672
|
+
return revokeConsentGrant(id, userId)
|
|
673
|
+
},
|
|
674
|
+
|
|
655
675
|
// ── the upstream providers (TODO.identity/08) ──
|
|
656
676
|
async listIdentityProviders(): Promise<IdentityProvider[]> {
|
|
657
677
|
return listIdentityProviders()
|
package/src/store.ts
CHANGED
|
@@ -436,6 +436,44 @@ export interface OidcKeyRow {
|
|
|
436
436
|
retiredAt: string | null
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
// ── the remembered consent grants (TODO.identity-features/12) ────────
|
|
440
|
+
|
|
441
|
+
/** A remembered consent grant (the oidc_consent_grants row): the account
|
|
442
|
+
* holder's "Allow", remembered per (user, client, scope set) so a repeat
|
|
443
|
+
* authorization the grant COVERS skips the consent page. A LIVE grant
|
|
444
|
+
* carries revoked_at NULL — revocation flips the stamp and the row stays
|
|
445
|
+
* (the audit chain's resolvable record); the account erasure removes the
|
|
446
|
+
* rows outright (the PAT doctrine). scope is the CANONICAL set spelling
|
|
447
|
+
* (normalizeOidcScopeSet) — the partial unique index keys one live row
|
|
448
|
+
* per (user, client, scope) triple. */
|
|
449
|
+
export interface OidcConsentGrant {
|
|
450
|
+
id: string
|
|
451
|
+
userId: string
|
|
452
|
+
clientId: string
|
|
453
|
+
/** The granted scope set, space-joined in the canonical spelling. */
|
|
454
|
+
scope: string
|
|
455
|
+
createdAt: string
|
|
456
|
+
revokedAt: string | null
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The scope set's canonical spelling: split on whitespace, drop empties
|
|
460
|
+
* and duplicates, sort — 'profile openid' and 'openid profile' are the
|
|
461
|
+
* SAME set, so the (user, client, scope) triple's uniqueness holds
|
|
462
|
+
* honestly. */
|
|
463
|
+
export function normalizeOidcScopeSet(scope: string): string {
|
|
464
|
+
return [...new Set(scope.split(/\s+/).filter(Boolean))].sort().join(' ')
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** The skip check's coverage math (the authorize endpoint's rule): a live
|
|
468
|
+
* grant covers the request when EVERY requested scope is in the granted
|
|
469
|
+
* set. Both sides normalize first, so a hand-edited row still reads as a
|
|
470
|
+
* set — never trusted as a string match. */
|
|
471
|
+
export function consentGrantCovers(grantScope: string, requestedScope: string): boolean {
|
|
472
|
+
const granted = new Set(normalizeOidcScopeSet(grantScope).split(' ').filter(Boolean))
|
|
473
|
+
const requested = normalizeOidcScopeSet(requestedScope).split(' ').filter(Boolean)
|
|
474
|
+
return requested.length > 0 && requested.every(s => granted.has(s))
|
|
475
|
+
}
|
|
476
|
+
|
|
439
477
|
// ── the upstream providers (TODO.identity/08) ────────────────────────
|
|
440
478
|
|
|
441
479
|
/** An upstream identity provider the OP links + accepts (a registry
|
|
@@ -551,7 +589,8 @@ export interface OpClientRoleAssignment {
|
|
|
551
589
|
* rows removed (passkeys, TOTP secrets, recovery codes, and the
|
|
552
590
|
* account's pending ceremony state). TODO.identity-features/08 adds
|
|
553
591
|
* `personalAccessTokens`: the developer-token rows (a dead account's
|
|
554
|
-
* tokens die with it).
|
|
592
|
+
* tokens die with it). TODO.identity-features/12 adds `consentGrants`:
|
|
593
|
+
* the remembered consent rows (a dead account's grants die with it). */
|
|
555
594
|
export interface OpAccountErasure {
|
|
556
595
|
sessions: number
|
|
557
596
|
accessTokens: number
|
|
@@ -563,6 +602,7 @@ export interface OpAccountErasure {
|
|
|
563
602
|
tokens: number
|
|
564
603
|
factors: number
|
|
565
604
|
personalAccessTokens: number
|
|
605
|
+
consentGrants: number
|
|
566
606
|
}
|
|
567
607
|
|
|
568
608
|
// ── the account console (TODO.identity/06) ───────────────────────────
|
|
@@ -1468,6 +1508,28 @@ export interface ServerStore {
|
|
|
1468
1508
|
upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
|
|
1469
1509
|
retireOidcKey(kid: string): Promise<void>
|
|
1470
1510
|
|
|
1511
|
+
// ── the remembered consent grants (TODO.identity-features/12) ──
|
|
1512
|
+
/** The authorize endpoint's remembered-consent read: the account's
|
|
1513
|
+
* LIVE grant for this client whose scope set COVERS the requested set
|
|
1514
|
+
* (the consentGrantCovers math over the live rows), or null — the
|
|
1515
|
+
* consent page shows. Both scope spellings normalize before the math. */
|
|
1516
|
+
getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null>
|
|
1517
|
+
/** The consent decision's remember (the allow): the upsert per
|
|
1518
|
+
* (user, client, scope) — a live triple's row refreshes its stamp (the
|
|
1519
|
+
* re-affirmed consent); a REVOKED triple's re-allow lands a FRESH live
|
|
1520
|
+
* row (the partial unique index keeps the revoked rows out of the
|
|
1521
|
+
* collision, so the history survives). The scope cell stores the
|
|
1522
|
+
* canonical spelling (normalizeOidcScopeSet). Answers the live row. */
|
|
1523
|
+
recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant>
|
|
1524
|
+
/** The account console's "apps they can access": the account's LIVE
|
|
1525
|
+
* grants, newest first (the revoked rows never list — the audit chain
|
|
1526
|
+
* carries them). */
|
|
1527
|
+
listConsentGrants(userId: string): Promise<OidcConsentGrant[]>
|
|
1528
|
+
/** The console's "Revoke access": flips revoked_at on the account's OWN
|
|
1529
|
+
* live row — a second revoke or another account's row answers false
|
|
1530
|
+
* (the PAT guard's posture). The row STAYS. */
|
|
1531
|
+
revokeConsentGrant(id: string, userId: string): Promise<boolean>
|
|
1532
|
+
|
|
1471
1533
|
// ── the upstream providers (TODO.identity/08) ──
|
|
1472
1534
|
/** The upstream registry (admin-managed; OP_UPSTREAM_SEED bootstraps).
|
|
1473
1535
|
* Secrets are NEVER in these rows — clientSecretRef is an env name. */
|