@cosmicdrift/kumiko-bundled-features 0.160.0 → 0.162.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 +6 -6
- package/src/auth-email-password/__tests__/login-mfa-setup-required.integration.test.ts +129 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +168 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +5 -0
- package/src/auth-email-password/constants.ts +6 -0
- package/src/auth-email-password/feature.ts +8 -1
- package/src/auth-email-password/handlers/login.write.ts +6 -3
- package/src/auth-email-password/handlers/self-registration-status.query.ts +20 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +11 -0
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/index.ts +5 -1
- package/src/auth-email-password/self-registration-toggle.ts +25 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +6 -2
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +79 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +14 -5
- package/src/auth-email-password/web/auth-client.ts +4 -3
- package/src/auth-email-password/web/auth-gate.tsx +44 -2
- package/src/auth-email-password/web/login-screen.tsx +11 -5
- package/src/auth-foundation/__tests__/feature.test.ts +16 -3
- package/src/auth-foundation/__tests__/session-store.test.ts +1 -0
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +136 -0
- package/src/auth-foundation/boot-checks.ts +54 -0
- package/src/auth-foundation/feature.ts +77 -23
- package/src/auth-foundation/index.ts +14 -0
- package/src/auth-foundation/resolve-anonymous-access.ts +54 -0
- package/src/auth-foundation/types.ts +63 -0
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +253 -0
- package/src/auth-mfa/__tests__/enable-start-preauth.integration.test.ts +214 -0
- package/src/auth-mfa/__tests__/mfa-preauth-setup-token.test.ts +87 -0
- package/src/auth-mfa/constants.ts +3 -0
- package/src/auth-mfa/feature.ts +15 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +170 -0
- package/src/auth-mfa/handlers/enable-start-preauth.write.ts +87 -0
- package/src/auth-mfa/mfa-preauth-setup-token.ts +86 -0
- package/src/auth-mfa/mfa-setup-token.ts +7 -1
- package/src/auth-mfa/mfa-status-checker.ts +13 -4
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +144 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +202 -0
- package/src/auth-mfa/web/i18n.ts +14 -0
- package/src/auth-mfa/web/index.ts +8 -2
- package/src/auth-mfa/web/mfa-client.ts +113 -0
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +244 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +96 -0
- package/src/feature-toggles/compose-tier-resolver.ts +41 -0
- package/src/feature-toggles/index.ts +1 -0
- package/src/feature-toggles/toggle-runtime.ts +14 -0
- package/src/sessions/feature.ts +8 -1
|
@@ -9,7 +9,9 @@ export const AUTH_MFA_FEATURE = "auth-mfa" as const;
|
|
|
9
9
|
|
|
10
10
|
export const AuthMfaHandlers = {
|
|
11
11
|
enableStart: "auth-mfa:write:enable-start",
|
|
12
|
+
enableStartPreauth: "auth-mfa:write:enable-start-preauth",
|
|
12
13
|
enableConfirm: "auth-mfa:write:enable-confirm",
|
|
14
|
+
enableConfirmPreauth: "auth-mfa:write:enable-confirm-preauth",
|
|
13
15
|
disable: "auth-mfa:write:disable",
|
|
14
16
|
regenerateRecovery: "auth-mfa:write:regenerate-recovery",
|
|
15
17
|
verify: "auth-mfa:write:verify",
|
|
@@ -34,6 +36,7 @@ export const AuthMfaErrorCodes = {
|
|
|
34
36
|
|
|
35
37
|
export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
|
|
36
38
|
export const MFA_CHALLENGE_TOKEN_TTL_MINUTES = 10;
|
|
39
|
+
export const MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES = 10;
|
|
37
40
|
export const MFA_VERIFY_MAX_ATTEMPTS = 5;
|
|
38
41
|
export const MFA_VERIFY_LOCKOUT_MINUTES = 5;
|
|
39
42
|
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { mfaRequiredConfigKey } from "./config";
|
|
|
3
3
|
import { MFA_ENABLE_SCREEN_ID } from "./constants";
|
|
4
4
|
import { createDisableHandler } from "./handlers/disable.write";
|
|
5
5
|
import { createEnableConfirmHandler } from "./handlers/enable-confirm.write";
|
|
6
|
+
import { createEnableConfirmPreauthHandler } from "./handlers/enable-confirm-preauth.write";
|
|
6
7
|
import { createEnableStartHandler } from "./handlers/enable-start.write";
|
|
8
|
+
import { createEnableStartPreauthHandler } from "./handlers/enable-start-preauth.write";
|
|
7
9
|
import { mfaReencryptJob } from "./handlers/reencrypt.job";
|
|
8
10
|
import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.write";
|
|
9
11
|
import { mfaStatusQuery } from "./handlers/status.query";
|
|
@@ -119,12 +121,25 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
119
121
|
enableStart: r.writeHandler(
|
|
120
122
|
createEnableStartHandler({ setupTokenSecret: opts.setupTokenSecret, issuer: opts.issuer }),
|
|
121
123
|
),
|
|
124
|
+
enableStartPreauth: r.writeHandler(
|
|
125
|
+
createEnableStartPreauthHandler({
|
|
126
|
+
challengeTokenSecret: opts.challengeTokenSecret,
|
|
127
|
+
setupTokenSecret: opts.setupTokenSecret,
|
|
128
|
+
issuer: opts.issuer,
|
|
129
|
+
}),
|
|
130
|
+
),
|
|
122
131
|
enableConfirm: r.writeHandler(
|
|
123
132
|
createEnableConfirmHandler({
|
|
124
133
|
setupTokenSecret: opts.setupTokenSecret,
|
|
125
134
|
revokeAllOtherSessions: sharedRevoker,
|
|
126
135
|
}),
|
|
127
136
|
),
|
|
137
|
+
enableConfirmPreauth: r.writeHandler(
|
|
138
|
+
createEnableConfirmPreauthHandler({
|
|
139
|
+
setupTokenSecret: opts.setupTokenSecret,
|
|
140
|
+
revokeAllOtherSessions: sharedRevoker,
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
128
143
|
disable: r.writeHandler(createDisableHandler({ revokeAllOtherSessions: sharedRevoker })),
|
|
129
144
|
regenerateRecovery: r.writeHandler(
|
|
130
145
|
createRegenerateRecoveryHandler({ revokeAllOtherSessions: sharedRevoker }),
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import {
|
|
3
|
+
buildSessionRoles,
|
|
4
|
+
createSystemUser,
|
|
5
|
+
defineWriteHandler,
|
|
6
|
+
type SessionUser,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
|
+
import { Temporal } from "temporal-polyfill";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { burnToken } from "../../shared";
|
|
12
|
+
import { USER_STATUS, UserQueries } from "../../user";
|
|
13
|
+
import { base32Decode } from "../base32";
|
|
14
|
+
import { MFA_VERIFY_LOCKOUT_MINUTES, MFA_VERIFY_MAX_ATTEMPTS } from "../constants";
|
|
15
|
+
import { findUserMfaRow } from "../db/queries";
|
|
16
|
+
import { invalidSetupToken, invalidTotpCode, mfaAlreadyEnabled, tooManyAttempts } from "../errors";
|
|
17
|
+
import { verifyMfaSetupToken } from "../mfa-setup-token";
|
|
18
|
+
import {
|
|
19
|
+
clearMfaVerifyAttempts,
|
|
20
|
+
getMfaVerifyLockoutState,
|
|
21
|
+
recordFailedMfaVerifyAttempt,
|
|
22
|
+
} from "../mfa-verify-attempts";
|
|
23
|
+
import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
24
|
+
import { verifyTotp } from "../totp";
|
|
25
|
+
|
|
26
|
+
export type EnableConfirmPreauthOptions = {
|
|
27
|
+
// Must match the secret enable-start-preauth.write.ts signs setupTokens
|
|
28
|
+
// with — same secret + format as the session-authed enable-confirm.
|
|
29
|
+
readonly setupTokenSecret: string;
|
|
30
|
+
readonly maxAttempts?: number;
|
|
31
|
+
readonly lockoutMinutes?: number;
|
|
32
|
+
// Wired late by run-prod-app, same shared callback as enable-confirm and
|
|
33
|
+
// enable-start's session-authed siblings.
|
|
34
|
+
readonly revokeAllOtherSessions?: (
|
|
35
|
+
userId: string,
|
|
36
|
+
currentSid: string | undefined,
|
|
37
|
+
) => Promise<number>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
41
|
+
entityName: "user-mfa",
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Pre-auth twin of enable-confirm.write.ts: identity comes from the
|
|
45
|
+
// verified setupToken (minted by enable-start-preauth.write.ts), not from
|
|
46
|
+
// event.user — there is no session yet. Combines enable-confirm's
|
|
47
|
+
// enrollment-completion logic with verify.write.ts's session-derivation
|
|
48
|
+
// (membership lookup, status re-check, buildSessionRoles) since this IS
|
|
49
|
+
// the completion of the blocked login, not just an enrollment step — a
|
|
50
|
+
// successful call here logs the user in.
|
|
51
|
+
export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOptions) {
|
|
52
|
+
const maxAttempts = opts.maxAttempts ?? MFA_VERIFY_MAX_ATTEMPTS;
|
|
53
|
+
const lockoutMinutes = opts.lockoutMinutes ?? MFA_VERIFY_LOCKOUT_MINUTES;
|
|
54
|
+
|
|
55
|
+
return defineWriteHandler({
|
|
56
|
+
name: "enable-confirm-preauth",
|
|
57
|
+
schema: z.object({
|
|
58
|
+
setupToken: z.string().min(1),
|
|
59
|
+
code: z.string().length(6),
|
|
60
|
+
}),
|
|
61
|
+
access: { roles: ["all"] },
|
|
62
|
+
handler: async (event, ctx) => {
|
|
63
|
+
const verify = verifyMfaSetupToken(event.payload.setupToken, opts.setupTokenSecret);
|
|
64
|
+
if (!verify.ok) return invalidSetupToken();
|
|
65
|
+
// A setupToken minted for the session-authed enable-start never
|
|
66
|
+
// carries tenantId (see mfa-setup-token.ts) — reject it here rather
|
|
67
|
+
// than falling back to a guest tenant, that token was never meant to
|
|
68
|
+
// reach this pre-auth endpoint.
|
|
69
|
+
const { userId, tenantId } = verify.payload;
|
|
70
|
+
if (tenantId === undefined) return invalidSetupToken();
|
|
71
|
+
|
|
72
|
+
// Same guessing surface as /auth/mfa/verify (a short TOTP code) and
|
|
73
|
+
// the same per-userId cap — deliberately shared counter, see
|
|
74
|
+
// mfa-verify-attempts.ts.
|
|
75
|
+
if (ctx.redis) {
|
|
76
|
+
const state = await getMfaVerifyLockoutState(ctx.redis, userId);
|
|
77
|
+
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
if (state.lockedUntil > now) {
|
|
80
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
81
|
+
return tooManyAttempts(retryAfterSeconds);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const secret = base32Decode(verify.payload.totpSecretBase32);
|
|
87
|
+
if (verifyTotp(secret, event.payload.code) === false) {
|
|
88
|
+
if (ctx.redis) {
|
|
89
|
+
await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
|
|
90
|
+
}
|
|
91
|
+
return invalidTotpCode();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Burn the setup token on the first successful confirm — same
|
|
95
|
+
// single-use guarantee as enable-confirm.write.ts.
|
|
96
|
+
if (ctx.redis) {
|
|
97
|
+
const burnResult = await burnToken(ctx.redis, "mfa-setup", userId, verify.expiresAtMs);
|
|
98
|
+
if (burnResult === "already-used") return invalidSetupToken();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// "system" mode: no session exists yet, tenantId comes from the
|
|
102
|
+
// verified token, mirroring enable-start-preauth.write.ts.
|
|
103
|
+
const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
|
|
104
|
+
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
105
|
+
const existing = await findUserMfaRow(scopedDb, scopedUser);
|
|
106
|
+
if (existing) return mfaAlreadyEnabled();
|
|
107
|
+
|
|
108
|
+
const result = await executor.create(
|
|
109
|
+
{
|
|
110
|
+
userId,
|
|
111
|
+
totpSecret: verify.payload.totpSecretBase32,
|
|
112
|
+
recoveryCodes: encodeRecoveryCodes(verify.payload.recoveryCodeHashes),
|
|
113
|
+
enabledAt: Temporal.Now.instant(),
|
|
114
|
+
lastUsedAt: null,
|
|
115
|
+
},
|
|
116
|
+
scopedUser,
|
|
117
|
+
scopedDb,
|
|
118
|
+
);
|
|
119
|
+
if (!result.isSuccess) return result;
|
|
120
|
+
|
|
121
|
+
if (ctx.redis) {
|
|
122
|
+
await clearMfaVerifyAttempts(ctx.redis, userId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// No currentSid to exclude — this is the first session for this
|
|
126
|
+
// login, not a follow-up on an already-running one.
|
|
127
|
+
if (opts.revokeAllOtherSessions) {
|
|
128
|
+
await opts.revokeAllOtherSessions(userId, undefined);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Re-derive the full session the way verify.write.ts does — the
|
|
132
|
+
// setupToken only proves "password + new TOTP secret", not roles;
|
|
133
|
+
// roles come from a fresh membership lookup, same as every other
|
|
134
|
+
// login-completing handler.
|
|
135
|
+
const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
|
|
136
|
+
const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
137
|
+
id: userId,
|
|
138
|
+
})) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
|
|
139
|
+
|
|
140
|
+
if (!userRow) return invalidSetupToken();
|
|
141
|
+
if (
|
|
142
|
+
userRow?.status === USER_STATUS.Restricted ||
|
|
143
|
+
userRow?.status === USER_STATUS.DeletionRequested ||
|
|
144
|
+
userRow?.status === USER_STATUS.Deleted
|
|
145
|
+
) {
|
|
146
|
+
return invalidSetupToken();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const globalRoles = parseRoles(userRow?.roles ?? null);
|
|
150
|
+
|
|
151
|
+
const memberships = (await ctx.queryAs(systemUser, "tenant:query:memberships", {
|
|
152
|
+
userId,
|
|
153
|
+
})) as ReadonlyArray<{ tenantId: string; roles: readonly string[] }>; // @cast-boundary engine-payload
|
|
154
|
+
const membership = memberships.find((m) => m.tenantId === tenantId);
|
|
155
|
+
if (!membership) return invalidSetupToken();
|
|
156
|
+
// buildSessionRoles calls stripForbiddenMembershipRoles to strip reserved
|
|
157
|
+
// roles from the membership portion (globalRoles keeps SystemAdmin) —
|
|
158
|
+
// read-time backstop against a rebuild-resurrected role, same as
|
|
159
|
+
// verify.write.ts.
|
|
160
|
+
const mergedRoles = buildSessionRoles(globalRoles, membership.roles);
|
|
161
|
+
|
|
162
|
+
const baseSession: SessionUser = { id: userId, tenantId, roles: mergedRoles };
|
|
163
|
+
const claims = await ctx.resolveAuthClaims(baseSession);
|
|
164
|
+
const session: SessionUser =
|
|
165
|
+
Object.keys(claims).length > 0 ? { ...baseSession, claims } : baseSession;
|
|
166
|
+
|
|
167
|
+
return { isSuccess: true, data: { kind: "mfa-preauth-confirm-success", session } };
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { defineWriteHandler, type SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { base32Encode } from "../base32";
|
|
5
|
+
import { MFA_SETUP_TOKEN_TTL_MINUTES } from "../constants";
|
|
6
|
+
import { findUserMfaRow } from "../db/queries";
|
|
7
|
+
import { invalidChallengeToken, mfaAlreadyEnabled } from "../errors";
|
|
8
|
+
import { verifyMfaPreauthSetupToken } from "../mfa-preauth-setup-token";
|
|
9
|
+
import { signMfaSetupToken } from "../mfa-setup-token";
|
|
10
|
+
import { buildOtpauthUri } from "../otpauth-uri";
|
|
11
|
+
import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes";
|
|
12
|
+
import { generateTotpSecret } from "../totp";
|
|
13
|
+
|
|
14
|
+
export type EnableStartPreauthOptions = {
|
|
15
|
+
// Verifies the incoming preauthSetupToken — must match login.write.ts's
|
|
16
|
+
// mfaStatusChecker (challengeTokenSecret), NOT setupTokenSecret.
|
|
17
|
+
readonly challengeTokenSecret: string;
|
|
18
|
+
// Signs the outgoing secret-carrying setupToken — same secret + token
|
|
19
|
+
// format as enable-start.write.ts, so the later pre-auth confirm step can
|
|
20
|
+
// verify it with the existing verifyMfaSetupToken.
|
|
21
|
+
readonly setupTokenSecret: string;
|
|
22
|
+
readonly issuer: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Pre-auth twin of enable-start.write.ts: identity comes from a verified
|
|
26
|
+
// preauthSetupToken (minted by login.write.ts when enforcement policy
|
|
27
|
+
// blocks an unenrolled user), not from event.user — there is no session at
|
|
28
|
+
// this point. Runs pre-session the same way verify.write.ts does (dispatched
|
|
29
|
+
// by the framework's /auth/mfa/preauth-enable-start route with GUEST_USER).
|
|
30
|
+
export function createEnableStartPreauthHandler(opts: EnableStartPreauthOptions) {
|
|
31
|
+
return defineWriteHandler({
|
|
32
|
+
name: "enable-start-preauth",
|
|
33
|
+
schema: z.object({
|
|
34
|
+
preauthSetupToken: z.string().min(1),
|
|
35
|
+
accountLabel: z.string().min(1).max(200),
|
|
36
|
+
}),
|
|
37
|
+
access: { roles: ["all"] },
|
|
38
|
+
handler: async (event, ctx) => {
|
|
39
|
+
const verified = verifyMfaPreauthSetupToken(
|
|
40
|
+
event.payload.preauthSetupToken,
|
|
41
|
+
opts.challengeTokenSecret,
|
|
42
|
+
);
|
|
43
|
+
// Same generic "sign in again" error as an expired/invalid MFA
|
|
44
|
+
// challenge token — both mean the login-flow token expired, no
|
|
45
|
+
// distinct i18n needed (see #1465's scoping comment).
|
|
46
|
+
if (!verified.ok) return invalidChallengeToken();
|
|
47
|
+
const { userId, tenantId } = verified.payload;
|
|
48
|
+
|
|
49
|
+
// "system" mode: the guest dispatch identity's own tenantId is
|
|
50
|
+
// meaningless here — the preauthSetupToken is the source of truth
|
|
51
|
+
// for which tenant's row to read, mirroring verify.write.ts.
|
|
52
|
+
const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
|
|
53
|
+
const scopedUser: SessionUser = { id: userId, tenantId, roles: ["User"] };
|
|
54
|
+
const existing = await findUserMfaRow(scopedDb, scopedUser);
|
|
55
|
+
if (existing) return mfaAlreadyEnabled();
|
|
56
|
+
|
|
57
|
+
const secret = generateTotpSecret();
|
|
58
|
+
const recoveryCodes = generateRecoveryCodes();
|
|
59
|
+
const recoveryCodeHashes = await hashRecoveryCodes(recoveryCodes);
|
|
60
|
+
|
|
61
|
+
const { token: setupToken } = signMfaSetupToken(
|
|
62
|
+
{
|
|
63
|
+
userId,
|
|
64
|
+
totpSecretBase32: base32Encode(secret),
|
|
65
|
+
recoveryCodeHashes,
|
|
66
|
+
tenantId,
|
|
67
|
+
},
|
|
68
|
+
MFA_SETUP_TOKEN_TTL_MINUTES,
|
|
69
|
+
opts.setupTokenSecret,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
isSuccess: true,
|
|
74
|
+
data: {
|
|
75
|
+
setupToken,
|
|
76
|
+
otpauthUri: buildOtpauthUri({
|
|
77
|
+
issuer: opts.issuer,
|
|
78
|
+
accountLabel: event.payload.accountLabel,
|
|
79
|
+
secret,
|
|
80
|
+
}),
|
|
81
|
+
// Plaintext — this is the one and only time these are shown.
|
|
82
|
+
recoveryCodes,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// HMAC-signed token minted by login.write.ts when enforcement policy blocks
|
|
2
|
+
// an unenrolled user (mfa-setup-required) — proves the password check
|
|
3
|
+
// already succeeded, so a later pre-auth enable-start variant (#1231) can
|
|
4
|
+
// let the user enroll without a full session. No secret material (unlike
|
|
5
|
+
// mfa-setup-token.ts) — just enough to look the user back up.
|
|
6
|
+
//
|
|
7
|
+
// Same domain-separated HMAC shape as mfa-challenge-token.ts, distinct
|
|
8
|
+
// signing string so this can never be replayed as a login challenge or
|
|
9
|
+
// vice versa. Reuses challengeTokenSecret rather than adding a third
|
|
10
|
+
// feature secret: both tokens assert the identical trust boundary
|
|
11
|
+
// ("password already verified, short-lived follow-up allowed").
|
|
12
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
13
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { Temporal } from "temporal-polyfill";
|
|
15
|
+
|
|
16
|
+
export type MfaPreauthSetupPayload = {
|
|
17
|
+
readonly userId: string;
|
|
18
|
+
readonly tenantId: TenantId;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type EncodedBody = MfaPreauthSetupPayload & { readonly expiresAtMs: number };
|
|
22
|
+
|
|
23
|
+
function sign(input: string, secret: string): string {
|
|
24
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function signMfaPreauthSetupToken(
|
|
28
|
+
payload: MfaPreauthSetupPayload,
|
|
29
|
+
ttlMinutes: number,
|
|
30
|
+
secret: string,
|
|
31
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
32
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
33
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
34
|
+
const body: EncodedBody = { ...payload, expiresAtMs: expiresAt.epochMilliseconds };
|
|
35
|
+
const bodyB64 = Buffer.from(JSON.stringify(body)).toString("base64url");
|
|
36
|
+
const signature = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
37
|
+
return { token: `${bodyB64}.${signature}`, expiresAt };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type VerifyMfaPreauthSetupResult =
|
|
41
|
+
| { readonly ok: true; readonly payload: MfaPreauthSetupPayload; readonly expiresAtMs: number }
|
|
42
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
43
|
+
|
|
44
|
+
function isEncodedBody(value: unknown): value is EncodedBody {
|
|
45
|
+
if (typeof value !== "object" || value === null) return false;
|
|
46
|
+
const v = value as Record<string, unknown>;
|
|
47
|
+
return (
|
|
48
|
+
typeof v["userId"] === "string" &&
|
|
49
|
+
typeof v["tenantId"] === "string" &&
|
|
50
|
+
typeof v["expiresAtMs"] === "number"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verifyMfaPreauthSetupToken(
|
|
55
|
+
token: string,
|
|
56
|
+
secret: string,
|
|
57
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
58
|
+
): VerifyMfaPreauthSetupResult {
|
|
59
|
+
const parts = token.split(".");
|
|
60
|
+
if (parts.length !== 2) return { ok: false, reason: "malformed" };
|
|
61
|
+
const [bodyB64, providedSig] = parts;
|
|
62
|
+
if (!bodyB64 || !providedSig) return { ok: false, reason: "malformed" };
|
|
63
|
+
|
|
64
|
+
const expected = sign(`mfa-preauth-setup:${bodyB64}`, secret);
|
|
65
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
66
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
67
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
68
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
69
|
+
|
|
70
|
+
let parsed: unknown;
|
|
71
|
+
try {
|
|
72
|
+
parsed = JSON.parse(Buffer.from(bodyB64, "base64url").toString("utf8"));
|
|
73
|
+
} catch {
|
|
74
|
+
return { ok: false, reason: "malformed" };
|
|
75
|
+
}
|
|
76
|
+
if (!isEncodedBody(parsed)) return { ok: false, reason: "malformed" };
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(parsed.expiresAtMs)) > 0
|
|
80
|
+
) {
|
|
81
|
+
return { ok: false, reason: "expired" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const { expiresAtMs, ...payload } = parsed;
|
|
85
|
+
return { ok: true, payload, expiresAtMs };
|
|
86
|
+
}
|
|
@@ -16,6 +16,11 @@ export type MfaSetupPayload = {
|
|
|
16
16
|
readonly userId: string;
|
|
17
17
|
readonly totpSecretBase32: string;
|
|
18
18
|
readonly recoveryCodeHashes: readonly string[];
|
|
19
|
+
// Set only by enable-start-preauth.write.ts — the session-authed
|
|
20
|
+
// enable-start.write.ts has no tenantId to carry (event.user.tenantId
|
|
21
|
+
// already covers that path). The pre-auth confirm handler has no session
|
|
22
|
+
// to fall back on, so this is the only source of which tenant to scope to.
|
|
23
|
+
readonly tenantId?: string;
|
|
19
24
|
};
|
|
20
25
|
|
|
21
26
|
type EncodedBody = MfaSetupPayload & { readonly expiresAtMs: number };
|
|
@@ -49,7 +54,8 @@ function isEncodedBody(value: unknown): value is EncodedBody {
|
|
|
49
54
|
typeof v["totpSecretBase32"] === "string" &&
|
|
50
55
|
Array.isArray(v["recoveryCodeHashes"]) &&
|
|
51
56
|
v["recoveryCodeHashes"].every((h) => typeof h === "string") &&
|
|
52
|
-
typeof v["expiresAtMs"] === "number"
|
|
57
|
+
typeof v["expiresAtMs"] === "number" &&
|
|
58
|
+
(v["tenantId"] === undefined || typeof v["tenantId"] === "string")
|
|
53
59
|
);
|
|
54
60
|
}
|
|
55
61
|
|
|
@@ -6,9 +6,10 @@ import {
|
|
|
6
6
|
type TenantId,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { type MfaRequiredPolicy, mfaRequiredConfigHandle, mfaRequiredConfigKey } from "./config";
|
|
9
|
-
import { MFA_CHALLENGE_TOKEN_TTL_MINUTES } from "./constants";
|
|
9
|
+
import { MFA_CHALLENGE_TOKEN_TTL_MINUTES, MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES } from "./constants";
|
|
10
10
|
import { findUserMfaRow } from "./db/queries";
|
|
11
11
|
import { signMfaChallengeToken } from "./mfa-challenge-token";
|
|
12
|
+
import { signMfaPreauthSetupToken } from "./mfa-preauth-setup-token";
|
|
12
13
|
|
|
13
14
|
export type MfaStatusCheckResult =
|
|
14
15
|
| { readonly required: false }
|
|
@@ -17,7 +18,7 @@ export type MfaStatusCheckResult =
|
|
|
17
18
|
// yet. Distinct from `required: true` (which means "enrolled, prove it
|
|
18
19
|
// now") — see config.ts's ponytail note on why this hard-blocks login
|
|
19
20
|
// with no in-band recovery until PR3's enrollment-during-login UI ships.
|
|
20
|
-
| { readonly setupRequired: true };
|
|
21
|
+
| { readonly setupRequired: true; readonly preauthSetupToken: string };
|
|
21
22
|
|
|
22
23
|
// Consumed by auth-email-password's login.write.ts via `mfaStatusChecker`
|
|
23
24
|
// (a generic callback type declared THERE, not here — auth-email-password
|
|
@@ -67,9 +68,17 @@ export function createMfaStatusChecker(opts: {
|
|
|
67
68
|
scopedDb,
|
|
68
69
|
)) ?? "optional") as MfaRequiredPolicy | "optional";
|
|
69
70
|
if (policy === "optional") return { required: false };
|
|
70
|
-
|
|
71
|
+
const buildSetupToken = (): string =>
|
|
72
|
+
signMfaPreauthSetupToken(
|
|
73
|
+
{ userId, tenantId },
|
|
74
|
+
MFA_PREAUTH_SETUP_TOKEN_TTL_MINUTES,
|
|
75
|
+
opts.challengeTokenSecret,
|
|
76
|
+
).token;
|
|
77
|
+
if (policy === "all") return { setupRequired: true, preauthSetupToken: buildSetupToken() };
|
|
71
78
|
// policy === "admins"
|
|
72
79
|
const isAdmin = roles.some((role) => access.admin.includes(role));
|
|
73
|
-
return isAdmin
|
|
80
|
+
return isAdmin
|
|
81
|
+
? { setupRequired: true, preauthSetupToken: buildSetupToken() }
|
|
82
|
+
: { required: false };
|
|
74
83
|
};
|
|
75
84
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
-
import { verifyMfaChallenge } from "../mfa-client";
|
|
2
|
+
import { confirmMfaSetupPreauth, startMfaSetupPreauth, verifyMfaChallenge } from "../mfa-client";
|
|
3
3
|
|
|
4
4
|
beforeEach(() => {
|
|
5
5
|
globalThis.fetch = mock(
|
|
@@ -116,3 +116,146 @@ describe("verifyMfaChallenge", () => {
|
|
|
116
116
|
expect(res).toEqual({ kind: "failure", error: { reason: "mfa_verify_failed" } });
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
describe("startMfaSetupPreauth", () => {
|
|
121
|
+
test("posts preauthSetupToken + accountLabel to /api/auth/mfa/preauth-enable-start", async () => {
|
|
122
|
+
const fetchMock = mock(async () =>
|
|
123
|
+
jsonResponse({
|
|
124
|
+
isSuccess: true,
|
|
125
|
+
setupToken: "setup-1",
|
|
126
|
+
otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD&issuer=App",
|
|
127
|
+
recoveryCodes: ["r1", "r2"],
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
131
|
+
|
|
132
|
+
await startMfaSetupPreauth("preauth-1", "user@example.com");
|
|
133
|
+
|
|
134
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
135
|
+
"/api/auth/mfa/preauth-enable-start",
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
method: "POST",
|
|
138
|
+
credentials: "same-origin",
|
|
139
|
+
body: JSON.stringify({ preauthSetupToken: "preauth-1", accountLabel: "user@example.com" }),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("success → kind:success mit setupToken/otpauthUri/recoveryCodes", async () => {
|
|
145
|
+
globalThis.fetch = mock(async () =>
|
|
146
|
+
jsonResponse({
|
|
147
|
+
isSuccess: true,
|
|
148
|
+
setupToken: "setup-1",
|
|
149
|
+
otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD&issuer=App",
|
|
150
|
+
recoveryCodes: ["r1", "r2"],
|
|
151
|
+
}),
|
|
152
|
+
) as unknown as typeof fetch;
|
|
153
|
+
|
|
154
|
+
const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
|
|
155
|
+
|
|
156
|
+
expect(res).toEqual({
|
|
157
|
+
kind: "success",
|
|
158
|
+
data: {
|
|
159
|
+
setupToken: "setup-1",
|
|
160
|
+
otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD&issuer=App",
|
|
161
|
+
recoveryCodes: ["r1", "r2"],
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("HTTP 429 → kind:failure reason:rate_limited", async () => {
|
|
167
|
+
globalThis.fetch = mock(
|
|
168
|
+
async () => new Response(null, { status: 429 }),
|
|
169
|
+
) as unknown as typeof fetch;
|
|
170
|
+
|
|
171
|
+
const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
|
|
172
|
+
|
|
173
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "rate_limited" } });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("error als String → kind:failure reason:<string>", async () => {
|
|
177
|
+
globalThis.fetch = mock(async () =>
|
|
178
|
+
jsonResponse({ isSuccess: false, error: "invalid_challenge_token" }),
|
|
179
|
+
) as unknown as typeof fetch;
|
|
180
|
+
|
|
181
|
+
const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
|
|
182
|
+
|
|
183
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "invalid_challenge_token" } });
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("error-Objekt ohne details.reason → fällt auf error.code zurück", async () => {
|
|
187
|
+
globalThis.fetch = mock(async () =>
|
|
188
|
+
jsonResponse({ isSuccess: false, error: { code: "mfa_already_enabled" } }),
|
|
189
|
+
) as unknown as typeof fetch;
|
|
190
|
+
|
|
191
|
+
const res = await startMfaSetupPreauth("preauth-1", "user@example.com");
|
|
192
|
+
|
|
193
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "mfa_already_enabled" } });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("confirmMfaSetupPreauth", () => {
|
|
198
|
+
test("posts setupToken + code to /api/auth/mfa/preauth-confirm", async () => {
|
|
199
|
+
const fetchMock = mock(async () =>
|
|
200
|
+
jsonResponse({ isSuccess: true, token: "t", user: { id: "u1", tenantId: "t1", roles: [] } }),
|
|
201
|
+
);
|
|
202
|
+
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
203
|
+
|
|
204
|
+
await confirmMfaSetupPreauth("setup-1", "123456");
|
|
205
|
+
|
|
206
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
207
|
+
"/api/auth/mfa/preauth-confirm",
|
|
208
|
+
expect.objectContaining({
|
|
209
|
+
method: "POST",
|
|
210
|
+
credentials: "same-origin",
|
|
211
|
+
body: JSON.stringify({ setupToken: "setup-1", code: "123456" }),
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("success → kind:success mit token+user", async () => {
|
|
217
|
+
globalThis.fetch = mock(async () =>
|
|
218
|
+
jsonResponse({
|
|
219
|
+
isSuccess: true,
|
|
220
|
+
token: "test-token",
|
|
221
|
+
user: { id: "u1", tenantId: "t1", roles: ["User"] },
|
|
222
|
+
}),
|
|
223
|
+
) as unknown as typeof fetch;
|
|
224
|
+
|
|
225
|
+
const res = await confirmMfaSetupPreauth("setup-1", "123456");
|
|
226
|
+
|
|
227
|
+
expect(res).toEqual({
|
|
228
|
+
kind: "success",
|
|
229
|
+
data: { token: "test-token", user: { id: "u1", tenantId: "t1", roles: ["User"] } },
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("HTTP 429 → kind:failure reason:rate_limited", async () => {
|
|
234
|
+
globalThis.fetch = mock(
|
|
235
|
+
async () => new Response(null, { status: 429 }),
|
|
236
|
+
) as unknown as typeof fetch;
|
|
237
|
+
|
|
238
|
+
const res = await confirmMfaSetupPreauth("setup-1", "000000");
|
|
239
|
+
|
|
240
|
+
expect(res).toEqual({ kind: "failure", error: { reason: "rate_limited" } });
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("error-Objekt mit details.reason → reason + retryAfterSeconds übernommen", async () => {
|
|
244
|
+
globalThis.fetch = mock(async () =>
|
|
245
|
+
jsonResponse({
|
|
246
|
+
isSuccess: false,
|
|
247
|
+
error: {
|
|
248
|
+
code: "mfa_setup_confirm_failed",
|
|
249
|
+
details: { reason: "too_many_attempts", retryAfterSeconds: 30 },
|
|
250
|
+
},
|
|
251
|
+
}),
|
|
252
|
+
) as unknown as typeof fetch;
|
|
253
|
+
|
|
254
|
+
const res = await confirmMfaSetupPreauth("setup-1", "000000");
|
|
255
|
+
|
|
256
|
+
expect(res).toEqual({
|
|
257
|
+
kind: "failure",
|
|
258
|
+
error: { reason: "too_many_attempts", retryAfterSeconds: 30 },
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|