@oimlsmart/platform-server 0.2.3 → 0.2.5
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/package.json +1 -1
- package/src/store/d1.ts +46 -11
- package/src/store/sqlite/op-accounts-store.ts +27 -6
- package/src/store/sqlite/op-store.ts +8 -3
- package/src/store/sqlite/schema.sql +9 -0
- package/src/store/sqlite/store.ts +4 -0
- package/src/store/sqlite.ts +1 -1
- package/src/store.ts +45 -8
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oimlsmart/platform-server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
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
|
@@ -640,6 +640,11 @@ export class D1ServerStore implements ServerStore {
|
|
|
640
640
|
if (!codeCols.results.some(c => c.name === 'amr')) {
|
|
641
641
|
await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN amr TEXT').run()
|
|
642
642
|
}
|
|
643
|
+
// TODO.identity-sso (the wave-A tail): the code carries the
|
|
644
|
+
// consenting session's authentication instant (migration 0024).
|
|
645
|
+
if (!codeCols.results.some(c => c.name === 'auth_time')) {
|
|
646
|
+
await this.db.prepare('ALTER TABLE oidc_codes ADD COLUMN auth_time TEXT').run()
|
|
647
|
+
}
|
|
643
648
|
const tokenCols = await this.db.prepare('PRAGMA table_info(oidc_access_tokens)').all<{ name: string }>()
|
|
644
649
|
if (!tokenCols.results.some(c => c.name === 'amr')) {
|
|
645
650
|
await this.db.prepare('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT').run()
|
|
@@ -862,11 +867,11 @@ export class D1ServerStore implements ServerStore {
|
|
|
862
867
|
// reassignment takes effect on the next request; deactivation ends
|
|
863
868
|
// the session at once.
|
|
864
869
|
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
|
|
870
|
+
`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
871
|
FROM sessions s JOIN users u ON s.user_id = u.id
|
|
867
872
|
WHERE s.token = ? AND s.expires_at > datetime('now') AND u.active = 1`,
|
|
868
873
|
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 }>()
|
|
874
|
+
).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
875
|
if (!session) return null
|
|
871
876
|
const amr = parseRoles(session.amr)
|
|
872
877
|
const payload: AuthUserPayload = {
|
|
@@ -880,6 +885,9 @@ export class D1ServerStore implements ServerStore {
|
|
|
880
885
|
provider: session.provider,
|
|
881
886
|
emailVerifiedAt: session.email_verified_at ?? null,
|
|
882
887
|
...(amr?.length ? { amr } : {}),
|
|
888
|
+
// TODO.identity-sso (the wave-A tail): the authentication instant —
|
|
889
|
+
// the ID token's auth_time derives from it (the consumer converts).
|
|
890
|
+
sessionCreatedAt: session.created_at,
|
|
883
891
|
}
|
|
884
892
|
// TODO.identity/11: the active-org context (the membership model) —
|
|
885
893
|
// the payload's org/roles follow the session's stamped context. The
|
|
@@ -1295,16 +1303,20 @@ export class D1ServerStore implements ServerStore {
|
|
|
1295
1303
|
* (stored as JSON; the token endpoint emits it as the ID token's
|
|
1296
1304
|
* amr). Absent = no provenance recorded. */
|
|
1297
1305
|
amr?: string[] | null
|
|
1306
|
+
/** TODO.identity-sso (the wave-A tail): the consenting session's
|
|
1307
|
+
* authentication instant (verbatim; absent = none recorded) — the
|
|
1308
|
+
* token endpoint emits it as the ID token's auth_time. */
|
|
1309
|
+
authTime?: string | null
|
|
1298
1310
|
ttlMs: number
|
|
1299
1311
|
}): Promise<void> {
|
|
1300
1312
|
await this.ensureMembershipSupport()
|
|
1301
1313
|
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
1302
1314
|
await this.ensureOidcColumns()
|
|
1303
1315
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1316
|
+
`INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, auth_time, expires_at)
|
|
1317
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1306
1318
|
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,
|
|
1319
|
+
input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, expiresAt,
|
|
1308
1320
|
).run()
|
|
1309
1321
|
}
|
|
1310
1322
|
|
|
@@ -1330,6 +1342,7 @@ export class D1ServerStore implements ServerStore {
|
|
|
1330
1342
|
userId: row.user_id as string,
|
|
1331
1343
|
contextOrg: (row.context_org as string | null) ?? null,
|
|
1332
1344
|
amr: parseRoles((row.amr as string | null) ?? null) ?? null,
|
|
1345
|
+
authTime: (row.auth_time as string | null) ?? null,
|
|
1333
1346
|
expiresAt: row.expires_at as string,
|
|
1334
1347
|
}
|
|
1335
1348
|
}
|
|
@@ -1996,8 +2009,9 @@ export class D1ServerStore implements ServerStore {
|
|
|
1996
2009
|
newEmail: row.new_email as string,
|
|
1997
2010
|
deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
|
|
1998
2011
|
// TODO.identity-features/01: rows predating the kind column (or a
|
|
1999
|
-
// store over a pre-0022 database) read as the legacy ceremony
|
|
2000
|
-
|
|
2012
|
+
// store over a pre-0022 database) read as the legacy ceremony; the
|
|
2013
|
+
// 0.2.4 'verify' kind reads by its name.
|
|
2014
|
+
kind: row.kind === 'add' ? 'add' : row.kind === 'verify' ? 'verify' : 'change',
|
|
2001
2015
|
createdAt: row.created_at as string,
|
|
2002
2016
|
expiresAt: row.expires_at as string,
|
|
2003
2017
|
consumedAt: (row.consumed_at as string | null) ?? null,
|
|
@@ -2009,13 +2023,15 @@ export class D1ServerStore implements ServerStore {
|
|
|
2009
2023
|
* pending 'change' rows (only the newest change link works — the
|
|
2010
2024
|
* pre-01 doctrine); an 'add' request voids the account's earlier
|
|
2011
2025
|
* pending 'add' rows FOR THE SAME address (other addresses' links
|
|
2012
|
-
* stand)
|
|
2026
|
+
* stand); a 'verify' request voids the account's earlier pending
|
|
2027
|
+
* 'verify' rows (the target is the current primary — one per
|
|
2028
|
+
* account, the change doctrine's scoping). */
|
|
2013
2029
|
async createEmailChangeToken(input: {
|
|
2014
2030
|
token: string
|
|
2015
2031
|
userId: string
|
|
2016
2032
|
newEmail: string
|
|
2017
2033
|
deliveredBy: 'mailer' | 'shown'
|
|
2018
|
-
kind?: 'change' | 'add'
|
|
2034
|
+
kind?: 'change' | 'add' | 'verify'
|
|
2019
2035
|
ttlMs: number
|
|
2020
2036
|
}): Promise<EmailChangeToken> {
|
|
2021
2037
|
await this.ensureAccountEmailSupport()
|
|
@@ -2025,6 +2041,11 @@ export class D1ServerStore implements ServerStore {
|
|
|
2025
2041
|
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
|
|
2026
2042
|
input.userId,
|
|
2027
2043
|
).run()
|
|
2044
|
+
} else if (kind === 'verify') {
|
|
2045
|
+
await this.stmt(
|
|
2046
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'verify' AND consumed_at IS NULL",
|
|
2047
|
+
input.userId,
|
|
2048
|
+
).run()
|
|
2028
2049
|
} else {
|
|
2029
2050
|
await this.stmt(
|
|
2030
2051
|
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
|
|
@@ -2067,8 +2088,13 @@ export class D1ServerStore implements ServerStore {
|
|
|
2067
2088
|
* too, this account's included), then move users.email. 'add' (the
|
|
2068
2089
|
* per-address verification): the account_emails row landed unverified
|
|
2069
2090
|
* at the request; the completion stamps it (a row removed meanwhile
|
|
2070
|
-
* burns the link as 'unknown').
|
|
2071
|
-
* the address
|
|
2091
|
+
* burns the link as 'unknown'). 'verify' (the 0.2.4 kind): the
|
|
2092
|
+
* re-verification of the address the account ALREADY holds as its
|
|
2093
|
+
* primary — the completion stamps users.email_verified_at when the
|
|
2094
|
+
* token's new_email IS STILL the primary (a primary moved meanwhile
|
|
2095
|
+
* burns the link as 'unknown', the vanished-target doctrine).
|
|
2096
|
+
* A 'mailer'-delivered token verifies the address; a shown one never
|
|
2097
|
+
* does. */
|
|
2072
2098
|
async completeEmailChange(token: string): Promise<CompleteEmailChangeResult> {
|
|
2073
2099
|
await this.ensureAccountEmailSupport()
|
|
2074
2100
|
const res = await this.stmt(
|
|
@@ -2084,6 +2110,15 @@ export class D1ServerStore implements ServerStore {
|
|
|
2084
2110
|
if (verified) await this.markAccountEmailVerified(row.userId, row.newEmail)
|
|
2085
2111
|
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
2086
2112
|
}
|
|
2113
|
+
if (row.kind === 'verify') {
|
|
2114
|
+
// The address never changes hands in this ceremony — 'conflict'
|
|
2115
|
+
// does not exist here; the honest burns are the moved primary and
|
|
2116
|
+
// the gone account, both read from the users row.
|
|
2117
|
+
const current = await this.stmt('SELECT email FROM users WHERE id = ?', row.userId).first<{ email: string }>()
|
|
2118
|
+
if (!current || current.email !== row.newEmail) return { kind: 'unknown' }
|
|
2119
|
+
if (verified) await this.stmt("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?", row.userId).run()
|
|
2120
|
+
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
2121
|
+
}
|
|
2087
2122
|
const taken = await this.stmt('SELECT id FROM users WHERE email = ?', row.newEmail).first<{ id: string }>()
|
|
2088
2123
|
if (taken) return { kind: 'conflict' }
|
|
2089
2124
|
const takenAdditional = await this.stmt('SELECT user_id FROM account_emails WHERE email = ?', row.newEmail).first<{ user_id: string }>()
|
|
@@ -469,8 +469,8 @@ function toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
|
|
|
469
469
|
newEmail: row.new_email as string,
|
|
470
470
|
deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
|
|
471
471
|
// TODO.identity-features/01: rows predating the kind column read as
|
|
472
|
-
// the legacy ceremony.
|
|
473
|
-
kind: row.kind === 'add' ? 'add' : 'change',
|
|
472
|
+
// the legacy ceremony; the 0.2.4 'verify' kind reads by its name.
|
|
473
|
+
kind: row.kind === 'add' ? 'add' : row.kind === 'verify' ? 'verify' : 'change',
|
|
474
474
|
createdAt: row.created_at as string,
|
|
475
475
|
expiresAt: row.expires_at as string,
|
|
476
476
|
consumedAt: (row.consumed_at as string | null) ?? null,
|
|
@@ -482,13 +482,15 @@ function toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
|
|
|
482
482
|
* pending 'change' rows (only the newest change link works — the
|
|
483
483
|
* pre-01 doctrine); an 'add' request voids the account's earlier
|
|
484
484
|
* pending 'add' rows FOR THE SAME address (other addresses' links
|
|
485
|
-
* stand)
|
|
485
|
+
* stand); a 'verify' request voids the account's earlier pending
|
|
486
|
+
* 'verify' rows (the target is the current primary — one per account,
|
|
487
|
+
* the change doctrine's scoping). */
|
|
486
488
|
export function createEmailChangeToken(input: {
|
|
487
489
|
token: string
|
|
488
490
|
userId: string
|
|
489
491
|
newEmail: string
|
|
490
492
|
deliveredBy: 'mailer' | 'shown'
|
|
491
|
-
kind?: 'change' | 'add'
|
|
493
|
+
kind?: 'change' | 'add' | 'verify'
|
|
492
494
|
ttlMs: number
|
|
493
495
|
}): EmailChangeToken {
|
|
494
496
|
const db = getDb()
|
|
@@ -497,6 +499,10 @@ export function createEmailChangeToken(input: {
|
|
|
497
499
|
db.prepare(
|
|
498
500
|
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
|
|
499
501
|
).run(input.userId)
|
|
502
|
+
} else if (kind === 'verify') {
|
|
503
|
+
db.prepare(
|
|
504
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'verify' AND consumed_at IS NULL",
|
|
505
|
+
).run(input.userId)
|
|
500
506
|
} else {
|
|
501
507
|
db.prepare(
|
|
502
508
|
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
|
|
@@ -535,8 +541,14 @@ export function getPendingEmailChange(userId: string): EmailChangeToken | null {
|
|
|
535
541
|
* this account's included), then move users.email. 'add' (the
|
|
536
542
|
* per-address verification): the account_emails row landed unverified
|
|
537
543
|
* at the request; the completion stamps it (a row removed meanwhile
|
|
538
|
-
* burns the link as 'unknown').
|
|
539
|
-
* address
|
|
544
|
+
* burns the link as 'unknown'). 'verify' (the 0.2.4 kind): the
|
|
545
|
+
* re-verification of the address the account ALREADY holds as its
|
|
546
|
+
* primary — the completion stamps users.email_verified_at when the
|
|
547
|
+
* token's new_email IS STILL the primary (a primary moved meanwhile —
|
|
548
|
+
* a completed 'change', an admin re-address — burns the link as
|
|
549
|
+
* 'unknown', the vanished-target doctrine; the erasure's token sweep
|
|
550
|
+
* burns it earlier). A 'mailer'-delivered token verifies the address;
|
|
551
|
+
* a shown one never does. */
|
|
540
552
|
export function completeEmailChange(token: string): CompleteEmailChangeResult {
|
|
541
553
|
const db = getDb()
|
|
542
554
|
const res = db.prepare(
|
|
@@ -552,6 +564,15 @@ export function completeEmailChange(token: string): CompleteEmailChangeResult {
|
|
|
552
564
|
if (verified) markAccountEmailVerified(row.userId, row.newEmail)
|
|
553
565
|
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
554
566
|
}
|
|
567
|
+
if (row.kind === 'verify') {
|
|
568
|
+
// The address NEVER changes hands in this ceremony — 'conflict' does
|
|
569
|
+
// not exist here; the honest burns are the moved primary and the
|
|
570
|
+
// gone account, both read from the users row.
|
|
571
|
+
const current = db.prepare('SELECT email FROM users WHERE id = ?').get(row.userId) as { email: string } | undefined
|
|
572
|
+
if (!current || current.email !== row.newEmail) return { kind: 'unknown' }
|
|
573
|
+
if (verified) db.prepare("UPDATE users SET email_verified_at = datetime('now') WHERE id = ?").run(row.userId)
|
|
574
|
+
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
575
|
+
}
|
|
555
576
|
const taken = db.prepare('SELECT id FROM users WHERE email = ?').get(row.newEmail) as { id: string } | undefined
|
|
556
577
|
if (taken) return { kind: 'conflict' }
|
|
557
578
|
const takenAdditional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(row.newEmail) as { user_id: string } | undefined
|
|
@@ -174,14 +174,18 @@ export function createOidcCode(input: {
|
|
|
174
174
|
* (stored as JSON; the token endpoint emits it as the ID token's
|
|
175
175
|
* amr). Absent = no provenance recorded. */
|
|
176
176
|
amr?: string[] | null
|
|
177
|
+
/** TODO.identity-sso (the wave-A tail): the consenting session's
|
|
178
|
+
* authentication instant (verbatim; absent = none recorded) — the
|
|
179
|
+
* token endpoint emits it as the ID token's auth_time. */
|
|
180
|
+
authTime?: string | null
|
|
177
181
|
ttlMs: number
|
|
178
182
|
}): void {
|
|
179
183
|
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
180
184
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
185
|
+
INSERT INTO oidc_codes (code, client_id, redirect_uri, scope, nonce, code_challenge, user_id, context_org, amr, auth_time, expires_at)
|
|
186
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
183
187
|
`).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)
|
|
188
|
+
input.contextOrg ?? null, input.amr?.length ? JSON.stringify(input.amr) : null, input.authTime ?? null, expiresAt)
|
|
185
189
|
}
|
|
186
190
|
|
|
187
191
|
/** Atomically consume the code: the UPDATE flips consumed_at exactly
|
|
@@ -204,6 +208,7 @@ export function consumeOidcCode(code: string): OidcCode | null {
|
|
|
204
208
|
userId: row.user_id as string,
|
|
205
209
|
contextOrg: (row.context_org as string | null) ?? null,
|
|
206
210
|
amr: parseJsonStringList(row.amr),
|
|
211
|
+
authTime: (row.auth_time as string | null) ?? null,
|
|
207
212
|
expiresAt: row.expires_at as string,
|
|
208
213
|
}
|
|
209
214
|
}
|
|
@@ -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
|
|
@@ -459,6 +463,11 @@ CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_user ON enrollment_tokens (user
|
|
|
459
463
|
-- TODO.identity-features/01: kind names the ceremony — 'change' (the
|
|
460
464
|
-- primary replacement above) or 'add' (the per-address verification of an
|
|
461
465
|
-- account_emails row; completion stamps the row's verified_at).
|
|
466
|
+
-- The 0.2.4 kind: 'verify' — the re-verification of the account's CURRENT
|
|
467
|
+
-- primary (new_email carries it as requested; a mailer-delivered
|
|
468
|
+
-- completion stamps users.email_verified_at while the address still IS
|
|
469
|
+
-- the primary). A value, never a column: the kind TEXT carries it, so no
|
|
470
|
+
-- migration rides the 0.2.4 seam change.
|
|
462
471
|
CREATE TABLE IF NOT EXISTS email_change_tokens (
|
|
463
472
|
token TEXT PRIMARY KEY,
|
|
464
473
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
@@ -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
|
@@ -791,7 +791,7 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
791
791
|
userId: string
|
|
792
792
|
newEmail: string
|
|
793
793
|
deliveredBy: 'mailer' | 'shown'
|
|
794
|
-
kind?: 'change' | 'add'
|
|
794
|
+
kind?: 'change' | 'add' | 'verify'
|
|
795
795
|
ttlMs: number
|
|
796
796
|
}): Promise<EmailChangeToken> {
|
|
797
797
|
return createEmailChangeToken(input)
|
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
|
|
|
@@ -637,13 +648,19 @@ export interface OpAccountErasure {
|
|
|
637
648
|
* primary-address replacement; completion moves users.email) or 'add'
|
|
638
649
|
* (the per-address verification of an account_emails row; completion
|
|
639
650
|
* stamps the row's verified_at). Rows predating the kind column read
|
|
640
|
-
* 'change' (the migration's default).
|
|
651
|
+
* 'change' (the migration's default).
|
|
652
|
+
* The 0.2.4 kind: 'verify' — the re-verification of the address the
|
|
653
|
+
* account ALREADY holds as its primary (the invited-not-yet-set-up and
|
|
654
|
+
* the admin-re-addressed postures, whose primary never went through a
|
|
655
|
+
* mailbox proof). new_email carries the primary AS REQUESTED;
|
|
656
|
+
* completion stamps users.email_verified_at when the address is STILL
|
|
657
|
+
* the account's primary and the link traveled by mailer. */
|
|
641
658
|
export interface EmailChangeToken {
|
|
642
659
|
token: string
|
|
643
660
|
userId: string
|
|
644
661
|
newEmail: string
|
|
645
662
|
deliveredBy: 'mailer' | 'shown'
|
|
646
|
-
kind: 'change' | 'add'
|
|
663
|
+
kind: 'change' | 'add' | 'verify'
|
|
647
664
|
createdAt: string
|
|
648
665
|
expiresAt: string
|
|
649
666
|
consumedAt: string | null
|
|
@@ -653,7 +670,9 @@ export interface EmailChangeToken {
|
|
|
653
670
|
export type CompleteEmailChangeResult =
|
|
654
671
|
| { kind: 'ok'; userId: string; newEmail: string; verified: boolean }
|
|
655
672
|
/** Never existed or already consumed (indistinguishable, the
|
|
656
|
-
* enrollment rule)
|
|
673
|
+
* enrollment rule) — or the ceremony's target vanished between
|
|
674
|
+
* request and completion: the 'add' row removed, the 'verify'
|
|
675
|
+
* primary moved (the link burns the same, honestly). */
|
|
657
676
|
| { kind: 'unknown' }
|
|
658
677
|
/** Past the TTL: burned on presentation, never redeemable later. */
|
|
659
678
|
| { kind: 'expired' }
|
|
@@ -1572,6 +1591,11 @@ export interface ServerStore {
|
|
|
1572
1591
|
* (stored as JSON; the token endpoint emits it as the ID token's
|
|
1573
1592
|
* amr). Absent = no provenance recorded. */
|
|
1574
1593
|
amr?: string[] | null
|
|
1594
|
+
/** TODO.identity-sso (the wave-A tail): the consenting session's
|
|
1595
|
+
* authentication instant (sessions.created_at, verbatim; absent =
|
|
1596
|
+
* none recorded) — the token endpoint emits it as the ID token's
|
|
1597
|
+
* auth_time. */
|
|
1598
|
+
authTime?: string | null
|
|
1575
1599
|
ttlMs: number
|
|
1576
1600
|
}): Promise<void>
|
|
1577
1601
|
/** Atomically consume the code: answers the row exactly once (a
|
|
@@ -1787,7 +1811,10 @@ export interface ServerStore {
|
|
|
1787
1811
|
* account's earlier pending 'change' rows (only the newest change
|
|
1788
1812
|
* link works — the pre-01 doctrine); an 'add' request voids the
|
|
1789
1813
|
* account's earlier pending 'add' rows FOR THE SAME address (other
|
|
1790
|
-
* addresses' links stand)
|
|
1814
|
+
* addresses' links stand); a 'verify' request voids the account's
|
|
1815
|
+
* earlier pending 'verify' rows (the target is the CURRENT primary —
|
|
1816
|
+
* one per account, the change doctrine's scoping; cross-kind links
|
|
1817
|
+
* never touch each other). deliveredBy is stamped at request time and
|
|
1791
1818
|
* decides whether completion may verify the address. kind defaults
|
|
1792
1819
|
* 'change'. */
|
|
1793
1820
|
createEmailChangeToken(input: {
|
|
@@ -1795,12 +1822,14 @@ export interface ServerStore {
|
|
|
1795
1822
|
userId: string
|
|
1796
1823
|
newEmail: string
|
|
1797
1824
|
deliveredBy: 'mailer' | 'shown'
|
|
1798
|
-
kind?: 'change' | 'add'
|
|
1825
|
+
kind?: 'change' | 'add' | 'verify'
|
|
1799
1826
|
ttlMs: number
|
|
1800
1827
|
}): Promise<EmailChangeToken>
|
|
1801
1828
|
getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
|
|
1802
1829
|
/** The account's pending change (the newest unconsumed, unexpired row),
|
|
1803
|
-
* so the console can show it.
|
|
1830
|
+
* so the console can show it. 'verify' rows have NO pending read: the
|
|
1831
|
+
* waiting state IS users.email_verified_at NULL (the 'add' doctrine —
|
|
1832
|
+
* the account_emails rows carry their own). */
|
|
1804
1833
|
getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
|
|
1805
1834
|
/** Complete the ceremony: consume the token ATOMICALLY (a presented
|
|
1806
1835
|
* link works exactly once, expired or not), judge the expiry, then
|
|
@@ -1808,8 +1837,16 @@ export interface ServerStore {
|
|
|
1808
1837
|
* BOTH address tables (a conflict burns the token honestly) and moves
|
|
1809
1838
|
* the account's primary (users.email); 'add' stamps the
|
|
1810
1839
|
* account_emails row's verified_at (a row removed between request and
|
|
1811
|
-
* completion answers 'unknown')
|
|
1812
|
-
*
|
|
1840
|
+
* completion answers 'unknown'); 'verify' (the 0.2.4 kind, the
|
|
1841
|
+
* resend-verification act for the CURRENT primary) stamps
|
|
1842
|
+
* users.email_verified_at when the token's new_email IS STILL the
|
|
1843
|
+
* account's primary — a primary moved meanwhile (a completed 'change',
|
|
1844
|
+
* an admin re-address, the erasure) burns the link as 'unknown', and
|
|
1845
|
+
* 'conflict' never applies (no address changes hands). verified = the
|
|
1846
|
+
* token traveled by mailer (mailbox proven); a shown link never
|
|
1847
|
+
* verifies. The answer carries { userId, newEmail, verified } for
|
|
1848
|
+
* every kind, so the consumer's completion route audits a proven
|
|
1849
|
+
* 'verify' exactly as it audits an 'add' (its account.email_verified). */
|
|
1813
1850
|
completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
|
|
1814
1851
|
|
|
1815
1852
|
// ── multiple emails per account (TODO.identity-features/01) ──
|