@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
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,11 @@ 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).
|
|
594
|
+
* TODO.identity-features/01 adds `emails`: the additional-address rows
|
|
595
|
+
* (every address of the account goes — the tombstone's primary is the
|
|
596
|
+
* anonymized users.email). */
|
|
555
597
|
export interface OpAccountErasure {
|
|
556
598
|
sessions: number
|
|
557
599
|
accessTokens: number
|
|
@@ -563,21 +605,29 @@ export interface OpAccountErasure {
|
|
|
563
605
|
tokens: number
|
|
564
606
|
factors: number
|
|
565
607
|
personalAccessTokens: number
|
|
608
|
+
consentGrants: number
|
|
609
|
+
emails: number
|
|
566
610
|
}
|
|
567
611
|
|
|
568
612
|
// ── the account console (TODO.identity/06) ───────────────────────────
|
|
569
613
|
|
|
570
|
-
/** The verify-
|
|
614
|
+
/** The verify-an-address ceremony's row (the enrollment link's doctrine:
|
|
571
615
|
* a 256-bit random token backed by the D1 row; one-time, 24 h).
|
|
572
616
|
* deliveredBy records the channel the link traveled: 'mailer' (sent to
|
|
573
617
|
* the NEW address; completing verifies it) or 'shown' (no mailer
|
|
574
618
|
* configured, the link was displayed to the signed-in holder; the change
|
|
575
|
-
* applies but the address stays unverified, honestly).
|
|
619
|
+
* applies but the address stays unverified, honestly).
|
|
620
|
+
* TODO.identity-features/01: kind names the ceremony — 'change' (the
|
|
621
|
+
* primary-address replacement; completion moves users.email) or 'add'
|
|
622
|
+
* (the per-address verification of an account_emails row; completion
|
|
623
|
+
* stamps the row's verified_at). Rows predating the kind column read
|
|
624
|
+
* 'change' (the migration's default). */
|
|
576
625
|
export interface EmailChangeToken {
|
|
577
626
|
token: string
|
|
578
627
|
userId: string
|
|
579
628
|
newEmail: string
|
|
580
629
|
deliveredBy: 'mailer' | 'shown'
|
|
630
|
+
kind: 'change' | 'add'
|
|
581
631
|
createdAt: string
|
|
582
632
|
expiresAt: string
|
|
583
633
|
consumedAt: string | null
|
|
@@ -595,6 +645,38 @@ export type CompleteEmailChangeResult =
|
|
|
595
645
|
* (the token is burned; the change must start over). */
|
|
596
646
|
| { kind: 'conflict' }
|
|
597
647
|
|
|
648
|
+
// ── multiple emails per account (TODO.identity-features/01) ──────────
|
|
649
|
+
|
|
650
|
+
/** One of the account's addresses. The PRIMARY is the users row's email
|
|
651
|
+
* (isPrimary — the OIDC `email` claim's source, never an
|
|
652
|
+
* account_emails row); the ADDITIONAL addresses are the account_emails
|
|
653
|
+
* rows. verifiedAt NULL = the mailbox is unproven: an unverified
|
|
654
|
+
* additional NEVER names the account (not to sign-in, not to recovery,
|
|
655
|
+
* never a notification's target). */
|
|
656
|
+
export interface AccountEmail {
|
|
657
|
+
userId: string
|
|
658
|
+
email: string
|
|
659
|
+
verifiedAt: string | null
|
|
660
|
+
isPrimary: boolean
|
|
661
|
+
/** Who added the row (the holder's session email, an admin's); NULL on
|
|
662
|
+
* the primary line (the users row carries no such provenance). */
|
|
663
|
+
addedBy: string | null
|
|
664
|
+
createdAt: string
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** The additional-address add's honest outcomes. */
|
|
668
|
+
export type AddAccountEmailResult =
|
|
669
|
+
/** The row landed (unverified — the verification ceremony follows). */
|
|
670
|
+
| 'added'
|
|
671
|
+
/** The account ALREADY carries the address as an additional (the add
|
|
672
|
+
* is an idempotent no-op; the route re-sends the verification for an
|
|
673
|
+
* unverified row). */
|
|
674
|
+
| 'present'
|
|
675
|
+
/** Another account holds the address (as its primary or an
|
|
676
|
+
* additional), or it IS this account's primary — an address names at
|
|
677
|
+
* most one account across the estate. */
|
|
678
|
+
| 'conflict'
|
|
679
|
+
|
|
598
680
|
// ── strong authentication: the factor registry (TODO.identity-sso/02 + /03)
|
|
599
681
|
|
|
600
682
|
/** A registered passkey (the webauthn_credentials row). publicKeyCose is
|
|
@@ -1468,6 +1550,28 @@ export interface ServerStore {
|
|
|
1468
1550
|
upsertOidcKey(input: { kid: string; publicJwk: string }): Promise<void>
|
|
1469
1551
|
retireOidcKey(kid: string): Promise<void>
|
|
1470
1552
|
|
|
1553
|
+
// ── the remembered consent grants (TODO.identity-features/12) ──
|
|
1554
|
+
/** The authorize endpoint's remembered-consent read: the account's
|
|
1555
|
+
* LIVE grant for this client whose scope set COVERS the requested set
|
|
1556
|
+
* (the consentGrantCovers math over the live rows), or null — the
|
|
1557
|
+
* consent page shows. Both scope spellings normalize before the math. */
|
|
1558
|
+
getConsentGrant(userId: string, clientId: string, scope: string): Promise<OidcConsentGrant | null>
|
|
1559
|
+
/** The consent decision's remember (the allow): the upsert per
|
|
1560
|
+
* (user, client, scope) — a live triple's row refreshes its stamp (the
|
|
1561
|
+
* re-affirmed consent); a REVOKED triple's re-allow lands a FRESH live
|
|
1562
|
+
* row (the partial unique index keeps the revoked rows out of the
|
|
1563
|
+
* collision, so the history survives). The scope cell stores the
|
|
1564
|
+
* canonical spelling (normalizeOidcScopeSet). Answers the live row. */
|
|
1565
|
+
recordConsentGrant(input: { userId: string; clientId: string; scope: string }): Promise<OidcConsentGrant>
|
|
1566
|
+
/** The account console's "apps they can access": the account's LIVE
|
|
1567
|
+
* grants, newest first (the revoked rows never list — the audit chain
|
|
1568
|
+
* carries them). */
|
|
1569
|
+
listConsentGrants(userId: string): Promise<OidcConsentGrant[]>
|
|
1570
|
+
/** The console's "Revoke access": flips revoked_at on the account's OWN
|
|
1571
|
+
* live row — a second revoke or another account's row answers false
|
|
1572
|
+
* (the PAT guard's posture). The row STAYS. */
|
|
1573
|
+
revokeConsentGrant(id: string, userId: string): Promise<boolean>
|
|
1574
|
+
|
|
1471
1575
|
// ── the upstream providers (TODO.identity/08) ──
|
|
1472
1576
|
/** The upstream registry (admin-managed; OP_UPSTREAM_SEED bootstraps).
|
|
1473
1577
|
* Secrets are NEVER in these rows — clientSecretRef is an env name. */
|
|
@@ -1519,7 +1623,13 @@ export interface ServerStore {
|
|
|
1519
1623
|
/** The password sign-in's lookup: the credential + the account's
|
|
1520
1624
|
* active flag by email (normalized). Null = no such credential — the
|
|
1521
1625
|
* route still runs one full-cost verify (the timing-shape rule,
|
|
1522
|
-
* auth/passwords.ts). The hash never leaves the server.
|
|
1626
|
+
* auth/passwords.ts). The hash never leaves the server.
|
|
1627
|
+
* TODO.identity-features/01: the address resolves by ANY of the
|
|
1628
|
+
* account's VERIFIED addresses — the primary (users.email) or a
|
|
1629
|
+
* proven account_emails row. An unverified additional never resolves
|
|
1630
|
+
* (the mailbox is unproven), and a primary owner always wins over an
|
|
1631
|
+
* additional row (the deterministic rule — a stray duplicate shadows,
|
|
1632
|
+
* never ambiguates). */
|
|
1523
1633
|
getPasswordLogin(email: string): Promise<{ userId: string; hash: string; active: boolean } | null>
|
|
1524
1634
|
/** Set/replace the account's password credential (enrollment
|
|
1525
1635
|
* completion, the account page's change). */
|
|
@@ -1618,15 +1728,20 @@ export interface ServerStore {
|
|
|
1618
1728
|
* "sign out everywhere else" action + the password change's
|
|
1619
1729
|
* best-practice revocation). Answers the revoked count. */
|
|
1620
1730
|
deleteOtherSessions(userId: string, keepToken: string): Promise<number>
|
|
1621
|
-
/** Mint the verify-
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
*
|
|
1731
|
+
/** Mint the verify-an-address ceremony's token. The void rule keeps
|
|
1732
|
+
* one live link per ceremony target: a 'change' request VOIDS the
|
|
1733
|
+
* account's earlier pending 'change' rows (only the newest change
|
|
1734
|
+
* link works — the pre-01 doctrine); an 'add' request voids the
|
|
1735
|
+
* account's earlier pending 'add' rows FOR THE SAME address (other
|
|
1736
|
+
* addresses' links stand). deliveredBy is stamped at request time and
|
|
1737
|
+
* decides whether completion may verify the address. kind defaults
|
|
1738
|
+
* 'change'. */
|
|
1625
1739
|
createEmailChangeToken(input: {
|
|
1626
1740
|
token: string
|
|
1627
1741
|
userId: string
|
|
1628
1742
|
newEmail: string
|
|
1629
1743
|
deliveredBy: 'mailer' | 'shown'
|
|
1744
|
+
kind?: 'change' | 'add'
|
|
1630
1745
|
ttlMs: number
|
|
1631
1746
|
}): Promise<EmailChangeToken>
|
|
1632
1747
|
getEmailChangeToken(token: string): Promise<EmailChangeToken | null>
|
|
@@ -1634,12 +1749,51 @@ export interface ServerStore {
|
|
|
1634
1749
|
* so the console can show it. */
|
|
1635
1750
|
getPendingEmailChange(userId: string): Promise<EmailChangeToken | null>
|
|
1636
1751
|
/** Complete the ceremony: consume the token ATOMICALLY (a presented
|
|
1637
|
-
* link works exactly once, expired or not), judge the expiry,
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
1640
|
-
* (
|
|
1752
|
+
* link works exactly once, expired or not), judge the expiry, then
|
|
1753
|
+
* act on the kind: 'change' re-checks the address's uniqueness across
|
|
1754
|
+
* BOTH address tables (a conflict burns the token honestly) and moves
|
|
1755
|
+
* the account's primary (users.email); 'add' stamps the
|
|
1756
|
+
* account_emails row's verified_at (a row removed between request and
|
|
1757
|
+
* completion answers 'unknown'). verified = the token traveled by
|
|
1758
|
+
* mailer (mailbox proven); a shown link never verifies. */
|
|
1641
1759
|
completeEmailChange(token: string): Promise<CompleteEmailChangeResult>
|
|
1642
1760
|
|
|
1761
|
+
// ── multiple emails per account (TODO.identity-features/01) ──
|
|
1762
|
+
/** The account's addresses, the PRIMARY first (the users row's email +
|
|
1763
|
+
* its verification stamp), then the additional account_emails rows
|
|
1764
|
+
* (oldest first). The console's emails section and the security-mail
|
|
1765
|
+
* fan-out read this. */
|
|
1766
|
+
listAccountEmails(userId: string): Promise<AccountEmail[]>
|
|
1767
|
+
/** Resolve the account by ANY of its addresses (normalized): the
|
|
1768
|
+
* primary always names it; an additional ONLY when verified (an
|
|
1769
|
+
* unproven address never names the account — not to sign-in, not to
|
|
1770
|
+
* recovery). The primary owner wins over an additional row (the
|
|
1771
|
+
* deterministic rule). */
|
|
1772
|
+
findUserByAnyEmail(email: string): Promise<AuthUserPayload | null>
|
|
1773
|
+
/** Add an ADDITIONAL address (normalized lowercase; the row lands
|
|
1774
|
+
* UNVERIFIED — the verify-an-address ceremony's kind 'add' token
|
|
1775
|
+
* proves the mailbox). The tagged result names the outcome; the
|
|
1776
|
+
* unique index + the cross-table check make an address name at most
|
|
1777
|
+
* one account. */
|
|
1778
|
+
addAccountEmail(userId: string, email: string, addedBy?: string | null): Promise<AddAccountEmailResult>
|
|
1779
|
+
/** The verification ceremony's stamp on the account's OWN additional
|
|
1780
|
+
* row (the kind 'add' completion): verified_at flips, once (the
|
|
1781
|
+
* guarded update). Answers false when no such row stands. */
|
|
1782
|
+
markAccountEmailVerified(userId: string, email: string): Promise<boolean>
|
|
1783
|
+
/** Promote a VERIFIED additional to primary: the promoted address
|
|
1784
|
+
* becomes users.email (its verification stamp travels, so the claims
|
|
1785
|
+
* stay verified), and the previous primary takes the row's place in
|
|
1786
|
+
* account_emails with ITS stamp (it stays a verified additional —
|
|
1787
|
+
* sign-in by it keeps working). 'unknown' = no such additional row;
|
|
1788
|
+
* 'unverified' = the row stands unproven (a primary is always
|
|
1789
|
+
* proven). */
|
|
1790
|
+
setPrimaryAccountEmail(userId: string, email: string): Promise<'ok' | 'unknown' | 'unverified'>
|
|
1791
|
+
/** Remove an ADDITIONAL address. 'primary' = the address IS the
|
|
1792
|
+
* account's primary (promote another first — the primary is never
|
|
1793
|
+
* removed from under the holder); 'unknown' = no such additional
|
|
1794
|
+
* row. */
|
|
1795
|
+
removeAccountEmail(userId: string, email: string): Promise<'ok' | 'primary' | 'unknown'>
|
|
1796
|
+
|
|
1643
1797
|
// ── strong authentication: the factor registry (TODO.identity-sso/02 + /03) ──
|
|
1644
1798
|
/** The one-time WebAuthn ceremony challenge. The challenge value IS the
|
|
1645
1799
|
* key (the clientDataJSON binds it); expires_at = now + ttlMs. */
|