@oimlsmart/platform-server 0.1.0
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/README.md +92 -0
- package/migrations/0001_init.sql +69 -0
- package/migrations/0002_identity.sql +24 -0
- package/migrations/0003_federation_peers.sql +21 -0
- package/migrations/0003_users_rbac.sql +8 -0
- package/migrations/0004_oidc_op.sql +61 -0
- package/migrations/0005_upstream_providers.sql +34 -0
- package/migrations/0006_op_accounts.sql +28 -0
- package/migrations/0007_org_join_requests.sql +26 -0
- package/migrations/0008_op_client_roles.sql +19 -0
- package/migrations/0009_account_console.sql +32 -0
- package/migrations/0009_sso_states.sql +13 -0
- package/migrations/0010_notify_events.sql +23 -0
- package/migrations/0011_op_launch.sql +18 -0
- package/migrations/0011_org_memberships.sql +62 -0
- package/migrations/0012_notify_subscriptions.sql +57 -0
- package/migrations/0012_strong_auth.sql +109 -0
- package/migrations/0013_org_registry.sql +53 -0
- package/migrations/0014_notify_inbox.sql +34 -0
- package/migrations/0015_certificate_holder_attribution.sql +63 -0
- package/migrations/0016_instrument_registrations.sql +78 -0
- package/package.json +52 -0
- package/src/client-info.ts +25 -0
- package/src/context.ts +31 -0
- package/src/github.ts +284 -0
- package/src/mailer.ts +309 -0
- package/src/oidc.ts +369 -0
- package/src/profile/node.ts +83 -0
- package/src/profile.ts +582 -0
- package/src/rbac/node.ts +42 -0
- package/src/rbac.ts +53 -0
- package/src/session.ts +45 -0
- package/src/store/d1.ts +2850 -0
- package/src/store/sqlite/entities.ts +71 -0
- package/src/store/sqlite/events.ts +82 -0
- package/src/store/sqlite/factors-store.ts +348 -0
- package/src/store/sqlite/notify.ts +247 -0
- package/src/store/sqlite/op-accounts-store.ts +470 -0
- package/src/store/sqlite/op-store.ts +280 -0
- package/src/store/sqlite/schema.sql +745 -0
- package/src/store/sqlite/store.ts +1390 -0
- package/src/store/sqlite/upstream-store.ts +148 -0
- package/src/store/sqlite.ts +1027 -0
- package/src/store.ts +1826 -0
- package/src/vocab/index.ts +12 -0
- package/src/vocab/permissions.ts +398 -0
- package/src/vocab/rbac.ts +281 -0
- package/src/vocab/roles.ts +162 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
2
|
+
// The OIDC Provider's SQLite half (TODO.identity/01) — the sync
|
|
3
|
+
// implementations behind the ServerStore OP methods
|
|
4
|
+
// (sqlite-server-store.ts delegates here one-for-one, mirroring
|
|
5
|
+
// store.ts's role for the auth domain).
|
|
6
|
+
//
|
|
7
|
+
// NODE-ONLY: better-sqlite3 through ./store's getDb. The Worker bundle
|
|
8
|
+
// never sees this module (the D1 store implements the same surface in
|
|
9
|
+
// d1-store.ts).
|
|
10
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
11
|
+
|
|
12
|
+
import { getDb } from './store'
|
|
13
|
+
import type {
|
|
14
|
+
OidcAccessToken,
|
|
15
|
+
OidcAuthorization,
|
|
16
|
+
OidcClient,
|
|
17
|
+
OidcClientLaunch,
|
|
18
|
+
OidcCode,
|
|
19
|
+
OidcKeyRow,
|
|
20
|
+
} from '../../store'
|
|
21
|
+
|
|
22
|
+
function toOidcClient(row: Record<string, unknown>): OidcClient {
|
|
23
|
+
return {
|
|
24
|
+
clientId: row.client_id as string,
|
|
25
|
+
name: row.name as string,
|
|
26
|
+
secretHash: (row.secret_hash as string | null) ?? null,
|
|
27
|
+
redirectUris: JSON.parse(row.redirect_uris as string) as string[],
|
|
28
|
+
claimsPolicy: row.claims_policy ? JSON.parse(row.claims_policy as string) as OidcClient['claimsPolicy'] : null,
|
|
29
|
+
// The SSO-home launch metadata (migration 0011): no launch_url = the
|
|
30
|
+
// client never appears on the launcher. A pre-0011 database reads
|
|
31
|
+
// the columns as absent — launch stays null, the honest default.
|
|
32
|
+
launch: row.launch_url
|
|
33
|
+
? {
|
|
34
|
+
url: row.launch_url as string,
|
|
35
|
+
icon: (row.launch_icon as string | null) ?? null,
|
|
36
|
+
description: (row.launch_description as string | null) ?? null,
|
|
37
|
+
visibility: ((row.launch_visibility as string | null) ?? 'roles') as OidcClientLaunch['visibility'],
|
|
38
|
+
}
|
|
39
|
+
: null,
|
|
40
|
+
status: row.status as OidcClient['status'],
|
|
41
|
+
createdAt: row.created_at as string,
|
|
42
|
+
createdBy: (row.created_by as string | null) ?? null,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toOidcAuthorization(row: Record<string, unknown>): OidcAuthorization {
|
|
47
|
+
return {
|
|
48
|
+
id: row.id as string,
|
|
49
|
+
clientId: row.client_id as string,
|
|
50
|
+
redirectUri: row.redirect_uri as string,
|
|
51
|
+
scope: row.scope as string,
|
|
52
|
+
state: row.state as string,
|
|
53
|
+
nonce: (row.nonce as string | null) ?? null,
|
|
54
|
+
codeChallenge: row.code_challenge as string,
|
|
55
|
+
userId: (row.user_id as string | null) ?? null,
|
|
56
|
+
decision: (row.decision as OidcAuthorization['decision']) ?? null,
|
|
57
|
+
createdAt: row.created_at as string,
|
|
58
|
+
expiresAt: row.expires_at as string,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getOidcClient(clientId: string): OidcClient | null {
|
|
63
|
+
const row = getDb().prepare('SELECT * FROM oidc_clients WHERE client_id = ?').get(clientId) as Record<string, unknown> | undefined
|
|
64
|
+
return row ? toOidcClient(row) : null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listOidcClients(): OidcClient[] {
|
|
68
|
+
const rows = getDb().prepare('SELECT * FROM oidc_clients ORDER BY created_at, client_id').all() as Array<Record<string, unknown>>
|
|
69
|
+
return rows.map(toOidcClient)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function upsertOidcClient(input: {
|
|
73
|
+
clientId: string
|
|
74
|
+
name: string
|
|
75
|
+
secretHash: string | null
|
|
76
|
+
redirectUris: string[]
|
|
77
|
+
claimsPolicy: { claims: string[] } | null
|
|
78
|
+
createdBy?: string | null
|
|
79
|
+
}): OidcClient {
|
|
80
|
+
getDb().prepare(`
|
|
81
|
+
INSERT INTO oidc_clients (client_id, name, secret_hash, redirect_uris, claims_policy, created_by)
|
|
82
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
83
|
+
ON CONFLICT (client_id) DO UPDATE SET
|
|
84
|
+
name = excluded.name,
|
|
85
|
+
secret_hash = excluded.secret_hash,
|
|
86
|
+
redirect_uris = excluded.redirect_uris,
|
|
87
|
+
claims_policy = excluded.claims_policy
|
|
88
|
+
`).run(
|
|
89
|
+
input.clientId,
|
|
90
|
+
input.name,
|
|
91
|
+
input.secretHash,
|
|
92
|
+
JSON.stringify(input.redirectUris),
|
|
93
|
+
input.claimsPolicy ? JSON.stringify(input.claimsPolicy) : null,
|
|
94
|
+
input.createdBy ?? null,
|
|
95
|
+
)
|
|
96
|
+
return getOidcClient(input.clientId)!
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function setOidcClientStatus(clientId: string, status: OidcClient['status']): OidcClient | null {
|
|
100
|
+
const res = getDb().prepare('UPDATE oidc_clients SET status = ? WHERE client_id = ?').run(status, clientId)
|
|
101
|
+
return res.changes > 0 ? getOidcClient(clientId) : null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The SSO-home launch metadata write (migration 0011): the launcher's
|
|
105
|
+
* card, or null to take the client off it. The registry upsert above
|
|
106
|
+
* never touches these columns, so a re-seed keeps the admin's edits. */
|
|
107
|
+
export function setOidcClientLaunch(clientId: string, launch: OidcClientLaunch | null): OidcClient | null {
|
|
108
|
+
const res = getDb().prepare(`
|
|
109
|
+
UPDATE oidc_clients SET launch_url = ?, launch_icon = ?, launch_description = ?, launch_visibility = ?
|
|
110
|
+
WHERE client_id = ?
|
|
111
|
+
`).run(
|
|
112
|
+
launch?.url ?? null,
|
|
113
|
+
launch?.icon ?? null,
|
|
114
|
+
launch?.description ?? null,
|
|
115
|
+
launch?.visibility ?? 'roles',
|
|
116
|
+
clientId,
|
|
117
|
+
)
|
|
118
|
+
return res.changes > 0 ? getOidcClient(clientId) : null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createOidcAuthorization(input: {
|
|
122
|
+
id: string
|
|
123
|
+
clientId: string
|
|
124
|
+
redirectUri: string
|
|
125
|
+
scope: string
|
|
126
|
+
state: string
|
|
127
|
+
nonce: string | null
|
|
128
|
+
codeChallenge: string
|
|
129
|
+
userId: string | null
|
|
130
|
+
ttlMs: number
|
|
131
|
+
}): OidcAuthorization {
|
|
132
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
133
|
+
getDb().prepare(`
|
|
134
|
+
INSERT INTO oidc_authorizations
|
|
135
|
+
(id, client_id, redirect_uri, scope, state, nonce, code_challenge, user_id, expires_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
137
|
+
`).run(
|
|
138
|
+
input.id, input.clientId, input.redirectUri, input.scope, input.state,
|
|
139
|
+
input.nonce, input.codeChallenge, input.userId, expiresAt,
|
|
140
|
+
)
|
|
141
|
+
return getOidcAuthorization(input.id)!
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function getOidcAuthorization(id: string): OidcAuthorization | null {
|
|
145
|
+
const row = getDb().prepare('SELECT * FROM oidc_authorizations WHERE id = ?').get(id) as Record<string, unknown> | undefined
|
|
146
|
+
return row ? toOidcAuthorization(row) : null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function decideOidcAuthorization(
|
|
150
|
+
id: string,
|
|
151
|
+
decision: { userId: string; decision: 'allow' | 'deny' },
|
|
152
|
+
): OidcAuthorization | null {
|
|
153
|
+
// The decision binds to the row's OWN account (userId must equal the
|
|
154
|
+
// row's stamped user) and flips atomically — a decided or
|
|
155
|
+
// cross-account row loses the race.
|
|
156
|
+
const res = getDb().prepare(
|
|
157
|
+
'UPDATE oidc_authorizations SET decision = ? WHERE id = ? AND decision IS NULL AND user_id = ?',
|
|
158
|
+
).run(decision.decision, id, decision.userId)
|
|
159
|
+
return res.changes > 0 ? getOidcAuthorization(id) : null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function createOidcCode(input: {
|
|
163
|
+
code: string
|
|
164
|
+
clientId: string
|
|
165
|
+
redirectUri: string
|
|
166
|
+
scope: string
|
|
167
|
+
nonce: string | null
|
|
168
|
+
codeChallenge: string
|
|
169
|
+
userId: string
|
|
170
|
+
/** TODO.identity/11: the session's stamped active-org context at the
|
|
171
|
+
* consent decision (NULL = the primary context). */
|
|
172
|
+
contextOrg?: string | null
|
|
173
|
+
/** TODO.identity-sso/02+03: the consenting session's amr provenance
|
|
174
|
+
* (stored as JSON; the token endpoint emits it as the ID token's
|
|
175
|
+
* amr). Absent = no provenance recorded. */
|
|
176
|
+
amr?: string[] | null
|
|
177
|
+
ttlMs: number
|
|
178
|
+
}): void {
|
|
179
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
180
|
+
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
183
|
+
`).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)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Atomically consume the code: the UPDATE flips consumed_at exactly
|
|
188
|
+
* once — a replay loses the race and answers null (→ invalid_grant).
|
|
189
|
+
* An expired code is consumed too (never a second chance). */
|
|
190
|
+
export function consumeOidcCode(code: string): OidcCode | null {
|
|
191
|
+
const db = getDb()
|
|
192
|
+
const res = db.prepare("UPDATE oidc_codes SET consumed_at = datetime('now') WHERE code = ? AND consumed_at IS NULL").run(code)
|
|
193
|
+
if (res.changes === 0) return null
|
|
194
|
+
const row = db.prepare('SELECT * FROM oidc_codes WHERE code = ?').get(code) as Record<string, unknown> | undefined
|
|
195
|
+
if (!row) return null
|
|
196
|
+
if (new Date(row.expires_at as string).getTime() <= Date.now()) return null
|
|
197
|
+
return {
|
|
198
|
+
code: row.code as string,
|
|
199
|
+
clientId: row.client_id as string,
|
|
200
|
+
redirectUri: row.redirect_uri as string,
|
|
201
|
+
scope: row.scope as string,
|
|
202
|
+
nonce: (row.nonce as string | null) ?? null,
|
|
203
|
+
codeChallenge: row.code_challenge as string,
|
|
204
|
+
userId: row.user_id as string,
|
|
205
|
+
contextOrg: (row.context_org as string | null) ?? null,
|
|
206
|
+
amr: parseJsonStringList(row.amr),
|
|
207
|
+
expiresAt: row.expires_at as string,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function createOidcAccessToken(input: {
|
|
212
|
+
token: string
|
|
213
|
+
userId: string
|
|
214
|
+
clientId: string
|
|
215
|
+
scope: string
|
|
216
|
+
/** The granting code's context (userinfo answers the ID token's
|
|
217
|
+
* claims). */
|
|
218
|
+
contextOrg?: string | null
|
|
219
|
+
/** TODO.identity-sso/02+03: the authorizing authentication's amr —
|
|
220
|
+
* userinfo answers the same truth the ID token carried. */
|
|
221
|
+
amr?: string[] | null
|
|
222
|
+
ttlMs: number
|
|
223
|
+
}): void {
|
|
224
|
+
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
225
|
+
getDb().prepare(
|
|
226
|
+
'INSERT INTO oidc_access_tokens (token, user_id, client_id, scope, context_org, amr, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
227
|
+
).run(input.token, input.userId, input.clientId, input.scope, input.contextOrg ?? null,
|
|
228
|
+
input.amr?.length ? JSON.stringify(input.amr) : null, expiresAt)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function getOidcAccessToken(token: string): OidcAccessToken | null {
|
|
232
|
+
const row = getDb().prepare(
|
|
233
|
+
"SELECT * FROM oidc_access_tokens WHERE token = ? AND expires_at > datetime('now')",
|
|
234
|
+
).get(token) as Record<string, unknown> | undefined
|
|
235
|
+
if (!row) return null
|
|
236
|
+
return {
|
|
237
|
+
token: row.token as string,
|
|
238
|
+
userId: row.user_id as string,
|
|
239
|
+
clientId: row.client_id as string,
|
|
240
|
+
scope: row.scope as string,
|
|
241
|
+
contextOrg: (row.context_org as string | null) ?? null,
|
|
242
|
+
amr: parseJsonStringList(row.amr),
|
|
243
|
+
expiresAt: row.expires_at as string,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** The amr column's honest parse (a JSON array of strings, else null —
|
|
248
|
+
* the provenance is absent on rows that predate the wave). */
|
|
249
|
+
function parseJsonStringList(raw: unknown): string[] | null {
|
|
250
|
+
if (typeof raw !== 'string' || !raw) return null
|
|
251
|
+
try {
|
|
252
|
+
const parsed = JSON.parse(raw) as unknown
|
|
253
|
+
if (!Array.isArray(parsed)) return null
|
|
254
|
+
const list = parsed.filter((v): v is string => typeof v === 'string')
|
|
255
|
+
return list.length ? list : null
|
|
256
|
+
} catch {
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function listOidcKeys(): OidcKeyRow[] {
|
|
262
|
+
const rows = getDb().prepare('SELECT * FROM oidc_keys ORDER BY created_at, kid').all() as Array<Record<string, unknown>>
|
|
263
|
+
return rows.map(row => ({
|
|
264
|
+
kid: row.kid as string,
|
|
265
|
+
publicJwk: row.public_jwk as string,
|
|
266
|
+
status: row.status as OidcKeyRow['status'],
|
|
267
|
+
createdAt: row.created_at as string,
|
|
268
|
+
retiredAt: (row.retired_at as string | null) ?? null,
|
|
269
|
+
}))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function upsertOidcKey(input: { kid: string; publicJwk: string }): void {
|
|
273
|
+
getDb().prepare(
|
|
274
|
+
'INSERT OR IGNORE INTO oidc_keys (kid, public_jwk) VALUES (?, ?)',
|
|
275
|
+
).run(input.kid, input.publicJwk)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function retireOidcKey(kid: string): void {
|
|
279
|
+
getDb().prepare("UPDATE oidc_keys SET status = 'retired', retired_at = datetime('now') WHERE kid = ? AND status = 'active'").run(kid)
|
|
280
|
+
}
|