@astram/guard 0.10.2 → 0.11.1

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 (45) hide show
  1. package/dist/auth/account.controller.d.ts +76 -19
  2. package/dist/auth/account.controller.d.ts.map +1 -1
  3. package/dist/auth/account.controller.js +270 -53
  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 +10 -2
  7. package/dist/auth/auth.module.d.ts +8 -0
  8. package/dist/auth/auth.module.d.ts.map +1 -1
  9. package/dist/auth/auth.module.js +80 -7
  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 +26 -0
  17. package/dist/auth/email-change-messages.d.ts.map +1 -0
  18. package/dist/auth/email-change-messages.js +56 -0
  19. package/dist/auth/link-base-url.d.ts +24 -0
  20. package/dist/auth/link-base-url.d.ts.map +1 -0
  21. package/dist/auth/link-base-url.js +93 -0
  22. package/dist/auth/ports/email-address-changer.d.ts +65 -0
  23. package/dist/auth/ports/email-address-changer.d.ts.map +1 -0
  24. package/dist/auth/ports/email-address-changer.js +8 -0
  25. package/dist/auth/ports/email-change-token-store.d.ts +39 -0
  26. package/dist/auth/ports/email-change-token-store.d.ts.map +1 -0
  27. package/dist/auth/ports/email-change-token-store.js +12 -0
  28. package/dist/auth/tokens.d.ts +39 -1
  29. package/dist/auth/tokens.d.ts.map +1 -1
  30. package/dist/auth/tokens.js +40 -2
  31. package/dist/authentication/mariadb-email-address-changer.d.ts +33 -0
  32. package/dist/authentication/mariadb-email-address-changer.d.ts.map +1 -0
  33. package/dist/authentication/mariadb-email-address-changer.js +97 -0
  34. package/dist/authentication/mariadb-email-change-token-store.d.ts +25 -0
  35. package/dist/authentication/mariadb-email-change-token-store.d.ts.map +1 -0
  36. package/dist/authentication/mariadb-email-change-token-store.js +72 -0
  37. package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
  38. package/dist/authentication/mariadb-mfa-device-store.js +8 -1
  39. package/dist/index.d.ts +6 -1
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +16 -1
  42. package/dist/module/shield.module.d.ts +11 -0
  43. package/dist/module/shield.module.d.ts.map +1 -1
  44. package/dist/module/shield.module.js +6 -0
  45. package/package.json +3 -3
