@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.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.
- package/package.json +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +22 -7
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.159.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"./subscription-stripe": "./src/subscription-stripe/index.ts",
|
|
49
49
|
"./subscription-mollie": "./src/subscription-mollie/index.ts",
|
|
50
50
|
"./foundation-shared": "./src/foundation-shared/index.ts",
|
|
51
|
+
"./auth-foundation": "./src/auth-foundation/index.ts",
|
|
51
52
|
"./mail-foundation": "./src/mail-foundation/index.ts",
|
|
52
53
|
"./inbound-mail-foundation": "./src/inbound-mail-foundation/index.ts",
|
|
53
54
|
"./inbound-provider-inmemory": "./src/inbound-provider-inmemory/index.ts",
|
|
@@ -116,11 +117,11 @@
|
|
|
116
117
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
117
118
|
},
|
|
118
119
|
"dependencies": {
|
|
119
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
120
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
121
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
122
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
120
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.159.1",
|
|
121
|
+
"@cosmicdrift/kumiko-framework": "0.159.1",
|
|
122
|
+
"@cosmicdrift/kumiko-headless": "0.159.1",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer": "0.159.1",
|
|
124
|
+
"@cosmicdrift/kumiko-renderer-web": "0.159.1",
|
|
124
125
|
"@mollie/api-client": "^4.5.0",
|
|
125
126
|
"imapflow": "^1.3.3",
|
|
126
127
|
"mailparser": "^3.9.8",
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// Account-unlock integration tests (#1266) — the self-service escape hatch
|
|
2
|
+
// for the account-lockout monotonic counter: after a lock expires, the next
|
|
3
|
+
// wrong password immediately re-locks (see account-lockout.integration.test.ts
|
|
4
|
+
// "expired lock + wrong password → immediate re-lock"). This flow gives a
|
|
5
|
+
// legitimate user a way out via mailbox-ownership proof instead of being
|
|
6
|
+
// stuck retrying.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
11
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import {
|
|
13
|
+
setupTestStack,
|
|
14
|
+
type TestStack,
|
|
15
|
+
TestUsers,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
unsafePushTables,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
20
|
+
import { Temporal } from "temporal-polyfill";
|
|
21
|
+
import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
|
|
22
|
+
import { createConfigFeature } from "../../config";
|
|
23
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
24
|
+
import { configValuesTable } from "../../config/table";
|
|
25
|
+
import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
|
|
26
|
+
import { notificationPreferencesTable } from "../../delivery/tables";
|
|
27
|
+
import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
|
|
28
|
+
import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
|
|
29
|
+
import { hashPassword } from "../../shared";
|
|
30
|
+
import { createTemplateResolverFeature } from "../../template-resolver/feature";
|
|
31
|
+
import { createTenantFeature } from "../../tenant";
|
|
32
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
33
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
34
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
35
|
+
import { UserHandlers } from "../../user";
|
|
36
|
+
import { createUserFeature } from "../../user/feature";
|
|
37
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
38
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
39
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
40
|
+
import { getLockoutState, type LockoutState } from "../lockout-store";
|
|
41
|
+
import { signUnlockToken } from "../unlock-token";
|
|
42
|
+
|
|
43
|
+
const emailTransport = createInMemoryTransport();
|
|
44
|
+
|
|
45
|
+
let stack: TestStack;
|
|
46
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
47
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
48
|
+
const unlockSecret = randomBytes(32).toString("base64");
|
|
49
|
+
const appUnlockUrl = "https://app.example.com/unlock-account";
|
|
50
|
+
|
|
51
|
+
const MAX_ATTEMPTS = 3;
|
|
52
|
+
const LOCK_MINUTES = 1;
|
|
53
|
+
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
56
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
57
|
+
|
|
58
|
+
stack = await setupTestStack({
|
|
59
|
+
features: [
|
|
60
|
+
createConfigFeature(),
|
|
61
|
+
createUserFeature(),
|
|
62
|
+
createTenantFeature(),
|
|
63
|
+
createTemplateResolverFeature(),
|
|
64
|
+
createRendererFoundationFeature(),
|
|
65
|
+
createDeliveryFeature(),
|
|
66
|
+
createRendererSimpleFeature(),
|
|
67
|
+
createChannelEmailFeature({
|
|
68
|
+
transport: emailTransport,
|
|
69
|
+
renderer: simpleRenderer,
|
|
70
|
+
resolveEmail: async () => "unused@test.local",
|
|
71
|
+
}),
|
|
72
|
+
createAuthEmailPasswordFeature({
|
|
73
|
+
accountLockout: {
|
|
74
|
+
maxFailedAttempts: MAX_ATTEMPTS,
|
|
75
|
+
lockoutDurationMinutes: LOCK_MINUTES,
|
|
76
|
+
},
|
|
77
|
+
accountUnlock: { hmacSecret: unlockSecret, tokenTtlMinutes: 15, appUrl: appUnlockUrl },
|
|
78
|
+
}),
|
|
79
|
+
],
|
|
80
|
+
extraContext: (deps) => ({
|
|
81
|
+
...createDeliveryTestContext(deps),
|
|
82
|
+
configResolver: resolver,
|
|
83
|
+
configEncryption: encryption,
|
|
84
|
+
}),
|
|
85
|
+
authConfig: {
|
|
86
|
+
membershipQuery: "tenant:query:memberships",
|
|
87
|
+
loginHandler: AuthHandlers.login,
|
|
88
|
+
loginErrorStatusMap: {
|
|
89
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
90
|
+
[AuthErrors.noMembership]: 403,
|
|
91
|
+
[AuthErrors.accountLocked]: 423,
|
|
92
|
+
},
|
|
93
|
+
accountUnlock: {
|
|
94
|
+
requestHandler: AuthHandlers.requestAccountUnlock,
|
|
95
|
+
confirmHandler: AuthHandlers.confirmAccountUnlock,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
101
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
102
|
+
await unsafePushTables(stack.db, {
|
|
103
|
+
configValuesTable,
|
|
104
|
+
tenantMembershipsTable,
|
|
105
|
+
notificationPreferencesTable,
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterAll(async () => {
|
|
110
|
+
await stack.cleanup();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
beforeEach(async () => {
|
|
114
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
115
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
116
|
+
await stack.redis.flushNamespace();
|
|
117
|
+
emailTransport.sent.length = 0;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
async function seedLoginUser(
|
|
121
|
+
email: string,
|
|
122
|
+
password: string,
|
|
123
|
+
): Promise<{ id: string; tenantId: TenantId }> {
|
|
124
|
+
const hash = await hashPassword(password);
|
|
125
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
126
|
+
UserHandlers.create,
|
|
127
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
|
|
128
|
+
systemAdmin,
|
|
129
|
+
);
|
|
130
|
+
const tenantId: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
131
|
+
await seedTenantMembership(stack.db, { userId: created.id, tenantId, roles: ["User"] });
|
|
132
|
+
return { id: created.id, tenantId };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function loginAttempt(email: string, password: string): Promise<Response> {
|
|
136
|
+
return stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function post(path: string, body: unknown): Promise<Response> {
|
|
140
|
+
return stack.http.raw("POST", path, body);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function readLockoutState(userId: string): Promise<LockoutState | null> {
|
|
144
|
+
return getLockoutState(stack.redis.redis, userId);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function lockAccount(email: string): Promise<void> {
|
|
148
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
149
|
+
await loginAttempt(email, `wrong-${i}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe("POST /auth/request-account-unlock", () => {
|
|
154
|
+
test("known email → 200, delivery sends mail with unlock URL", async () => {
|
|
155
|
+
await seedLoginUser("alice@example.com", "initial-pw!");
|
|
156
|
+
|
|
157
|
+
const res = await post("/api/auth/request-account-unlock", { email: "alice@example.com" });
|
|
158
|
+
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
expect(await res.json()).toEqual({ isSuccess: true });
|
|
161
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
162
|
+
const sent = emailTransport.sent[0];
|
|
163
|
+
if (!sent) throw new Error("no email sent");
|
|
164
|
+
expect(sent.to).toBe("alice@example.com");
|
|
165
|
+
expect(sent.html).toContain(`${appUnlockUrl}?token=`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("unknown email → 200 with NO mail sent (enumeration-safe)", async () => {
|
|
169
|
+
const res = await post("/api/auth/request-account-unlock", { email: "ghost@example.com" });
|
|
170
|
+
|
|
171
|
+
expect(res.status).toBe(200);
|
|
172
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("POST /auth/confirm-account-unlock", () => {
|
|
177
|
+
test("valid token on a locked account → clears failureCount AND lockedUntil", async () => {
|
|
178
|
+
const seed = await seedLoginUser("bob@example.com", "right-pw");
|
|
179
|
+
await lockAccount("bob@example.com");
|
|
180
|
+
expect((await readLockoutState(seed.id))?.lockedUntil).not.toBeNull();
|
|
181
|
+
|
|
182
|
+
const { token } = signUnlockToken(seed.id, 15, unlockSecret);
|
|
183
|
+
const res = await post("/api/auth/confirm-account-unlock", { token });
|
|
184
|
+
|
|
185
|
+
expect(res.status).toBe(200);
|
|
186
|
+
expect(await res.json()).toEqual({ isSuccess: true });
|
|
187
|
+
expect(await readLockoutState(seed.id)).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// This is the actual bug fix from #1266: the monotonic counter otherwise
|
|
191
|
+
// re-locks on the very next wrong password once the lock window expires.
|
|
192
|
+
// Confirming the unlock token must clear the COUNT key, not just the
|
|
193
|
+
// until key — otherwise the escape hatch doesn't actually escape anything.
|
|
194
|
+
test("after unlock, a subsequent wrong password does NOT immediately re-lock", async () => {
|
|
195
|
+
const seed = await seedLoginUser("carol@example.com", "right-pw");
|
|
196
|
+
await lockAccount("carol@example.com");
|
|
197
|
+
|
|
198
|
+
const { token } = signUnlockToken(seed.id, 15, unlockSecret);
|
|
199
|
+
await post("/api/auth/confirm-account-unlock", { token });
|
|
200
|
+
|
|
201
|
+
const res = await loginAttempt("carol@example.com", "still-wrong-once");
|
|
202
|
+
expect(res.status).toBe(401);
|
|
203
|
+
const body = await res.json();
|
|
204
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
205
|
+
|
|
206
|
+
const state = await readLockoutState(seed.id);
|
|
207
|
+
expect(state?.failureCount).toBe(1);
|
|
208
|
+
expect(state?.lockedUntil).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("unlocked account can log in again with the correct password", async () => {
|
|
212
|
+
const seed = await seedLoginUser("dana@example.com", "right-pw");
|
|
213
|
+
await lockAccount("dana@example.com");
|
|
214
|
+
|
|
215
|
+
const { token } = signUnlockToken(seed.id, 15, unlockSecret);
|
|
216
|
+
await post("/api/auth/confirm-account-unlock", { token });
|
|
217
|
+
|
|
218
|
+
const res = await loginAttempt("dana@example.com", "right-pw");
|
|
219
|
+
expect(res.status).toBe(200);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("confirming on a non-locked account is a harmless no-op", async () => {
|
|
223
|
+
const seed = await seedLoginUser("erin@example.com", "right-pw");
|
|
224
|
+
|
|
225
|
+
const { token } = signUnlockToken(seed.id, 15, unlockSecret);
|
|
226
|
+
const res = await post("/api/auth/confirm-account-unlock", { token });
|
|
227
|
+
|
|
228
|
+
expect(res.status).toBe(200);
|
|
229
|
+
expect(await readLockoutState(seed.id)).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("replaying the same unlock token twice is harmless (no burn-store)", async () => {
|
|
233
|
+
const seed = await seedLoginUser("frank@example.com", "right-pw");
|
|
234
|
+
await lockAccount("frank@example.com");
|
|
235
|
+
|
|
236
|
+
const { token } = signUnlockToken(seed.id, 15, unlockSecret);
|
|
237
|
+
const res1 = await post("/api/auth/confirm-account-unlock", { token });
|
|
238
|
+
const res2 = await post("/api/auth/confirm-account-unlock", { token });
|
|
239
|
+
|
|
240
|
+
expect(res1.status).toBe(200);
|
|
241
|
+
expect(res2.status).toBe(200);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("tampered token → 422 invalid_unlock_token; lockout state untouched", async () => {
|
|
245
|
+
const seed = await seedLoginUser("greta@example.com", "right-pw");
|
|
246
|
+
await lockAccount("greta@example.com");
|
|
247
|
+
const { token } = signUnlockToken(seed.id, 15, unlockSecret);
|
|
248
|
+
const tampered = `${token.slice(0, -3)}XXX`;
|
|
249
|
+
|
|
250
|
+
const res = await post("/api/auth/confirm-account-unlock", { token: tampered });
|
|
251
|
+
|
|
252
|
+
expect(res.status).toBe(422);
|
|
253
|
+
const body = await res.json();
|
|
254
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidUnlockToken);
|
|
255
|
+
expect((await readLockoutState(seed.id))?.lockedUntil).not.toBeNull();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("token signed with a different secret → 422", async () => {
|
|
259
|
+
const seed = await seedLoginUser("henry@example.com", "right-pw");
|
|
260
|
+
const { token } = signUnlockToken(seed.id, 15, "wrong-secret-wrong-secret-wrong!!");
|
|
261
|
+
|
|
262
|
+
const res = await post("/api/auth/confirm-account-unlock", { token });
|
|
263
|
+
|
|
264
|
+
expect(res.status).toBe(422);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("expired token → 422 invalid_unlock_token", async () => {
|
|
268
|
+
const seed = await seedLoginUser("iris@example.com", "right-pw");
|
|
269
|
+
const past = Temporal.Now.instant().subtract({ minutes: 30 });
|
|
270
|
+
const { token } = signUnlockToken(seed.id, 15, unlockSecret, past);
|
|
271
|
+
|
|
272
|
+
const res = await post("/api/auth/confirm-account-unlock", { token });
|
|
273
|
+
|
|
274
|
+
expect(res.status).toBe(422);
|
|
275
|
+
const body = await res.json();
|
|
276
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidUnlockToken);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("malformed body → 400 invalid_body", async () => {
|
|
280
|
+
const res = await post("/api/auth/confirm-account-unlock", { wrong: "shape" });
|
|
281
|
+
expect(res.status).toBe(400);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseAuthUserRow } from "../auth-user-row";
|
|
3
|
+
|
|
4
|
+
describe("parseAuthUserRow", () => {
|
|
5
|
+
test("returns null for null/undefined", () => {
|
|
6
|
+
expect(parseAuthUserRow(null)).toBeNull();
|
|
7
|
+
expect(parseAuthUserRow(undefined)).toBeNull();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("returns null for a non-object value", () => {
|
|
11
|
+
expect(parseAuthUserRow("nope")).toBeNull();
|
|
12
|
+
expect(parseAuthUserRow(42)).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("returns null when id is missing or not a string", () => {
|
|
16
|
+
expect(parseAuthUserRow({})).toBeNull();
|
|
17
|
+
expect(parseAuthUserRow({ id: 123 })).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns the row as-is once id is a string", () => {
|
|
21
|
+
const row = {
|
|
22
|
+
id: "u1",
|
|
23
|
+
email: "a@example.com",
|
|
24
|
+
version: 3,
|
|
25
|
+
passwordHash: "hash",
|
|
26
|
+
status: "active",
|
|
27
|
+
};
|
|
28
|
+
expect(parseAuthUserRow(row)).toEqual(row);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("passes through a row with only id set — other fields stay undefined", () => {
|
|
32
|
+
const result = parseAuthUserRow({ id: "u2" });
|
|
33
|
+
expect(result?.id).toBe("u2");
|
|
34
|
+
expect(result?.email).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -236,6 +236,27 @@ describe("scenario 5: change-password success", () => {
|
|
|
236
236
|
});
|
|
237
237
|
});
|
|
238
238
|
|
|
239
|
+
describe("scenario 5c: change-password common-password rejection (#1340)", () => {
|
|
240
|
+
test("common newPassword → 400 (schema rejects breach-list password)", async () => {
|
|
241
|
+
const seed = await seedLoginUser({ email: "common-cp@example.com", password: "good-old-pw" });
|
|
242
|
+
const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
|
|
243
|
+
|
|
244
|
+
const error = await stack.http.writeErr(
|
|
245
|
+
AuthHandlers.changePassword,
|
|
246
|
+
{ oldPassword: "good-old-pw", newPassword: "password1" },
|
|
247
|
+
signedIn,
|
|
248
|
+
);
|
|
249
|
+
expect(error.httpStatus).toBe(400);
|
|
250
|
+
|
|
251
|
+
// Old password still works — the rejected change never landed.
|
|
252
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
253
|
+
email: "common-cp@example.com",
|
|
254
|
+
password: "good-old-pw",
|
|
255
|
+
});
|
|
256
|
+
expect(loginRes.status).toBe(200);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
239
260
|
describe("scenario 5b: change-password when the aggregate stream tenant != session tenant (sysadmin pattern)", () => {
|
|
240
261
|
test("user whose stream lives in a non-session tenant still changes password", async () => {
|
|
241
262
|
// Sysadmin pattern: created via systemAdmin (stream = systemAdmin.tenantId),
|
|
@@ -286,7 +286,7 @@ describe("POST /auth/verify-email", () => {
|
|
|
286
286
|
// memberships + recoverable stream", never blanket-opens zero-membership.
|
|
287
287
|
// Build a fully-populated user row with NO event stream: seed a normal
|
|
288
288
|
// user (gets all NOT-NULL columns), capture its row, then re-key it to a
|
|
289
|
-
// fresh id + email.
|
|
289
|
+
// fresh id + email. getUnscopedAggregateStreamTenant(orphanId) finds no events
|
|
290
290
|
// (the stream lives under the original id), and no membership is seeded
|
|
291
291
|
// → tenantOrder is empty.
|
|
292
292
|
const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { isCommonPassword, passwordSchema } from "../password-policy";
|
|
3
|
+
|
|
4
|
+
test("rejects a breach-list password (case-insensitive)", () => {
|
|
5
|
+
expect(isCommonPassword("Password1")).toBe(true);
|
|
6
|
+
expect(passwordSchema.safeParse("password1").success).toBe(false);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("accepts a long unique password", () => {
|
|
10
|
+
expect(passwordSchema.safeParse("aX9-not-in-any-list-42").success).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("still enforces min length", () => {
|
|
14
|
+
expect(passwordSchema.safeParse("tiny").success).toBe(false);
|
|
15
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
3
4
|
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
5
|
import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
6
|
import {
|
|
@@ -76,6 +77,7 @@ beforeAll(async () => {
|
|
|
76
77
|
// "user.postSave" that triggers autoRevokeOnPasswordChange whenever
|
|
77
78
|
// the passwordHash delta is present. Integration-test proves the
|
|
78
79
|
// reset-flow's changes.passwordHash triggers the same hook.
|
|
80
|
+
authFoundationFeature,
|
|
79
81
|
createSessionsFeature({
|
|
80
82
|
autoRevokeOnPasswordChange: async (userId) => {
|
|
81
83
|
autoRevokeCalls.push(userId);
|
|
@@ -252,6 +254,18 @@ describe("POST /auth/reset-password", () => {
|
|
|
252
254
|
expect(res.status).toBe(400);
|
|
253
255
|
});
|
|
254
256
|
|
|
257
|
+
test("common newPassword → 400 (schema rejects breach-list password)", async () => {
|
|
258
|
+
const seed = await seedUser({ email: "frank@example.com", password: "original" });
|
|
259
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
260
|
+
|
|
261
|
+
const res = await post("/api/auth/reset-password", {
|
|
262
|
+
token,
|
|
263
|
+
newPassword: "password1",
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(res.status).toBe(400);
|
|
267
|
+
});
|
|
268
|
+
|
|
255
269
|
test("expired token via the route → 422 invalid_reset_token", async () => {
|
|
256
270
|
const seed = await seedUser({ email: "time@example.com", password: "once-valid-1234" });
|
|
257
271
|
// Sign with now set far in the past so expiry already fired.
|
|
@@ -348,7 +362,7 @@ describe("POST /auth/reset-password", () => {
|
|
|
348
362
|
// never blanket-opens zero-membership.
|
|
349
363
|
// Build a fully-populated user row with NO event stream: seed a normal
|
|
350
364
|
// user (gets all NOT-NULL columns), capture its row, then re-key it to a
|
|
351
|
-
// fresh id + email.
|
|
365
|
+
// fresh id + email. getUnscopedAggregateStreamTenant(orphanId) finds no events
|
|
352
366
|
// (the stream lives under the original id), and no membership is seeded
|
|
353
367
|
// → tenantOrder is empty.
|
|
354
368
|
const donor = await seedUser({ email: "donor@example.com", password: "donor-pw-1234" });
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// so legacy stateless tokens are expected to have expired. Default false
|
|
5
|
-
// keeps pre-upgrade tokens working; this suite flips it on and asserts.
|
|
1
|
+
// Pins the default sessionChecker behavior: a JWT that arrives WITHOUT a
|
|
2
|
+
// `jti` is rejected at the middleware. There is no relaxed mode — any app
|
|
3
|
+
// that wires sessionChecker gets this for free.
|
|
6
4
|
|
|
7
5
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
6
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -21,12 +19,8 @@ let stack: TestStack;
|
|
|
21
19
|
const TENANT: TenantId = testTenantId(1);
|
|
22
20
|
const userId = TestUsers.systemAdmin.id;
|
|
23
21
|
|
|
24
|
-
// Stub checker that always accepts.
|
|
25
|
-
//
|
|
26
|
-
// fires in the strict-mode path. It's present to satisfy the framework's
|
|
27
|
-
// "you wired sessionChecker, so we'll run it if we have an sid" contract.
|
|
28
|
-
// Accepts the full AuthSessionChecker signature (sid + expectedUserId)
|
|
29
|
-
// even though it doesn't use the args.
|
|
22
|
+
// Stub checker that always accepts. Accepts the full AuthSessionChecker
|
|
23
|
+
// signature (sid + expectedUserId) even though it doesn't use the args.
|
|
30
24
|
async function stubChecker(_sid: string, _expectedUserId: string): Promise<"live"> {
|
|
31
25
|
return "live";
|
|
32
26
|
}
|
|
@@ -42,7 +36,6 @@ beforeAll(async () => {
|
|
|
42
36
|
authConfig: {
|
|
43
37
|
membershipQuery: "tenant:query:memberships",
|
|
44
38
|
sessionChecker: stubChecker,
|
|
45
|
-
sessionStrictMode: true,
|
|
46
39
|
},
|
|
47
40
|
});
|
|
48
41
|
});
|
|
@@ -51,7 +44,7 @@ afterAll(async () => {
|
|
|
51
44
|
await stack.cleanup();
|
|
52
45
|
});
|
|
53
46
|
|
|
54
|
-
describe("
|
|
47
|
+
describe("sessionChecker wired: sidless JWTs are rejected", () => {
|
|
55
48
|
test("JWT without jti → 401 with reason=no_sid", async () => {
|
|
56
49
|
// Hand-signed JWT that carries id + tenantId + roles but NO jti. The
|
|
57
50
|
// standard testing request-helper signs JWTs the same way on user
|
|
@@ -93,7 +86,7 @@ describe("sessionStrictMode: sidless JWTs are rejected", () => {
|
|
|
93
86
|
expect(res.status).not.toBe(401);
|
|
94
87
|
// Narrow the "not 401" to exclude other 4xx middleware errors too.
|
|
95
88
|
// A 403 from access-layer or a 400 from shape-validation wouldn't
|
|
96
|
-
// come from
|
|
89
|
+
// come from the sid gate, but either would be a different code
|
|
97
90
|
// path than the one we're testing — flag them if they surface.
|
|
98
91
|
expect(res.status).not.toBe(403);
|
|
99
92
|
expect(res.status).not.toBe(400);
|
|
@@ -320,4 +320,16 @@ describe("POST /api/auth/signup-confirm", () => {
|
|
|
320
320
|
const loginAttacker = await postLogin(email, attackerPassword);
|
|
321
321
|
expect(loginAttacker.status).not.toBe(200);
|
|
322
322
|
});
|
|
323
|
+
|
|
324
|
+
test("common password → 400 (schema rejects breach-list password) (#1340)", async () => {
|
|
325
|
+
const email = "common-signup@example.com";
|
|
326
|
+
const token = await requestSignup(email);
|
|
327
|
+
|
|
328
|
+
const confirmRes = await postSignupConfirm(token, "password1");
|
|
329
|
+
expect(confirmRes.status).toBe(400);
|
|
330
|
+
|
|
331
|
+
// No user created — the rejected confirm never landed.
|
|
332
|
+
const userRows = await selectMany(stack.db, userTable, { email });
|
|
333
|
+
expect(userRows).toHaveLength(0);
|
|
334
|
+
});
|
|
323
335
|
});
|
|
@@ -19,6 +19,12 @@ export const AuthHandlers = {
|
|
|
19
19
|
resetPassword: "auth-email-password:write:reset-password",
|
|
20
20
|
requestEmailVerification: "auth-email-password:write:request-email-verification",
|
|
21
21
|
verifyEmail: "auth-email-password:write:verify-email",
|
|
22
|
+
// Account-Unlock Magic-Link (fixes the lockout-DoS where the monotonic
|
|
23
|
+
// failure-counter re-locks immediately after expiry, #1266). Mirrors
|
|
24
|
+
// reset: request mints an HMAC token + mails it, confirm clears the
|
|
25
|
+
// Redis lockout state.
|
|
26
|
+
requestAccountUnlock: "auth-email-password:write:request-account-unlock",
|
|
27
|
+
confirmAccountUnlock: "auth-email-password:write:confirm-account-unlock",
|
|
22
28
|
// Magic-Link Self-Signup (Pre-Activation-Token-Pattern). request mintet
|
|
23
29
|
// einen opaken Random-Token, speichert ihn bidirektional in Redis und
|
|
24
30
|
// sendet eine Aktivierungs-Mail. confirm löst den Token ein und legt
|
|
@@ -59,6 +65,11 @@ export const AuthErrors = {
|
|
|
59
65
|
invalidVerificationToken: "invalid_verification_token",
|
|
60
66
|
verificationNotConfigured: "email_verification_not_configured",
|
|
61
67
|
emailNotVerified: "email_not_verified",
|
|
68
|
+
// Account-Unlock: mirrors the reset-token handling — every verify
|
|
69
|
+
// failure (malformed/bad-signature/expired) collapses to this single
|
|
70
|
+
// code so a probing client can't distinguish tampered from stale.
|
|
71
|
+
invalidUnlockToken: "invalid_unlock_token",
|
|
72
|
+
unlockNotConfigured: "account_unlock_not_configured",
|
|
62
73
|
// Self-Signup: alle confirm-Failures (unbekannter Token, schon
|
|
63
74
|
// konsumiert, abgelaufen) collapsen auf diesen Code — gleicher
|
|
64
75
|
// anti-enumeration-Trade-off wie reset/verify.
|
|
@@ -103,6 +114,10 @@ export const AuthErrors = {
|
|
|
103
114
|
export const AUTH_LOCKOUT_DEFAULT_MAX_FAILED_ATTEMPTS = 5;
|
|
104
115
|
export const AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES = 15;
|
|
105
116
|
|
|
117
|
+
// Account-unlock token TTL — same as reset (15min): short-lived, the user
|
|
118
|
+
// clicks it right after hitting the lockout screen.
|
|
119
|
+
export const AUTH_UNLOCK_DEFAULT_TTL_MINUTES = 15;
|
|
120
|
+
|
|
106
121
|
export const AUTH_RESET_DEFAULT_TTL_MINUTES = 15;
|
|
107
122
|
// Verification tokens live longer by default because the user may not be
|
|
108
123
|
// at their computer the moment they sign up — 24h covers "verify after
|
|
@@ -73,6 +73,14 @@ const STRINGS = {
|
|
|
73
73
|
inviteExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
|
|
74
74
|
inviteIgnore:
|
|
75
75
|
"Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.",
|
|
76
|
+
unlockSubject: (app: string) => `${app} — Konto entsperren`,
|
|
77
|
+
unlockGreeting: "Hallo,",
|
|
78
|
+
unlockIntro: (app: string) =>
|
|
79
|
+
`dein ${app}-Konto wurde nach mehreren fehlgeschlagenen Anmeldeversuchen vorübergehend gesperrt. Klicke auf den folgenden Link, um es sofort zu entsperren:`,
|
|
80
|
+
unlockButton: "Konto entsperren",
|
|
81
|
+
unlockExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
|
|
82
|
+
unlockIgnore:
|
|
83
|
+
"Falls du diese Sperre nicht ausgelöst hast, kannst du diese E-Mail ignorieren — die Sperre läuft von selbst ab.",
|
|
76
84
|
},
|
|
77
85
|
en: {
|
|
78
86
|
resetSubject: (app: string) => `${app} — Reset your password`,
|
|
@@ -105,6 +113,14 @@ const STRINGS = {
|
|
|
105
113
|
inviteButton: "Accept invitation",
|
|
106
114
|
inviteExpiry: (when: string) => `The link expires on ${when}.`,
|
|
107
115
|
inviteIgnore: "If you weren't expecting this invitation, you can ignore this email.",
|
|
116
|
+
unlockSubject: (app: string) => `${app} — Unlock your account`,
|
|
117
|
+
unlockGreeting: "Hi,",
|
|
118
|
+
unlockIntro: (app: string) =>
|
|
119
|
+
`your ${app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:`,
|
|
120
|
+
unlockButton: "Unlock account",
|
|
121
|
+
unlockExpiry: (when: string) => `The link expires on ${when}.`,
|
|
122
|
+
unlockIgnore:
|
|
123
|
+
"If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
|
|
108
124
|
},
|
|
109
125
|
} as const;
|
|
110
126
|
|
|
@@ -151,6 +167,22 @@ export function renderResetPasswordEmail(args: RenderTokenContentArgs): AuthMail
|
|
|
151
167
|
});
|
|
152
168
|
}
|
|
153
169
|
|
|
170
|
+
export function renderUnlockAccountEmail(args: RenderTokenContentArgs): AuthMailContent {
|
|
171
|
+
const locale = args.locale ?? "en";
|
|
172
|
+
const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
|
|
173
|
+
const t = STRINGS[locale];
|
|
174
|
+
return tokenMailContent({
|
|
175
|
+
subject: t.unlockSubject(appName),
|
|
176
|
+
header: t.unlockButton,
|
|
177
|
+
greeting: t.unlockGreeting,
|
|
178
|
+
intro: t.unlockIntro(appName),
|
|
179
|
+
buttonLabel: t.unlockButton,
|
|
180
|
+
buttonUrl: args.url,
|
|
181
|
+
expiry: t.unlockExpiry(formatExpiry(args.expiresAt)),
|
|
182
|
+
ignore: t.unlockIgnore,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
154
186
|
export function renderVerifyEmail(args: RenderTokenContentArgs): AuthMailContent {
|
|
155
187
|
const locale = args.locale ?? "en";
|
|
156
188
|
const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
|
|
@@ -37,6 +37,15 @@ export function invalidResetToken() {
|
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// @wrapper-known error-helper
|
|
41
|
+
export function invalidUnlockToken() {
|
|
42
|
+
return writeFailure(
|
|
43
|
+
new UnprocessableError(AuthErrors.invalidUnlockToken, {
|
|
44
|
+
i18nKey: "auth.errors.invalidUnlockToken",
|
|
45
|
+
}),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
// @wrapper-known error-helper
|
|
41
50
|
export function invalidVerificationToken() {
|
|
42
51
|
return writeFailure(
|