@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.
- package/dist/auth/account.controller.d.ts +73 -19
- package/dist/auth/account.controller.d.ts.map +1 -1
- package/dist/auth/account.controller.js +251 -56
- package/dist/auth/auth.controller.d.ts +3 -1
- package/dist/auth/auth.controller.d.ts.map +1 -1
- package/dist/auth/auth.controller.js +17 -4
- package/dist/auth/auth.module.d.ts +6 -0
- package/dist/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +53 -1
- package/dist/auth/confirm-email.controller.d.ts +33 -0
- package/dist/auth/confirm-email.controller.d.ts.map +1 -0
- package/dist/auth/confirm-email.controller.js +205 -0
- package/dist/auth/email-address.d.ts +30 -0
- package/dist/auth/email-address.d.ts.map +1 -0
- package/dist/auth/email-address.js +49 -0
- package/dist/auth/email-change-messages.d.ts +21 -0
- package/dist/auth/email-change-messages.d.ts.map +1 -0
- package/dist/auth/email-change-messages.js +50 -0
- package/dist/auth/ports/email-address-changer.d.ts +65 -0
- package/dist/auth/ports/email-address-changer.d.ts.map +1 -0
- package/dist/auth/ports/email-address-changer.js +8 -0
- package/dist/auth/ports/email-change-token-store.d.ts +39 -0
- package/dist/auth/ports/email-change-token-store.d.ts.map +1 -0
- package/dist/auth/ports/email-change-token-store.js +12 -0
- package/dist/auth/tokens.d.ts +36 -0
- package/dist/auth/tokens.d.ts.map +1 -1
- package/dist/auth/tokens.js +37 -1
- package/dist/authentication/mariadb-email-address-changer.d.ts +33 -0
- package/dist/authentication/mariadb-email-address-changer.d.ts.map +1 -0
- package/dist/authentication/mariadb-email-address-changer.js +97 -0
- package/dist/authentication/mariadb-email-change-token-store.d.ts +25 -0
- package/dist/authentication/mariadb-email-change-token-store.d.ts.map +1 -0
- package/dist/authentication/mariadb-email-change-token-store.js +72 -0
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +8 -1
- package/dist/authz/permission-version-cache.d.ts.map +1 -1
- package/dist/authz/permission-version-cache.js +12 -2
- package/dist/enforcement/uuid.pipe.d.ts +35 -0
- package/dist/enforcement/uuid.pipe.d.ts.map +1 -0
- package/dist/enforcement/uuid.pipe.js +67 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -3
- package/dist/module/shield.module.d.ts +9 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +6 -0
- package/package.json +3 -3
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
* composed engines via DI tokens; it never news-up an engine or an adapter.
|
|
54
54
|
*/
|
|
55
55
|
import type { Authenticator, PasswordHasher, SessionAuthority } from '@astram/core';
|
|
56
|
+
import type { EmailChangeTokenStore } from './ports/email-change-token-store.js';
|
|
56
57
|
import type { SessionRestrictionStore } from '../session/session-restriction-store.js';
|
|
57
58
|
import type { SessionRecordStore } from '@astram/core';
|
|
58
59
|
import type { Clock, SessionLifetimeConfig } from '@astram/core';
|
|
@@ -113,7 +114,8 @@ export declare class AuthController {
|
|
|
113
114
|
private readonly rootIds?;
|
|
114
115
|
private readonly sessionRecords?;
|
|
115
116
|
private readonly sessionRestrictions?;
|
|
116
|
-
|
|
117
|
+
private readonly emailChangeTokens?;
|
|
118
|
+
constructor(authenticator: Authenticator, sessionAuthority: SessionAuthority, pendingMfa: PendingMfaStore, tokenBinder?: SessionTokenBinder | undefined, profileResolver?: ProfileResolver | undefined, credentialResolver?: (CredentialResolver | null) | undefined, resetTokenStore?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, credentialWriter?: (CredentialWriter | null) | undefined, sessionSweeper?: (SessionSweeper | null) | undefined, passwordHasher?: PasswordHasher | undefined, resetLinkBaseUrl?: string | undefined, recoveryCodes?: (RecoveryCodeStore | null) | undefined, loginLimiter?: (AttemptLimiter | null) | undefined, loginIpLimiter?: (AttemptLimiter | null) | undefined, trustProxyCheck?: (TrustProxyBootCheck | null) | undefined, recoveryLimiter?: (AttemptLimiter | null) | undefined, forgotLimiter?: (AttemptLimiter | null) | undefined, forgotHintLimiter?: (AttemptLimiter | null) | undefined, resetLimiter?: (AttemptLimiter | null) | undefined, auditSink?: (AuditSink | null) | undefined, sessionFactsProjector?: (SessionFactsProjector | null) | undefined, scopeSubtreeResolver?: (ScopeSubtreeResolver | null) | undefined, membershipChecker?: (MembershipChecker | null) | undefined, permissionFacts?: (MariaDbPermissionFactsAdapter | null) | undefined, mfaLimiter?: (AttemptLimiter | null) | undefined, mfaStepUp?: (MfaStepUp | null) | undefined, clock?: (Clock | null) | undefined, lifetimes?: (SessionLifetimeConfig | null) | undefined, rootIds?: (readonly string[] | null) | undefined, sessionRecords?: (Pick<SessionRecordStore, "findSessionById"> | null) | undefined, sessionRestrictions?: (SessionRestrictionStore | null) | undefined, emailChangeTokens?: (EmailChangeTokenStore | null) | undefined);
|
|
117
119
|
/**
|
|
118
120
|
* The access-token binding TTL for an establishment (security hardening #8):
|
|
119
121
|
* `clamp(session.expiresAtMs − now, 1, accessTtlMs)` — on login AND refresh. Never derived
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.controller.d.ts","sourceRoot":"","sources":["../../src/auth/auth.controller.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqDG;AAeH,OAAO,KAAK,EACV,aAAa,EAIb,cAAc,EAGd,gBAAgB,EAEjB,MAAM,cAAc,CAAC;AA+BtB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,yCAAyC,CAAC;AACvF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.controller.d.ts","sourceRoot":"","sources":["../../src/auth/auth.controller.ts"],"names":[],"mappings":"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"}
|
|
@@ -73,6 +73,7 @@ const common_2 = require("@nestjs/common");
|
|
|
73
73
|
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
74
74
|
const tokens_js_1 = require("./tokens.js");
|
|
75
75
|
const mariadb_session_record_store_js_1 = require("../session/mariadb-session-record-store.js");
|
|
76
|
+
const engine_1 = require("@astram/engine");
|
|
76
77
|
const crypto_adapters_js_1 = require("../session/crypto-adapters.js");
|
|
77
78
|
const session_lifetimes_js_1 = require("../session/session-lifetimes.js");
|
|
78
79
|
const mfa_step_up_js_1 = require("./mfa-step-up.js");
|
|
@@ -215,7 +216,8 @@ let AuthController = class AuthController {
|
|
|
215
216
|
rootIds;
|
|
216
217
|
sessionRecords;
|
|
217
218
|
sessionRestrictions;
|
|
218
|
-
|
|
219
|
+
emailChangeTokens;
|
|
220
|
+
constructor(authenticator, sessionAuthority, pendingMfa, tokenBinder, profileResolver, credentialResolver, resetTokenStore, emailSender, credentialWriter, sessionSweeper, passwordHasher, resetLinkBaseUrl, recoveryCodes, loginLimiter, loginIpLimiter, trustProxyCheck, recoveryLimiter, forgotLimiter, forgotHintLimiter, resetLimiter, auditSink, sessionFactsProjector, scopeSubtreeResolver, membershipChecker, permissionFacts, mfaLimiter, mfaStepUp, clock, lifetimes, rootIds, sessionRecords, sessionRestrictions, emailChangeTokens) {
|
|
219
221
|
this.authenticator = authenticator;
|
|
220
222
|
this.sessionAuthority = sessionAuthority;
|
|
221
223
|
this.pendingMfa = pendingMfa;
|
|
@@ -248,6 +250,7 @@ let AuthController = class AuthController {
|
|
|
248
250
|
this.rootIds = rootIds;
|
|
249
251
|
this.sessionRecords = sessionRecords;
|
|
250
252
|
this.sessionRestrictions = sessionRestrictions;
|
|
253
|
+
this.emailChangeTokens = emailChangeTokens;
|
|
251
254
|
}
|
|
252
255
|
/**
|
|
253
256
|
* The access-token binding TTL for an establishment (security hardening #8):
|
|
@@ -768,8 +771,12 @@ let AuthController = class AuthController {
|
|
|
768
771
|
if (claims === null) {
|
|
769
772
|
throw new common_1.HttpException({ error: 'unauthorized' }, common_1.HttpStatus.UNAUTHORIZED);
|
|
770
773
|
}
|
|
771
|
-
|
|
772
|
-
|
|
774
|
+
// Phase 11 #1 / KI #79: only a canonical uuid is checked and BOUND. The membership check is SQL
|
|
775
|
+
// (`utf8mb4_unicode_ci` ignores case, trailing spaces and width), so any other spelling would
|
|
776
|
+
// pass the gate and be bound as the session's tenant in the caller's spelling — its
|
|
777
|
+
// `shield:permver:<tenant>` key then misses every bump published to the stored id.
|
|
778
|
+
const targetNodeId = (0, engine_1.canonicalUuid)(body?.targetNodeId);
|
|
779
|
+
if (targetNodeId === null) {
|
|
773
780
|
throw new common_1.HttpException({ error: 'invalid_target' }, common_1.HttpStatus.BAD_REQUEST);
|
|
774
781
|
}
|
|
775
782
|
// 2) ROOT does not switch (already unbounded; a switch could only NARROW its reach).
|
|
@@ -1088,6 +1095,10 @@ let AuthController = class AuthController {
|
|
|
1088
1095
|
}
|
|
1089
1096
|
// Burn any other outstanding reset tokens for the principal (single active reset).
|
|
1090
1097
|
await store.invalidateAllForPrincipal(principalId);
|
|
1098
|
+
// Phase 12 (A8): a pending self-service email change dies with the old password too.
|
|
1099
|
+
await this.emailChangeTokens?.invalidateAllForPrincipal(principalId).catch((err) => {
|
|
1100
|
+
sweepError ??= err;
|
|
1101
|
+
});
|
|
1091
1102
|
if (sweepError !== undefined)
|
|
1092
1103
|
throw sweepError;
|
|
1093
1104
|
}
|
|
@@ -1758,7 +1769,9 @@ exports.AuthController = AuthController = __decorate([
|
|
|
1758
1769
|
__param(30, (0, common_1.Inject)(mariadb_session_record_store_js_1.SHIELD_SESSION_RECORD_STORE)),
|
|
1759
1770
|
__param(31, (0, common_2.Optional)()),
|
|
1760
1771
|
__param(31, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_RESTRICTIONS)),
|
|
1761
|
-
|
|
1772
|
+
__param(32, (0, common_2.Optional)()),
|
|
1773
|
+
__param(32, (0, common_1.Inject)(tokens_js_1.SHIELD_EMAIL_CHANGE_TOKEN_STORE)),
|
|
1774
|
+
__metadata("design:paramtypes", [Object, Object, pending_mfa_store_js_1.PendingMfaStore, Function, Object, Object, Object, Object, Object, Object, Object, String, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object])
|
|
1762
1775
|
], AuthController);
|
|
1763
1776
|
/**
|
|
1764
1777
|
* Narrow an `authenticated` result to its guaranteed principal. The core engine
|
|
@@ -218,6 +218,12 @@ export interface AuthModuleOptions {
|
|
|
218
218
|
* target node before re-binding the session. Only meaningful under `auth: true`.
|
|
219
219
|
*/
|
|
220
220
|
readonly membershipChecker?: MembershipChecker;
|
|
221
|
+
/**
|
|
222
|
+
* The base URL of the app's confirm-email page (Phase 12, KI #14). The mailed link is
|
|
223
|
+
* `${emailChangeLinkBaseUrl}#t=<token>` (the token in the fragment, never the query). Default
|
|
224
|
+
* `http://localhost:5173/confirm-email`.
|
|
225
|
+
*/
|
|
226
|
+
readonly emailChangeLinkBaseUrl?: string;
|
|
221
227
|
}
|
|
222
228
|
/**
|
|
223
229
|
* The no-override default sender decision (pure apart from the warning). Exported for
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;AAEH,OAAO,EAAkB,KAAK,aAAa,EAAiB,MAAM,gBAAgB,CAAC;AAanF,OAAO,KAAK,EAGV,kBAAkB,EAOlB,QAAQ,EACR,cAAc,EAEf,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"auth.module.d.ts","sourceRoot":"","sources":["../../src/auth/auth.module.ts"],"names":[],"mappings":"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"}
|
package/dist/auth/auth.module.js
CHANGED
|
@@ -118,6 +118,9 @@ const mariadb_profile_resolver_js_1 = require("../authentication/mariadb-profile
|
|
|
118
118
|
const mariadb_credential_writer_js_1 = require("../authentication/mariadb-credential-writer.js");
|
|
119
119
|
const mariadb_profile_writer_js_1 = require("../authentication/mariadb-profile-writer.js");
|
|
120
120
|
const mariadb_reset_token_store_js_1 = require("../authentication/mariadb-reset-token-store.js");
|
|
121
|
+
const mariadb_email_change_token_store_js_1 = require("../authentication/mariadb-email-change-token-store.js");
|
|
122
|
+
const mariadb_email_address_changer_js_1 = require("../authentication/mariadb-email-address-changer.js");
|
|
123
|
+
const email_change_messages_js_1 = require("./email-change-messages.js");
|
|
121
124
|
const mariadb_recovery_code_store_js_1 = require("../authentication/mariadb-recovery-code-store.js");
|
|
122
125
|
const mariadb_mfa_device_store_js_1 = require("../authentication/mariadb-mfa-device-store.js");
|
|
123
126
|
const mariadb_session_sweeper_js_1 = require("../authentication/mariadb-session-sweeper.js");
|
|
@@ -130,6 +133,7 @@ const index_js_1 = require("../session/index.js");
|
|
|
130
133
|
const persistence_module_js_1 = require("./persistence.module.js");
|
|
131
134
|
const auth_controller_js_1 = require("./auth.controller.js");
|
|
132
135
|
const account_controller_js_1 = require("./account.controller.js");
|
|
136
|
+
const confirm_email_controller_js_1 = require("./confirm-email.controller.js");
|
|
133
137
|
const pending_mfa_store_js_1 = require("./pending-mfa-store.js");
|
|
134
138
|
const mfa_step_up_js_1 = require("./mfa-step-up.js");
|
|
135
139
|
const tokens_js_1 = require("./tokens.js");
|
|
@@ -647,6 +651,46 @@ function makeResetTokenStoreProvider(override) {
|
|
|
647
651
|
inject: [tokens_js_1.SHIELD_DB_POOL],
|
|
648
652
|
};
|
|
649
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* The self-service email-change token store (Phase 12, KI #14) — always the framework default
|
|
656
|
+
* over `shield_identity.email_change_token` (identity migration 020); no consumer override seam.
|
|
657
|
+
*/
|
|
658
|
+
const emailChangeTokenStoreProvider = {
|
|
659
|
+
provide: tokens_js_1.SHIELD_EMAIL_CHANGE_TOKEN_STORE,
|
|
660
|
+
useFactory: (pool) => new mariadb_email_change_token_store_js_1.MariaDbEmailChangeTokenStore(pool),
|
|
661
|
+
inject: [tokens_js_1.SHIELD_DB_POOL],
|
|
662
|
+
};
|
|
663
|
+
/** The ONE email read/write path for an email change (Phase 12): the framework default. */
|
|
664
|
+
const emailAddressChangerProvider = {
|
|
665
|
+
provide: tokens_js_1.SHIELD_EMAIL_ADDRESS_CHANGER,
|
|
666
|
+
useFactory: (pool) => new mariadb_email_address_changer_js_1.MariaDbEmailAddressChanger(pool),
|
|
667
|
+
inject: [tokens_js_1.SHIELD_DB_POOL],
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* The per-account email-change REQUEST limiter (Phase 12) — `email-change:pid:<id>`, 3 per 60-min
|
|
671
|
+
* window, so the route cannot spam an inbox. `consume` fails closed (503).
|
|
672
|
+
*/
|
|
673
|
+
const emailChangeLimiterProvider = {
|
|
674
|
+
provide: tokens_js_1.SHIELD_EMAIL_CHANGE_LIMITER,
|
|
675
|
+
useFactory: (redis) => new attempt_limiter_js_1.AttemptLimiter(redis, { maxAttempts: 3, windowMs: 60 * 60 * 1000 }),
|
|
676
|
+
inject: [tokens_js_1.SHIELD_REDIS],
|
|
677
|
+
};
|
|
678
|
+
/**
|
|
679
|
+
* The `POST /auth/confirm-email` per-IP limiter (Phase 12, A6) — `confirm-email:ip:<ip>`, 10 per
|
|
680
|
+
* 15-min window (like reset-password); enforced only with an explicit `trust proxy`.
|
|
681
|
+
*/
|
|
682
|
+
const confirmEmailLimiterProvider = {
|
|
683
|
+
provide: tokens_js_1.SHIELD_CONFIRM_EMAIL_LIMITER,
|
|
684
|
+
useFactory: (redis) => new attempt_limiter_js_1.AttemptLimiter(redis, { maxAttempts: 10, windowMs: 15 * 60 * 1000 }),
|
|
685
|
+
inject: [tokens_js_1.SHIELD_REDIS],
|
|
686
|
+
};
|
|
687
|
+
/** The confirm-link base (`ShieldModule.forRoot({ emailChangeLinkBaseUrl })`, dev default). */
|
|
688
|
+
function makeEmailChangeLinkBaseUrlProvider(base) {
|
|
689
|
+
return {
|
|
690
|
+
provide: tokens_js_1.SHIELD_EMAIL_CHANGE_LINK_BASE_URL,
|
|
691
|
+
useValue: base ?? email_change_messages_js_1.DEFAULT_EMAIL_CHANGE_LINK_BASE_URL,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
650
694
|
/**
|
|
651
695
|
* Bind the {@link EmailSender} (security hardening #6):
|
|
652
696
|
* - a supplied sender → used verbatim (any environment);
|
|
@@ -800,7 +844,8 @@ let AuthModule = AuthModule_1 = class AuthModule {
|
|
|
800
844
|
// injects the two composed engines (below) + PendingMfaStore.
|
|
801
845
|
// AuthFactsController serves the framework-default `/identity/auth-facts` +
|
|
802
846
|
// `/identity/permissions-version` (over the SHIELD_PERMISSION_FACTS adapter below).
|
|
803
|
-
|
|
847
|
+
// ConfirmEmailController (Phase 12): `POST /auth/confirm-email`, beside AuthController.
|
|
848
|
+
controllers: [auth_controller_js_1.AuthController, confirm_email_controller_js_1.ConfirmEmailController, account_controller_js_1.AccountController, auth_facts_controller_js_1.AuthFactsController],
|
|
804
849
|
providers: [
|
|
805
850
|
passwordHasherProvider,
|
|
806
851
|
makeCredentialResolverProvider(options.credentialResolver),
|
|
@@ -809,6 +854,11 @@ let AuthModule = AuthModule_1 = class AuthModule {
|
|
|
809
854
|
// The account/credential-surface seams (null when absent → route 501).
|
|
810
855
|
makeCredentialWriterProvider(options.credentialWriter),
|
|
811
856
|
makeResetTokenStoreProvider(options.resetTokenStore),
|
|
857
|
+
emailChangeTokenStoreProvider,
|
|
858
|
+
emailAddressChangerProvider,
|
|
859
|
+
emailChangeLimiterProvider,
|
|
860
|
+
confirmEmailLimiterProvider,
|
|
861
|
+
makeEmailChangeLinkBaseUrlProvider(options.emailChangeLinkBaseUrl),
|
|
812
862
|
makeEmailSenderProvider(options.emailSender),
|
|
813
863
|
makeRecoveryCodeStoreProvider(options.recoveryCodeStore),
|
|
814
864
|
makeProfileWriterProvider(options.profileWriter),
|
|
@@ -912,6 +962,8 @@ let AuthModule = AuthModule_1 = class AuthModule {
|
|
|
912
962
|
// and the account/auth controllers see them app-wide through @Global Shield.
|
|
913
963
|
tokens_js_1.SHIELD_CREDENTIAL_WRITER,
|
|
914
964
|
tokens_js_1.SHIELD_RESET_TOKEN_STORE,
|
|
965
|
+
// Phase 12: identity's admin email change invalidates pending self-service tokens.
|
|
966
|
+
tokens_js_1.SHIELD_EMAIL_CHANGE_TOKEN_STORE,
|
|
915
967
|
tokens_js_1.SHIELD_EMAIL_SENDER,
|
|
916
968
|
tokens_js_1.SHIELD_RECOVERY_CODE_STORE,
|
|
917
969
|
tokens_js_1.SHIELD_PROFILE_WRITER,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AttemptLimiter } from './rate-limit/attempt-limiter.js';
|
|
2
|
+
import { TrustProxyBootCheck } from './rate-limit/trust-proxy-check.js';
|
|
3
|
+
import type { AuditSink } from './ports/audit-sink.js';
|
|
4
|
+
import type { EmailSender } from './ports/email-sender.js';
|
|
5
|
+
import type { EmailChangeTokenStore } from './ports/email-change-token-store.js';
|
|
6
|
+
import type { EmailAddressChanger } from './ports/email-address-changer.js';
|
|
7
|
+
import type { ResetTokenStore } from './ports/reset-token-store.js';
|
|
8
|
+
import type { SessionSweeper } from './ports/session-sweeper.js';
|
|
9
|
+
export declare class ConfirmEmailController {
|
|
10
|
+
private readonly tokens?;
|
|
11
|
+
private readonly addresses?;
|
|
12
|
+
private readonly sweeper?;
|
|
13
|
+
private readonly resetTokens?;
|
|
14
|
+
private readonly emailSender?;
|
|
15
|
+
private readonly auditSink?;
|
|
16
|
+
private readonly limiter?;
|
|
17
|
+
private readonly trustProxyCheck?;
|
|
18
|
+
constructor(tokens?: (EmailChangeTokenStore | null) | undefined, addresses?: (EmailAddressChanger | null) | undefined, sweeper?: (SessionSweeper | null) | undefined, resetTokens?: (ResetTokenStore | null) | undefined, emailSender?: (EmailSender | null) | undefined, auditSink?: (AuditSink | null) | undefined, limiter?: (AttemptLimiter | null) | undefined, trustProxyCheck?: (Pick<TrustProxyBootCheck, "perIpLimitingEnabled"> | null) | undefined);
|
|
19
|
+
confirmEmail(body: {
|
|
20
|
+
token?: unknown;
|
|
21
|
+
}, ip: string): Promise<{
|
|
22
|
+
ok: true;
|
|
23
|
+
}>;
|
|
24
|
+
/**
|
|
25
|
+
* Best-effort auth audit (A9): masked address, a reason code; never a token. Attributed to the
|
|
26
|
+
* token's user and the tenant of the session that requested the change (KI #14 re-review P1-2:
|
|
27
|
+
* the confirm request may carry no session, and `audit_log.al_tenant_id` is NOT NULL). Only a
|
|
28
|
+
* consumed token has a binding, so an unknown / expired / used token is never audited: there is
|
|
29
|
+
* no user or tenant to attribute it to (the per-IP limiter bounds that path).
|
|
30
|
+
*/
|
|
31
|
+
private audit;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=confirm-email.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"confirm-email.controller.d.ts","sourceRoot":"","sources":["../../src/auth/confirm-email.controller.ts"],"names":[],"mappings":"AA2CA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACtE,OAAO,EAAE,mBAAmB,EAAE,MAAM,mCAAmC,CAAC;AACxE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAC3D,OAAO,KAAK,EAEV,qBAAqB,EACtB,MAAM,qCAAqC,CAAC;AAC7C,OAAO,KAAK,EAAE,mBAAmB,EAAsB,MAAM,kCAAkC,CAAC;AAChG,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AACpE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,4BAA4B,CAAC;AAcjE,qBACa,sBAAsB;IAI/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IAGxB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;IAG3B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;IAGzB,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAG7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC;IAG7B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC;IAG3B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC;IAGzB,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;gBArBhB,MAAM,CAAC,GAAE,qBAAqB,GAAG,IAAI,aAAA,EAGrC,SAAS,CAAC,GAAE,mBAAmB,GAAG,IAAI,aAAA,EAGtC,OAAO,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAG/B,WAAW,CAAC,GAAE,eAAe,GAAG,IAAI,aAAA,EAGpC,WAAW,CAAC,GAAE,WAAW,GAAG,IAAI,aAAA,EAGhC,SAAS,CAAC,GAAE,SAAS,GAAG,IAAI,aAAA,EAG5B,OAAO,CAAC,GAAE,cAAc,GAAG,IAAI,aAAA,EAG/B,eAAe,CAAC,GAAE,IAAI,CAAC,mBAAmB,EAAE,sBAAsB,CAAC,GAAG,IAAI,aAAA;IAMvF,YAAY,CACR,IAAI,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,EAE3B,EAAE,EAAE,MAAM,GACf,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAA;KAAE,CAAC;IA6ExB;;;;;;OAMG;IACH,OAAO,CAAC,KAAK;CAkBd"}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.ConfirmEmailController = void 0;
|
|
16
|
+
/**
|
|
17
|
+
* `POST /auth/confirm-email` — the CONFIRM half of the verified self-service email change (security
|
|
18
|
+
* hardening Phase 12, KI #14). `@Public()`: the link may be opened on a device with no session; the
|
|
19
|
+
* single-use token IS the credential. The request half is `PATCH /account/profile`.
|
|
20
|
+
*
|
|
21
|
+
* Flow:
|
|
22
|
+
* 1. per-IP budget `confirm-email:ip:<ip>` (A6; enforced only with an explicit `trust proxy`);
|
|
23
|
+
* 2. consume the token atomically (sha256; single use) — unknown / expired / used → 400;
|
|
24
|
+
* 3. ONE transaction (`EmailAddressChanger.change`): lock the user + credential rows, refuse a
|
|
25
|
+
* stale binding or a non-active user (400, A3) and a collision (409), write `u_email`, move the
|
|
26
|
+
* login hint that equalled the old email, burn the user's pending email-change tokens;
|
|
27
|
+
* 4. after commit: revoke every session of the user EXCEPT the requesting one (A7, durable revoke
|
|
28
|
+
* + Redis unbind through the session sweeper), burn outstanding reset tokens, notify the OLD
|
|
29
|
+
* address (A10, no link), audit (A9).
|
|
30
|
+
*
|
|
31
|
+
* Outcomes: 200 `{ ok: true }`; 400 `invalid_token`; 409 `email_unavailable`; 429
|
|
32
|
+
* `too_many_requests`; 503 `authentication_unavailable` (limiter / store outage, or a follow-up
|
|
33
|
+
* after the committed change failed); 501 `not_available` when a seam is unplugged.
|
|
34
|
+
*
|
|
35
|
+
* The token is never logged, echoed, or placed in an audit row; the body is never logged.
|
|
36
|
+
*/
|
|
37
|
+
const common_1 = require("@nestjs/common");
|
|
38
|
+
const node_crypto_1 = require("node:crypto");
|
|
39
|
+
const classification_decorator_js_1 = require("../enforcement/classification.decorator.js");
|
|
40
|
+
const tokens_js_1 = require("./tokens.js");
|
|
41
|
+
const trust_proxy_check_js_1 = require("./rate-limit/trust-proxy-check.js");
|
|
42
|
+
const email_address_js_1 = require("./email-address.js");
|
|
43
|
+
const email_change_messages_js_1 = require("./email-change-messages.js");
|
|
44
|
+
function fail(status, error) {
|
|
45
|
+
throw new common_1.HttpException({ error }, status);
|
|
46
|
+
}
|
|
47
|
+
function invalidToken() {
|
|
48
|
+
fail(common_1.HttpStatus.BAD_REQUEST, 'invalid_token');
|
|
49
|
+
}
|
|
50
|
+
function unavailable() {
|
|
51
|
+
fail(common_1.HttpStatus.SERVICE_UNAVAILABLE, 'authentication_unavailable');
|
|
52
|
+
}
|
|
53
|
+
let ConfirmEmailController = class ConfirmEmailController {
|
|
54
|
+
tokens;
|
|
55
|
+
addresses;
|
|
56
|
+
sweeper;
|
|
57
|
+
resetTokens;
|
|
58
|
+
emailSender;
|
|
59
|
+
auditSink;
|
|
60
|
+
limiter;
|
|
61
|
+
trustProxyCheck;
|
|
62
|
+
constructor(tokens, addresses, sweeper, resetTokens, emailSender, auditSink, limiter, trustProxyCheck) {
|
|
63
|
+
this.tokens = tokens;
|
|
64
|
+
this.addresses = addresses;
|
|
65
|
+
this.sweeper = sweeper;
|
|
66
|
+
this.resetTokens = resetTokens;
|
|
67
|
+
this.emailSender = emailSender;
|
|
68
|
+
this.auditSink = auditSink;
|
|
69
|
+
this.limiter = limiter;
|
|
70
|
+
this.trustProxyCheck = trustProxyCheck;
|
|
71
|
+
}
|
|
72
|
+
async confirmEmail(body, ip) {
|
|
73
|
+
const tokens = this.tokens;
|
|
74
|
+
const addresses = this.addresses;
|
|
75
|
+
if (tokens == null || addresses == null)
|
|
76
|
+
fail(common_1.HttpStatus.NOT_IMPLEMENTED, 'not_available');
|
|
77
|
+
// A6: count every attempt first (fail closed); a blank ip is its own bucket, never a shared one.
|
|
78
|
+
const limiter = this.trustProxyCheck?.perIpLimitingEnabled === true ? this.limiter : null;
|
|
79
|
+
const ipKey = `confirm-email:ip:${ip || 'unknown-ip'}`;
|
|
80
|
+
if (limiter != null) {
|
|
81
|
+
let allowed;
|
|
82
|
+
try {
|
|
83
|
+
({ allowed } = await limiter.consume(ipKey));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
unavailable();
|
|
87
|
+
}
|
|
88
|
+
if (!allowed)
|
|
89
|
+
fail(common_1.HttpStatus.TOO_MANY_REQUESTS, 'too_many_requests');
|
|
90
|
+
}
|
|
91
|
+
const token = typeof body?.token === 'string' ? body.token : '';
|
|
92
|
+
if (token === '')
|
|
93
|
+
invalidToken();
|
|
94
|
+
let binding;
|
|
95
|
+
try {
|
|
96
|
+
binding = await tokens.consume((0, node_crypto_1.createHash)('sha256').update(token).digest('hex'));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
unavailable();
|
|
100
|
+
}
|
|
101
|
+
if (binding === null)
|
|
102
|
+
invalidToken();
|
|
103
|
+
// A valid single-use token proved the caller: give the attempt back.
|
|
104
|
+
await limiter?.refund(ipKey).catch(() => undefined);
|
|
105
|
+
let outcome;
|
|
106
|
+
try {
|
|
107
|
+
outcome = await addresses.change({
|
|
108
|
+
principalId: binding.principalId,
|
|
109
|
+
newEmail: binding.newEmail,
|
|
110
|
+
expectedOldEmail: binding.oldEmail,
|
|
111
|
+
requireActive: true,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
unavailable();
|
|
116
|
+
}
|
|
117
|
+
const maskedNew = (0, email_address_js_1.maskEmail)(binding.newEmail);
|
|
118
|
+
if (outcome.status !== 'changed') {
|
|
119
|
+
const reason = outcome.status === 'unavailable' ? 'email_unavailable' : outcome.status;
|
|
120
|
+
this.audit(binding, 'account.email_change_refused', { reason, newEmail: maskedNew });
|
|
121
|
+
if (outcome.status === 'unavailable')
|
|
122
|
+
fail(common_1.HttpStatus.CONFLICT, 'email_unavailable');
|
|
123
|
+
invalidToken();
|
|
124
|
+
}
|
|
125
|
+
// After commit. Each follow-up is attempted; a failure is surfaced (503) only after the rest
|
|
126
|
+
// ran, so the notice and the audit always go out for a committed change.
|
|
127
|
+
let followUpFailed = false;
|
|
128
|
+
if (this.sweeper != null) {
|
|
129
|
+
await this.sweeper
|
|
130
|
+
.revokeOthersForPrincipal(binding.principalId, binding.sessionId)
|
|
131
|
+
.catch(() => {
|
|
132
|
+
followUpFailed = true;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (this.resetTokens != null) {
|
|
136
|
+
await this.resetTokens.invalidateAllForPrincipal(binding.principalId).catch(() => {
|
|
137
|
+
followUpFailed = true;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
if (outcome.oldEmail !== null && outcome.oldEmail !== '' && this.emailSender != null) {
|
|
141
|
+
await this.emailSender
|
|
142
|
+
.send((0, email_change_messages_js_1.emailChangedNoticeMessage)(outcome.oldEmail, maskedNew, Date.now()))
|
|
143
|
+
.catch(() => undefined); // best-effort: the change is committed
|
|
144
|
+
}
|
|
145
|
+
this.audit(binding, 'account.email_changed', {
|
|
146
|
+
newEmail: maskedNew,
|
|
147
|
+
loginHintMoved: outcome.hintMoved,
|
|
148
|
+
});
|
|
149
|
+
if (followUpFailed)
|
|
150
|
+
unavailable();
|
|
151
|
+
return { ok: true };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Best-effort auth audit (A9): masked address, a reason code; never a token. Attributed to the
|
|
155
|
+
* token's user and the tenant of the session that requested the change (KI #14 re-review P1-2:
|
|
156
|
+
* the confirm request may carry no session, and `audit_log.al_tenant_id` is NOT NULL). Only a
|
|
157
|
+
* consumed token has a binding, so an unknown / expired / used token is never audited: there is
|
|
158
|
+
* no user or tenant to attribute it to (the per-IP limiter bounds that path).
|
|
159
|
+
*/
|
|
160
|
+
audit(binding, action, attributes) {
|
|
161
|
+
void this.auditSink
|
|
162
|
+
?.record({
|
|
163
|
+
type: 'authentication',
|
|
164
|
+
occurredAtMs: Date.now(),
|
|
165
|
+
actorId: binding.principalId,
|
|
166
|
+
tenantId: binding.tenantId,
|
|
167
|
+
action,
|
|
168
|
+
attributes,
|
|
169
|
+
})
|
|
170
|
+
.catch(() => {
|
|
171
|
+
/* best-effort: an audit failure never changes the response */
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
exports.ConfirmEmailController = ConfirmEmailController;
|
|
176
|
+
__decorate([
|
|
177
|
+
(0, classification_decorator_js_1.Public)(),
|
|
178
|
+
(0, common_1.Post)('confirm-email'),
|
|
179
|
+
(0, common_1.HttpCode)(common_1.HttpStatus.OK),
|
|
180
|
+
__param(0, (0, common_1.Body)()),
|
|
181
|
+
__param(1, (0, common_1.Ip)()),
|
|
182
|
+
__metadata("design:type", Function),
|
|
183
|
+
__metadata("design:paramtypes", [Object, String]),
|
|
184
|
+
__metadata("design:returntype", Promise)
|
|
185
|
+
], ConfirmEmailController.prototype, "confirmEmail", null);
|
|
186
|
+
exports.ConfirmEmailController = ConfirmEmailController = __decorate([
|
|
187
|
+
(0, common_1.Controller)('auth'),
|
|
188
|
+
__param(0, (0, common_1.Optional)()),
|
|
189
|
+
__param(0, (0, common_1.Inject)(tokens_js_1.SHIELD_EMAIL_CHANGE_TOKEN_STORE)),
|
|
190
|
+
__param(1, (0, common_1.Optional)()),
|
|
191
|
+
__param(1, (0, common_1.Inject)(tokens_js_1.SHIELD_EMAIL_ADDRESS_CHANGER)),
|
|
192
|
+
__param(2, (0, common_1.Optional)()),
|
|
193
|
+
__param(2, (0, common_1.Inject)(tokens_js_1.SHIELD_SESSION_SWEEPER)),
|
|
194
|
+
__param(3, (0, common_1.Optional)()),
|
|
195
|
+
__param(3, (0, common_1.Inject)(tokens_js_1.SHIELD_RESET_TOKEN_STORE)),
|
|
196
|
+
__param(4, (0, common_1.Optional)()),
|
|
197
|
+
__param(4, (0, common_1.Inject)(tokens_js_1.SHIELD_EMAIL_SENDER)),
|
|
198
|
+
__param(5, (0, common_1.Optional)()),
|
|
199
|
+
__param(5, (0, common_1.Inject)(tokens_js_1.SHIELD_AUDIT_SINK)),
|
|
200
|
+
__param(6, (0, common_1.Optional)()),
|
|
201
|
+
__param(6, (0, common_1.Inject)(tokens_js_1.SHIELD_CONFIRM_EMAIL_LIMITER)),
|
|
202
|
+
__param(7, (0, common_1.Optional)()),
|
|
203
|
+
__param(7, (0, common_1.Inject)(trust_proxy_check_js_1.TrustProxyBootCheck)),
|
|
204
|
+
__metadata("design:paramtypes", [Object, Object, Object, Object, Object, Object, Object, Object])
|
|
205
|
+
], ConfirmEmailController);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email-address helpers shared by the self-service email change (guard `PATCH /account/profile`,
|
|
3
|
+
* `POST /auth/confirm-email`) and the administrator path (`@astram/identity` `UserService`), so
|
|
4
|
+
* the two never drift (security hardening Phase 12, KI #14). Exported from the guard main entry;
|
|
5
|
+
* identity imports them (identity may import guard, never the reverse — ADR-0003/ADR-0026).
|
|
6
|
+
*
|
|
7
|
+
* Pure functions, no I/O.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* The canonical stored form of an email address (A5): trim → NFKC → trim → lower-case. Applied
|
|
11
|
+
* ONCE at the edge; that one value is what the token row, the collision check and the final write
|
|
12
|
+
* use. The second trim removes spaces NFKC itself produces (a no-break space folds to a space).
|
|
13
|
+
*
|
|
14
|
+
* Not the limiter-key canonicalisation (`canonicalHint`, which keys Redis budgets): that one stays
|
|
15
|
+
* unchanged so existing limiter keys do not move.
|
|
16
|
+
*/
|
|
17
|
+
export declare function canonicalEmail(raw: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* A deliberately minimal email shape check (non-empty, a single `@`, a `.` in the domain, no
|
|
20
|
+
* whitespace). A full RFC-5322 validator adds no security value here, only false rejections; the
|
|
21
|
+
* authoritative check is the confirm link reaching the inbox.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isEmailShaped(value: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The masked form used in audit rows and the old-address notice (A9, A10): the first character of
|
|
26
|
+
* the local part, `***`, then the domain (`alice@example.com` → `a***@example.com`). A value with no
|
|
27
|
+
* usable `@` is never echoed (`***`).
|
|
28
|
+
*/
|
|
29
|
+
export declare function maskEmail(value: string): string;
|
|
30
|
+
//# sourceMappingURL=email-address.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-address.d.ts","sourceRoot":"","sources":["../../src/auth/email-address.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAMpD;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAI/C"}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Email-address helpers shared by the self-service email change (guard `PATCH /account/profile`,
|
|
4
|
+
* `POST /auth/confirm-email`) and the administrator path (`@astram/identity` `UserService`), so
|
|
5
|
+
* the two never drift (security hardening Phase 12, KI #14). Exported from the guard main entry;
|
|
6
|
+
* identity imports them (identity may import guard, never the reverse — ADR-0003/ADR-0026).
|
|
7
|
+
*
|
|
8
|
+
* Pure functions, no I/O.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.canonicalEmail = canonicalEmail;
|
|
12
|
+
exports.isEmailShaped = isEmailShaped;
|
|
13
|
+
exports.maskEmail = maskEmail;
|
|
14
|
+
/**
|
|
15
|
+
* The canonical stored form of an email address (A5): trim → NFKC → trim → lower-case. Applied
|
|
16
|
+
* ONCE at the edge; that one value is what the token row, the collision check and the final write
|
|
17
|
+
* use. The second trim removes spaces NFKC itself produces (a no-break space folds to a space).
|
|
18
|
+
*
|
|
19
|
+
* Not the limiter-key canonicalisation (`canonicalHint`, which keys Redis budgets): that one stays
|
|
20
|
+
* unchanged so existing limiter keys do not move.
|
|
21
|
+
*/
|
|
22
|
+
function canonicalEmail(raw) {
|
|
23
|
+
return raw.trim().normalize('NFKC').trim().toLowerCase();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A deliberately minimal email shape check (non-empty, a single `@`, a `.` in the domain, no
|
|
27
|
+
* whitespace). A full RFC-5322 validator adds no security value here, only false rejections; the
|
|
28
|
+
* authoritative check is the confirm link reaching the inbox.
|
|
29
|
+
*/
|
|
30
|
+
function isEmailShaped(value) {
|
|
31
|
+
if (/\s/.test(value))
|
|
32
|
+
return false;
|
|
33
|
+
const at = value.indexOf('@');
|
|
34
|
+
if (at <= 0 || at !== value.lastIndexOf('@') || at === value.length - 1)
|
|
35
|
+
return false;
|
|
36
|
+
const domain = value.slice(at + 1);
|
|
37
|
+
return domain.includes('.') && !domain.startsWith('.') && !domain.endsWith('.');
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The masked form used in audit rows and the old-address notice (A9, A10): the first character of
|
|
41
|
+
* the local part, `***`, then the domain (`alice@example.com` → `a***@example.com`). A value with no
|
|
42
|
+
* usable `@` is never echoed (`***`).
|
|
43
|
+
*/
|
|
44
|
+
function maskEmail(value) {
|
|
45
|
+
const at = value.lastIndexOf('@');
|
|
46
|
+
if (at <= 0 || at === value.length - 1)
|
|
47
|
+
return '***';
|
|
48
|
+
return `${value.slice(0, 1)}***@${value.slice(at + 1)}`;
|
|
49
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two emails of the verified self-service email change (security hardening Phase 12, KI #14).
|
|
3
|
+
* The framework composes them; the {@link EmailSender} only delivers. Both carry HTML and text.
|
|
4
|
+
*
|
|
5
|
+
* - {@link confirmEmailChangeMessage}: to the NEW address, with the confirm link. The raw token is
|
|
6
|
+
* in the link's FRAGMENT (`#t=`, A4) — it is never placed in a query string, logged, or returned.
|
|
7
|
+
* - {@link emailChangedNoticeMessage}: to the OLD address after a confirmed change (A10). No link;
|
|
8
|
+
* the time, the masked new address, and what to do if it wasn't the owner.
|
|
9
|
+
*/
|
|
10
|
+
import type { EmailMessage } from './ports/email-sender.js';
|
|
11
|
+
/** Email-change token time-to-live: 30 minutes (same as a password reset). */
|
|
12
|
+
export declare const EMAIL_CHANGE_TTL_MS: number;
|
|
13
|
+
/** The default confirm-page base (a dev Vite origin), like the reset-link default. */
|
|
14
|
+
export declare const DEFAULT_EMAIL_CHANGE_LINK_BASE_URL = "http://localhost:5173/confirm-email";
|
|
15
|
+
/** The A10 sentence, verbatim. */
|
|
16
|
+
export declare const NOT_YOU_SENTENCE = "If this wasn't you, reset your password from the login page and contact your administrator.";
|
|
17
|
+
/** The confirm link: the token rides in the fragment, never the query (A4). */
|
|
18
|
+
export declare function emailChangeLink(base: string, rawToken: string): string;
|
|
19
|
+
export declare function confirmEmailChangeMessage(to: string, link: string): EmailMessage;
|
|
20
|
+
export declare function emailChangedNoticeMessage(to: string, maskedNewEmail: string, atMs: number): EmailMessage;
|
|
21
|
+
//# sourceMappingURL=email-change-messages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"email-change-messages.d.ts","sourceRoot":"","sources":["../../src/auth/email-change-messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAE5D,8EAA8E;AAC9E,eAAO,MAAM,mBAAmB,QAAiB,CAAC;AAElD,sFAAsF;AACtF,eAAO,MAAM,kCAAkC,wCAAwC,CAAC;AAExF,kCAAkC;AAClC,eAAO,MAAM,gBAAgB,gGACkE,CAAC;AAUhG,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED,wBAAgB,yBAAyB,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,YAAY,CAehF;AAED,wBAAgB,yBAAyB,CACvC,EAAE,EAAE,MAAM,EACV,cAAc,EAAE,MAAM,EACtB,IAAI,EAAE,MAAM,GACX,YAAY,CAYd"}
|