@astram/guard 0.10.1 → 0.11.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.
Files changed (47) hide show
  1. package/dist/auth/account.controller.d.ts +73 -19
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +251 -56
  4. package/dist/auth/auth.controller.d.ts +3 -1
  5. package/dist/auth/auth.controller.d.ts.map +1 -1
  6. package/dist/auth/auth.controller.js +17 -4
  7. package/dist/auth/auth.module.d.ts +6 -0
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +53 -1
  10. package/dist/auth/confirm-email.controller.d.ts +33 -0
  11. package/dist/auth/confirm-email.controller.d.ts.map +1 -0
  12. package/dist/auth/confirm-email.controller.js +205 -0
  13. package/dist/auth/email-address.d.ts +30 -0
  14. package/dist/auth/email-address.d.ts.map +1 -0
  15. package/dist/auth/email-address.js +49 -0
  16. package/dist/auth/email-change-messages.d.ts +21 -0
  17. package/dist/auth/email-change-messages.d.ts.map +1 -0
  18. package/dist/auth/email-change-messages.js +50 -0
  19. package/dist/auth/ports/email-address-changer.d.ts +65 -0
  20. package/dist/auth/ports/email-address-changer.d.ts.map +1 -0
  21. package/dist/auth/ports/email-address-changer.js +8 -0
  22. package/dist/auth/ports/email-change-token-store.d.ts +39 -0
  23. package/dist/auth/ports/email-change-token-store.d.ts.map +1 -0
  24. package/dist/auth/ports/email-change-token-store.js +12 -0
  25. package/dist/auth/tokens.d.ts +36 -0
  26. package/dist/auth/tokens.d.ts.map +1 -1
  27. package/dist/auth/tokens.js +37 -1
  28. package/dist/authentication/mariadb-email-address-changer.d.ts +33 -0
  29. package/dist/authentication/mariadb-email-address-changer.d.ts.map +1 -0
  30. package/dist/authentication/mariadb-email-address-changer.js +97 -0
  31. package/dist/authentication/mariadb-email-change-token-store.d.ts +25 -0
  32. package/dist/authentication/mariadb-email-change-token-store.d.ts.map +1 -0
  33. package/dist/authentication/mariadb-email-change-token-store.js +72 -0
  34. package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
  35. package/dist/authentication/mariadb-mfa-device-store.js +8 -1
  36. package/dist/authz/permission-version-cache.d.ts.map +1 -1
  37. package/dist/authz/permission-version-cache.js +12 -2
  38. package/dist/enforcement/uuid.pipe.d.ts +35 -0
  39. package/dist/enforcement/uuid.pipe.d.ts.map +1 -0
  40. package/dist/enforcement/uuid.pipe.js +67 -0
  41. package/dist/index.d.ts +7 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +26 -3
  44. package/dist/module/shield.module.d.ts +9 -0
  45. package/dist/module/shield.module.d.ts.map +1 -1
  46. package/dist/module/shield.module.js +6 -0
  47. package/package.json +3 -3
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.NOT_YOU_SENTENCE = exports.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL = exports.EMAIL_CHANGE_TTL_MS = void 0;
4
+ exports.emailChangeLink = emailChangeLink;
5
+ exports.confirmEmailChangeMessage = confirmEmailChangeMessage;
6
+ exports.emailChangedNoticeMessage = emailChangedNoticeMessage;
7
+ /** Email-change token time-to-live: 30 minutes (same as a password reset). */
8
+ exports.EMAIL_CHANGE_TTL_MS = 30 * 60 * 1000;
9
+ /** The default confirm-page base (a dev Vite origin), like the reset-link default. */
10
+ exports.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL = 'http://localhost:5173/confirm-email';
11
+ /** The A10 sentence, verbatim. */
12
+ exports.NOT_YOU_SENTENCE = "If this wasn't you, reset your password from the login page and contact your administrator.";
13
+ function escapeHtml(value) {
14
+ return value
15
+ .replace(/&/g, '&')
16
+ .replace(/</g, '&lt;')
17
+ .replace(/>/g, '&gt;')
18
+ .replace(/"/g, '&quot;');
19
+ }
20
+ /** The confirm link: the token rides in the fragment, never the query (A4). */
21
+ function emailChangeLink(base, rawToken) {
22
+ return `${base}#t=${rawToken}`;
23
+ }
24
+ function confirmEmailChangeMessage(to, link) {
25
+ const minutes = Math.round(exports.EMAIL_CHANGE_TTL_MS / 60000);
26
+ return {
27
+ to,
28
+ subject: 'Confirm your new email address',
29
+ text: 'You asked to use this address for your account. Confirm it by opening this link ' +
30
+ `within ${minutes} minutes: ${link}\n\n` +
31
+ 'If you did not ask for this, ignore this message; nothing changes.',
32
+ html: '<p>You asked to use this address for your account. Confirm it by opening this link ' +
33
+ `within ${minutes} minutes:</p>` +
34
+ `<p><a href="${escapeHtml(link)}" rel="noreferrer">${escapeHtml(link)}</a></p>` +
35
+ '<p>If you did not ask for this, ignore this message; nothing changes.</p>',
36
+ };
37
+ }
38
+ function emailChangedNoticeMessage(to, maskedNewEmail, atMs) {
39
+ const when = new Date(atMs)
40
+ .toISOString()
41
+ .replace('T', ' ')
42
+ .replace(/\.\d+Z$/, ' UTC');
43
+ const what = `The email address on your account was changed to ${maskedNewEmail} at ${when}.`;
44
+ return {
45
+ to,
46
+ subject: 'Your account email address was changed',
47
+ text: `${what}\n\n${exports.NOT_YOU_SENTENCE}`,
48
+ html: `<p>${escapeHtml(what)}</p><p>${escapeHtml(exports.NOT_YOU_SENTENCE)}</p>`,
49
+ };
50
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * EmailAddressChanger — the ONE place a user's email address is read for, and written by, an email
3
+ * change (security hardening Phase 12, KI #14). Framework default `MariaDbEmailAddressChanger` over
4
+ * `shield_app.users` + `shield_identity.identity_credential`. Used by guard's self-service routes
5
+ * and constructed by `@astram/identity` for the administrator path, so the two cannot drift.
6
+ */
7
+ /** A user's address as stored, for the request-time checks. */
8
+ export interface CurrentEmail {
9
+ /** `users.u_email` exactly as stored (may be null). */
10
+ readonly email: string | null;
11
+ /** `users.u_status === 'active'`. */
12
+ readonly active: boolean;
13
+ /** The stored address is collation-equal (`utf8mb4_unicode_ci`) to the candidate. */
14
+ readonly sameAsCandidate: boolean;
15
+ }
16
+ /** A parameterized statement on the change's own transaction connection. */
17
+ export type TransactionQuery = (sql: string, params?: unknown[]) => Promise<unknown>;
18
+ export interface EmailChangeRequest {
19
+ readonly principalId: string;
20
+ /** The CANONICAL new address (`canonicalEmail`, A5). */
21
+ readonly newEmail: string;
22
+ /**
23
+ * When given, the change is refused (`stale`) unless the stored `u_email` still equals it
24
+ * EXACTLY (A3: an admin or another flow changed it after the request). Omit on the admin path.
25
+ */
26
+ readonly expectedOldEmail?: string | null;
27
+ /** Refuse (`inactive`) unless `u_status = 'active'` (the self-service confirm). */
28
+ readonly requireActive?: boolean;
29
+ /**
30
+ * Runs on the change's transaction AFTER the writes and BEFORE commit (e.g. the admin path's
31
+ * audit row): a throw rolls the whole change back (fail closed).
32
+ */
33
+ readonly withinTransaction?: (query: TransactionQuery, result: {
34
+ readonly oldEmail: string | null;
35
+ readonly hintMoved: boolean;
36
+ }) => Promise<void>;
37
+ }
38
+ export type EmailChangeOutcome = {
39
+ readonly status: 'changed';
40
+ readonly oldEmail: string | null;
41
+ readonly hintMoved: boolean;
42
+ }
43
+ /** Another user's `u_email` or another principal's login hint is collation-equal to it. */
44
+ | {
45
+ readonly status: 'unavailable';
46
+ } | {
47
+ readonly status: 'not_found';
48
+ } | {
49
+ readonly status: 'inactive';
50
+ } | {
51
+ readonly status: 'stale';
52
+ };
53
+ export interface EmailAddressChanger {
54
+ /** The user's stored address and whether it already equals `candidate`; `null` = no such user. */
55
+ readCurrent(principalId: string, candidate: string): Promise<CurrentEmail | null>;
56
+ /**
57
+ * Apply an email change in ONE transaction: lock the user row and the user's credential rows,
58
+ * refuse a collision (another user's `u_email`, another principal's `ic_identity_hint`;
59
+ * collation-equal, locked reads, self excluded), write `u_email`, move `ic_identity_hint` when it
60
+ * equalled the OLD email (a username hint is untouched), and burn the user's pending
61
+ * email-change tokens. Throws on a store error (caller → 503).
62
+ */
63
+ change(request: EmailChangeRequest): Promise<EmailChangeOutcome>;
64
+ }
65
+ //# sourceMappingURL=email-address-changer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-address-changer.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/email-address-changer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,+DAA+D;AAC/D,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,qCAAqC;IACrC,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,qFAAqF;IACrF,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;CACnC;AAED,4EAA4E;AAC5E,MAAM,MAAM,gBAAgB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;AAErF,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B;;;OAGG;IACH,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,mFAAmF;IACnF,QAAQ,CAAC,aAAa,CAAC,EAAE,OAAO,CAAC;IACjC;;;OAGG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,CAC3B,KAAK,EAAE,gBAAgB,EACvB,MAAM,EAAE;QAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;KAAE,KACtE,OAAO,CAAC,IAAI,CAAC,CAAC;CACpB;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;CAAE;AAC/F,2FAA2F;GACzF;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAAE,GAClC;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAAE,GAChC;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAA;CAAE,GAC/B;IAAE,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;AAEjC,MAAM,WAAW,mBAAmB;IAClC,kGAAkG;IAClG,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAC;IAClF;;;;;;OAMG;IACH,MAAM,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;CAClE"}
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ /**
3
+ * EmailAddressChanger — the ONE place a user's email address is read for, and written by, an email
4
+ * change (security hardening Phase 12, KI #14). Framework default `MariaDbEmailAddressChanger` over
5
+ * `shield_app.users` + `shield_identity.identity_credential`. Used by guard's self-service routes
6
+ * and constructed by `@astram/identity` for the administrator path, so the two cannot drift.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,39 @@
1
+ /**
2
+ * EmailChangeTokenStore — persists single-use, TTL'd self-service email-change tokens (security
3
+ * hardening Phase 12, KI #14). The framework hashes the raw token (SHA-256) before it reaches this
4
+ * store; the store never sees the raw token.
5
+ *
6
+ * A token binds (A3) the user, the CANONICAL new address, the email the user had at request time
7
+ * and the requesting session. One pending change per user (A2): `issue` invalidates every earlier
8
+ * unconsumed token of the principal. `consume` is atomic and single-use: it returns the binding iff
9
+ * the token is unexpired AND unconsumed, and marks it consumed in the same statement.
10
+ */
11
+ /** What an issued token binds (A3). */
12
+ export interface EmailChangeTokenBinding {
13
+ readonly principalId: string;
14
+ /** The canonical new address (trim → NFKC → lower-case, A5). */
15
+ readonly newEmail: string;
16
+ /** `users.u_email` when the change was requested (confirm refuses if it changed since). */
17
+ readonly oldEmail: string | null;
18
+ /** The requesting session (kept on confirm; every other session is revoked, A7). */
19
+ readonly sessionId: string;
20
+ /**
21
+ * The requesting session's tenant. The confirm step may run with no session, so its audit rows
22
+ * (`account.email_changed` / `account.email_change_refused`) take the tenant from here
23
+ * (`audit_log.al_tenant_id` is NOT NULL).
24
+ */
25
+ readonly tenantId: string;
26
+ }
27
+ export interface EmailChangeTokenIssue extends EmailChangeTokenBinding {
28
+ readonly tokenHash: string;
29
+ readonly expiresAtMs: number;
30
+ }
31
+ export interface EmailChangeTokenStore {
32
+ /** Delete expired rows, invalidate the principal's other pending tokens, insert this one. */
33
+ issue(token: EmailChangeTokenIssue): Promise<void>;
34
+ /** Atomically consume (single use); `null` for unknown / expired / already consumed. */
35
+ consume(tokenHash: string): Promise<EmailChangeTokenBinding | null>;
36
+ /** Burn every pending token of the principal (password reset/change, admin change, confirm). */
37
+ invalidateAllForPrincipal(principalId: string): Promise<void>;
38
+ }
39
+ //# sourceMappingURL=email-change-token-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"email-change-token-store.d.ts","sourceRoot":"","sources":["../../../src/auth/ports/email-change-token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,uCAAuC;AACvC,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,gEAAgE;IAChE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,2FAA2F;IAC3F,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,oFAAoF;IACpF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,qBAAsB,SAAQ,uBAAuB;IACpE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,qBAAqB;IACpC,6FAA6F;IAC7F,KAAK,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,wFAAwF;IACxF,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC,CAAC;IACpE,gGAAgG;IAChG,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D"}
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ /**
3
+ * EmailChangeTokenStore — persists single-use, TTL'd self-service email-change tokens (security
4
+ * hardening Phase 12, KI #14). The framework hashes the raw token (SHA-256) before it reaches this
5
+ * store; the store never sees the raw token.
6
+ *
7
+ * A token binds (A3) the user, the CANONICAL new address, the email the user had at request time
8
+ * and the requesting session. One pending change per user (A2): `issue` invalidates every earlier
9
+ * unconsumed token of the principal. `consume` is atomic and single-use: it returns the binding iff
10
+ * the token is unexpired AND unconsumed, and marks it consumed in the same statement.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -82,6 +82,42 @@ export declare const SHIELD_CREDENTIAL_WRITER: unique symbol;
82
82
  * never the raw token). Consumed by forgot-password + reset-password.
83
83
  */
84
84
  export declare const SHIELD_RESET_TOKEN_STORE: unique symbol;
85
+ /**
86
+ * The `EmailChangeTokenStore` (guard port, `../auth/ports/email-change-token-store.ts`) — the
87
+ * single-use, TTL'd self-service email-change token store (Phase 12, KI #14). Framework default
88
+ * `MariaDbEmailChangeTokenStore` over `shield_identity.email_change_token`. Consumed by
89
+ * `PATCH /account/profile` (issue), `POST /auth/confirm-email` (consume), and invalidated by a
90
+ * password reset / change (A8). Absent → an email change answers 501.
91
+ */
92
+ export declare const SHIELD_EMAIL_CHANGE_TOKEN_STORE: unique symbol;
93
+ /**
94
+ * The `EmailAddressChanger` (guard port, `../auth/ports/email-address-changer.ts`) — reads a
95
+ * user's stored address for the request checks and applies a confirmed change in ONE transaction
96
+ * (collision refusal + login-hint follow). Framework default `MariaDbEmailAddressChanger` over
97
+ * {@link SHIELD_DB_POOL}. Phase 12, KI #14.
98
+ */
99
+ export declare const SHIELD_EMAIL_ADDRESS_CHANGER: unique symbol;
100
+ /**
101
+ * The email-change confirm-link base URL (a plain `string`, `ShieldModule.forRoot({
102
+ * emailChangeLinkBaseUrl })`, default `http://localhost:5173/confirm-email`). The link is
103
+ * `${base}#t=<rawToken>` — the token rides in the FRAGMENT, never the query (A4), so it is not
104
+ * sent to the server that serves the page nor leaked through a Referer.
105
+ */
106
+ export declare const SHIELD_EMAIL_CHANGE_LINK_BASE_URL: unique symbol;
107
+ /**
108
+ * The per-account email-change REQUEST limiter (Phase 12) — keyed `email-change:pid:<principalId>`,
109
+ * 3 per 60-min window, so `PATCH /account/profile` cannot be used to spam an inbox. Counted
110
+ * (`consume`, fail closed → 503) after the current password verified; 429 when exhausted.
111
+ */
112
+ export declare const SHIELD_EMAIL_CHANGE_LIMITER: unique symbol;
113
+ /**
114
+ * The `POST /auth/confirm-email` per-client-IP limiter (Phase 12, A6) — keyed
115
+ * `confirm-email:ip:<ip>`, 10 per 15-min window, like reset-password. Every attempt is counted
116
+ * (`consume`, fail closed → 503), a successful confirm gives its attempt back; 429 when exhausted.
117
+ * ENFORCED ONLY with an explicit Express `trust proxy` (`TrustProxyBootCheck`), else OFF — a shared
118
+ * proxy IP would otherwise lock every user out.
119
+ */
120
+ export declare const SHIELD_CONFIRM_EMAIL_LIMITER: unique symbol;
85
121
  /**
86
122
  * The `EmailSender` (guard port, `../auth/ports/email-sender.ts`) — the delivery
87
123
  * seam (SendGrid/SMTP/test capturer). The framework builds the message; the
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAEvF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,eAAsC,CAAC;AAU7E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,eAA4C,CAAC;AAEzF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,eAAwC,CAAC"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/auth/tokens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AAEvF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,eAA2B,CAAC;AAEvD;;;;GAIG;AACH,eAAO,MAAM,YAAY,eAAyB,CAAC;AAEnD;;;;;;GAMG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAW3E;;;;;;GAMG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;;;GAMG;AACH,eAAO,MAAM,+BAA+B,eAA4C,CAAC;AAEzF;;;;;GAKG;AACH,eAAO,MAAM,4BAA4B,eAAyC,CAAC;AAEnF;;;;;GAKG;AACH,eAAO,MAAM,iCAAiC,eAA8C,CAAC;AAE7F;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,eAAwC,CAAC;AAEjF;;;;;;GAMG;AACH,eAAO,MAAM,4BAA4B,eAAyC,CAAC;AAEnF;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,eAAgC,CAAC;AAEjE;;;GAGG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;GAGG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,eAA8B,CAAC;AAE7D;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,eAAmC,CAAC;AAEvE;;;;;;;GAOG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,eAAe,eAA4B,CAAC;AAEzD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,8BAA8B,eAA2C,CAAC;AAEvF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,6BAA6B,eAA0C,CAAC;AAErF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,yBAAyB,eAAsC,CAAC;AAU7E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,eAA+B,CAAC;AAE/D;;;;;GAKG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,qBAAqB,eAAkC,CAAC;AAErE;;;;;;;;;GASG;AACH,eAAO,MAAM,0BAA0B,eAAuC,CAAC;AAE/E;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,eAAiC,CAAC;AAEnE;;;;;;;GAOG;AACH,eAAO,MAAM,uBAAuB,eAAoC,CAAC;AAEzE;;;;;;;GAOG;AACH,eAAO,MAAM,+BAA+B,eAA4C,CAAC;AAEzF;;;;;;GAMG;AACH,eAAO,MAAM,2BAA2B,eAAwC,CAAC"}
@@ -23,7 +23,7 @@
23
23
  * ADR-0003: no framework/DB/crypto imports here — just Symbols (+ one re-export).
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.SHIELD_SESSION_RESTRICTIONS = exports.SHIELD_PERMISSION_VERSION_CACHE = exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_LOGIN_TOTP_VERIFIER = exports.SHIELD_MFA_STEP_UP = exports.SHIELD_MFA_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_IP_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_MEMBERSHIP_CHECKER = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
26
+ exports.SHIELD_SESSION_RESTRICTIONS = exports.SHIELD_PERMISSION_VERSION_CACHE = exports.SHIELD_PERMISSION_FACTS = exports.SHIELD_RESET_LIMITER = exports.SHIELD_FORGOT_HINT_LIMITER = exports.SHIELD_FORGOT_LIMITER = exports.SHIELD_LOGIN_TOTP_VERIFIER = exports.SHIELD_MFA_STEP_UP = exports.SHIELD_MFA_LIMITER = exports.SHIELD_RECOVERY_LIMITER = exports.SHIELD_LOGIN_IP_LIMITER = exports.SHIELD_LOGIN_LIMITER = exports.SHIELD_MEMBERSHIP_CHECKER = exports.SHIELD_SCOPE_SUBTREE_RESOLVER = exports.SHIELD_SESSION_FACTS_PROJECTOR = exports.SHIELD_ROOT_IDS = exports.SHIELD_TOTP_VERIFIER_OVERRIDE = exports.SHIELD_TOTP_VERIFIER = exports.SHIELD_RESET_LINK_BASE_URL = exports.SHIELD_SESSION_SWEEPER = exports.SHIELD_MFA_DEVICE_STORE = exports.SHIELD_AUDIT_SINK = exports.SHIELD_PROFILE_WRITER = exports.SHIELD_RECOVERY_CODE_STORE = exports.SHIELD_EMAIL_SENDER = exports.SHIELD_CONFIRM_EMAIL_LIMITER = exports.SHIELD_EMAIL_CHANGE_LIMITER = exports.SHIELD_EMAIL_CHANGE_LINK_BASE_URL = exports.SHIELD_EMAIL_ADDRESS_CHANGER = exports.SHIELD_EMAIL_CHANGE_TOKEN_STORE = exports.SHIELD_RESET_TOKEN_STORE = exports.SHIELD_CREDENTIAL_WRITER = exports.SHIELD_SESSION_AUTHORITY = exports.SHIELD_AUTHENTICATOR = exports.SHIELD_PROFILE_RESOLVER = exports.SHIELD_CREDENTIAL_RESOLVER = exports.SHIELD_REDIS = exports.SHIELD_DB_POOL = exports.SHIELD_PASSWORD_HASHER = void 0;
27
27
  var argon2id_password_hasher_js_1 = require("../authentication/argon2id-password-hasher.js");
28
28
  Object.defineProperty(exports, "SHIELD_PASSWORD_HASHER", { enumerable: true, get: function () { return argon2id_password_hasher_js_1.SHIELD_PASSWORD_HASHER; } });
29
29
  /**
@@ -94,6 +94,42 @@ exports.SHIELD_CREDENTIAL_WRITER = Symbol('SHIELD_CREDENTIAL_WRITER');
94
94
  * never the raw token). Consumed by forgot-password + reset-password.
95
95
  */
96
96
  exports.SHIELD_RESET_TOKEN_STORE = Symbol('SHIELD_RESET_TOKEN_STORE');
97
+ /**
98
+ * The `EmailChangeTokenStore` (guard port, `../auth/ports/email-change-token-store.ts`) — the
99
+ * single-use, TTL'd self-service email-change token store (Phase 12, KI #14). Framework default
100
+ * `MariaDbEmailChangeTokenStore` over `shield_identity.email_change_token`. Consumed by
101
+ * `PATCH /account/profile` (issue), `POST /auth/confirm-email` (consume), and invalidated by a
102
+ * password reset / change (A8). Absent → an email change answers 501.
103
+ */
104
+ exports.SHIELD_EMAIL_CHANGE_TOKEN_STORE = Symbol('SHIELD_EMAIL_CHANGE_TOKEN_STORE');
105
+ /**
106
+ * The `EmailAddressChanger` (guard port, `../auth/ports/email-address-changer.ts`) — reads a
107
+ * user's stored address for the request checks and applies a confirmed change in ONE transaction
108
+ * (collision refusal + login-hint follow). Framework default `MariaDbEmailAddressChanger` over
109
+ * {@link SHIELD_DB_POOL}. Phase 12, KI #14.
110
+ */
111
+ exports.SHIELD_EMAIL_ADDRESS_CHANGER = Symbol('SHIELD_EMAIL_ADDRESS_CHANGER');
112
+ /**
113
+ * The email-change confirm-link base URL (a plain `string`, `ShieldModule.forRoot({
114
+ * emailChangeLinkBaseUrl })`, default `http://localhost:5173/confirm-email`). The link is
115
+ * `${base}#t=<rawToken>` — the token rides in the FRAGMENT, never the query (A4), so it is not
116
+ * sent to the server that serves the page nor leaked through a Referer.
117
+ */
118
+ exports.SHIELD_EMAIL_CHANGE_LINK_BASE_URL = Symbol('SHIELD_EMAIL_CHANGE_LINK_BASE_URL');
119
+ /**
120
+ * The per-account email-change REQUEST limiter (Phase 12) — keyed `email-change:pid:<principalId>`,
121
+ * 3 per 60-min window, so `PATCH /account/profile` cannot be used to spam an inbox. Counted
122
+ * (`consume`, fail closed → 503) after the current password verified; 429 when exhausted.
123
+ */
124
+ exports.SHIELD_EMAIL_CHANGE_LIMITER = Symbol('SHIELD_EMAIL_CHANGE_LIMITER');
125
+ /**
126
+ * The `POST /auth/confirm-email` per-client-IP limiter (Phase 12, A6) — keyed
127
+ * `confirm-email:ip:<ip>`, 10 per 15-min window, like reset-password. Every attempt is counted
128
+ * (`consume`, fail closed → 503), a successful confirm gives its attempt back; 429 when exhausted.
129
+ * ENFORCED ONLY with an explicit Express `trust proxy` (`TrustProxyBootCheck`), else OFF — a shared
130
+ * proxy IP would otherwise lock every user out.
131
+ */
132
+ exports.SHIELD_CONFIRM_EMAIL_LIMITER = Symbol('SHIELD_CONFIRM_EMAIL_LIMITER');
97
133
  /**
98
134
  * The `EmailSender` (guard port, `../auth/ports/email-sender.ts`) — the delivery
99
135
  * seam (SendGrid/SMTP/test capturer). The framework builds the message; the
@@ -0,0 +1,33 @@
1
+ /**
2
+ * MariaDbEmailAddressChanger — the framework-default {@link EmailAddressChanger} (security hardening
3
+ * Phase 12, KI #14) over `shield_app.users`, `shield_identity.identity_credential` and
4
+ * `shield_identity.email_change_token`. Guard's `POST /auth/confirm-email` uses it over the auth
5
+ * pool; `@astram/identity`'s administrator path constructs it over its authz pool, so the two
6
+ * paths share one collision rule and one login-hint rule.
7
+ *
8
+ * `change` (one MariaDB transaction, lock-then-recheck):
9
+ * 0. `GET_LOCK('shield_email_change')` — serialises email changes. `u_email` has no index, so the
10
+ * locked collision read below locks a scan of `users`; without the named lock two concurrent
11
+ * changes would deadlock on each other's row locks.
12
+ * 1. lock the user row (`FOR UPDATE`); refuse `not_found`, `inactive` (when required), `stale`
13
+ * (stored email ≠ the expected old one, exact compare — A3);
14
+ * 2. refuse `unavailable` when another user's `u_email` or another principal's login hint is
15
+ * collation-equal (`utf8mb4_unicode_ci`: case-, accent- and trailing-space-insensitive) to
16
+ * the new address — locked reads, self excluded;
17
+ * 3. lock the user's own credential rows; write `u_email`;
18
+ * 4. login-hint follow: `ic_identity_hint` (the PK) is updated IN PLACE to the new address when it
19
+ * equalled the OLD email (collation-equal); a username hint matches nothing and stays;
20
+ * 5. burn the user's pending email-change tokens; run `withinTransaction`; commit.
21
+ * Every query is parameterized; addresses are compared by the column collation in SQL.
22
+ */
23
+ import type { Pool } from 'mariadb';
24
+ import type { CurrentEmail, EmailAddressChanger, EmailChangeOutcome, EmailChangeRequest } from '../auth/ports/email-address-changer.js';
25
+ export declare class MariaDbEmailAddressChanger implements EmailAddressChanger {
26
+ private readonly pool;
27
+ private readonly now;
28
+ constructor(pool: Pool, now?: () => number);
29
+ readCurrent(principalId: string, candidate: string): Promise<CurrentEmail | null>;
30
+ change(request: EmailChangeRequest): Promise<EmailChangeOutcome>;
31
+ private changeLocked;
32
+ }
33
+ //# sourceMappingURL=mariadb-email-address-changer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mariadb-email-address-changer.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-email-address-changer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,IAAI,EAAkB,MAAM,SAAS,CAAC;AACpD,OAAO,KAAK,EACV,YAAY,EACZ,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EACnB,MAAM,wCAAwC,CAAC;AAWhD,qBAAa,0BAA2B,YAAW,mBAAmB;IAElE,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,IAAI,EACV,GAAG,GAAE,MAAM,MAAiB;IAGzC,WAAW,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC;IAejF,MAAM,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;YA6BxD,YAAY;CA+D3B"}
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MariaDbEmailAddressChanger = void 0;
4
+ /** Seconds to wait for the email-change named lock before giving up (caller → 503). */
5
+ const LOCK_TIMEOUT_S = 10;
6
+ class MariaDbEmailAddressChanger {
7
+ pool;
8
+ now;
9
+ constructor(pool, now = Date.now) {
10
+ this.pool = pool;
11
+ this.now = now;
12
+ }
13
+ async readCurrent(principalId, candidate) {
14
+ const rows = (await this.pool.query(`SELECT u_email, u_status, (u_email = ?) AS same_as_candidate
15
+ FROM \`shield_app\`.\`users\` WHERE u_uuid = ? LIMIT 1`, [candidate, principalId]));
16
+ const row = rows[0];
17
+ if (row === undefined)
18
+ return null;
19
+ return {
20
+ email: row.u_email ?? null,
21
+ active: row.u_status === 'active',
22
+ sameAsCandidate: Number(row.same_as_candidate ?? 0) === 1,
23
+ };
24
+ }
25
+ async change(request) {
26
+ const conn = await this.pool.getConnection();
27
+ let locked = false;
28
+ try {
29
+ const got = (await conn.query("SELECT GET_LOCK('shield_email_change', ?) AS got", [
30
+ LOCK_TIMEOUT_S,
31
+ ]));
32
+ if (Number(got[0]?.got ?? 0) !== 1)
33
+ throw new Error('email change lock unavailable');
34
+ locked = true;
35
+ await conn.beginTransaction();
36
+ try {
37
+ const outcome = await this.changeLocked(conn, request);
38
+ if (outcome.status === 'changed')
39
+ await conn.commit();
40
+ else
41
+ await conn.rollback();
42
+ return outcome;
43
+ }
44
+ catch (err) {
45
+ await conn.rollback().catch(() => undefined);
46
+ throw err;
47
+ }
48
+ }
49
+ finally {
50
+ if (locked) {
51
+ await conn
52
+ .query("SELECT RELEASE_LOCK('shield_email_change') AS released")
53
+ .catch(() => undefined);
54
+ }
55
+ conn.release();
56
+ }
57
+ }
58
+ async changeLocked(conn, request) {
59
+ const { principalId, newEmail } = request;
60
+ const self = (await conn.query('SELECT u_email, u_status FROM `shield_app`.`users` WHERE u_uuid = ? FOR UPDATE', [principalId]));
61
+ const row = self[0];
62
+ if (row === undefined)
63
+ return { status: 'not_found' };
64
+ if (request.requireActive === true && row.u_status !== 'active')
65
+ return { status: 'inactive' };
66
+ const oldEmail = row.u_email ?? null;
67
+ if (request.expectedOldEmail !== undefined && oldEmail !== request.expectedOldEmail) {
68
+ return { status: 'stale' };
69
+ }
70
+ const otherUser = (await conn.query('SELECT u_uuid FROM `shield_app`.`users` WHERE u_email = ? AND u_uuid <> ? LIMIT 1 FOR UPDATE', [newEmail, principalId]));
71
+ if (otherUser.length > 0)
72
+ return { status: 'unavailable' };
73
+ const hintOwners = (await conn.query('SELECT ic_principal_id FROM `shield_identity`.`identity_credential` WHERE ic_identity_hint = ? FOR UPDATE', [newEmail]));
74
+ if (hintOwners.some((h) => h.ic_principal_id !== principalId))
75
+ return { status: 'unavailable' };
76
+ const selfAlreadyHasNewHint = hintOwners.length > 0;
77
+ await conn.query('SELECT ic_identity_hint FROM `shield_identity`.`identity_credential` WHERE ic_principal_id = ? FOR UPDATE', [principalId]);
78
+ await conn.query('UPDATE `shield_app`.`users` SET u_email = ? WHERE u_uuid = ?', [
79
+ newEmail,
80
+ principalId,
81
+ ]);
82
+ let hintMoved = false;
83
+ if (oldEmail !== null && oldEmail !== '' && !selfAlreadyHasNewHint) {
84
+ const moved = (await conn.query(`UPDATE \`shield_identity\`.\`identity_credential\`
85
+ SET ic_identity_hint = ?, ic_updated_at_ms = ?
86
+ WHERE ic_principal_id = ? AND ic_identity_hint = ?`, [newEmail, this.now(), principalId, oldEmail]));
87
+ hintMoved = moved.affectedRows > 0;
88
+ }
89
+ await conn.query(`UPDATE \`shield_identity\`.\`email_change_token\` SET ect_consumed_at_ms = ?
90
+ WHERE ect_principal_id = ? AND ect_consumed_at_ms IS NULL`, [this.now(), principalId]);
91
+ if (request.withinTransaction !== undefined) {
92
+ await request.withinTransaction((sql, params) => conn.query(sql, params ?? []), { oldEmail, hintMoved });
93
+ }
94
+ return { status: 'changed', oldEmail, hintMoved };
95
+ }
96
+ }
97
+ exports.MariaDbEmailAddressChanger = MariaDbEmailAddressChanger;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * MariaDbEmailChangeTokenStore — the framework-DEFAULT {@link EmailChangeTokenStore} (security
3
+ * hardening Phase 12, KI #14), over `shield_identity`.`email_change_token` (identity migration
4
+ * `1710000000020`). The framework hashes the raw token (SHA-256) BEFORE it reaches this store.
5
+ *
6
+ * - `issue` runs ONE transaction: it locks the user row (`shield_app.users ... FOR UPDATE`) so two
7
+ * concurrent requests of one user serialise, deletes expired rows (A11), invalidates the user's
8
+ * pending tokens (A2: one pending change) and inserts the new one. An unknown user rolls back.
9
+ * - `consume` is ONE conditional UPDATE (`... WHERE hash = ? AND consumed IS NULL AND not expired`):
10
+ * `affectedRows === 1` wins, so of two concurrent confirms exactly one gets the binding. Unknown /
11
+ * expired / used all return `null` (no distinguishing throw); a store outage propagates.
12
+ *
13
+ * Every query is parameterized; the token hash is never logged nor placed in an error.
14
+ */
15
+ import type { Pool } from 'mariadb';
16
+ import type { EmailChangeTokenBinding, EmailChangeTokenIssue, EmailChangeTokenStore } from '../auth/ports/email-change-token-store.js';
17
+ export declare class MariaDbEmailChangeTokenStore implements EmailChangeTokenStore {
18
+ private readonly pool;
19
+ private readonly now;
20
+ constructor(pool: Pool, now?: () => number);
21
+ issue(token: EmailChangeTokenIssue): Promise<void>;
22
+ consume(tokenHash: string): Promise<EmailChangeTokenBinding | null>;
23
+ invalidateAllForPrincipal(principalId: string): Promise<void>;
24
+ }
25
+ //# sourceMappingURL=mariadb-email-change-token-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mariadb-email-change-token-store.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-email-change-token-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,IAAI,EAAkB,MAAM,SAAS,CAAC;AACpD,OAAO,KAAK,EACV,uBAAuB,EACvB,qBAAqB,EACrB,qBAAqB,EACtB,MAAM,2CAA2C,CAAC;AAYnD,qBAAa,4BAA6B,YAAW,qBAAqB;IAEtE,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,IAAI,EACV,GAAG,GAAE,MAAM,MAAiB;IAGzC,KAAK,CAAC,KAAK,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC;IA2ClD,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,GAAG,IAAI,CAAC;IAwBnE,yBAAyB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAOpE"}
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MariaDbEmailChangeTokenStore = void 0;
4
+ const TABLE = '`shield_identity`.`email_change_token`';
5
+ class MariaDbEmailChangeTokenStore {
6
+ pool;
7
+ now;
8
+ constructor(pool, now = Date.now) {
9
+ this.pool = pool;
10
+ this.now = now;
11
+ }
12
+ async issue(token) {
13
+ const now = this.now();
14
+ const conn = await this.pool.getConnection();
15
+ try {
16
+ await conn.beginTransaction();
17
+ try {
18
+ const user = (await conn.query('SELECT u_uuid FROM `shield_app`.`users` WHERE u_uuid = ? FOR UPDATE', [token.principalId]));
19
+ if (user.length === 0)
20
+ throw new Error('email change: unknown user');
21
+ await conn.query(`DELETE FROM ${TABLE} WHERE ect_expires_at_ms <= ?`, [now]);
22
+ await conn.query(`UPDATE ${TABLE} SET ect_consumed_at_ms = ?
23
+ WHERE ect_principal_id = ? AND ect_consumed_at_ms IS NULL`, [now, token.principalId]);
24
+ await conn.query(`INSERT INTO ${TABLE}
25
+ (ect_token_hash, ect_principal_id, ect_new_email, ect_old_email, ect_session_id,
26
+ ect_tenant_id, ect_expires_at_ms, ect_consumed_at_ms, ect_created_at_ms)
27
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`, [
28
+ token.tokenHash,
29
+ token.principalId,
30
+ token.newEmail,
31
+ token.oldEmail,
32
+ token.sessionId,
33
+ token.tenantId,
34
+ token.expiresAtMs,
35
+ now,
36
+ ]);
37
+ await conn.commit();
38
+ }
39
+ catch (err) {
40
+ await conn.rollback().catch(() => undefined);
41
+ throw err;
42
+ }
43
+ }
44
+ finally {
45
+ conn.release();
46
+ }
47
+ }
48
+ async consume(tokenHash) {
49
+ const now = this.now();
50
+ const res = (await this.pool.query(`UPDATE ${TABLE} SET ect_consumed_at_ms = ?
51
+ WHERE ect_token_hash = ? AND ect_consumed_at_ms IS NULL AND ect_expires_at_ms > ?`, [now, tokenHash, now]));
52
+ if (res.affectedRows !== 1)
53
+ return null;
54
+ const rows = (await this.pool.query(`SELECT ect_principal_id, ect_new_email, ect_old_email, ect_session_id, ect_tenant_id
55
+ FROM ${TABLE} WHERE ect_token_hash = ? LIMIT 1`, [tokenHash]));
56
+ const row = rows[0];
57
+ if (row === undefined)
58
+ return null; // the user was deleted in between (FK cascade)
59
+ return {
60
+ principalId: row.ect_principal_id,
61
+ newEmail: row.ect_new_email,
62
+ oldEmail: row.ect_old_email ?? null,
63
+ sessionId: row.ect_session_id,
64
+ tenantId: row.ect_tenant_id,
65
+ };
66
+ }
67
+ async invalidateAllForPrincipal(principalId) {
68
+ await this.pool.query(`UPDATE ${TABLE} SET ect_consumed_at_ms = ?
69
+ WHERE ect_principal_id = ? AND ect_consumed_at_ms IS NULL`, [this.now(), principalId]);
70
+ }
71
+ }
72
+ exports.MariaDbEmailChangeTokenStore = MariaDbEmailChangeTokenStore;
@@ -1 +1 @@
1
- {"version":3,"file":"mariadb-mfa-device-store.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-mfa-device-store.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAuBnF,qBAAa,qBAAsB,YAAW,cAAc;IAC9C,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IASlD,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAS5D,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IASxE,KAAK,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAiBzE,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAShE,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAetF,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAQ/D,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IASpD,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAU/C"}
1
+ {"version":3,"file":"mariadb-mfa-device-store.d.ts","sourceRoot":"","sources":["../../src/authentication/mariadb-mfa-device-store.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,mCAAmC,CAAC;AAuBnF,qBAAa,qBAAsB,YAAW,cAAc;IAC9C,OAAO,CAAC,QAAQ,CAAC,IAAI;gBAAJ,IAAI,EAAE,IAAI;IAEjC,OAAO,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IASlD,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAS5D,SAAS,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IASxE,KAAK,CAAC,WAAW,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAiBzE,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAkBhE,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAetF,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAQ/D,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IASpD,SAAS,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAU/C"}
@@ -67,7 +67,14 @@ class MariaDbMfaDeviceStore {
67
67
  async confirm(principalId, deviceId) {
68
68
  const res = (await this.pool.query(`UPDATE \`shield_identity\`.\`mfa_device\` SET md_confirmed_at_ms = ?
69
69
  WHERE md_id = ? AND md_principal_id = ? AND md_confirmed_at_ms IS NULL`, [Date.now(), deviceId, principalId]));
70
- return res.affectedRows === 1;
70
+ if (res.affectedRows !== 1)
71
+ return false;
72
+ // KI #14 re-review: once a device is confirmed MFA is on, so every later begin needs a code.
73
+ // Drop the principal's OTHER pending devices: one begun by a stolen session while MFA was
74
+ // still off (between the user's own begin and confirm) must not stay confirmable.
75
+ await this.pool.query(`DELETE FROM \`shield_identity\`.\`mfa_device\`
76
+ WHERE md_principal_id = ? AND md_confirmed_at_ms IS NULL AND md_id <> ?`, [principalId, deviceId]);
77
+ return true;
71
78
  }
72
79
  async rename(principalId, deviceId, name) {
73
80
  const res = (await this.pool.query(`UPDATE \`shield_identity\`.\`mfa_device\` SET md_name = ?
@@ -1 +1 @@
1
- {"version":3,"file":"permission-version-cache.d.ts","sourceRoot":"","sources":["../../src/authz/permission-version-cache.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAEjC,0DAA0D;AAC1D,eAAO,MAAM,6BAA6B,oBAAoB,CAAC;AAE/D,qEAAqE;AACrE,eAAO,MAAM,iCAAiC,QAAS,CAAC;AAcxD,gDAAgD;AAChD,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9C,wFAAwF;IACxF,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,2FAA2F;AAC3F,MAAM,WAAW,0BAA0B;IACzC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3D;AAOD,MAAM,WAAW,kCAAkC;IACjD,yFAAyF;IACzF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,qBAAa,2BACX,YAAW,uBAAuB,EAAE,0BAA0B;IAM5D,OAAO,CAAC,QAAQ,CAAC,KAAK;IAJxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyC;IAChE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAGZ,KAAK,EAAE,KAAK,EAC7B,OAAO,GAAE,kCAAuC;IAS5C,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAO7C,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7D;;;;OAIG;IACG,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAShE"}
1
+ {"version":3,"file":"permission-version-cache.d.ts","sourceRoot":"","sources":["../../src/authz/permission-version-cache.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,KAAK,MAAM,SAAS,CAAC;AAGjC,0DAA0D;AAC1D,eAAO,MAAM,6BAA6B,oBAAoB,CAAC;AAE/D,qEAAqE;AACrE,eAAO,MAAM,iCAAiC,QAAS,CAAC;AAcxD,gDAAgD;AAChD,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9C,wFAAwF;IACxF,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,2FAA2F;AAC3F,MAAM,WAAW,0BAA0B;IACzC,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3D;AAiBD,MAAM,WAAW,kCAAkC;IACjD,yFAAyF;IACzF,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,qBAAa,2BACX,YAAW,uBAAuB,EAAE,0BAA0B;IAM5D,OAAO,CAAC,QAAQ,CAAC,KAAK;IAJxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAyC;IAChE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;gBAGZ,KAAK,EAAE,KAAK,EAC7B,OAAO,GAAE,kCAAuC;IAS5C,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC;IAO7C,KAAK,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW7D;;;;OAIG;IACG,OAAO,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAShE"}
@@ -21,6 +21,7 @@ exports.RedisPermissionVersionCache = exports.DEFAULT_PERMISSION_VERSION_TTL_MS
21
21
  * ⛔ No secret here: tenant ids and integers only. Logs carry no tenant id.
22
22
  */
23
23
  const common_1 = require("@nestjs/common");
24
+ const engine_1 = require("@astram/engine");
24
25
  /** Redis key prefix for a tenant's permission version. */
25
26
  exports.PERMISSION_VERSION_KEY_PREFIX = 'shield:permver:';
26
27
  /** Default TTL of a cached version (the bound on a lost publish). */
@@ -36,6 +37,15 @@ if (not c) or c < n then
36
37
  end
37
38
  return 0
38
39
  `;
40
+ /**
41
+ * The Redis key for a tenant's version. Phase 11 #1 (KI #79): a uuid tenant id is keyed in its
42
+ * canonical (lower-case) form, so a reader bound to one spelling and a publisher holding the stored
43
+ * spelling always meet on the same key (the DB fill matches either: `utf8mb4_unicode_ci`). A
44
+ * non-uuid id (test fixtures) is used as given.
45
+ */
46
+ function versionKey(tenantId) {
47
+ return `${exports.PERMISSION_VERSION_KEY_PREFIX}${(0, engine_1.canonicalUuid)(tenantId) ?? tenantId}`;
48
+ }
39
49
  /** A valid version: a non-negative safe integer. */
40
50
  function isVersion(v) {
41
51
  return typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
@@ -53,7 +63,7 @@ class RedisPermissionVersionCache {
53
63
  : exports.DEFAULT_PERMISSION_VERSION_TTL_MS;
54
64
  }
55
65
  async get(tenantId) {
56
- const raw = await this.redis.get(`${exports.PERMISSION_VERSION_KEY_PREFIX}${tenantId}`);
66
+ const raw = await this.redis.get(versionKey(tenantId));
57
67
  if (raw === null)
58
68
  return null;
59
69
  const v = Number(raw);
@@ -62,7 +72,7 @@ class RedisPermissionVersionCache {
62
72
  async raise(tenantId, version) {
63
73
  if (!isVersion(version))
64
74
  return;
65
- await this.redis.eval(RAISE_SCRIPT, 1, `${exports.PERMISSION_VERSION_KEY_PREFIX}${tenantId}`, String(version), String(this.ttlMs));
75
+ await this.redis.eval(RAISE_SCRIPT, 1, versionKey(tenantId), String(version), String(this.ttlMs));
66
76
  }
67
77
  /**
68
78
  * Best-effort publish after a committed bump. A failure is logged (no tenant id, no value) and