@cosmicdrift/kumiko-bundled-features 0.147.0 → 0.147.2
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 +12 -7
- package/src/auth-email-password/feature.ts +6 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/login.write.ts +66 -9
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +15 -5
- package/src/auth-email-password/web/__tests__/test-utils.tsx +12 -2
- package/src/auth-email-password/web/auth-client.ts +34 -12
- package/src/auth-email-password/web/auth-gate.tsx +32 -3
- package/src/auth-email-password/web/client-plugin.ts +11 -2
- package/src/auth-email-password/web/index.ts +4 -2
- package/src/auth-email-password/web/login-screen.tsx +31 -2
- package/src/auth-email-password/web/session.tsx +11 -6
- package/src/auth-mfa/__tests__/disable-and-regenerate.integration.test.ts +202 -0
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +150 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +167 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +168 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +186 -0
- package/src/auth-mfa/config.ts +26 -0
- package/src/auth-mfa/constants.ts +25 -0
- package/src/auth-mfa/db/queries.ts +34 -0
- package/src/auth-mfa/errors.ts +73 -0
- package/src/auth-mfa/feature.ts +146 -0
- package/src/auth-mfa/handlers/disable.write.ts +51 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +68 -0
- package/src/auth-mfa/handlers/enable-start.write.ts +66 -0
- package/src/auth-mfa/handlers/reencrypt.job.ts +185 -0
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +62 -0
- package/src/auth-mfa/handlers/verify.write.ts +141 -0
- package/src/auth-mfa/i18n.ts +12 -0
- package/src/auth-mfa/index.ts +15 -0
- package/src/auth-mfa/mfa-challenge-token.ts +90 -0
- package/src/auth-mfa/mfa-setup-token.ts +90 -0
- package/src/auth-mfa/mfa-status-checker.ts +75 -0
- package/src/auth-mfa/mfa-verify-attempts.ts +90 -0
- package/src/auth-mfa/recovery-codes.ts +44 -0
- package/src/auth-mfa/schema/user-mfa.ts +40 -0
- package/src/auth-mfa/totp.ts +8 -0
- package/src/auth-mfa/verify-factor.ts +30 -0
- package/src/auth-mfa/web/client-plugin.ts +37 -0
- package/src/auth-mfa/web/i18n.ts +69 -0
- package/src/auth-mfa/web/index.ts +15 -0
- package/src/auth-mfa/web/mfa-client.ts +60 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +187 -0
- package/src/auth-mfa/web/mfa-verify-screen.tsx +106 -0
- package/src/auth-mfa-user-data/__tests__/hooks.integration.test.ts +132 -0
- package/src/auth-mfa-user-data/hooks.ts +53 -0
- package/src/auth-mfa-user-data/index.ts +21 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +4 -0
- package/src/legal-pages/feature.ts +19 -28
- package/src/managed-pages/feature.ts +1 -1
- package/src/sessions/feature.ts +2 -1
- package/src/sessions/session-callbacks.ts +23 -0
- package/src/shared/index.ts +1 -0
- package/src/{auth-email-password → shared}/token-burn-store.ts +1 -1
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createTestUser,
|
|
5
|
+
setupTestStack,
|
|
6
|
+
type TestStack,
|
|
7
|
+
unsafeCreateEntityTable,
|
|
8
|
+
unsafePushTables,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import {
|
|
11
|
+
createTestEnvelopeCipher,
|
|
12
|
+
expectErrorIncludes,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
14
|
+
import { createConfigFeature } from "../../config";
|
|
15
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
16
|
+
import { configValuesTable } from "../../config/table";
|
|
17
|
+
import { createUserFeature } from "../../user/feature";
|
|
18
|
+
import { userEntity } from "../../user/schema/user";
|
|
19
|
+
import { base32Decode } from "../base32";
|
|
20
|
+
import { AuthMfaHandlers } from "../constants";
|
|
21
|
+
import { createAuthMfaFeature } from "../feature";
|
|
22
|
+
import { userMfaEntity } from "../schema/user-mfa";
|
|
23
|
+
import { currentTotpCode } from "../totp";
|
|
24
|
+
|
|
25
|
+
let stack: TestStack;
|
|
26
|
+
|
|
27
|
+
const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
const encryption = createTestEnvelopeCipher();
|
|
31
|
+
configureEntityFieldEncryption(encryption);
|
|
32
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
33
|
+
stack = await setupTestStack({
|
|
34
|
+
features: [
|
|
35
|
+
createConfigFeature(),
|
|
36
|
+
createUserFeature(),
|
|
37
|
+
createAuthMfaFeature({
|
|
38
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
39
|
+
issuer: "Kumiko Test",
|
|
40
|
+
challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
44
|
+
});
|
|
45
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
46
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
47
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await stack.cleanup();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Runs enable-start + enable-confirm for a fresh user and returns the
|
|
55
|
+
// user + the live secret so tests can derive current TOTP codes.
|
|
56
|
+
async function enableMfaFor(userId: string): Promise<{
|
|
57
|
+
user: ReturnType<typeof createTestUser>;
|
|
58
|
+
secret: Buffer;
|
|
59
|
+
recoveryCodes: string[];
|
|
60
|
+
}> {
|
|
61
|
+
const user = createTestUser({ id: userId, roles: ["User"] });
|
|
62
|
+
const start = await stack.http.writeOk<{
|
|
63
|
+
setupToken: string;
|
|
64
|
+
otpauthUri: string;
|
|
65
|
+
recoveryCodes: string[];
|
|
66
|
+
}>(AuthMfaHandlers.enableStart, { accountLabel: `${userId}@example.com` }, user);
|
|
67
|
+
|
|
68
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
69
|
+
const secret = base32Decode(secretParam);
|
|
70
|
+
await stack.http.writeOk(
|
|
71
|
+
AuthMfaHandlers.enableConfirm,
|
|
72
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
73
|
+
user,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
return { user, secret, recoveryCodes: start.recoveryCodes };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("disable", () => {
|
|
80
|
+
test("a valid TOTP code disables MFA, and a fresh enable-start succeeds afterward", async () => {
|
|
81
|
+
const { user, secret } = await enableMfaFor("disable-totp-1");
|
|
82
|
+
|
|
83
|
+
const res = await stack.http.writeOk<{ disabled: boolean }>(
|
|
84
|
+
AuthMfaHandlers.disable,
|
|
85
|
+
{ code: currentTotpCode(secret) },
|
|
86
|
+
user,
|
|
87
|
+
);
|
|
88
|
+
expect(res.disabled).toBe(true);
|
|
89
|
+
|
|
90
|
+
const secondStart = await stack.http.writeOk<{ setupToken: string }>(
|
|
91
|
+
AuthMfaHandlers.enableStart,
|
|
92
|
+
{ accountLabel: "disable-totp-1@example.com" },
|
|
93
|
+
user,
|
|
94
|
+
);
|
|
95
|
+
expect(secondStart.setupToken).toBeTruthy();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("MFA can be fully re-enabled after disable (re-create on a deleted aggregate)", async () => {
|
|
99
|
+
const { user, secret: firstSecret } = await enableMfaFor("disable-reenable-1");
|
|
100
|
+
|
|
101
|
+
await stack.http.writeOk<{ disabled: boolean }>(
|
|
102
|
+
AuthMfaHandlers.disable,
|
|
103
|
+
{ code: currentTotpCode(firstSecret) },
|
|
104
|
+
user,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const restart = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
108
|
+
AuthMfaHandlers.enableStart,
|
|
109
|
+
{ accountLabel: "disable-reenable-1@example.com" },
|
|
110
|
+
user,
|
|
111
|
+
);
|
|
112
|
+
const secretParam = new URLSearchParams(restart.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
113
|
+
const secondSecret = base32Decode(secretParam);
|
|
114
|
+
|
|
115
|
+
const confirm = await stack.http.writeOk<{ enabled: boolean }>(
|
|
116
|
+
AuthMfaHandlers.enableConfirm,
|
|
117
|
+
{ setupToken: restart.setupToken, code: currentTotpCode(secondSecret) },
|
|
118
|
+
user,
|
|
119
|
+
);
|
|
120
|
+
expect(confirm.enabled).toBe(true);
|
|
121
|
+
|
|
122
|
+
// The re-enabled row is fully usable — a real code disables it again.
|
|
123
|
+
const res = await stack.http.writeOk<{ disabled: boolean }>(
|
|
124
|
+
AuthMfaHandlers.disable,
|
|
125
|
+
{ code: currentTotpCode(secondSecret) },
|
|
126
|
+
user,
|
|
127
|
+
);
|
|
128
|
+
expect(res.disabled).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("a valid recovery code also disables MFA", async () => {
|
|
132
|
+
const { user, recoveryCodes } = await enableMfaFor("disable-recovery-1");
|
|
133
|
+
|
|
134
|
+
const res = await stack.http.writeOk<{ disabled: boolean }>(
|
|
135
|
+
AuthMfaHandlers.disable,
|
|
136
|
+
{ code: recoveryCodes[0] },
|
|
137
|
+
user,
|
|
138
|
+
);
|
|
139
|
+
expect(res.disabled).toBe(true);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a wrong code is rejected and MFA stays enabled", async () => {
|
|
143
|
+
const { user, secret } = await enableMfaFor("disable-wrong-1");
|
|
144
|
+
|
|
145
|
+
const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "000000" }, user);
|
|
146
|
+
expectErrorIncludes(err, "invalid_totp_code");
|
|
147
|
+
|
|
148
|
+
// Still enabled — a real code still works.
|
|
149
|
+
const res = await stack.http.writeOk<{ disabled: boolean }>(
|
|
150
|
+
AuthMfaHandlers.disable,
|
|
151
|
+
{ code: currentTotpCode(secret) },
|
|
152
|
+
user,
|
|
153
|
+
);
|
|
154
|
+
expect(res.disabled).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("disable on a user without MFA is rejected", async () => {
|
|
158
|
+
const user = createTestUser({ id: "disable-never-enabled-1", roles: ["User"] });
|
|
159
|
+
const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "123456" }, user);
|
|
160
|
+
expectErrorIncludes(err, "mfa_not_enabled");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("regenerate-recovery", () => {
|
|
165
|
+
test("issues 8 fresh codes and invalidates the old ones", async () => {
|
|
166
|
+
const { user, secret, recoveryCodes: oldCodes } = await enableMfaFor("regen-1");
|
|
167
|
+
|
|
168
|
+
const res = await stack.http.writeOk<{ recoveryCodes: string[] }>(
|
|
169
|
+
AuthMfaHandlers.regenerateRecovery,
|
|
170
|
+
{ code: currentTotpCode(secret) },
|
|
171
|
+
user,
|
|
172
|
+
);
|
|
173
|
+
expect(res.recoveryCodes).toHaveLength(8);
|
|
174
|
+
expect(res.recoveryCodes).not.toEqual(oldCodes);
|
|
175
|
+
|
|
176
|
+
// An old code no longer works to disable MFA.
|
|
177
|
+
const oldCode = oldCodes[0];
|
|
178
|
+
if (oldCode === undefined) throw new Error("test setup produced no old recovery code");
|
|
179
|
+
const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: oldCode }, user);
|
|
180
|
+
expectErrorIncludes(err, "invalid_totp_code");
|
|
181
|
+
|
|
182
|
+
// A NEW code works.
|
|
183
|
+
const newCode = res.recoveryCodes[0];
|
|
184
|
+
if (newCode === undefined) throw new Error("regenerate produced no new recovery code");
|
|
185
|
+
const disableRes = await stack.http.writeOk<{ disabled: boolean }>(
|
|
186
|
+
AuthMfaHandlers.disable,
|
|
187
|
+
{ code: newCode },
|
|
188
|
+
user,
|
|
189
|
+
);
|
|
190
|
+
expect(disableRes.disabled).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("regenerate on a user without MFA is rejected", async () => {
|
|
194
|
+
const user = createTestUser({ id: "regen-never-enabled-1", roles: ["User"] });
|
|
195
|
+
const err = await stack.http.writeErr(
|
|
196
|
+
AuthMfaHandlers.regenerateRecovery,
|
|
197
|
+
{ code: "123456" },
|
|
198
|
+
user,
|
|
199
|
+
);
|
|
200
|
+
expectErrorIncludes(err, "mfa_not_enabled");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
createTestUser,
|
|
5
|
+
setupTestStack,
|
|
6
|
+
type TestStack,
|
|
7
|
+
unsafeCreateEntityTable,
|
|
8
|
+
unsafePushTables,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import {
|
|
11
|
+
createTestEnvelopeCipher,
|
|
12
|
+
expectErrorIncludes,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
14
|
+
import { createConfigFeature } from "../../config";
|
|
15
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
16
|
+
import { configValuesTable } from "../../config/table";
|
|
17
|
+
import { createUserFeature } from "../../user/feature";
|
|
18
|
+
import { userEntity } from "../../user/schema/user";
|
|
19
|
+
import { base32Decode } from "../base32";
|
|
20
|
+
import { AuthMfaHandlers } from "../constants";
|
|
21
|
+
import { createAuthMfaFeature } from "../feature";
|
|
22
|
+
import { userMfaEntity } from "../schema/user-mfa";
|
|
23
|
+
import { currentTotpCode } from "../totp";
|
|
24
|
+
|
|
25
|
+
let stack: TestStack;
|
|
26
|
+
|
|
27
|
+
const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
// totpSecret is `encrypted: true` — needs a cipher configured before any
|
|
31
|
+
// enable-confirm write, same as any other encrypted-entity-field test.
|
|
32
|
+
const encryption = createTestEnvelopeCipher();
|
|
33
|
+
configureEntityFieldEncryption(encryption);
|
|
34
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
35
|
+
stack = await setupTestStack({
|
|
36
|
+
features: [
|
|
37
|
+
createConfigFeature(),
|
|
38
|
+
createUserFeature(),
|
|
39
|
+
createAuthMfaFeature({
|
|
40
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
41
|
+
issuer: "Kumiko Test",
|
|
42
|
+
challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
46
|
+
});
|
|
47
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
48
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
49
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterAll(async () => {
|
|
53
|
+
await stack.cleanup();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("enable-start + enable-confirm round trip", () => {
|
|
57
|
+
test("start returns a setup token + otpauth URI + 8 recovery codes", async () => {
|
|
58
|
+
const user = createTestUser({ id: "enable-start-1", roles: ["User"] });
|
|
59
|
+
|
|
60
|
+
const res = await stack.http.writeOk<{
|
|
61
|
+
setupToken: string;
|
|
62
|
+
otpauthUri: string;
|
|
63
|
+
recoveryCodes: string[];
|
|
64
|
+
}>(AuthMfaHandlers.enableStart, { accountLabel: "jane@example.com" }, user);
|
|
65
|
+
|
|
66
|
+
expect(res.setupToken.split(".").length).toBe(2);
|
|
67
|
+
expect(res.otpauthUri).toStartWith("otpauth://totp/");
|
|
68
|
+
expect(res.otpauthUri).toContain("jane%40example.com");
|
|
69
|
+
expect(res.recoveryCodes).toHaveLength(8);
|
|
70
|
+
// Format XXXX-XXXX
|
|
71
|
+
for (const code of res.recoveryCodes) {
|
|
72
|
+
expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("confirm with the right code enables MFA", async () => {
|
|
77
|
+
const user = createTestUser({ id: "enable-confirm-1", roles: ["User"] });
|
|
78
|
+
|
|
79
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
80
|
+
AuthMfaHandlers.enableStart,
|
|
81
|
+
{ accountLabel: "confirm@example.com" },
|
|
82
|
+
user,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
86
|
+
const secret = base32Decode(secretParam);
|
|
87
|
+
const code = currentTotpCode(secret);
|
|
88
|
+
|
|
89
|
+
const confirmed = await stack.http.writeOk<{ enabled: boolean }>(
|
|
90
|
+
AuthMfaHandlers.enableConfirm,
|
|
91
|
+
{ setupToken: start.setupToken, code },
|
|
92
|
+
user,
|
|
93
|
+
);
|
|
94
|
+
expect(confirmed.enabled).toBe(true);
|
|
95
|
+
|
|
96
|
+
// Second enable-start now rejects — MFA already enabled for this user.
|
|
97
|
+
const err = await stack.http.writeErr(
|
|
98
|
+
AuthMfaHandlers.enableStart,
|
|
99
|
+
{ accountLabel: "confirm@example.com" },
|
|
100
|
+
user,
|
|
101
|
+
);
|
|
102
|
+
expectErrorIncludes(err, "mfa_already_enabled");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("confirm with a wrong code is rejected and does not create a row", async () => {
|
|
106
|
+
const user = createTestUser({ id: "enable-confirm-wrong-1", roles: ["User"] });
|
|
107
|
+
|
|
108
|
+
const start = await stack.http.writeOk<{ setupToken: string }>(
|
|
109
|
+
AuthMfaHandlers.enableStart,
|
|
110
|
+
{ accountLabel: "wrong@example.com" },
|
|
111
|
+
user,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const err = await stack.http.writeErr(
|
|
115
|
+
AuthMfaHandlers.enableConfirm,
|
|
116
|
+
{ setupToken: start.setupToken, code: "000000" },
|
|
117
|
+
user,
|
|
118
|
+
);
|
|
119
|
+
expectErrorIncludes(err, "invalid_totp_code");
|
|
120
|
+
|
|
121
|
+
// MFA is still not enabled — a fresh enable-start succeeds.
|
|
122
|
+
const secondStart = await stack.http.writeOk<{ setupToken: string }>(
|
|
123
|
+
AuthMfaHandlers.enableStart,
|
|
124
|
+
{ accountLabel: "wrong@example.com" },
|
|
125
|
+
user,
|
|
126
|
+
);
|
|
127
|
+
expect(secondStart.setupToken).toBeTruthy();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("confirm rejects a setup token signed for a different user", async () => {
|
|
131
|
+
const userA = createTestUser({ id: "enable-confirm-cross-a", roles: ["User"] });
|
|
132
|
+
const userB = createTestUser({ id: "enable-confirm-cross-b", roles: ["User"] });
|
|
133
|
+
|
|
134
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
135
|
+
AuthMfaHandlers.enableStart,
|
|
136
|
+
{ accountLabel: "cross@example.com" },
|
|
137
|
+
userA,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
141
|
+
const code = currentTotpCode(base32Decode(secretParam));
|
|
142
|
+
|
|
143
|
+
const err = await stack.http.writeErr(
|
|
144
|
+
AuthMfaHandlers.enableConfirm,
|
|
145
|
+
{ setupToken: start.setupToken, code },
|
|
146
|
+
userB,
|
|
147
|
+
);
|
|
148
|
+
expectErrorIncludes(err, "invalid_setup_token");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// KEK-rotation job for kumiko-framework#266 Step 8. Proves two things:
|
|
2
|
+
//
|
|
3
|
+
// 1. The job actually rewraps totpSecret onto the current KEK version.
|
|
4
|
+
// 2. Advisor-flagged trap: rotation must not just touch the live
|
|
5
|
+
// projection — a FULL PROJECTION REBUILD after rotation must still
|
|
6
|
+
// decrypt correctly. Events store CIPHERTEXT (see #967 — "the
|
|
7
|
+
// immutable log never sees plaintext, replay reproduces the row
|
|
8
|
+
// byte-identically"), so a rebuild replays events in order and lands
|
|
9
|
+
// on the LATEST event's ciphertext. That's only true if the rotation
|
|
10
|
+
// job goes through executor.update() (which appends a real event)
|
|
11
|
+
// instead of a raw UPDATE on the projection table — this test is the
|
|
12
|
+
// regression guard for that specific design choice.
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
17
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
18
|
+
import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { rebuildProjection } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
20
|
+
import {
|
|
21
|
+
createEnvelopeCipher,
|
|
22
|
+
createEnvMasterKeyProvider,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
24
|
+
import {
|
|
25
|
+
createTestUser,
|
|
26
|
+
setupTestStack,
|
|
27
|
+
type TestStack,
|
|
28
|
+
unsafeCreateEntityTable,
|
|
29
|
+
unsafePushTables,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
31
|
+
import { createMutableMasterKeyProvider } from "@cosmicdrift/kumiko-framework/testing";
|
|
32
|
+
import { createConfigFeature } from "../../config";
|
|
33
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
34
|
+
import { configValuesTable } from "../../config/table";
|
|
35
|
+
import { createUserFeature } from "../../user/feature";
|
|
36
|
+
import { userEntity } from "../../user/schema/user";
|
|
37
|
+
import { base32Decode } from "../base32";
|
|
38
|
+
import { AuthMfaHandlers } from "../constants";
|
|
39
|
+
import { createAuthMfaFeature } from "../feature";
|
|
40
|
+
import { mfaReencryptJob } from "../handlers/reencrypt.job";
|
|
41
|
+
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
42
|
+
import { currentTotpCode } from "../totp";
|
|
43
|
+
|
|
44
|
+
const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
|
|
45
|
+
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
46
|
+
|
|
47
|
+
let stack: TestStack;
|
|
48
|
+
const v1Key = randomBytes(32).toString("base64");
|
|
49
|
+
const v2Key = randomBytes(32).toString("base64");
|
|
50
|
+
const mutableProvider = createMutableMasterKeyProvider(
|
|
51
|
+
createEnvMasterKeyProvider({
|
|
52
|
+
env: {
|
|
53
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
54
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
55
|
+
},
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
const cipher = createEnvelopeCipher(mutableProvider, {});
|
|
61
|
+
configureEntityFieldEncryption(cipher);
|
|
62
|
+
const resolver = createConfigResolver({ cipher });
|
|
63
|
+
stack = await setupTestStack({
|
|
64
|
+
features: [
|
|
65
|
+
createConfigFeature(),
|
|
66
|
+
createUserFeature(),
|
|
67
|
+
createAuthMfaFeature({
|
|
68
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
69
|
+
issuer: "Kumiko Test",
|
|
70
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
71
|
+
}),
|
|
72
|
+
],
|
|
73
|
+
masterKeyProvider: mutableProvider,
|
|
74
|
+
extraContext: { configResolver: resolver, configEncryption: cipher },
|
|
75
|
+
});
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
78
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
afterAll(async () => {
|
|
82
|
+
await stack.cleanup();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
type RawUserMfaRow = { id: string; totpSecret: string; version: number };
|
|
86
|
+
|
|
87
|
+
async function readRawRow(): Promise<RawUserMfaRow> {
|
|
88
|
+
const rows = await selectMany<RawUserMfaRow>(stack.db, userMfaTable, {});
|
|
89
|
+
const row = rows[0];
|
|
90
|
+
if (!row) throw new Error("no user-mfa row");
|
|
91
|
+
return row;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const noopLog = {
|
|
95
|
+
info: () => {},
|
|
96
|
+
warn: () => {},
|
|
97
|
+
error: () => {},
|
|
98
|
+
debug: () => {},
|
|
99
|
+
child: () => noopLog,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function jobCtx(): AppContext {
|
|
103
|
+
return {
|
|
104
|
+
db: stack.db,
|
|
105
|
+
registry: stack.registry,
|
|
106
|
+
masterKeyProvider: mutableProvider,
|
|
107
|
+
log: noopLog,
|
|
108
|
+
} as AppContext; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/log
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("auth-mfa KEK-rotation job — kumiko-framework#266 Step 8", () => {
|
|
112
|
+
test("rotation rewraps totpSecret onto the current KEK, and a full projection rebuild still decrypts correctly", async () => {
|
|
113
|
+
const user = createTestUser({ id: 1, roles: ["User"] });
|
|
114
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
115
|
+
AuthMfaHandlers.enableStart,
|
|
116
|
+
{ accountLabel: "user-1@example.com" },
|
|
117
|
+
user,
|
|
118
|
+
);
|
|
119
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
120
|
+
const secret = base32Decode(secretParam);
|
|
121
|
+
await stack.http.writeOk(
|
|
122
|
+
AuthMfaHandlers.enableConfirm,
|
|
123
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
124
|
+
user,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const beforeRow = await readRawRow();
|
|
128
|
+
expect(JSON.parse(beforeRow.totpSecret).kekVersion).toBe(1);
|
|
129
|
+
|
|
130
|
+
// Simulate "ops added a new master key version and flipped CURRENT=2" —
|
|
131
|
+
// same simulate-rotation-without-restart helper secrets/rotate uses.
|
|
132
|
+
mutableProvider.replace(
|
|
133
|
+
createEnvMasterKeyProvider({
|
|
134
|
+
env: {
|
|
135
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: v1Key,
|
|
136
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: v2Key,
|
|
137
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
await mfaReencryptJob({}, jobCtx());
|
|
143
|
+
|
|
144
|
+
const afterJobRow = await readRawRow();
|
|
145
|
+
expect(JSON.parse(afterJobRow.totpSecret).kekVersion).toBe(2);
|
|
146
|
+
|
|
147
|
+
// The regression guard: rebuild the projection from scratch (replays
|
|
148
|
+
// every event for this aggregate) and confirm it still lands on the
|
|
149
|
+
// V2 envelope — NOT a resurrected V1 wrap from the original
|
|
150
|
+
// enable-confirm event.
|
|
151
|
+
await rebuildProjection("auth-mfa:projection:user-mfa-entity", {
|
|
152
|
+
db: stack.db,
|
|
153
|
+
registry: stack.registry,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const afterRebuildRow = await readRawRow();
|
|
157
|
+
expect(JSON.parse(afterRebuildRow.totpSecret).kekVersion).toBe(2);
|
|
158
|
+
|
|
159
|
+
// Decrypt-level proof, not just the version tag: a valid TOTP code
|
|
160
|
+
// against the post-rebuild secret still verifies — the plaintext
|
|
161
|
+
// survived rotation + rebuild byte-identically. disable() decrypts
|
|
162
|
+
// totpSecret to check the code, so a decrypt failure (wrong key)
|
|
163
|
+
// would surface here as invalid_totp_code or a hard error, not a
|
|
164
|
+
// silent pass.
|
|
165
|
+
await stack.http.writeOk(AuthMfaHandlers.disable, { code: currentTotpCode(secret) }, user);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
3
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import {
|
|
5
|
+
createTestUser,
|
|
6
|
+
setupTestStack,
|
|
7
|
+
type TestStack,
|
|
8
|
+
unsafeCreateEntityTable,
|
|
9
|
+
unsafePushTables,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
|
+
import {
|
|
12
|
+
createTestEnvelopeCipher,
|
|
13
|
+
expectErrorIncludes,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
15
|
+
import { createConfigFeature } from "../../config";
|
|
16
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
17
|
+
import { configValuesTable } from "../../config/table";
|
|
18
|
+
import { userSessionEntity, userSessionTable } from "../../sessions/schema/user-session";
|
|
19
|
+
import { createSessionCallbacks } from "../../sessions/session-callbacks";
|
|
20
|
+
import { createUserFeature } from "../../user/feature";
|
|
21
|
+
import { userEntity } from "../../user/schema/user";
|
|
22
|
+
import { base32Decode } from "../base32";
|
|
23
|
+
import { AuthMfaHandlers } from "../constants";
|
|
24
|
+
import { bindMfaRevokeAllOtherSessionsFromFeature, createAuthMfaFeature } from "../feature";
|
|
25
|
+
import { userMfaEntity } from "../schema/user-mfa";
|
|
26
|
+
import { currentTotpCode } from "../totp";
|
|
27
|
+
|
|
28
|
+
// Enable/disable/regenerate are security-relevant state changes on the
|
|
29
|
+
// account — every OTHER live session must be signed out (stolen-session
|
|
30
|
+
// defense), but the session that performed the change must survive.
|
|
31
|
+
|
|
32
|
+
let stack: TestStack;
|
|
33
|
+
let sessionCallbacks: ReturnType<typeof createSessionCallbacks>;
|
|
34
|
+
|
|
35
|
+
const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
const encryption = createTestEnvelopeCipher();
|
|
39
|
+
configureEntityFieldEncryption(encryption);
|
|
40
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
41
|
+
const mfaFeature = createAuthMfaFeature({
|
|
42
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
43
|
+
issuer: "Kumiko Test",
|
|
44
|
+
challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
|
|
45
|
+
});
|
|
46
|
+
stack = await setupTestStack({
|
|
47
|
+
features: [createConfigFeature(), createUserFeature(), mfaFeature],
|
|
48
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
sessionCallbacks = createSessionCallbacks({ db: stack.db });
|
|
52
|
+
const bind = bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature);
|
|
53
|
+
if (!bind) throw new Error("auth-mfa did not export bindRevokeAllOtherSessions");
|
|
54
|
+
bind(sessionCallbacks.sessionRevokeAllOthers);
|
|
55
|
+
|
|
56
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
57
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
58
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
59
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
await stack.cleanup();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
beforeEach(async () => {
|
|
67
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("session auto-revoke on MFA state changes", () => {
|
|
71
|
+
test("enable-confirm revokes every OTHER session but keeps the caller's", async () => {
|
|
72
|
+
const userId = "revoke-enable-1";
|
|
73
|
+
const currentSid = await sessionCallbacks.sessionCreator(createTestUser({ id: userId }), {
|
|
74
|
+
ip: "127.0.0.1",
|
|
75
|
+
userAgent: "test",
|
|
76
|
+
});
|
|
77
|
+
const otherSid = await sessionCallbacks.sessionCreator(createTestUser({ id: userId }), {
|
|
78
|
+
ip: "127.0.0.1",
|
|
79
|
+
userAgent: "test",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const user = createTestUser({ id: userId, sid: currentSid });
|
|
83
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
84
|
+
AuthMfaHandlers.enableStart,
|
|
85
|
+
{ accountLabel: `${userId}@example.com` },
|
|
86
|
+
user,
|
|
87
|
+
);
|
|
88
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
89
|
+
const secret = base32Decode(secretParam);
|
|
90
|
+
await stack.http.writeOk(
|
|
91
|
+
AuthMfaHandlers.enableConfirm,
|
|
92
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
93
|
+
user,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
|
|
97
|
+
expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("disable revokes every OTHER session but keeps the caller's", async () => {
|
|
101
|
+
const userId = "revoke-disable-1";
|
|
102
|
+
const setupUser = createTestUser({ id: userId });
|
|
103
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
104
|
+
AuthMfaHandlers.enableStart,
|
|
105
|
+
{ accountLabel: `${userId}@example.com` },
|
|
106
|
+
setupUser,
|
|
107
|
+
);
|
|
108
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
109
|
+
const secret = base32Decode(secretParam);
|
|
110
|
+
await stack.http.writeOk(
|
|
111
|
+
AuthMfaHandlers.enableConfirm,
|
|
112
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
113
|
+
setupUser,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const currentSid = await sessionCallbacks.sessionCreator(setupUser, {
|
|
117
|
+
ip: "127.0.0.1",
|
|
118
|
+
userAgent: "test",
|
|
119
|
+
});
|
|
120
|
+
const otherSid = await sessionCallbacks.sessionCreator(setupUser, {
|
|
121
|
+
ip: "127.0.0.1",
|
|
122
|
+
userAgent: "test",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const caller = createTestUser({ id: userId, sid: currentSid });
|
|
126
|
+
await stack.http.writeOk<{ disabled: boolean }>(
|
|
127
|
+
AuthMfaHandlers.disable,
|
|
128
|
+
{ code: currentTotpCode(secret) },
|
|
129
|
+
caller,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
|
|
133
|
+
expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("revoked");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a failed disable attempt does NOT revoke any session", async () => {
|
|
137
|
+
const userId = "revoke-fail-1";
|
|
138
|
+
const setupUser = createTestUser({ id: userId });
|
|
139
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
140
|
+
AuthMfaHandlers.enableStart,
|
|
141
|
+
{ accountLabel: `${userId}@example.com` },
|
|
142
|
+
setupUser,
|
|
143
|
+
);
|
|
144
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
145
|
+
const secret = base32Decode(secretParam);
|
|
146
|
+
await stack.http.writeOk(
|
|
147
|
+
AuthMfaHandlers.enableConfirm,
|
|
148
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
149
|
+
setupUser,
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const currentSid = await sessionCallbacks.sessionCreator(setupUser, {
|
|
153
|
+
ip: "127.0.0.1",
|
|
154
|
+
userAgent: "test",
|
|
155
|
+
});
|
|
156
|
+
const otherSid = await sessionCallbacks.sessionCreator(setupUser, {
|
|
157
|
+
ip: "127.0.0.1",
|
|
158
|
+
userAgent: "test",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const caller = createTestUser({ id: userId, sid: currentSid });
|
|
162
|
+
const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "000000" }, caller);
|
|
163
|
+
expectErrorIncludes(err, "invalid_totp_code");
|
|
164
|
+
|
|
165
|
+
expect(await sessionCallbacks.sessionChecker(currentSid, userId)).toBe("live");
|
|
166
|
+
expect(await sessionCallbacks.sessionChecker(otherSid, userId)).toBe("live");
|
|
167
|
+
});
|
|
168
|
+
});
|