@cosmicdrift/kumiko-bundled-features 0.149.2 → 0.150.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/package.json +7 -6
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +1 -1
- package/src/audit/__tests__/audit-security.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/auth.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow-kms.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/multi-roles.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +1 -1
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -2
- package/src/auth-email-password/handlers/login.write.ts +1 -1
- package/src/auth-email-password/handlers/reset-password.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +6 -1
- package/src/auth-email-password/seeding.ts +1 -1
- package/src/auth-email-password/web/auth-gate.tsx +2 -0
- package/src/auth-email-password/web/login-screen.tsx +2 -0
- package/src/auth-mfa/__tests__/base32.test.ts +28 -0
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +16 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +60 -0
- package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +2 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +2 -1
- package/src/auth-mfa/__tests__/totp.test.ts +11 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +67 -1
- package/src/auth-mfa/base32.ts +7 -1
- package/src/auth-mfa/constants.ts +14 -0
- package/src/auth-mfa/errors.ts +9 -16
- package/src/auth-mfa/feature.ts +4 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +20 -1
- package/src/auth-mfa/handlers/verify.write.ts +26 -3
- package/src/auth-mfa/index.ts +0 -1
- package/src/auth-mfa/recovery-codes.ts +12 -2
- package/src/auth-mfa/schema/user-mfa.ts +3 -0
- package/src/auth-mfa/testing.ts +5 -0
- package/src/auth-mfa/web/__tests__/mfa-error-keys.test.ts +20 -0
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +37 -28
- package/src/auth-mfa/web/mfa-error-keys.ts +7 -5
- package/src/auth-mfa/web/mfa-verify-screen.tsx +11 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +7 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +2 -0
- package/src/config/handlers/cascade.query.ts +2 -1
- package/src/config/handlers/values.query.ts +2 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +15 -2
- package/src/legal-pages/feature.ts +2 -1
- package/src/managed-pages/feature.ts +1 -6
- package/src/seo/feature.ts +1 -6
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +51 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +44 -0
- package/src/sessions/__tests__/test-helpers.ts +1 -1
- package/src/sessions/constants.ts +5 -0
- package/src/sessions/feature.ts +67 -1
- package/src/sessions/handlers/detail.query.ts +36 -0
- package/src/sessions/i18n.ts +23 -0
- package/src/shared/index.ts +1 -0
- package/src/shared/system-query.ts +5 -0
- package/src/tenant/__tests__/tenant-security.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +163 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +39 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +1 -1
- package/src/user-data-rights/handlers/download-by-job.query.ts +5 -2
- package/src/user-data-rights/handlers/download-by-token.query.ts +5 -4
- package/src/user-data-rights/lib/tenant-file-provider.ts +62 -0
- package/src/user-profile/__tests__/change-email.integration.test.ts +1 -1
- package/src/user-profile/handlers/change-email.write.ts +2 -2
- package/src/auth-email-password/password-hashing.ts +0 -4
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
3
|
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
3
4
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
5
|
import {
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
import {
|
|
12
13
|
createTestEnvelopeCipher,
|
|
13
14
|
expectErrorIncludes,
|
|
15
|
+
seedRow,
|
|
14
16
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
15
17
|
import { createConfigFeature } from "../../config";
|
|
16
18
|
import { createConfigResolver } from "../../config/resolver";
|
|
@@ -18,8 +20,9 @@ import { configValuesTable } from "../../config/table";
|
|
|
18
20
|
import { createTenantFeature } from "../../tenant";
|
|
19
21
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
20
22
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
23
|
+
import { USER_STATUS } from "../../user";
|
|
21
24
|
import { createUserFeature } from "../../user/feature";
|
|
22
|
-
import { userEntity } from "../../user/schema/user";
|
|
25
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
23
26
|
import { base32Decode } from "../base32";
|
|
24
27
|
import { AuthMfaHandlers } from "../constants";
|
|
25
28
|
import { createAuthMfaFeature } from "../feature";
|
|
@@ -87,6 +90,15 @@ async function enableMfaFor(idSeed: number): Promise<{
|
|
|
87
90
|
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
88
91
|
user,
|
|
89
92
|
);
|
|
93
|
+
// verify.write.ts re-checks tenant membership the same way login.write.ts
|
|
94
|
+
// does — without a seeded row here every verify would now hit the
|
|
95
|
+
// membership-revoked gate below instead of the scenario each test means
|
|
96
|
+
// to exercise.
|
|
97
|
+
await seedRow(stack.db, tenantMembershipsTable, {
|
|
98
|
+
userId: user.id,
|
|
99
|
+
tenantId: user.tenantId,
|
|
100
|
+
roles: JSON.stringify(["User"]),
|
|
101
|
+
});
|
|
90
102
|
return { user, secret, recoveryCodes: start.recoveryCodes };
|
|
91
103
|
}
|
|
92
104
|
|
|
@@ -184,3 +196,57 @@ describe("mfa verify — completes a two-step login", () => {
|
|
|
184
196
|
expectErrorIncludes(err, "invalid_challenge_token");
|
|
185
197
|
});
|
|
186
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("mfa verify — re-checks account state the way login.write.ts does", () => {
|
|
201
|
+
test("membership revoked between login and verify → challenge rejected, no fallback to global roles", async () => {
|
|
202
|
+
const { user, secret } = await enableMfaFor(5);
|
|
203
|
+
const challengeToken = challengeFor(user.id, user.tenantId);
|
|
204
|
+
|
|
205
|
+
// Simulate the membership being pulled after the challenge token was
|
|
206
|
+
// issued but before verify runs — the exact gap login.write.ts's
|
|
207
|
+
// noMembership() guards against.
|
|
208
|
+
await asRawClient(stack.db).unsafe(
|
|
209
|
+
`DELETE FROM "${tenantMembershipsTable.tableName}" WHERE user_id = $1 AND tenant_id = $2`,
|
|
210
|
+
[user.id, user.tenantId],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const err = await stack.http.writeErr(
|
|
214
|
+
AuthMfaHandlers.verify,
|
|
215
|
+
{ challengeToken, code: currentTotpCode(secret) },
|
|
216
|
+
GUEST,
|
|
217
|
+
);
|
|
218
|
+
expectErrorIncludes(err, "invalid_challenge_token");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("account restricted between login and verify → challenge rejected", async () => {
|
|
222
|
+
const { user, secret } = await enableMfaFor(6);
|
|
223
|
+
const challengeToken = challengeFor(user.id, user.tenantId);
|
|
224
|
+
|
|
225
|
+
// auth-mfa never writes a read_users row itself (enableMfaFor only
|
|
226
|
+
// drives enable-start/enable-confirm) — seed one so the status gate has
|
|
227
|
+
// a row to actually flip, then flip it to simulate a restriction landing
|
|
228
|
+
// between login and verify.
|
|
229
|
+
await seedRow(stack.db, userTable, {
|
|
230
|
+
id: user.id,
|
|
231
|
+
tenantId: user.tenantId,
|
|
232
|
+
email: `user-${user.id}@example.com`,
|
|
233
|
+
passwordHash: "h",
|
|
234
|
+
displayName: "Restricted User",
|
|
235
|
+
locale: "de",
|
|
236
|
+
emailVerified: true,
|
|
237
|
+
roles: "[]",
|
|
238
|
+
status: USER_STATUS.Active,
|
|
239
|
+
});
|
|
240
|
+
await asRawClient(stack.db).unsafe(
|
|
241
|
+
`UPDATE "${userTable.tableName}" SET status = $1 WHERE id = $2`,
|
|
242
|
+
[USER_STATUS.Restricted, user.id],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const err = await stack.http.writeErr(
|
|
246
|
+
AuthMfaHandlers.verify,
|
|
247
|
+
{ challengeToken, code: currentTotpCode(secret) },
|
|
248
|
+
GUEST,
|
|
249
|
+
);
|
|
250
|
+
expectErrorIncludes(err, "invalid_challenge_token");
|
|
251
|
+
});
|
|
252
|
+
});
|
package/src/auth-mfa/base32.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function base32Encode(bytes: Buffer): string {
|
|
|
27
27
|
// character so a corrupted stored secret fails loud at read time instead of
|
|
28
28
|
// silently producing wrong codes.
|
|
29
29
|
export function base32Decode(encoded: string): Buffer {
|
|
30
|
-
const clean = encoded.trim().toUpperCase().replace(/\s+/g, "");
|
|
30
|
+
const clean = encoded.trim().toUpperCase().replace(/\s+/g, "").replace(/=+$/, "");
|
|
31
31
|
let bits = 0;
|
|
32
32
|
let value = 0;
|
|
33
33
|
const bytes: number[] = [];
|
|
@@ -41,5 +41,11 @@ export function base32Decode(encoded: string): Buffer {
|
|
|
41
41
|
bits -= 8;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
// Leftover bits must be padding zeros (a canonical-length encoding never
|
|
45
|
+
// leaves a full byte's worth, i.e. >=5 residual bits) — anything else means
|
|
46
|
+
// truncated/non-canonical input decoded to the wrong byte count.
|
|
47
|
+
if (bits >= 5 || (value & ((1 << bits) - 1)) !== 0) {
|
|
48
|
+
throw new Error("base32Decode: invalid length or non-zero padding bits");
|
|
49
|
+
}
|
|
44
50
|
return Buffer.from(bytes);
|
|
45
51
|
}
|
|
@@ -19,6 +19,20 @@ export const AuthMfaQueries = {
|
|
|
19
19
|
status: "auth-mfa:query:user-mfa:status",
|
|
20
20
|
} as const;
|
|
21
21
|
|
|
22
|
+
// Write-failure codes shared between errors.ts (server, mints them into
|
|
23
|
+
// UnprocessableError) and web/mfa-error-keys.ts (client, maps them to i18n
|
|
24
|
+
// keys) — one source so a renamed code can't drift out of sync between the
|
|
25
|
+
// two switches.
|
|
26
|
+
export const AuthMfaErrorCodes = {
|
|
27
|
+
mfaAlreadyEnabled: "mfa_already_enabled",
|
|
28
|
+
mfaNotEnabled: "mfa_not_enabled",
|
|
29
|
+
invalidSetupToken: "invalid_setup_token",
|
|
30
|
+
invalidTotpCode: "invalid_totp_code",
|
|
31
|
+
invalidRecoveryCode: "invalid_recovery_code",
|
|
32
|
+
invalidChallengeToken: "invalid_challenge_token",
|
|
33
|
+
tooManyAttempts: "too_many_attempts",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
22
36
|
export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
|
|
23
37
|
export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
|
|
24
38
|
export const MFA_VERIFY_MAX_ATTEMPTS = 5;
|
package/src/auth-mfa/errors.ts
CHANGED
|
@@ -3,20 +3,13 @@ import {
|
|
|
3
3
|
type WriteFailure,
|
|
4
4
|
writeFailure,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/errors";
|
|
6
|
+
import { AuthMfaErrorCodes } from "./constants";
|
|
6
7
|
|
|
7
|
-
export
|
|
8
|
-
mfaAlreadyEnabled: "mfa_already_enabled",
|
|
9
|
-
mfaNotEnabled: "mfa_not_enabled",
|
|
10
|
-
invalidSetupToken: "invalid_setup_token",
|
|
11
|
-
invalidTotpCode: "invalid_totp_code",
|
|
12
|
-
invalidRecoveryCode: "invalid_recovery_code",
|
|
13
|
-
invalidChallengeToken: "invalid_challenge_token",
|
|
14
|
-
tooManyAttempts: "too_many_attempts",
|
|
15
|
-
} as const;
|
|
8
|
+
export { AuthMfaErrorCodes as AuthMfaErrors } from "./constants";
|
|
16
9
|
|
|
17
10
|
export function mfaAlreadyEnabled(): WriteFailure {
|
|
18
11
|
return writeFailure(
|
|
19
|
-
new UnprocessableError(
|
|
12
|
+
new UnprocessableError(AuthMfaErrorCodes.mfaAlreadyEnabled, {
|
|
20
13
|
i18nKey: "authMfa.errors.mfaAlreadyEnabled",
|
|
21
14
|
}),
|
|
22
15
|
);
|
|
@@ -24,7 +17,7 @@ export function mfaAlreadyEnabled(): WriteFailure {
|
|
|
24
17
|
|
|
25
18
|
export function mfaNotEnabled(): WriteFailure {
|
|
26
19
|
return writeFailure(
|
|
27
|
-
new UnprocessableError(
|
|
20
|
+
new UnprocessableError(AuthMfaErrorCodes.mfaNotEnabled, {
|
|
28
21
|
i18nKey: "authMfa.errors.mfaNotEnabled",
|
|
29
22
|
}),
|
|
30
23
|
);
|
|
@@ -32,7 +25,7 @@ export function mfaNotEnabled(): WriteFailure {
|
|
|
32
25
|
|
|
33
26
|
export function invalidSetupToken(): WriteFailure {
|
|
34
27
|
return writeFailure(
|
|
35
|
-
new UnprocessableError(
|
|
28
|
+
new UnprocessableError(AuthMfaErrorCodes.invalidSetupToken, {
|
|
36
29
|
i18nKey: "authMfa.errors.invalidSetupToken",
|
|
37
30
|
}),
|
|
38
31
|
);
|
|
@@ -40,7 +33,7 @@ export function invalidSetupToken(): WriteFailure {
|
|
|
40
33
|
|
|
41
34
|
export function invalidTotpCode(): WriteFailure {
|
|
42
35
|
return writeFailure(
|
|
43
|
-
new UnprocessableError(
|
|
36
|
+
new UnprocessableError(AuthMfaErrorCodes.invalidTotpCode, {
|
|
44
37
|
i18nKey: "authMfa.errors.invalidTotpCode",
|
|
45
38
|
}),
|
|
46
39
|
);
|
|
@@ -48,7 +41,7 @@ export function invalidTotpCode(): WriteFailure {
|
|
|
48
41
|
|
|
49
42
|
export function invalidRecoveryCode(): WriteFailure {
|
|
50
43
|
return writeFailure(
|
|
51
|
-
new UnprocessableError(
|
|
44
|
+
new UnprocessableError(AuthMfaErrorCodes.invalidRecoveryCode, {
|
|
52
45
|
i18nKey: "authMfa.errors.invalidRecoveryCode",
|
|
53
46
|
}),
|
|
54
47
|
);
|
|
@@ -56,7 +49,7 @@ export function invalidRecoveryCode(): WriteFailure {
|
|
|
56
49
|
|
|
57
50
|
export function invalidChallengeToken(): WriteFailure {
|
|
58
51
|
return writeFailure(
|
|
59
|
-
new UnprocessableError(
|
|
52
|
+
new UnprocessableError(AuthMfaErrorCodes.invalidChallengeToken, {
|
|
60
53
|
i18nKey: "authMfa.errors.invalidChallengeToken",
|
|
61
54
|
}),
|
|
62
55
|
);
|
|
@@ -65,7 +58,7 @@ export function invalidChallengeToken(): WriteFailure {
|
|
|
65
58
|
// retryAfterSeconds drives the login UI countdown — must stay > 0.
|
|
66
59
|
export function tooManyAttempts(retryAfterSeconds: number): WriteFailure {
|
|
67
60
|
return writeFailure(
|
|
68
|
-
new UnprocessableError(
|
|
61
|
+
new UnprocessableError(AuthMfaErrorCodes.tooManyAttempts, {
|
|
69
62
|
i18nKey: "authMfa.errors.tooManyAttempts",
|
|
70
63
|
details: { retryAfterSeconds },
|
|
71
64
|
}),
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -80,6 +80,10 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
80
80
|
});
|
|
81
81
|
r.requires("user");
|
|
82
82
|
r.requires("config");
|
|
83
|
+
// verify.write.ts dispatches "tenant:query:memberships" — without this
|
|
84
|
+
// the app boots clean and the first MFA login dies at runtime on an
|
|
85
|
+
// unknown handler instead of failing at mount time.
|
|
86
|
+
r.requires("tenant");
|
|
83
87
|
r.config({ keys: { required: mfaRequiredConfigKey() } });
|
|
84
88
|
|
|
85
89
|
// Dormant custom-screen — the client maps MFA_ENABLE_SCREEN_ID to
|
|
@@ -2,8 +2,10 @@ import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { Temporal } from "temporal-polyfill";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
+
import { burnToken } from "../../shared";
|
|
5
6
|
import { base32Decode } from "../base32";
|
|
6
|
-
import {
|
|
7
|
+
import { findUserMfaRow } from "../db/queries";
|
|
8
|
+
import { invalidSetupToken, invalidTotpCode, mfaAlreadyEnabled } from "../errors";
|
|
7
9
|
import { verifyMfaSetupToken } from "../mfa-setup-token";
|
|
8
10
|
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
9
11
|
import { verifyTotp } from "../totp";
|
|
@@ -42,6 +44,23 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
|
|
|
42
44
|
const secret = base32Decode(verify.payload.totpSecretBase32);
|
|
43
45
|
if (!verifyTotp(secret, event.payload.code)) return invalidTotpCode();
|
|
44
46
|
|
|
47
|
+
// Burn the setup token on the first successful confirm — without this
|
|
48
|
+
// a `disable` doesn't retire it (re-enabling with the secret the user
|
|
49
|
+
// thinks they discarded), and two parallel confirms both proceed to
|
|
50
|
+
// executor.create instead of the second seeing mfa_already_enabled().
|
|
51
|
+
if (ctx.redis) {
|
|
52
|
+
const burnResult = await burnToken(
|
|
53
|
+
ctx.redis,
|
|
54
|
+
"mfa-setup",
|
|
55
|
+
event.user.id,
|
|
56
|
+
verify.expiresAtMs,
|
|
57
|
+
);
|
|
58
|
+
if (burnResult === "already-used") return invalidSetupToken();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const existing = await findUserMfaRow(ctx.db, event.user);
|
|
62
|
+
if (existing) return mfaAlreadyEnabled();
|
|
63
|
+
|
|
45
64
|
const result = await executor.create(
|
|
46
65
|
{
|
|
47
66
|
userId: event.user.id,
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
import { burnToken } from "../../shared";
|
|
11
|
-
import { UserQueries } from "../../user";
|
|
11
|
+
import { USER_STATUS, UserQueries } from "../../user";
|
|
12
12
|
import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
|
|
13
13
|
import { findUserMfaRow } from "../db/queries";
|
|
14
14
|
import { invalidChallengeToken, invalidTotpCode, tooManyAttempts } from "../errors";
|
|
@@ -121,14 +121,37 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
121
121
|
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
122
122
|
const userRow = (await ctx.queryAs(systemUser, UserQueries.detail, {
|
|
123
123
|
id: userId,
|
|
124
|
-
})) as { roles?: string | null } | null; // @cast-boundary engine-payload
|
|
124
|
+
})) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
|
|
125
|
+
|
|
126
|
+
// Re-check status + membership the way login.write.ts does after its
|
|
127
|
+
// password check — the challenge token only proves "password was
|
|
128
|
+
// correct 10 minutes ago", not that the account is still loginable
|
|
129
|
+
// now. Deliberately NOT re-checking strictEmailVerification here:
|
|
130
|
+
// MfaVerifyOptions has no such flag, and email verification can't
|
|
131
|
+
// regress between login and verify within the 10-minute window.
|
|
132
|
+
if (
|
|
133
|
+
userRow?.status === USER_STATUS.Restricted ||
|
|
134
|
+
userRow?.status === USER_STATUS.DeletionRequested ||
|
|
135
|
+
userRow?.status === USER_STATUS.Deleted
|
|
136
|
+
) {
|
|
137
|
+
return invalidChallengeToken();
|
|
138
|
+
}
|
|
139
|
+
|
|
125
140
|
const globalRoles = parseRoles(userRow?.roles ?? null);
|
|
126
141
|
|
|
127
142
|
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
128
143
|
userId,
|
|
129
144
|
})) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
|
|
130
145
|
const membership = memberships.find((m) => m.tenantId === tenantId);
|
|
131
|
-
|
|
146
|
+
// Membership revoked between login and verify (race, or a stale
|
|
147
|
+
// challenge token from before removal) — login.write.ts would have
|
|
148
|
+
// refused with noMembership() at the same juncture; mirror it here
|
|
149
|
+
// instead of silently falling back to globalRoles only.
|
|
150
|
+
if (!membership) return invalidChallengeToken();
|
|
151
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
152
|
+
// roles from the membership portion (globalRoles keeps SystemAdmin) —
|
|
153
|
+
// read-time backstop against a rebuild-resurrected role.
|
|
154
|
+
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
132
155
|
|
|
133
156
|
const baseSession: SessionUser = { id: userId, tenantId, roles: mergedRoles };
|
|
134
157
|
const claims = await ctx.resolveAuthClaims(baseSession);
|
package/src/auth-mfa/index.ts
CHANGED
|
@@ -7,6 +7,15 @@ const RECOVERY_CODE_COUNT = 8;
|
|
|
7
7
|
// ticket, not just a retry.
|
|
8
8
|
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
9
9
|
|
|
10
|
+
// Recovery codes get read aloud or copy-typed from a screen — a user retyping
|
|
11
|
+
// "abcd1234" lowercase or without the dash must still match. Applied on both
|
|
12
|
+
// sides (hash-time here is a no-op on freshly generated codes, but keeps
|
|
13
|
+
// hash and verify deckungsgleich if generation ever changes) so a formatting
|
|
14
|
+
// slip never surfaces as "wrong code" for an otherwise-correct code.
|
|
15
|
+
function normalizeRecoveryCode(code: string): string {
|
|
16
|
+
return code.toUpperCase().replace(/[^A-Z2-9]/g, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
function randomCodeGroup(): string {
|
|
11
20
|
let out = "";
|
|
12
21
|
for (let i = 0; i < 4; i++) {
|
|
@@ -26,7 +35,7 @@ export function generateRecoveryCodes(count: number = RECOVERY_CODE_COUNT): stri
|
|
|
26
35
|
// Reuses the same argon2id hashing as passwords — recovery codes are
|
|
27
36
|
// bearer-secrets of comparable sensitivity (whoever has one can log in).
|
|
28
37
|
export async function hashRecoveryCodes(codes: readonly string[]): Promise<string[]> {
|
|
29
|
-
return Promise.all(codes.map((code) => hashPassword(code)));
|
|
38
|
+
return Promise.all(codes.map((code) => hashPassword(normalizeRecoveryCode(code))));
|
|
30
39
|
}
|
|
31
40
|
|
|
32
41
|
// Sequential (not Promise.all) — recovery-code lists are ≤8 entries, and a
|
|
@@ -36,9 +45,10 @@ export async function findMatchingRecoveryCodeIndex(
|
|
|
36
45
|
code: string,
|
|
37
46
|
hashes: readonly string[],
|
|
38
47
|
): Promise<number> {
|
|
48
|
+
const normalized = normalizeRecoveryCode(code);
|
|
39
49
|
for (let i = 0; i < hashes.length; i++) {
|
|
40
50
|
const hash = hashes[i];
|
|
41
|
-
if (hash !== undefined && (await verifyPassword(hash,
|
|
51
|
+
if (hash !== undefined && (await verifyPassword(hash, normalized))) return i;
|
|
42
52
|
}
|
|
43
53
|
return -1;
|
|
44
54
|
}
|
|
@@ -33,6 +33,9 @@ export const userMfaEntity = createEntity({
|
|
|
33
33
|
// (see db/queries.ts's findUserMfaRow) rather than storing jsonb here.
|
|
34
34
|
// A redeemed code's hash is removed from the array (see disable/regen
|
|
35
35
|
// handlers), so array length also IS the remaining-codes count.
|
|
36
|
+
// No jsonb->text migration: zero-legacy/single-user framework repo, no
|
|
37
|
+
// production user_mfa rows exist to backfill (delete-and-re-enroll is
|
|
38
|
+
// the upgrade path for anyone who hit the old jsonb shape locally).
|
|
36
39
|
recoveryCodes: createTextField({
|
|
37
40
|
required: true,
|
|
38
41
|
encrypted: true,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Test-only helper — not part of the runtime surface (a real client never
|
|
2
|
+
// derives a code from the raw secret; the authenticator app does that).
|
|
3
|
+
// Kept out of the main "./auth-mfa" barrel so consumers don't see a
|
|
4
|
+
// server-side "valid code now" generator in autocomplete.
|
|
5
|
+
export { currentTotpCode } from "./totp";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { AuthMfaErrorCodes } from "../../constants";
|
|
3
|
+
import { defaultTranslations } from "../i18n";
|
|
4
|
+
import { mfaManageErrorKey } from "../mfa-error-keys";
|
|
5
|
+
|
|
6
|
+
describe("mfaManageErrorKey", () => {
|
|
7
|
+
for (const code of Object.values(AuthMfaErrorCodes)) {
|
|
8
|
+
test(`${code} maps to a key registered in en/de translations`, () => {
|
|
9
|
+
const key = mfaManageErrorKey(code);
|
|
10
|
+
expect(defaultTranslations["en"]?.[key]).toBeDefined();
|
|
11
|
+
expect(defaultTranslations["de"]?.[key]).toBeDefined();
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("unknown code falls back to the generic verifyFailed key", () => {
|
|
16
|
+
const key = mfaManageErrorKey("some_future_server_code");
|
|
17
|
+
expect(key).toBe("auth.mfa.errors.verifyFailed");
|
|
18
|
+
expect(defaultTranslations["en"]?.[key]).toBeDefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
package/src/auth-mfa/web/i18n.ts
CHANGED
|
@@ -13,6 +13,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
13
13
|
"auth.mfa.verify.code": "Code",
|
|
14
14
|
"auth.mfa.verify.submit": "Bestätigen",
|
|
15
15
|
"auth.mfa.verify.submitting": "…",
|
|
16
|
+
"auth.mfa.verify.backToLogin": "Zurück zum Login",
|
|
16
17
|
"auth.mfa.errors.invalidCode": "Ungültiger Code. Bitte erneut versuchen.",
|
|
17
18
|
"auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
|
|
18
19
|
"auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
|
|
@@ -62,6 +63,7 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
62
63
|
"auth.mfa.verify.code": "Code",
|
|
63
64
|
"auth.mfa.verify.submit": "Verify",
|
|
64
65
|
"auth.mfa.verify.submitting": "…",
|
|
66
|
+
"auth.mfa.verify.backToLogin": "Back to login",
|
|
65
67
|
"auth.mfa.errors.invalidCode": "Invalid code. Please try again.",
|
|
66
68
|
"auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
|
|
67
69
|
"auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { useDispatcher, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
11
11
|
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
12
|
-
import QRCode from "qrcode";
|
|
12
|
+
import QRCode from "qrcode/lib/browser";
|
|
13
13
|
import { type ReactNode, useState } from "react";
|
|
14
14
|
// kumiko-lint-ignore cross-feature-import client-only hook, the feature's server barrel has no web/ re-export
|
|
15
15
|
import { useSession } from "../../auth-email-password/web";
|
|
@@ -65,42 +65,51 @@ export function MfaEnableScreen({
|
|
|
65
65
|
const startSetup = async (): Promise<void> => {
|
|
66
66
|
setBusy(true);
|
|
67
67
|
setError(null);
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
try {
|
|
69
|
+
const res = await dispatcher.write<EnableStartResponse>(AuthMfaHandlers.enableStart, {
|
|
70
|
+
accountLabel: session.user?.email ?? "",
|
|
71
|
+
});
|
|
72
|
+
if (!res.isSuccess) {
|
|
73
|
+
setError(res.error.code);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const qrSvg = await QRCode.toString(res.data.otpauthUri, { type: "svg" });
|
|
77
|
+
setSetup({
|
|
78
|
+
setupToken: res.data.setupToken,
|
|
79
|
+
secretParam: extractSecret(res.data.otpauthUri),
|
|
80
|
+
recoveryCodes: res.data.recoveryCodes,
|
|
81
|
+
qrSvg,
|
|
82
|
+
});
|
|
83
|
+
setAcknowledged(false);
|
|
84
|
+
setCode("");
|
|
85
|
+
} catch {
|
|
86
|
+
setError("setup_failed");
|
|
87
|
+
} finally {
|
|
72
88
|
setBusy(false);
|
|
73
|
-
setError(res.error.code);
|
|
74
|
-
return;
|
|
75
89
|
}
|
|
76
|
-
const qrSvg = await QRCode.toString(res.data.otpauthUri, { type: "svg" });
|
|
77
|
-
setBusy(false);
|
|
78
|
-
setSetup({
|
|
79
|
-
setupToken: res.data.setupToken,
|
|
80
|
-
secretParam: extractSecret(res.data.otpauthUri),
|
|
81
|
-
recoveryCodes: res.data.recoveryCodes,
|
|
82
|
-
qrSvg,
|
|
83
|
-
});
|
|
84
|
-
setAcknowledged(false);
|
|
85
|
-
setCode("");
|
|
86
90
|
};
|
|
87
91
|
|
|
88
92
|
const confirmSetup = async (): Promise<void> => {
|
|
89
93
|
if (!setup) return;
|
|
90
94
|
setBusy(true);
|
|
91
95
|
setError(null);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
96
|
+
try {
|
|
97
|
+
const res = await dispatcher.write(AuthMfaHandlers.enableConfirm, {
|
|
98
|
+
setupToken: setup.setupToken,
|
|
99
|
+
code,
|
|
100
|
+
});
|
|
101
|
+
if (!res.isSuccess) {
|
|
102
|
+
setError(res.error.code);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
setEnabled(true);
|
|
106
|
+
setSetup(null);
|
|
107
|
+
onEnabled?.();
|
|
108
|
+
} catch {
|
|
109
|
+
setError("setup_failed");
|
|
110
|
+
} finally {
|
|
111
|
+
setBusy(false);
|
|
100
112
|
}
|
|
101
|
-
setEnabled(true);
|
|
102
|
-
setSetup(null);
|
|
103
|
-
onEnabled?.();
|
|
104
113
|
};
|
|
105
114
|
|
|
106
115
|
const content = (
|
|
@@ -7,17 +7,19 @@
|
|
|
7
7
|
// each re-deriving the key ad hoc (enable-screen used to template-string
|
|
8
8
|
// the raw snake_case code straight into the key, which never matched the
|
|
9
9
|
// camelCase keys actually registered below).
|
|
10
|
+
import { AuthMfaErrorCodes } from "../constants";
|
|
11
|
+
|
|
10
12
|
export function mfaManageErrorKey(code: string): string {
|
|
11
13
|
switch (code) {
|
|
12
|
-
case
|
|
14
|
+
case AuthMfaErrorCodes.invalidTotpCode:
|
|
13
15
|
return "auth.mfa.errors.invalidCode";
|
|
14
|
-
case
|
|
16
|
+
case AuthMfaErrorCodes.invalidRecoveryCode:
|
|
15
17
|
return "auth.mfa.errors.invalidRecoveryCode";
|
|
16
|
-
case
|
|
18
|
+
case AuthMfaErrorCodes.mfaAlreadyEnabled:
|
|
17
19
|
return "auth.mfa.errors.mfaAlreadyEnabled";
|
|
18
|
-
case
|
|
20
|
+
case AuthMfaErrorCodes.mfaNotEnabled:
|
|
19
21
|
return "auth.mfa.errors.mfaNotEnabled";
|
|
20
|
-
case
|
|
22
|
+
case AuthMfaErrorCodes.invalidSetupToken:
|
|
21
23
|
return "auth.mfa.errors.invalidSetupToken";
|
|
22
24
|
default:
|
|
23
25
|
return "auth.mfa.errors.verifyFailed";
|
|
@@ -21,6 +21,11 @@ export type MfaVerifyScreenProps = {
|
|
|
21
21
|
* refreshed. Optional — apps that swap this screen back out purely on
|
|
22
22
|
* session.status changing to "authenticated" don't need it. */
|
|
23
23
|
readonly onSuccess?: () => void;
|
|
24
|
+
// Some failures (challenge_expired, too_many_attempts) have no retry path —
|
|
25
|
+
// the challengeToken is dead. Without this, the host has no way back to
|
|
26
|
+
// login short of a full page reload. Optional: apps that always mount
|
|
27
|
+
// this screen fresh per challenge can omit it.
|
|
28
|
+
readonly onCancel?: () => void;
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
function reasonToKey(reason: string): string {
|
|
@@ -47,6 +52,7 @@ export function MfaVerifyScreen({
|
|
|
47
52
|
subtitle,
|
|
48
53
|
submitLabel,
|
|
49
54
|
onSuccess,
|
|
55
|
+
onCancel,
|
|
50
56
|
}: MfaVerifyScreenProps): ReactNode {
|
|
51
57
|
const t = useTranslation();
|
|
52
58
|
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
@@ -99,6 +105,11 @@ export function MfaVerifyScreen({
|
|
|
99
105
|
? t("auth.mfa.verify.submitting")
|
|
100
106
|
: (submitLabel ?? t("auth.mfa.verify.submit"))}
|
|
101
107
|
</Button>
|
|
108
|
+
{onCancel ? (
|
|
109
|
+
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
|
110
|
+
{t("auth.mfa.verify.backToLogin")}
|
|
111
|
+
</Button>
|
|
112
|
+
) : null}
|
|
102
113
|
</Form>
|
|
103
114
|
</div>
|
|
104
115
|
</AuthCard>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @types/qrcode only declares the main "qrcode" entry, not the browser-only
|
|
2
|
+
// subpath (see mfa-enable-screen.tsx's import — the main entry pulls in
|
|
3
|
+
// Node-only deps like yargs/pngjs via qrcode's own package.json#browser
|
|
4
|
+
// remap, which Metro doesn't honor). Same runtime shape, just re-typed.
|
|
5
|
+
declare module "qrcode/lib/browser" {
|
|
6
|
+
export * from "qrcode";
|
|
7
|
+
}
|
|
@@ -20,6 +20,7 @@ import { currentTotpCode } from "../../auth-mfa/totp";
|
|
|
20
20
|
import { createConfigFeature } from "../../config";
|
|
21
21
|
import { createConfigResolver } from "../../config/resolver";
|
|
22
22
|
import { configValuesTable } from "../../config/table";
|
|
23
|
+
import { createTenantFeature } from "../../tenant";
|
|
23
24
|
import { createUserFeature } from "../../user/feature";
|
|
24
25
|
import { userEntity } from "../../user/schema/user";
|
|
25
26
|
import { userMfaDeleteHook, userMfaExportHook } from "../hooks";
|
|
@@ -36,6 +37,7 @@ beforeAll(async () => {
|
|
|
36
37
|
features: [
|
|
37
38
|
createConfigFeature(),
|
|
38
39
|
createUserFeature(),
|
|
40
|
+
createTenantFeature(),
|
|
39
41
|
createAuthMfaFeature({
|
|
40
42
|
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
41
43
|
issuer: "Kumiko Test",
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type ConfigCascade,
|
|
3
3
|
type ConfigCascadeLevel,
|
|
4
4
|
defineQueryHandler,
|
|
5
|
+
isEncryptedAtRest,
|
|
5
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { requireConfigResolver } from "../feature";
|
|
@@ -59,7 +60,7 @@ export const cascadeQuery = defineQueryHandler({
|
|
|
59
60
|
|
|
60
61
|
// backing="secrets" is masked like encrypted — the resolver revealed the
|
|
61
62
|
// plaintext for internal reads, but it must never reach the cascade UI.
|
|
62
|
-
if (keyDef
|
|
63
|
+
if (isEncryptedAtRest(keyDef)) {
|
|
63
64
|
const maskedLevels: ConfigCascadeLevel[] = cascade.levels.map((l) => ({
|
|
64
65
|
...l,
|
|
65
66
|
value: l.hasValue ? MASKED : l.value,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type ConfigScope,
|
|
4
4
|
type ConfigValueSource,
|
|
5
5
|
defineQueryHandler,
|
|
6
|
+
isEncryptedAtRest,
|
|
6
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { requireConfigResolver } from "../feature";
|
|
@@ -63,7 +64,7 @@ export const valuesQuery = defineQueryHandler({
|
|
|
63
64
|
// key so the plaintext (which the resolver revealed for internal reads)
|
|
64
65
|
// never reaches the UI response.
|
|
65
66
|
let value: string | number | boolean | undefined;
|
|
66
|
-
if (keyDef
|
|
67
|
+
if (isEncryptedAtRest(keyDef)) {
|
|
67
68
|
value = cascade.value !== undefined ? MASKED : undefined;
|
|
68
69
|
} else {
|
|
69
70
|
value = cascade.value;
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// 3. PII-Encrypt (from/to/cc/subject/snippet) VOR dem append —
|
|
10
10
|
// Event-Log UND Projection tragen Ciphertext (#800-Muster).
|
|
11
11
|
// 4. inbound-message-received-append (Inline-Projection schreibt
|
|
12
|
-
// read_inbound_messages in derselben TX).
|
|
12
|
+
// read_inbound_messages in derselben TX). Savepoint-scoped
|
|
13
|
+
// (ctx.tryAppendEvent) — a losing concurrent ingest for the same
|
|
14
|
+
// messageAggId returns { duplicate: true } instead of failing the TX.
|
|
13
15
|
// 5. Thread-Rollup: mail-thread-updated-append mit neu berechnetem
|
|
14
16
|
// Snapshot (count+1, max(lastMessageAt)) — die apply ist ein
|
|
15
17
|
// dummer UPSERT.
|
|
@@ -199,13 +201,24 @@ export const ingestMessageHandler: WriteHandlerDef = {
|
|
|
199
201
|
providerName: payload.providerName,
|
|
200
202
|
providerCursor: payload.providerCursor,
|
|
201
203
|
};
|
|
202
|
-
await ctx.
|
|
204
|
+
const messageAppend = await ctx.tryAppendEvent({
|
|
203
205
|
aggregateId: messageAggId,
|
|
204
206
|
aggregateType: INBOUND_MESSAGE_AGGREGATE_TYPE,
|
|
205
207
|
type: INBOUND_MESSAGE_RECEIVED_EVENT_QN,
|
|
206
208
|
payload: messageEventPayload,
|
|
207
209
|
headers: messageHeaders,
|
|
208
210
|
});
|
|
211
|
+
if (!messageAppend.ok) {
|
|
212
|
+
// Lost the race against a concurrent ingest for the same messageAggId
|
|
213
|
+
// (Watch-Push vs. Poll-Reconciliation overlap). The winner already
|
|
214
|
+
// writes thread-rollup + seen-anchor for this message — steps 5+6
|
|
215
|
+
// skip here. tryAppendEvent's savepoint keeps this TX usable despite
|
|
216
|
+
// the caught VersionConflictError (see ctx.tryAppendEvent doc).
|
|
217
|
+
return {
|
|
218
|
+
isSuccess: true as const,
|
|
219
|
+
data: { duplicate: true as const, inboundMessageAggregateId: messageAggId },
|
|
220
|
+
};
|
|
221
|
+
}
|
|
209
222
|
|
|
210
223
|
// ---------------------------------------------------------------
|
|
211
224
|
// 5. Thread-Rollup. Der Handler berechnet den NEUEN Snapshot
|