@@ -0,0 +1,56 @@
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
+ /**
12
+ * What to do if the change was not the owner's (A10, reworded in Phase 13 #2a). After an
13
+ * attacker's change the login hint may have moved with the email (it follows when it equalled the
14
+ * old address), so a reset from the login page may not reach the victim: the administrator comes
15
+ * first, and the reset advice is qualified rather than given as the remedy.
16
+ */
17
+ exports.NOT_YOU_SENTENCE = "If this wasn't you, contact your administrator immediately. Your sign-in email may have " +
18
+ 'changed too, so a password reset from the login page may not reach you.';
19
+ function escapeHtml(value) {
20
+ return value
21
+ .replace(/&/g, '&')
22
+ .replace(/</g, '&lt;')
23
+ .replace(/>/g, '&gt;')
24
+ .replace(/"/g, '&quot;');
25
+ }
26
+ /** The confirm link: the token rides in the fragment, never the query (A4). */
27
+ function emailChangeLink(base, rawToken) {
28
+ return `${base}#t=${rawToken}`;
29
+ }
30
+ function confirmEmailChangeMessage(to, link) {
31
+ const minutes = Math.round(exports.EMAIL_CHANGE_TTL_MS / 60000);
32
+ return {
33
+ to,
34
+ subject: 'Confirm your new email address',
35
+ text: 'You asked to use this address for your account. Confirm it by opening this link ' +
36
+ `within ${minutes} minutes: ${link}\n\n` +
37
+ 'If you did not ask for this, ignore this message; nothing changes.',
38
+ html: '<p>You asked to use this address for your account. Confirm it by opening this link ' +
39
+ `within ${minutes} minutes:</p>` +
40
+ `<p><a href="${escapeHtml(link)}" rel="noreferrer">${escapeHtml(link)}</a></p>` +
41
+ '<p>If you did not ask for this, ignore this message; nothing changes.</p>',
42
+ };
43
+ }
44
+ function emailChangedNoticeMessage(to, maskedNewEmail, atMs) {
45
+ const when = new Date(atMs)
46
+ .toISOString()
47
+ .replace('T', ' ')
48
+ .replace(/\.\d+Z$/, ' UTC');
49
+ const what = `The email address on your account was changed to ${maskedNewEmail} at ${when}.`;
50
+ return {
51
+ to,
52
+ subject: 'Your account email address was changed',
53
+ text: `${what}\n\n${exports.NOT_YOU_SENTENCE}`,
54
+ html: `<p>${escapeHtml(what)}</p><p>${escapeHtml(exports.NOT_YOU_SENTENCE)}</p>`,
55
+ };
56
+ }
@@ -0,0 +1,24 @@
1
+ /** The default reset-page base (a dev Vite origin). */
2
+ export declare const DEFAULT_RESET_LINK_BASE_URL = "http://localhost:5173/reset-password";
3
+ export interface AuthLinkBaseUrls {
4
+ readonly resetLinkBaseUrl: string;
5
+ readonly emailChangeLinkBaseUrl: string;
6
+ }
7
+ export interface ResolveAuthLinkBaseUrlsInput {
8
+ /** The raw environment (`NODE_ENV`, `RESET_LINK_BASE_URL`, `EMAIL_CHANGE_LINK_BASE_URL`). */
9
+ readonly env: NodeJS.ProcessEnv;
10
+ /** `ShieldModule.forRoot({ emailChangeLinkBaseUrl })`. */
11
+ readonly emailChangeLinkBaseUrlOption?: string | undefined;
12
+ /**
13
+ * `true` when no app-supplied sender exists: `emailSender: 'disabled'` (nothing is delivered) or
14
+ * omitted (outside development/test the email-sender boot rule already fails the boot with its
15
+ * own error; in development/test the link rule is off anyway).
16
+ */
17
+ readonly mailDisabled: boolean;
18
+ }
19
+ /**
20
+ * Resolve both link bases and apply the boot rule. Throws (boot fails) outside development/test
21
+ * when a base is unset or local and mail is not `'disabled'`.
22
+ */
23
+ export declare function resolveAuthLinkBaseUrls(input: ResolveAuthLinkBaseUrlsInput): AuthLinkBaseUrls;
24
+ //# sourceMappingURL=link-base-url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-base-url.d.ts","sourceRoot":"","sources":["../../src/auth/link-base-url.ts"],"names":[],"mappings":"AAmBA,uDAAuD;AACvD,eAAO,MAAM,2BAA2B,yCAAyC,CAAC;AAElF,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,sBAAsB,EAAE,MAAM,CAAC;CACzC;AAED,MAAM,WAAW,4BAA4B;IAC3C,6FAA6F;IAC7F,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;IAChC,0DAA0D;IAC1D,QAAQ,CAAC,4BAA4B,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3D;;;;OAIG;IACH,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;CAChC;AAmCD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,4BAA4B,GAAG,gBAAgB,CAgC7F"}
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_RESET_LINK_BASE_URL = void 0;
4
+ exports.resolveAuthLinkBaseUrls = resolveAuthLinkBaseUrls;
5
+ /**
6
+ * The two link bases the framework mails (security hardening Phase 13 #1):
7
+ * - the password-reset link `${RESET_LINK_BASE_URL}?token=…` (`POST /auth/forgot-password`);
8
+ * - the email-change confirm link `${emailChangeLinkBaseUrl}#t=…` (`PATCH /account/profile`).
9
+ *
10
+ * Precedence: the `emailChangeLinkBaseUrl` option > `EMAIL_CHANGE_LINK_BASE_URL` > dev default;
11
+ * the reset link: `RESET_LINK_BASE_URL` > dev default. A blank value counts as unset.
12
+ *
13
+ * Boot rule (fail closed): outside `NODE_ENV` development/test (raw value, unset is NOT dev), with
14
+ * a mail sender, each base must be set, parse as an `http:`/`https:` URL and name a host that is
15
+ * not loopback/unspecified (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`, `0.0.0.0`, `::`).
16
+ * Otherwise auth composition throws, naming the variable(s) — never the configured value (it may
17
+ * carry credentials). `emailSender: 'disabled'` skips the rule: the no-op sender delivers nothing,
18
+ * so no link can reach a user (an omitted sender fails the boot earlier, see `auth.module.ts`).
19
+ * `https:` is not required (the reset path never required it).
20
+ */
21
+ const app_config_js_1 = require("../config/app-config.js");
22
+ const email_change_messages_js_1 = require("./email-change-messages.js");
23
+ /** The default reset-page base (a dev Vite origin). */
24
+ exports.DEFAULT_RESET_LINK_BASE_URL = 'http://localhost:5173/reset-password';
25
+ function present(value) {
26
+ return value !== undefined && value.trim() !== '' ? value.trim() : undefined;
27
+ }
28
+ function isLoopbackOrUnspecifiedHost(hostname) {
29
+ const host = hostname
30
+ .toLowerCase()
31
+ .replace(/^\[|\]$/gu, '')
32
+ .replace(/\.$/u, '');
33
+ if (host === 'localhost' || host.endsWith('.localhost'))
34
+ return true;
35
+ if (host === '0.0.0.0' || host === '::' || host === '::1')
36
+ return true;
37
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(host))
38
+ return true;
39
+ // IPv4-mapped loopback/unspecified (`::ffff:127.0.0.1` normalises to `::ffff:7f00:1`).
40
+ if (/^::ffff:(7f[0-9a-f]{2}:[0-9a-f]{1,4}|0:0)$/u.test(host))
41
+ return true;
42
+ return false;
43
+ }
44
+ /** Why a production link base is unusable, or `null` when it is fine. */
45
+ function problemWith(value) {
46
+ if (value === undefined)
47
+ return 'is not set';
48
+ let url;
49
+ try {
50
+ url = new URL(value);
51
+ }
52
+ catch {
53
+ return 'is not a valid URL';
54
+ }
55
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
56
+ return 'is not an http(s) URL';
57
+ if (isLoopbackOrUnspecifiedHost(url.hostname)) {
58
+ return 'points at a loopback/unspecified host (localhost, 127.0.0.1, ::1, 0.0.0.0)';
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Resolve both link bases and apply the boot rule. Throws (boot fails) outside development/test
64
+ * when a base is unset or local and mail is not `'disabled'`.
65
+ */
66
+ function resolveAuthLinkBaseUrls(input) {
67
+ const resetConfigured = present(input.env.RESET_LINK_BASE_URL);
68
+ const optionConfigured = present(input.emailChangeLinkBaseUrlOption);
69
+ const emailChangeConfigured = optionConfigured ?? present(input.env.EMAIL_CHANGE_LINK_BASE_URL);
70
+ if (!input.mailDisabled && !(0, app_config_js_1.isDevOrTestNodeEnv)(input.env.NODE_ENV)) {
71
+ const problems = [];
72
+ const resetProblem = problemWith(resetConfigured);
73
+ if (resetProblem)
74
+ problems.push(`RESET_LINK_BASE_URL ${resetProblem}`);
75
+ const changeProblem = problemWith(emailChangeConfigured);
76
+ if (changeProblem) {
77
+ const name = optionConfigured
78
+ ? 'the emailChangeLinkBaseUrl option'
79
+ : 'EMAIL_CHANGE_LINK_BASE_URL (or the emailChangeLinkBaseUrl option)';
80
+ problems.push(`${name} ${changeProblem}`);
81
+ }
82
+ if (problems.length > 0) {
83
+ const nodeEnv = input.env.NODE_ENV;
84
+ throw new Error('Astram auth composition: the mailed password-reset and email-change links need a ' +
85
+ `public base URL when NODE_ENV is not 'development' or 'test' (got: ${nodeEnv === undefined ? 'unset' : `'${nodeEnv}'`}): ${problems.join('; ')}. Set them to your app's /reset-password and /confirm-email ` +
86
+ "pages, or pass emailSender: 'disabled' if this deployment sends no email.");
87
+ }
88
+ }
89
+ return {
90
+ resetLinkBaseUrl: resetConfigured ?? exports.DEFAULT_RESET_LINK_BASE_URL,
91
+ emailChangeLinkBaseUrl: emailChangeConfigured ?? email_change_messages_js_1.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL,
92
+ };
93
+ }
@@ -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,43 @@ 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 })` > env `EMAIL_CHANGE_LINK_BASE_URL` > dev default
103
+ * `http://localhost:5173/confirm-email`; production boot rule in `link-base-url.ts`). The link is
104
+ * `${base}#t=<rawToken>` — the token rides in the FRAGMENT, never the query (A4), so it is not
105
+ * sent to the server that serves the page nor leaked through a Referer.
106
+ */
107
+ export declare const SHIELD_EMAIL_CHANGE_LINK_BASE_URL: unique symbol;
108
+ /**
109
+ * The per-account email-change REQUEST limiter (Phase 12) — keyed `email-change:pid:<principalId>`,
110
+ * 3 per 60-min window, so `PATCH /account/profile` cannot be used to spam an inbox. Counted
111
+ * (`consume`, fail closed → 503) after the current password verified; 429 when exhausted.
112
+ */
113
+ export declare const SHIELD_EMAIL_CHANGE_LIMITER: unique symbol;
114
+ /**
115
+ * The `POST /auth/confirm-email` per-client-IP limiter (Phase 12, A6) — keyed
116
+ * `confirm-email:ip:<ip>`, 10 per 15-min window, like reset-password. Every attempt is counted
117
+ * (`consume`, fail closed → 503), a successful confirm gives its attempt back; 429 when exhausted.
118
+ * ENFORCED ONLY with an explicit Express `trust proxy` (`TrustProxyBootCheck`), else OFF — a shared
119
+ * proxy IP would otherwise lock every user out.
120
+ */
121
+ export declare const SHIELD_CONFIRM_EMAIL_LIMITER: unique symbol;
85
122
  /**
86
123
  * The `EmailSender` (guard port, `../auth/ports/email-sender.ts`) — the delivery
87
124
  * seam (SendGrid/SMTP/test capturer). The framework builds the message; the
@@ -128,7 +165,8 @@ export declare const SHIELD_SESSION_SWEEPER: unique symbol;
128
165
  * composes the emailed reset link as `${base}?token=<rawToken>` so the
129
166
  * {@link SHIELD_EMAIL_SENDER} seam stays a dumb delivery channel (no URL/token
130
167
  * logic). Bound app-wide by `AuthModule` from the environment
131
- * (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password`). Consumed
168
+ * (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password`; outside
169
+ * development/test an unset or loopback value fails the boot, see `link-base-url.ts`). Consumed
132
170
  * by `POST /auth/forgot-password`.
133
171
  */
