@oimlsmart/platform-server 0.1.6 → 0.1.8
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/migrations/0022_account_emails.sql +45 -0
- package/package.json +1 -1
- package/src/store/d1.ts +337 -20
- package/src/store/sqlite/consent-grants-store.ts +85 -0
- package/src/store/sqlite/op-accounts-store.ts +209 -21
- package/src/store/sqlite/schema.sql +50 -0
- package/src/store/sqlite/store.ts +10 -0
- package/src/store/sqlite.ts +48 -1
- package/src/store.ts +166 -12
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
import { randomUUID } from 'crypto'
|
|
14
14
|
import { getDb } from './store'
|
|
15
15
|
import type {
|
|
16
|
+
AccountEmail,
|
|
17
|
+
AddAccountEmailResult,
|
|
18
|
+
AuthUserPayload,
|
|
16
19
|
CompleteEmailChangeResult,
|
|
17
20
|
CompleteEnrollmentResult,
|
|
18
21
|
EmailChangeToken,
|
|
@@ -23,6 +26,27 @@ import type {
|
|
|
23
26
|
UserAdminRow,
|
|
24
27
|
} from '../../store'
|
|
25
28
|
|
|
29
|
+
/** The AuthUserPayload projection of a users row (the sqlite/store.ts
|
|
30
|
+
* userPayload shape — the full assigned role set rides along). */
|
|
31
|
+
function accountPayload(row: Record<string, unknown>): AuthUserPayload {
|
|
32
|
+
let roles: string[] | undefined
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse((row.roles as string | null) ?? 'null') as unknown
|
|
35
|
+
roles = Array.isArray(parsed) && parsed.length ? parsed.filter((v): v is string => typeof v === 'string') : undefined
|
|
36
|
+
} catch { roles = undefined }
|
|
37
|
+
return {
|
|
38
|
+
id: row.id as string,
|
|
39
|
+
email: row.email as string,
|
|
40
|
+
name: row.name as string,
|
|
41
|
+
role: row.role as string,
|
|
42
|
+
...(roles?.length ? { roles } : {}),
|
|
43
|
+
orgId: (row.org_id as string | null) ?? null,
|
|
44
|
+
avatarUrl: (row.avatar_url as string | null) ?? undefined,
|
|
45
|
+
provider: row.provider as string,
|
|
46
|
+
emailVerifiedAt: (row.email_verified_at as string | null) ?? null,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
26
50
|
function toEnrollmentToken(row: Record<string, unknown>): EnrollmentToken {
|
|
27
51
|
return {
|
|
28
52
|
token: row.token as string,
|
|
@@ -35,7 +59,9 @@ function toEnrollmentToken(row: Record<string, unknown>): EnrollmentToken {
|
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
/** Create the OP password account. Answers null when the email is taken
|
|
38
|
-
* (the invite route's 409; the UNIQUE constraint is the race backstop).
|
|
62
|
+
* (the invite route's 409; the UNIQUE constraint is the race backstop).
|
|
63
|
+
* TODO.identity-features/01: the taken read spans BOTH address tables —
|
|
64
|
+
* an additional on another account blocks the address as a primary. */
|
|
39
65
|
export function createOpAccount(input: {
|
|
40
66
|
email: string
|
|
41
67
|
name: string
|
|
@@ -44,6 +70,8 @@ export function createOpAccount(input: {
|
|
|
44
70
|
}): UserAdminRow | null {
|
|
45
71
|
const db = getDb()
|
|
46
72
|
const id = randomUUID()
|
|
73
|
+
const additional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(input.email.trim().toLowerCase())
|
|
74
|
+
if (additional) return null
|
|
47
75
|
try {
|
|
48
76
|
db.prepare(
|
|
49
77
|
"INSERT INTO users (id, email, name, provider, role) VALUES (?, ?, ?, 'password', ?)",
|
|
@@ -68,13 +96,26 @@ export function createOpAccount(input: {
|
|
|
68
96
|
|
|
69
97
|
/** The password sign-in's lookup: the credential + the active flag, by
|
|
70
98
|
* (normalized) email. The credential's EXISTENCE is the qualifier — an
|
|
71
|
-
* account that holds a password may sign in with it.
|
|
99
|
+
* account that holds a password may sign in with it.
|
|
100
|
+
* TODO.identity-features/01: the address resolves by ANY of the
|
|
101
|
+
* account's VERIFIED addresses — the primary first (the primary owner
|
|
102
|
+
* always wins, the deterministic rule), then a proven account_emails
|
|
103
|
+
* row; an unverified additional never resolves. */
|
|
72
104
|
export function getPasswordLogin(email: string): { userId: string; hash: string; active: boolean } | null {
|
|
73
|
-
const
|
|
105
|
+
const db = getDb()
|
|
106
|
+
const normalized = email.trim().toLowerCase()
|
|
107
|
+
let row = db.prepare(
|
|
74
108
|
`SELECT u.id AS user_id, u.active AS active, p.hash AS hash
|
|
75
109
|
FROM users u JOIN passwords p ON p.user_id = u.id
|
|
76
110
|
WHERE u.email = ?`,
|
|
77
|
-
).get(
|
|
111
|
+
).get(normalized) as Record<string, unknown> | undefined
|
|
112
|
+
if (!row) {
|
|
113
|
+
row = db.prepare(
|
|
114
|
+
`SELECT u.id AS user_id, u.active AS active, p.hash AS hash
|
|
115
|
+
FROM users u JOIN passwords p ON p.user_id = u.id
|
|
116
|
+
WHERE u.id = (SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL)`,
|
|
117
|
+
).get(normalized) as Record<string, unknown> | undefined
|
|
118
|
+
}
|
|
78
119
|
if (!row) return null
|
|
79
120
|
return { userId: row.user_id as string, hash: row.hash as string, active: row.active !== 0 }
|
|
80
121
|
}
|
|
@@ -272,10 +313,15 @@ export function revokeOpUserCredentials(userId: string): { sessions: number; acc
|
|
|
272
313
|
/** The registry's edit act (name/email). The email UNIQUE conflict
|
|
273
314
|
* throws 'unique' (the route maps it to a 409, never a silent take).
|
|
274
315
|
* TODO.identity/06: an admin-set address never went through the
|
|
275
|
-
* verify-new-email ceremony, so the verification state resets.
|
|
316
|
+
* verify-new-email ceremony, so the verification state resets.
|
|
317
|
+
* TODO.identity-features/01: the conflict read spans BOTH address
|
|
318
|
+
* tables — an additional row (on any account, this one included) holds
|
|
319
|
+
* the address too. */
|
|
276
320
|
export function updateOpAccount(id: string, input: { name?: string; email?: string }): boolean {
|
|
277
321
|
const db = getDb()
|
|
278
322
|
if (input.email !== undefined) {
|
|
323
|
+
const additional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(input.email.trim().toLowerCase())
|
|
324
|
+
if (additional) throw new Error(`unique: ${input.email}`)
|
|
279
325
|
try {
|
|
280
326
|
db.prepare('UPDATE users SET email = ?, email_verified_at = NULL WHERE id = ?').run(input.email.trim().toLowerCase(), id)
|
|
281
327
|
} catch (e) {
|
|
@@ -313,6 +359,8 @@ export function eraseOpAccount(userId: string): {
|
|
|
313
359
|
tokens: number
|
|
314
360
|
factors: number
|
|
315
361
|
personalAccessTokens: number
|
|
362
|
+
consentGrants: number
|
|
363
|
+
emails: number
|
|
316
364
|
} | null {
|
|
317
365
|
const db = getDb()
|
|
318
366
|
const row = db.prepare('SELECT 1 AS ok FROM users WHERE id = ?').get(userId)
|
|
@@ -339,6 +387,12 @@ export function eraseOpAccount(userId: string): {
|
|
|
339
387
|
// TODO.identity-features/08: the developer tokens die with the account
|
|
340
388
|
// (the hashed rows go — a tombstone's tokens never exchange again).
|
|
341
389
|
const personalAccessTokens = db.prepare('DELETE FROM personal_access_tokens WHERE user_id = ?').run(userId).changes
|
|
390
|
+
// TODO.identity-features/12: the remembered consent grants die with the
|
|
391
|
+
// account (a tombstone never skips a consent page again).
|
|
392
|
+
const consentGrants = db.prepare('DELETE FROM oidc_consent_grants WHERE user_id = ?').run(userId).changes
|
|
393
|
+
// TODO.identity-features/01: the additional addresses die with the
|
|
394
|
+
// account (a tombstone's addresses never resolve a sign-in again).
|
|
395
|
+
const emails = db.prepare('DELETE FROM account_emails WHERE user_id = ?').run(userId).changes
|
|
342
396
|
db.prepare(
|
|
343
397
|
`UPDATE users SET
|
|
344
398
|
email = ?, name = 'Deleted account', provider = 'erased',
|
|
@@ -346,7 +400,7 @@ export function eraseOpAccount(userId: string): {
|
|
|
346
400
|
avatar_url = NULL, email_verified_at = NULL, active = 0
|
|
347
401
|
WHERE id = ?`,
|
|
348
402
|
).run(`deleted-${userId}@erased.invalid`, userId)
|
|
349
|
-
return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens }
|
|
403
|
+
return { ...revoked, links, clientRoles, memberships, tokens, factors, personalAccessTokens, consentGrants, emails }
|
|
350
404
|
}
|
|
351
405
|
|
|
352
406
|
/** The last OP-side sign-in per account, FROM THE AUDIT CHAIN: the
|
|
@@ -409,29 +463,44 @@ function toEmailChangeToken(row: Record<string, unknown>): EmailChangeToken {
|
|
|
409
463
|
userId: row.user_id as string,
|
|
410
464
|
newEmail: row.new_email as string,
|
|
411
465
|
deliveredBy: row.delivered_by === 'mailer' ? 'mailer' : 'shown',
|
|
466
|
+
// TODO.identity-features/01: rows predating the kind column read as
|
|
467
|
+
// the legacy ceremony.
|
|
468
|
+
kind: row.kind === 'add' ? 'add' : 'change',
|
|
412
469
|
createdAt: row.created_at as string,
|
|
413
470
|
expiresAt: row.expires_at as string,
|
|
414
471
|
consumedAt: (row.consumed_at as string | null) ?? null,
|
|
415
472
|
}
|
|
416
473
|
}
|
|
417
474
|
|
|
418
|
-
/** Mint the ceremony's token
|
|
419
|
-
*
|
|
475
|
+
/** Mint the ceremony's token. The void rule keeps ONE live link per
|
|
476
|
+
* ceremony target: a 'change' request voids the account's earlier
|
|
477
|
+
* pending 'change' rows (only the newest change link works — the
|
|
478
|
+
* pre-01 doctrine); an 'add' request voids the account's earlier
|
|
479
|
+
* pending 'add' rows FOR THE SAME address (other addresses' links
|
|
480
|
+
* stand). */
|
|
420
481
|
export function createEmailChangeToken(input: {
|
|
421
482
|
token: string
|
|
422
483
|
userId: string
|
|
423
484
|
newEmail: string
|
|
424
485
|
deliveredBy: 'mailer' | 'shown'
|
|
486
|
+
kind?: 'change' | 'add'
|
|
425
487
|
ttlMs: number
|
|
426
488
|
}): EmailChangeToken {
|
|
427
489
|
const db = getDb()
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
490
|
+
const kind = input.kind ?? 'change'
|
|
491
|
+
if (kind === 'change') {
|
|
492
|
+
db.prepare(
|
|
493
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL",
|
|
494
|
+
).run(input.userId)
|
|
495
|
+
} else {
|
|
496
|
+
db.prepare(
|
|
497
|
+
"UPDATE email_change_tokens SET consumed_at = datetime('now') WHERE user_id = ? AND kind = 'add' AND new_email = ? AND consumed_at IS NULL",
|
|
498
|
+
).run(input.userId, input.newEmail.trim().toLowerCase())
|
|
499
|
+
}
|
|
431
500
|
const expiresAt = new Date(Date.now() + input.ttlMs).toISOString()
|
|
432
501
|
db.prepare(
|
|
433
|
-
'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, expires_at) VALUES (?, ?, ?, ?, ?)',
|
|
434
|
-
).run(input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, expiresAt)
|
|
502
|
+
'INSERT INTO email_change_tokens (token, user_id, new_email, delivered_by, kind, expires_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
503
|
+
).run(input.token, input.userId, input.newEmail.trim().toLowerCase(), input.deliveredBy, kind, expiresAt)
|
|
435
504
|
return getEmailChangeToken(input.token)!
|
|
436
505
|
}
|
|
437
506
|
|
|
@@ -440,22 +509,29 @@ export function getEmailChangeToken(token: string): EmailChangeToken | null {
|
|
|
440
509
|
return row ? toEmailChangeToken(row) : null
|
|
441
510
|
}
|
|
442
511
|
|
|
443
|
-
/** The account's pending change (the newest live row),
|
|
444
|
-
* can show it.
|
|
512
|
+
/** The account's pending PRIMARY change (the newest live 'change' row),
|
|
513
|
+
* so the console can show it. The per-address verifications are the
|
|
514
|
+
* account_emails rows' own state (verified_at NULL = waiting), never a
|
|
515
|
+
* pending read here. */
|
|
445
516
|
export function getPendingEmailChange(userId: string): EmailChangeToken | null {
|
|
446
517
|
const row = getDb().prepare(
|
|
447
518
|
`SELECT * FROM email_change_tokens
|
|
448
|
-
WHERE user_id = ? AND consumed_at IS NULL AND expires_at > datetime('now')
|
|
519
|
+
WHERE user_id = ? AND kind = 'change' AND consumed_at IS NULL AND expires_at > datetime('now')
|
|
449
520
|
ORDER BY created_at DESC LIMIT 1`,
|
|
450
521
|
).get(userId) as Record<string, unknown> | undefined
|
|
451
522
|
return row ? toEmailChangeToken(row) : null
|
|
452
523
|
}
|
|
453
524
|
|
|
454
525
|
/** Complete the ceremony: consume ATOMICALLY (a presented link works
|
|
455
|
-
* exactly once, expired or not), judge the expiry,
|
|
456
|
-
*
|
|
457
|
-
*
|
|
458
|
-
*
|
|
526
|
+
* exactly once, expired or not), judge the expiry, then act on the
|
|
527
|
+
* kind. 'change' (the pre-01 primary replacement): re-check the
|
|
528
|
+
* address's uniqueness across BOTH address tables (a conflict burns the
|
|
529
|
+
* token honestly — an additional row anywhere holds the address too,
|
|
530
|
+
* this account's included), then move users.email. 'add' (the
|
|
531
|
+
* per-address verification): the account_emails row landed unverified
|
|
532
|
+
* at the request; the completion stamps it (a row removed meanwhile
|
|
533
|
+
* burns the link as 'unknown'). A 'mailer'-delivered token verifies the
|
|
534
|
+
* address; a shown one never does. */
|
|
459
535
|
export function completeEmailChange(token: string): CompleteEmailChangeResult {
|
|
460
536
|
const db = getDb()
|
|
461
537
|
const res = db.prepare(
|
|
@@ -464,11 +540,123 @@ export function completeEmailChange(token: string): CompleteEmailChangeResult {
|
|
|
464
540
|
if (res.changes === 0) return { kind: 'unknown' }
|
|
465
541
|
const row = getEmailChangeToken(token)!
|
|
466
542
|
if (new Date(row.expiresAt).getTime() <= Date.now()) return { kind: 'expired' }
|
|
543
|
+
const verified = row.deliveredBy === 'mailer'
|
|
544
|
+
if (row.kind === 'add') {
|
|
545
|
+
const standing = db.prepare('SELECT 1 AS ok FROM account_emails WHERE user_id = ? AND email = ?').get(row.userId, row.newEmail)
|
|
546
|
+
if (!standing) return { kind: 'unknown' }
|
|
547
|
+
if (verified) markAccountEmailVerified(row.userId, row.newEmail)
|
|
548
|
+
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
549
|
+
}
|
|
467
550
|
const taken = db.prepare('SELECT id FROM users WHERE email = ?').get(row.newEmail) as { id: string } | undefined
|
|
468
551
|
if (taken) return { kind: 'conflict' }
|
|
469
|
-
const
|
|
552
|
+
const takenAdditional = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(row.newEmail) as { user_id: string } | undefined
|
|
553
|
+
if (takenAdditional) return { kind: 'conflict' }
|
|
470
554
|
db.prepare(
|
|
471
555
|
`UPDATE users SET email = ?, email_verified_at = ${verified ? "datetime('now')" : 'NULL'} WHERE id = ?`,
|
|
472
556
|
).run(row.newEmail, row.userId)
|
|
473
557
|
return { kind: 'ok', userId: row.userId, newEmail: row.newEmail, verified }
|
|
474
558
|
}
|
|
559
|
+
|
|
560
|
+
// ── multiple emails per account (TODO.identity-features/01) ──────────
|
|
561
|
+
|
|
562
|
+
function toAccountEmail(row: Record<string, unknown>, isPrimary: boolean): AccountEmail {
|
|
563
|
+
return {
|
|
564
|
+
userId: row.user_id as string,
|
|
565
|
+
email: row.email as string,
|
|
566
|
+
verifiedAt: (row.verified_at as string | null) ?? null,
|
|
567
|
+
isPrimary,
|
|
568
|
+
addedBy: (row.added_by as string | null) ?? null,
|
|
569
|
+
createdAt: row.created_at as string,
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The account's addresses: the PRIMARY first (the users row's email +
|
|
574
|
+
* its verification stamp), then the additional account_emails rows
|
|
575
|
+
* (oldest first). */
|
|
576
|
+
export function listAccountEmails(userId: string): AccountEmail[] {
|
|
577
|
+
const db = getDb()
|
|
578
|
+
const primary = db.prepare(
|
|
579
|
+
'SELECT id AS user_id, email, email_verified_at AS verified_at, created_at FROM users WHERE id = ?',
|
|
580
|
+
).get(userId) as Record<string, unknown> | undefined
|
|
581
|
+
const rows = db.prepare(
|
|
582
|
+
'SELECT * FROM account_emails WHERE user_id = ? ORDER BY created_at, email',
|
|
583
|
+
).all(userId) as Array<Record<string, unknown>>
|
|
584
|
+
const out: AccountEmail[] = []
|
|
585
|
+
if (primary) out.push(toAccountEmail(primary, true))
|
|
586
|
+
out.push(...rows.map(r => toAccountEmail(r, false)))
|
|
587
|
+
return out
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Resolve the account by ANY of its addresses (normalized): the primary
|
|
591
|
+
* always names it (and the primary owner always wins — the
|
|
592
|
+
* deterministic rule); an additional ONLY when verified. */
|
|
593
|
+
export function findUserByAnyEmail(email: string): AuthUserPayload | null {
|
|
594
|
+
const db = getDb()
|
|
595
|
+
const normalized = email.trim().toLowerCase()
|
|
596
|
+
const primary = db.prepare('SELECT * FROM users WHERE email = ?').get(normalized) as Record<string, unknown> | undefined
|
|
597
|
+
if (primary) return accountPayload(primary)
|
|
598
|
+
const owner = db.prepare(
|
|
599
|
+
'SELECT user_id FROM account_emails WHERE email = ? AND verified_at IS NOT NULL',
|
|
600
|
+
).get(normalized) as { user_id: string } | undefined
|
|
601
|
+
if (!owner) return null
|
|
602
|
+
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(owner.user_id) as Record<string, unknown> | undefined
|
|
603
|
+
return user ? accountPayload(user) : null
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Add an ADDITIONAL address (normalized lowercase; the row lands
|
|
607
|
+
* UNVERIFIED). The account's own existing row answers 'present' (the
|
|
608
|
+
* idempotent re-add); any other hold of the address — a primary
|
|
609
|
+
* anywhere (this account's included) or another account's additional —
|
|
610
|
+
* answers 'conflict'. The unique index is the race backstop. */
|
|
611
|
+
export function addAccountEmail(userId: string, email: string, addedBy?: string | null): AddAccountEmailResult {
|
|
612
|
+
const db = getDb()
|
|
613
|
+
const normalized = email.trim().toLowerCase()
|
|
614
|
+
const takenPrimary = db.prepare('SELECT id FROM users WHERE email = ?').get(normalized)
|
|
615
|
+
if (takenPrimary) return 'conflict'
|
|
616
|
+
const existing = db.prepare('SELECT user_id FROM account_emails WHERE email = ?').get(normalized) as { user_id: string } | undefined
|
|
617
|
+
if (existing) return existing.user_id === userId ? 'present' : 'conflict'
|
|
618
|
+
try {
|
|
619
|
+
db.prepare('INSERT INTO account_emails (user_id, email, added_by) VALUES (?, ?, ?)').run(userId, normalized, addedBy ?? null)
|
|
620
|
+
} catch (e) {
|
|
621
|
+
if (String((e as Error).message).includes('UNIQUE')) return 'conflict'
|
|
622
|
+
throw e
|
|
623
|
+
}
|
|
624
|
+
return 'added'
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** The verification ceremony's stamp on the account's OWN row: the
|
|
628
|
+
* guarded UPDATE flips verified_at, once. */
|
|
629
|
+
export function markAccountEmailVerified(userId: string, email: string): boolean {
|
|
630
|
+
return getDb().prepare(
|
|
631
|
+
"UPDATE account_emails SET verified_at = datetime('now') WHERE user_id = ? AND email = ? AND verified_at IS NULL",
|
|
632
|
+
).run(userId, email.trim().toLowerCase()).changes > 0
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/** Promote a VERIFIED additional to primary: the promoted address
|
|
636
|
+
* becomes users.email with its verification stamp; the outgoing
|
|
637
|
+
* primary takes the row's place in account_emails with ITS stamp (it
|
|
638
|
+
* stays a verified additional — sign-in by it keeps working). */
|
|
639
|
+
export function setPrimaryAccountEmail(userId: string, email: string): 'ok' | 'unknown' | 'unverified' {
|
|
640
|
+
const db = getDb()
|
|
641
|
+
const normalized = email.trim().toLowerCase()
|
|
642
|
+
const row = db.prepare('SELECT * FROM account_emails WHERE user_id = ? AND email = ?').get(userId, normalized) as Record<string, unknown> | undefined
|
|
643
|
+
if (!row) return 'unknown'
|
|
644
|
+
if (!row.verified_at) return 'unverified'
|
|
645
|
+
const current = db.prepare('SELECT email, email_verified_at FROM users WHERE id = ?').get(userId) as { email: string; email_verified_at: string | null } | undefined
|
|
646
|
+
if (!current) return 'unknown'
|
|
647
|
+
db.prepare('UPDATE users SET email = ?, email_verified_at = ? WHERE id = ?').run(normalized, row.verified_at as string, userId)
|
|
648
|
+
db.prepare('DELETE FROM account_emails WHERE user_id = ? AND email = ?').run(userId, normalized)
|
|
649
|
+
db.prepare('INSERT INTO account_emails (user_id, email, verified_at) VALUES (?, ?, ?)').run(userId, current.email, current.email_verified_at)
|
|
650
|
+
return 'ok'
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Remove an ADDITIONAL address. The primary refuses honestly
|
|
654
|
+
* ('primary' — promote another address first). */
|
|
655
|
+
export function removeAccountEmail(userId: string, email: string): 'ok' | 'primary' | 'unknown' {
|
|
656
|
+
const db = getDb()
|
|
657
|
+
const normalized = email.trim().toLowerCase()
|
|
658
|
+
const current = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined
|
|
659
|
+
if (current?.email === normalized) return 'primary'
|
|
660
|
+
const res = db.prepare('DELETE FROM account_emails WHERE user_id = ? AND email = ?').run(userId, normalized)
|
|
661
|
+
return res.changes > 0 ? 'ok' : 'unknown'
|
|
662
|
+
}
|
|
@@ -444,11 +444,15 @@ CREATE INDEX IF NOT EXISTS idx_enrollment_tokens_user ON enrollment_tokens (user
|
|
|
444
444
|
-- was displayed to the signed-in holder; completing it applies the change
|
|
445
445
|
-- but the address stays unverified, honestly). A fresh request voids the
|
|
446
446
|
-- account's earlier pending rows: only the newest link works.
|
|
447
|
+
-- TODO.identity-features/01: kind names the ceremony — 'change' (the
|
|
448
|
+
-- primary replacement above) or 'add' (the per-address verification of an
|
|
449
|
+
-- account_emails row; completion stamps the row's verified_at).
|
|
447
450
|
CREATE TABLE IF NOT EXISTS email_change_tokens (
|
|
448
451
|
token TEXT PRIMARY KEY,
|
|
449
452
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
450
453
|
new_email TEXT NOT NULL,
|
|
451
454
|
delivered_by TEXT NOT NULL DEFAULT 'shown',
|
|
455
|
+
kind TEXT NOT NULL DEFAULT 'change',
|
|
452
456
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
453
457
|
expires_at TEXT NOT NULL,
|
|
454
458
|
consumed_at TEXT
|
|
@@ -825,3 +829,49 @@ CREATE TABLE IF NOT EXISTS personal_access_tokens (
|
|
|
825
829
|
UNIQUE (token_hash)
|
|
826
830
|
);
|
|
827
831
|
CREATE INDEX IF NOT EXISTS idx_personal_access_tokens_user ON personal_access_tokens (user_id);
|
|
832
|
+
|
|
833
|
+
-- ═══════════════════════════════════════════════════════════════════
|
|
834
|
+
-- The remembered consent grants (TODO.identity-features/12): the OP
|
|
835
|
+
-- remembers the account holder's "Allow" per (user, client, scope set) —
|
|
836
|
+
-- a repeat authorization the grant COVERS skips the consent page (unless
|
|
837
|
+
-- the request carries prompt=consent). scope is the canonical space-joined
|
|
838
|
+
-- set spelling (normalizeOidcScopeSet); the partial unique index keys ONE
|
|
839
|
+
-- LIVE grant per (user_id, client_id, scope) — revocation flips
|
|
840
|
+
-- revoked_at (the row stays for the audit + the history), a revoked
|
|
841
|
+
-- triple's re-allow lands a fresh row, and the account erasure removes
|
|
842
|
+
-- the rows outright. The D1 migration set carries the identical end state
|
|
843
|
+
-- (0021_oidc_consent_grants.sql).
|
|
844
|
+
-- ═══════════════════════════════════════════════════════════════════
|
|
845
|
+
CREATE TABLE IF NOT EXISTS oidc_consent_grants (
|
|
846
|
+
id TEXT PRIMARY KEY,
|
|
847
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
848
|
+
client_id TEXT NOT NULL,
|
|
849
|
+
scope TEXT NOT NULL,
|
|
850
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
851
|
+
revoked_at TEXT
|
|
852
|
+
);
|
|
853
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_oidc_consent_grants_live
|
|
854
|
+
ON oidc_consent_grants (user_id, client_id, scope) WHERE revoked_at IS NULL;
|
|
855
|
+
CREATE INDEX IF NOT EXISTS idx_oidc_consent_grants_user ON oidc_consent_grants (user_id);
|
|
856
|
+
|
|
857
|
+
-- ═══════════════════════════════════════════════════════════════════
|
|
858
|
+
-- Multiple emails per account (TODO.identity-features/01): the account
|
|
859
|
+
-- carries a primary + additional addresses. The PRIMARY stays users.email
|
|
860
|
+
-- (the OIDC `email` claim never changes shape); account_emails carries
|
|
861
|
+
-- the ADDITIONAL addresses, one row per (account, address), verified_at
|
|
862
|
+
-- NULL until the per-address ceremony (email_change_tokens kind 'add')
|
|
863
|
+
-- proves the mailbox. The unique index makes an additional address name
|
|
864
|
+
-- at most one account; the store's writes check across BOTH tables, and
|
|
865
|
+
-- the sign-in/recovery resolutions prefer the primary owner
|
|
866
|
+
-- deterministically. The D1 migration set carries the identical end
|
|
867
|
+
-- state (0022_account_emails.sql).
|
|
868
|
+
-- ═══════════════════════════════════════════════════════════════════
|
|
869
|
+
CREATE TABLE IF NOT EXISTS account_emails (
|
|
870
|
+
user_id TEXT NOT NULL REFERENCES users(id),
|
|
871
|
+
email TEXT NOT NULL,
|
|
872
|
+
verified_at TEXT,
|
|
873
|
+
added_by TEXT,
|
|
874
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
875
|
+
PRIMARY KEY (user_id, email)
|
|
876
|
+
);
|
|
877
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_account_emails_email ON account_emails (email);
|
|
@@ -144,6 +144,16 @@ function migrateAuthTables(db: Database.Database): void {
|
|
|
144
144
|
if (!amrTokenCols.some(c => c.name === 'amr')) {
|
|
145
145
|
db.exec('ALTER TABLE oidc_access_tokens ADD COLUMN amr TEXT')
|
|
146
146
|
}
|
|
147
|
+
// TODO.identity-features/01 (multiple emails per account): the
|
|
148
|
+
// ceremony token's kind column arrives with migration 0022 — a dev
|
|
149
|
+
// file predating it grows the column here ('change' — every existing
|
|
150
|
+
// row is the legacy primary-replacement ceremony). The account_emails
|
|
151
|
+
// table itself arrives via schema.sql's CREATE IF NOT EXISTS on every
|
|
152
|
+
// boot.
|
|
153
|
+
const emailChangeCols = db.prepare('PRAGMA table_info(email_change_tokens)').all() as Array<{ name: string }>
|
|
154
|
+
if (emailChangeCols.length && !emailChangeCols.some(c => c.name === 'kind')) {
|
|
155
|
+
db.exec("ALTER TABLE email_change_tokens ADD COLUMN kind TEXT NOT NULL DEFAULT 'change'")
|
|
156
|
+
}
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
// AuthUserPayload lives in ./backend (see the re-export above).
|
package/src/store/sqlite.ts
CHANGED
|
@@ -143,6 +143,7 @@ import {
|
|
|
143
143
|
upsertIdentityProvider,
|
|
144
144
|
} from './sqlite/upstream-store'
|
|
145
145
|
import {
|
|
146
|
+
addAccountEmail,
|
|
146
147
|
completeEmailChange,
|
|
147
148
|
completeEnrollment,
|
|
148
149
|
countSignInMethods,
|
|
@@ -155,24 +156,29 @@ import {
|
|
|
155
156
|
deletePasswordHash,
|
|
156
157
|
deleteSessionById,
|
|
157
158
|
eraseOpAccount,
|
|
159
|
+
findUserByAnyEmail,
|
|
158
160
|
getEmailChangeToken,
|
|
159
161
|
getEnrollmentToken,
|
|
160
162
|
getOpClientRoles,
|
|
161
163
|
getPasswordLogin,
|
|
162
164
|
getPendingEmailChange,
|
|
163
165
|
lastAccountSignIns,
|
|
166
|
+
listAccountEmails,
|
|
164
167
|
listAllOpClientRoles,
|
|
165
168
|
listOpClientRoles,
|
|
166
169
|
listOpLiveSessions,
|
|
167
170
|
listUserSessions,
|
|
171
|
+
markAccountEmailVerified,
|
|
172
|
+
removeAccountEmail,
|
|
168
173
|
revokeOpUserCredentials,
|
|
169
174
|
setOpClientRoles,
|
|
170
175
|
setPasswordHash,
|
|
176
|
+
setPrimaryAccountEmail,
|
|
171
177
|
setUserAvatar,
|
|
172
178
|
updateOpAccount,
|
|
173
179
|
updateUserName,
|
|
174
180
|
} 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'
|
|
181
|
+
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 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
182
|
import {
|
|
177
183
|
advanceWebauthnCounter,
|
|
178
184
|
consumeMfaPending,
|
|
@@ -205,6 +211,12 @@ import {
|
|
|
205
211
|
revokePersonalAccessToken,
|
|
206
212
|
stampPersonalAccessTokenUse,
|
|
207
213
|
} from './sqlite/pat-store'
|
|
214
|
+
import {
|
|
215
|
+
getConsentGrant,
|
|
216
|
+
listConsentGrants,
|
|
217
|
+
recordConsentGrant,
|
|
218
|
+
revokeConsentGrant,
|
|
219
|
+
} from './sqlite/consent-grants-store'
|
|
208
220
|
|
|
209
221
|
|
|
210
222
|
/** The workflow tables the reset wipe covers (the D1 store's
|
|
@@ -652,6 +664,20 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
652
664
|
retireOidcKey(kid)
|
|
653
665
|
},
|
|
654
666
|
|
|
667
|
+
// ── the remembered consent grants (TODO.identity-features/12) ──
|
|
668
|
+
async getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null> {
|
|
669
|
+
return getConsentGrant(userId, clientId, scope)
|
|
670
|
+
},
|
|
671
|
+
async recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant> {
|
|
672
|
+
return recordConsentGrant(input)
|
|
673
|
+
},
|
|
674
|
+
async listConsentGrants(userId: string): Promise<OidcConsentGrant[]> {
|
|
675
|
+
return listConsentGrants(userId)
|
|
676
|
+
},
|
|
677
|
+
async revokeConsentGrant(id: string, userId: string): Promise<boolean> {
|
|
678
|
+
return revokeConsentGrant(id, userId)
|
|
679
|
+
},
|
|
680
|
+
|
|
655
681
|
// ── the upstream providers (TODO.identity/08) ──
|
|
656
682
|
async listIdentityProviders(): Promise<IdentityProvider[]> {
|
|
657
683
|
return listIdentityProviders()
|
|
@@ -761,6 +787,7 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
761
787
|
userId: string
|
|
762
788
|
newEmail: string
|
|
763
789
|
deliveredBy: 'mailer' | 'shown'
|
|
790
|
+
kind?: 'change' | 'add'
|
|
764
791
|
ttlMs: number
|
|
765
792
|
}): Promise<EmailChangeToken> {
|
|
766
793
|
return createEmailChangeToken(input)
|
|
@@ -775,6 +802,26 @@ export function createSqliteServerStore(): ServerStore {
|
|
|
775
802
|
return completeEmailChange(token)
|
|
776
803
|
},
|
|
777
804
|
|
|
805
|
+
// ── multiple emails per account (TODO.identity-features/01) ──
|
|
806
|
+
async listAccountEmails(userId: string): Promise<AccountEmail[]> {
|
|
807
|
+
return listAccountEmails(userId)
|
|
808
|
+
},
|
|
809
|
+
async findUserByAnyEmail(email: string): Promise<AuthUserPayload | null> {
|
|
810
|
+
return findUserByAnyEmail(email)
|
|
811
|
+
},
|
|
812
|
+
async addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult> {
|
|
813
|
+
return addAccountEmail(userId, email, addedBy)
|
|
814
|
+
},
|
|
815
|
+
async markAccountEmailVerified(userId: string, email: string): Promise<boolean> {
|
|
816
|
+
return markAccountEmailVerified(userId, email)
|
|
817
|
+
},
|
|
818
|
+
async setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'> {
|
|
819
|
+
return setPrimaryAccountEmail(userId, email)
|
|
820
|
+
},
|
|
821
|
+
async removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'> {
|
|
822
|
+
return removeAccountEmail(userId, email)
|
|
823
|
+
},
|
|
824
|
+
|
|
778
825
|
// ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
|
|
779
826
|
async createWebauthnChallenge(input: {
|
|
780
827
|
challenge: string
|