134
172
  export declare const SHIELD_RESET_LINK_BASE_URL: unique symbol;
@@ -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;;;;;;GAMG;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;;;;;;;;GAQG;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,43 @@ 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 })` > env `EMAIL_CHANGE_LINK_BASE_URL` > dev default
115
+ * `http://localhost:5173/confirm-email`; production boot rule in `link-base-url.ts`). The link is
116
+ * `${base}#t=<rawToken>` — the token rides in the FRAGMENT, never the query (A4), so it is not
117
+ * sent to the server that serves the page nor leaked through a Referer.
118
+ */
119
+ exports.SHIELD_EMAIL_CHANGE_LINK_BASE_URL = Symbol('SHIELD_EMAIL_CHANGE_LINK_BASE_URL');
120
+ /**
121
+ * The per-account email-change REQUEST limiter (Phase 12) — keyed `email-change:pid:<principalId>`,
122
+ * 3 per 60-min window, so `PATCH /account/profile` cannot be used to spam an inbox. Counted
123
+ * (`consume`, fail closed → 503) after the current password verified; 429 when exhausted.
124
+ */
125
+ exports.SHIELD_EMAIL_CHANGE_LIMITER = Symbol('SHIELD_EMAIL_CHANGE_LIMITER');
126
+ /**
127
+ * The `POST /auth/confirm-email` per-client-IP limiter (Phase 12, A6) — keyed
128
+ * `confirm-email:ip:<ip>`, 10 per 15-min window, like reset-password. Every attempt is counted
129
+ * (`consume`, fail closed → 503), a successful confirm gives its attempt back; 429 when exhausted.
130
+ * ENFORCED ONLY with an explicit Express `trust proxy` (`TrustProxyBootCheck`), else OFF — a shared
131
+ * proxy IP would otherwise lock every user out.
132
+ */
133
+ exports.SHIELD_CONFIRM_EMAIL_LIMITER = Symbol('SHIELD_CONFIRM_EMAIL_LIMITER');
97
134
  /**
98
135
  * The `EmailSender` (guard port, `../auth/ports/email-sender.ts`) — the delivery
99
136
  * seam (SendGrid/SMTP/test capturer). The framework builds the message; the
@@ -140,7 +177,8 @@ exports.SHIELD_SESSION_SWEEPER = Symbol('SHIELD_SESSION_SWEEPER');
140
177
  * composes the emailed reset link as `${base}?token=<rawToken>` so the
141
178
  * {@link SHIELD_EMAIL_SENDER} seam stays a dumb delivery channel (no URL/token
142
179
  * logic). Bound app-wide by `AuthModule` from the environment
143
- * (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password`). Consumed
180
+ * (`RESET_LINK_BASE_URL`, default `http://localhost:5173/reset-password`; outside
181
+ * development/test an unset or loopback value fails the boot, see `link-base-url.ts`). Consumed
144
182
  * by `POST /auth/forgot-password`.
145
183
  */
146
184
  exports.SHIELD_RESET_LINK_BASE_URL = Symbol('SHIELD_RESET_LINK_BASE_URL');
@@ -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"}