@cosmicdrift/kumiko-bundled-features 0.200.1 → 0.202.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 -7
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +127 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +9 -0
- package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +18 -6
- package/src/auth-mfa/feature.ts +69 -2
- package/src/auth-mfa/handlers/disable.write.ts +7 -0
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +7 -0
- package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -0
- package/src/auth-mfa/index.ts +4 -0
- package/src/auth-mfa/mfa-code-verifier.ts +33 -0
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +5 -3
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +171 -4
- package/src/personal-access-tokens/constants.ts +8 -0
- package/src/personal-access-tokens/feature.ts +66 -2
- package/src/personal-access-tokens/handlers/create.write.ts +94 -36
- package/src/personal-access-tokens/index.ts +7 -2
- package/src/personal-access-tokens/revoke-for-user.ts +18 -0
- package/src/personal-access-tokens/web/i18n.ts +8 -0
- package/src/personal-access-tokens/web/pat-tokens-screen.tsx +31 -0
- package/src/sessions/__tests__/role-rederivation.integration.test.ts +137 -0
- package/src/sessions/__tests__/sessions.integration.test.ts +3 -1
- package/src/sessions/session-callbacks.ts +44 -13
- package/src/sessions/session-checker-fail-open.test.ts +74 -0
- package/src/step-dispatcher/webhook-runner.ts +15 -0
- package/src/tenant/handlers/remove-member.write.ts +13 -12
- package/src/tenant/handlers/update-member-roles.write.ts +13 -12
- package/src/tenant/membership-roles.ts +7 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +23 -1
- package/src/user-data-rights/feature.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.202.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -126,12 +126,12 @@
|
|
|
126
126
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
127
127
|
},
|
|
128
128
|
"dependencies": {
|
|
129
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
130
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
131
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
132
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
133
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
129
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.202.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.202.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.202.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.202.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.202.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.202.0",
|
|
135
135
|
"@mollie/api-client": "^4.5.0",
|
|
136
136
|
"@node-rs/argon2": "^2.0.2",
|
|
137
137
|
"@types/mailparser": "^3.4.6",
|
|
@@ -669,4 +669,131 @@ describe("privilege escalation via invite role", () => {
|
|
|
669
669
|
)) as { role: string };
|
|
670
670
|
expect(result.role).toBe("Editor");
|
|
671
671
|
});
|
|
672
|
+
|
|
673
|
+
// Regression guard for the opt-in canAssignRole hook (#security): this
|
|
674
|
+
// stack's invite feature was mounted WITHOUT canAssignRole, so any
|
|
675
|
+
// non-reserved app-defined role must still go through unchanged — the
|
|
676
|
+
// hierarchy gate below is a separate, opt-in stack, not a default.
|
|
677
|
+
test("without canAssignRole configured, any non-reserved app-defined role is still assignable", async () => {
|
|
678
|
+
const result = (await stack.http.writeOk(
|
|
679
|
+
AuthHandlers.inviteCreate,
|
|
680
|
+
{ email: "dpo-candidate@example.com", role: "DPO" },
|
|
681
|
+
aliceSession(),
|
|
682
|
+
)) as { role: string };
|
|
683
|
+
expect(result.role).toBe("DPO");
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
// canAssignRole is a role-hierarchy gate that must live in the app (roles are
|
|
688
|
+
// app-defined strings, not a framework concept) — a separate stack mounts the
|
|
689
|
+
// invite feature WITH the hook to prove it's actually enforced, distinct from
|
|
690
|
+
// the framework-reserved-role check covered above.
|
|
691
|
+
describe("invite-create role-hierarchy gate (opt-in canAssignRole)", () => {
|
|
692
|
+
let gateStack: TestStack;
|
|
693
|
+
let gateAliceId: string;
|
|
694
|
+
let gateTenantId: TenantId;
|
|
695
|
+
const gateTransport = createInMemoryTransport();
|
|
696
|
+
|
|
697
|
+
function gateAliceSession(): SessionUser {
|
|
698
|
+
return { id: gateAliceId, tenantId: gateTenantId, roles: ["Admin"] };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
beforeAll(async () => {
|
|
702
|
+
gateStack = await setupTestStack({
|
|
703
|
+
features: [
|
|
704
|
+
createConfigFeature(),
|
|
705
|
+
createUserFeature(),
|
|
706
|
+
createTenantFeature(),
|
|
707
|
+
createTemplateResolverFeature(),
|
|
708
|
+
createRendererFoundationFeature(),
|
|
709
|
+
createDeliveryFeature(),
|
|
710
|
+
createRendererSimpleFeature(),
|
|
711
|
+
createChannelEmailFeature({
|
|
712
|
+
transport: gateTransport,
|
|
713
|
+
renderer: simpleRenderer,
|
|
714
|
+
resolveEmail: async () => "unused@test.local",
|
|
715
|
+
}),
|
|
716
|
+
createAuthEmailPasswordFeature({
|
|
717
|
+
invite: {
|
|
718
|
+
tokenTtlMinutes: 60,
|
|
719
|
+
appUrl: APP_ACCEPT_URL,
|
|
720
|
+
// Only "Admin" may grant "DPO" — everything else may only grant
|
|
721
|
+
// non-DPO roles. A minimal, deliberately app-shaped hierarchy;
|
|
722
|
+
// the framework itself knows nothing about "DPO".
|
|
723
|
+
canAssignRole: (inviterRoles, targetRole) =>
|
|
724
|
+
targetRole !== "DPO" || inviterRoles.includes("Admin"),
|
|
725
|
+
},
|
|
726
|
+
}),
|
|
727
|
+
],
|
|
728
|
+
extraContext: (deps) => ({
|
|
729
|
+
...createDeliveryTestContext(deps),
|
|
730
|
+
configResolver: createConfigResolver(),
|
|
731
|
+
}),
|
|
732
|
+
authConfig: {
|
|
733
|
+
membershipQuery: "tenant:query:memberships",
|
|
734
|
+
loginHandler: AuthHandlers.login,
|
|
735
|
+
invite: {
|
|
736
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
737
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
738
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
739
|
+
},
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
await unsafeCreateEntityTable(gateStack.db, userEntity);
|
|
744
|
+
await unsafeCreateEntityTable(gateStack.db, tenantEntity);
|
|
745
|
+
await unsafeCreateEntityTable(gateStack.db, tenantInvitationEntity);
|
|
746
|
+
await unsafePushTables(gateStack.db, {
|
|
747
|
+
configValuesTable,
|
|
748
|
+
tenantMembershipsTable,
|
|
749
|
+
notificationPreferencesTable,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
gateTenantId = newTenantId("gate");
|
|
753
|
+
await seedTenant(gateStack.db, {
|
|
754
|
+
id: gateTenantId,
|
|
755
|
+
key: `tenant-gate-${gateTenantId.slice(0, 8)}`,
|
|
756
|
+
name: "Tenant Gate",
|
|
757
|
+
});
|
|
758
|
+
({ id: gateAliceId } = await seedUser(gateStack.db, {
|
|
759
|
+
email: "gate-alice@example.com",
|
|
760
|
+
displayName: "Gate Alice",
|
|
761
|
+
passwordHash: await hashPassword("gate-alice-pw-1234"),
|
|
762
|
+
emailVerified: true,
|
|
763
|
+
}));
|
|
764
|
+
await seedTenantMembership(gateStack.db, {
|
|
765
|
+
userId: gateAliceId,
|
|
766
|
+
tenantId: gateTenantId,
|
|
767
|
+
roles: ["Admin"],
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
afterAll(async () => {
|
|
772
|
+
await gateStack.cleanup();
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("canAssignRole → false blocks invite-create with a role-hierarchy error, no invitation persisted", async () => {
|
|
776
|
+
// TenantAdmin clears the handler's own access.admin gate but canAssignRole
|
|
777
|
+
// above only lets literal "Admin" grant "DPO" — the case this hook exists for.
|
|
778
|
+
const err = await gateStack.http.writeErr(
|
|
779
|
+
AuthHandlers.inviteCreate,
|
|
780
|
+
{ email: "blocked-target@example.com", role: "DPO" },
|
|
781
|
+
{ id: gateAliceId, tenantId: gateTenantId, roles: ["TenantAdmin"] },
|
|
782
|
+
);
|
|
783
|
+
expect(err.details).toMatchObject({ reason: "unassignable_membership_role", role: "DPO" });
|
|
784
|
+
const rows = await selectMany(gateStack.db, tenantInvitationsTable, {
|
|
785
|
+
email: "blocked-target@example.com",
|
|
786
|
+
});
|
|
787
|
+
expect(rows).toHaveLength(0);
|
|
788
|
+
expect(gateTransport.sent).toHaveLength(0);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
test("canAssignRole → true allows invite-create through", async () => {
|
|
792
|
+
const result = (await gateStack.http.writeOk(
|
|
793
|
+
AuthHandlers.inviteCreate,
|
|
794
|
+
{ email: "allowed-target@example.com", role: "DPO" },
|
|
795
|
+
gateAliceSession(),
|
|
796
|
+
)) as { role: string };
|
|
797
|
+
expect(result.role).toBe("DPO");
|
|
798
|
+
});
|
|
672
799
|
});
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
import {
|
|
34
34
|
findForbiddenMembershipRole,
|
|
35
35
|
reservedMembershipRoleError,
|
|
36
|
+
unassignableMembershipRoleError,
|
|
36
37
|
} from "../../tenant/membership-roles";
|
|
37
38
|
import { AUTH_INVITE_DEFAULT_TTL_MINUTES } from "../constants";
|
|
38
39
|
import type { AuthMailLocale } from "../email-templates";
|
|
@@ -64,6 +65,10 @@ export type InviteCreateOptions = {
|
|
|
64
65
|
readonly appUrl: string;
|
|
65
66
|
readonly appName?: string;
|
|
66
67
|
readonly locale?: AuthMailLocale;
|
|
68
|
+
// Opt-in role-hierarchy gate. Roles are app-defined strings, not a framework
|
|
69
|
+
// concept, so the hierarchy itself must live in the app — this hook lets it
|
|
70
|
+
// plug in without the framework hardcoding any role names.
|
|
71
|
+
readonly canAssignRole?: (inviterRoles: readonly string[], targetRole: string) => boolean;
|
|
67
72
|
};
|
|
68
73
|
|
|
69
74
|
const executor = createEventStoreExecutor(tenantInvitationsTable, tenantInvitationEntity, {
|
|
@@ -90,6 +95,10 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
|
|
|
90
95
|
return writeFailure(reservedMembershipRoleError(forbiddenRole));
|
|
91
96
|
}
|
|
92
97
|
|
|
98
|
+
if (opts.canAssignRole && !opts.canAssignRole(event.user.roles, event.payload.role)) {
|
|
99
|
+
return writeFailure(unassignableMembershipRoleError(event.payload.role));
|
|
100
|
+
}
|
|
101
|
+
|
|
93
102
|
const email = event.payload.email.toLowerCase();
|
|
94
103
|
const tenantId = event.user.tenantId;
|
|
95
104
|
const expiresAt = Temporal.Now.instant().add({ seconds: ttlSeconds });
|
|
@@ -30,6 +30,18 @@ import { currentTotpCode } from "../totp";
|
|
|
30
30
|
// account — every OTHER live session must be signed out (stolen-session
|
|
31
31
|
// defense), but the session that performed the change must survive.
|
|
32
32
|
|
|
33
|
+
// sessionChecker's "live" outcome can now carry re-derived roles as
|
|
34
|
+
// `{status: "live", roles: [...]}` instead of the bare string — these tests
|
|
35
|
+
// only care about liveness, not the exact role set, so unwrap the status.
|
|
36
|
+
async function checkerStatus(
|
|
37
|
+
callbacks: ReturnType<typeof createSessionCallbacks>,
|
|
38
|
+
sid: string,
|
|
39
|
+
userId: string,
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
const result = await callbacks.sessionChecker(sid, userId);
|
|
42
|
+
return typeof result === "string" ? result : result.status;
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
let stack: TestStack;
|
|
34
46
|
let sessionCallbacks: ReturnType<typeof createSessionCallbacks>;
|
|
35
47
|
|
|
@@ -94,8 +106,8 @@ describe("session auto-revoke on MFA state changes", () => {
|
|
|
94
106
|
user,
|
|
95
107
|
);
|
|
96
108
|
|
|
97
|
-
expect(await sessionCallbacks
|
|
98
|
-
expect(await sessionCallbacks
|
|
109
|
+
expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
|
|
110
|
+
expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
|
|
99
111
|
});
|
|
100
112
|
|
|
101
113
|
test("disable revokes every OTHER session but keeps the caller's", async () => {
|
|
@@ -130,8 +142,8 @@ describe("session auto-revoke on MFA state changes", () => {
|
|
|
130
142
|
caller,
|
|
131
143
|
);
|
|
132
144
|
|
|
133
|
-
expect(await sessionCallbacks
|
|
134
|
-
expect(await sessionCallbacks
|
|
145
|
+
expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
|
|
146
|
+
expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("revoked");
|
|
135
147
|
});
|
|
136
148
|
|
|
137
149
|
test("a failed disable attempt does NOT revoke any session", async () => {
|
|
@@ -163,7 +175,7 @@ describe("session auto-revoke on MFA state changes", () => {
|
|
|
163
175
|
const err = await stack.http.writeErr(AuthMfaHandlers.disable, { code: "000000" }, caller);
|
|
164
176
|
expectErrorIncludes(err, "invalid_totp_code");
|
|
165
177
|
|
|
166
|
-
expect(await sessionCallbacks
|
|
167
|
-
expect(await sessionCallbacks
|
|
178
|
+
expect(await checkerStatus(sessionCallbacks, currentSid, userId)).toBe("live");
|
|
179
|
+
expect(await checkerStatus(sessionCallbacks, otherSid, userId)).toBe("live");
|
|
168
180
|
});
|
|
169
181
|
});
|
package/src/auth-mfa/feature.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { createRegenerateRecoveryHandler } from "./handlers/regenerate-recovery.
|
|
|
11
11
|
import { mfaStatusQuery } from "./handlers/status.query";
|
|
12
12
|
import { createMfaVerifyHandler } from "./handlers/verify.write";
|
|
13
13
|
import { AUTH_MFA_FEATURE_I18N } from "./i18n";
|
|
14
|
+
import { createMfaCodeVerifier, type MfaCodeVerifier } from "./mfa-code-verifier";
|
|
14
15
|
import { createMfaStatusChecker, type MfaStatusChecker } from "./mfa-status-checker";
|
|
15
16
|
import { userMfaEntity } from "./schema/user-mfa";
|
|
16
17
|
|
|
@@ -70,6 +71,42 @@ export function mfaStatusCheckerFromFeature(
|
|
|
70
71
|
return undefined;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
// Reads the eagerly-built `verifyMfaCode` off a mounted auth-mfa feature's
|
|
75
|
+
// exports — personal-access-tokens wires this in as its optional
|
|
76
|
+
// mfaVerifier (re-auth gate for minting a token). Same no-bind-setter
|
|
77
|
+
// reasoning as checkMfaStatus above.
|
|
78
|
+
export function mfaVerifierFromFeature(feature: FeatureDefinition): MfaCodeVerifier | undefined {
|
|
79
|
+
const exports = feature.exports;
|
|
80
|
+
if (exports && typeof exports === "object" && "verifyMfaCode" in exports) {
|
|
81
|
+
const { verifyMfaCode } = exports as { verifyMfaCode: unknown };
|
|
82
|
+
if (typeof verifyMfaCode === "function") {
|
|
83
|
+
// @cast-boundary exports-walk — feature.exports is untyped by design
|
|
84
|
+
return verifyMfaCode as MfaCodeVerifier;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type BindRevokeAllPatTokens = (revoker: (userId: string) => Promise<number>) => void;
|
|
91
|
+
|
|
92
|
+
// Reads the late-bind setter off a mounted auth-mfa feature's exports —
|
|
93
|
+
// run{Prod,Dev}App call this once the personal-access-tokens feature (if
|
|
94
|
+
// mounted) has produced a concrete revoker, same wiring shape as
|
|
95
|
+
// bindMfaRevokeAllOtherSessionsFromFeature.
|
|
96
|
+
export function bindRevokeAllPatTokensFromFeature(
|
|
97
|
+
feature: FeatureDefinition,
|
|
98
|
+
): BindRevokeAllPatTokens | undefined {
|
|
99
|
+
const exports = feature.exports;
|
|
100
|
+
if (exports && typeof exports === "object" && "bindRevokeAllPatTokens" in exports) {
|
|
101
|
+
const { bindRevokeAllPatTokens } = exports as { bindRevokeAllPatTokens: unknown };
|
|
102
|
+
if (typeof bindRevokeAllPatTokens === "function") {
|
|
103
|
+
// @cast-boundary exports-walk — feature.exports is untyped by design
|
|
104
|
+
return bindRevokeAllPatTokens as BindRevokeAllPatTokens;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
73
110
|
export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefinition {
|
|
74
111
|
return defineFeature("auth-mfa", (r) => {
|
|
75
112
|
r.describe(
|
|
@@ -120,6 +157,14 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
120
157
|
const sharedRevoker = (userId: string, currentSid: string | undefined): Promise<number> =>
|
|
121
158
|
revokeAllOtherSessions?.(userId, currentSid) ?? Promise.resolve(0);
|
|
122
159
|
|
|
160
|
+
// Same late-bind shape as sharedRevoker above, for the personal-access-
|
|
161
|
+
// tokens feature (if mounted). Wired to enable-confirm(-preauth)/disable
|
|
162
|
+
// only — regenerate-recovery doesn't change enrollment state, so it's
|
|
163
|
+
// out of scope for the "MFA-enable/disable" finding this mirrors.
|
|
164
|
+
let revokeAllPatTokens: ((userId: string) => Promise<number>) | undefined;
|
|
165
|
+
const sharedPatRevoker = (userId: string): Promise<number> =>
|
|
166
|
+
revokeAllPatTokens?.(userId) ?? Promise.resolve(0);
|
|
167
|
+
|
|
123
168
|
const handlers = {
|
|
124
169
|
enableStart: r.writeHandler(
|
|
125
170
|
createEnableStartHandler({ setupTokenSecret: opts.setupTokenSecret, issuer: opts.issuer }),
|
|
@@ -135,15 +180,22 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
135
180
|
createEnableConfirmHandler({
|
|
136
181
|
setupTokenSecret: opts.setupTokenSecret,
|
|
137
182
|
revokeAllOtherSessions: sharedRevoker,
|
|
183
|
+
revokeAllPatTokens: sharedPatRevoker,
|
|
138
184
|
}),
|
|
139
185
|
),
|
|
140
186
|
enableConfirmPreauth: r.writeHandler(
|
|
141
187
|
createEnableConfirmPreauthHandler({
|
|
142
188
|
setupTokenSecret: opts.setupTokenSecret,
|
|
143
189
|
revokeAllOtherSessions: sharedRevoker,
|
|
190
|
+
revokeAllPatTokens: sharedPatRevoker,
|
|
191
|
+
}),
|
|
192
|
+
),
|
|
193
|
+
disable: r.writeHandler(
|
|
194
|
+
createDisableHandler({
|
|
195
|
+
revokeAllOtherSessions: sharedRevoker,
|
|
196
|
+
revokeAllPatTokens: sharedPatRevoker,
|
|
144
197
|
}),
|
|
145
198
|
),
|
|
146
|
-
disable: r.writeHandler(createDisableHandler({ revokeAllOtherSessions: sharedRevoker })),
|
|
147
199
|
regenerateRecovery: r.writeHandler(
|
|
148
200
|
createRegenerateRecoveryHandler({ revokeAllOtherSessions: sharedRevoker }),
|
|
149
201
|
),
|
|
@@ -164,10 +216,25 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
|
|
|
164
216
|
challengeTokenSecret: opts.challengeTokenSecret,
|
|
165
217
|
});
|
|
166
218
|
|
|
219
|
+
// No late-bind needed — same reasoning as checkMfaStatus: only needs the
|
|
220
|
+
// HandlerContext the caller already has.
|
|
221
|
+
const verifyMfaCode = createMfaCodeVerifier();
|
|
222
|
+
|
|
167
223
|
const bindRevokeAllOtherSessions: BindMfaRevokeAllOtherSessions = (revoker) => {
|
|
168
224
|
revokeAllOtherSessions = revoker;
|
|
169
225
|
};
|
|
170
226
|
|
|
171
|
-
|
|
227
|
+
const bindRevokeAllPatTokens: BindRevokeAllPatTokens = (revoker) => {
|
|
228
|
+
revokeAllPatTokens = revoker;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
handlers,
|
|
233
|
+
queries,
|
|
234
|
+
bindRevokeAllOtherSessions,
|
|
235
|
+
checkMfaStatus,
|
|
236
|
+
verifyMfaCode,
|
|
237
|
+
bindRevokeAllPatTokens,
|
|
238
|
+
};
|
|
172
239
|
});
|
|
173
240
|
}
|
|
@@ -12,6 +12,10 @@ export type DisableOptions = {
|
|
|
12
12
|
userId: string,
|
|
13
13
|
currentSid: string | undefined,
|
|
14
14
|
) => Promise<number>;
|
|
15
|
+
// Wired late by run-prod-app once the personal-access-tokens feature (if
|
|
16
|
+
// mounted) is concrete. Absent when PAT isn't mounted: disabling MFA just
|
|
17
|
+
// doesn't revoke PAT tokens.
|
|
18
|
+
readonly revokeAllPatTokens?: (userId: string) => Promise<number>;
|
|
15
19
|
};
|
|
16
20
|
|
|
17
21
|
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
@@ -60,6 +64,9 @@ export function createDisableHandler(opts: DisableOptions) {
|
|
|
60
64
|
if (opts.revokeAllOtherSessions) {
|
|
61
65
|
await opts.revokeAllOtherSessions(event.user.id, event.user.sid);
|
|
62
66
|
}
|
|
67
|
+
if (opts.revokeAllPatTokens) {
|
|
68
|
+
await opts.revokeAllPatTokens(event.user.id);
|
|
69
|
+
}
|
|
63
70
|
|
|
64
71
|
return { isSuccess: true, data: { disabled: true } };
|
|
65
72
|
},
|
|
@@ -36,6 +36,10 @@ export type EnableConfirmPreauthOptions = {
|
|
|
36
36
|
userId: string,
|
|
37
37
|
currentSid: string | undefined,
|
|
38
38
|
) => Promise<number>;
|
|
39
|
+
// Wired late by run-prod-app once the personal-access-tokens feature (if
|
|
40
|
+
// mounted) is concrete. Absent when PAT isn't mounted: enabling MFA just
|
|
41
|
+
// doesn't revoke PAT tokens.
|
|
42
|
+
readonly revokeAllPatTokens?: (userId: string) => Promise<number>;
|
|
39
43
|
};
|
|
40
44
|
|
|
41
45
|
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
@@ -202,6 +206,9 @@ export function createEnableConfirmPreauthHandler(opts: EnableConfirmPreauthOpti
|
|
|
202
206
|
if (opts.revokeAllOtherSessions) {
|
|
203
207
|
await opts.revokeAllOtherSessions(userId, undefined);
|
|
204
208
|
}
|
|
209
|
+
if (opts.revokeAllPatTokens) {
|
|
210
|
+
await opts.revokeAllPatTokens(userId);
|
|
211
|
+
}
|
|
205
212
|
|
|
206
213
|
return { isSuccess: true, data: { kind: "mfa-preauth-confirm-success", session } };
|
|
207
214
|
},
|
|
@@ -20,6 +20,10 @@ export type EnableConfirmOptions = {
|
|
|
20
20
|
userId: string,
|
|
21
21
|
currentSid: string | undefined,
|
|
22
22
|
) => Promise<number>;
|
|
23
|
+
// Wired late by run-prod-app once the personal-access-tokens feature (if
|
|
24
|
+
// mounted) is concrete. Absent when PAT isn't mounted: enabling MFA just
|
|
25
|
+
// doesn't revoke PAT tokens.
|
|
26
|
+
readonly revokeAllPatTokens?: (userId: string) => Promise<number>;
|
|
23
27
|
};
|
|
24
28
|
|
|
25
29
|
const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
@@ -99,6 +103,9 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
|
|
|
99
103
|
if (opts.revokeAllOtherSessions) {
|
|
100
104
|
await opts.revokeAllOtherSessions(event.user.id, event.user.sid);
|
|
101
105
|
}
|
|
106
|
+
if (opts.revokeAllPatTokens) {
|
|
107
|
+
await opts.revokeAllPatTokens(event.user.id);
|
|
108
|
+
}
|
|
102
109
|
|
|
103
110
|
return { isSuccess: true, data: { enabled: true } };
|
|
104
111
|
},
|
package/src/auth-mfa/index.ts
CHANGED
|
@@ -9,12 +9,16 @@ export {
|
|
|
9
9
|
export type {
|
|
10
10
|
AuthMfaFeatureOptions,
|
|
11
11
|
BindMfaRevokeAllOtherSessions,
|
|
12
|
+
BindRevokeAllPatTokens,
|
|
12
13
|
} from "./feature";
|
|
13
14
|
export {
|
|
14
15
|
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
16
|
+
bindRevokeAllPatTokensFromFeature,
|
|
15
17
|
createAuthMfaFeature,
|
|
16
18
|
mfaStatusCheckerFromFeature,
|
|
19
|
+
mfaVerifierFromFeature,
|
|
17
20
|
} from "./feature";
|
|
21
|
+
export type { MfaCodeVerifier, MfaCodeVerifyResult } from "./mfa-code-verifier";
|
|
18
22
|
export type { MfaStatusChecker, MfaStatusCheckResult } from "./mfa-status-checker";
|
|
19
23
|
export { userMfaEntity, userMfaTable } from "./schema/user-mfa";
|
|
20
24
|
export {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { findUserMfaRow } from "./db/queries";
|
|
4
|
+
import { verifyMfaFactor } from "./verify-factor";
|
|
5
|
+
|
|
6
|
+
export type MfaCodeVerifyResult = { readonly enrolled: boolean; readonly ok: boolean };
|
|
7
|
+
|
|
8
|
+
export type MfaCodeVerifier = (
|
|
9
|
+
ctx: HandlerContext,
|
|
10
|
+
userId: string,
|
|
11
|
+
tenantId: TenantId,
|
|
12
|
+
code: string | undefined,
|
|
13
|
+
) => Promise<MfaCodeVerifyResult>;
|
|
14
|
+
|
|
15
|
+
// Re-auth-only primitive (personal-access-tokens' currentPassword+mfaCode
|
|
16
|
+
// gate), distinct from login's mfaStatusChecker (enrollment/policy only, no
|
|
17
|
+
// code) and from verify.write.ts (full login-completion flow). TOTP-only —
|
|
18
|
+
// a recovery code accepted here without persisting single-use consumption
|
|
19
|
+
// (verify.write.ts's remainingHashes update) would become infinitely
|
|
20
|
+
// reusable, and a re-auth gate is not the account-recovery flow recovery
|
|
21
|
+
// codes exist for.
|
|
22
|
+
export function createMfaCodeVerifier(): MfaCodeVerifier {
|
|
23
|
+
return async (ctx, userId, tenantId, code) => {
|
|
24
|
+
const scopedDb = createTenantDb(ctx.db.raw, tenantId, "system");
|
|
25
|
+
const row = await findUserMfaRow(scopedDb, { id: userId, tenantId, roles: [] });
|
|
26
|
+
if (!row) return { enrolled: false, ok: false };
|
|
27
|
+
// Fail closed without ctx.redis, matching disable.write.ts/verify.write.ts —
|
|
28
|
+
// skipping the TOTP-replay burn would let an observed code be replayed.
|
|
29
|
+
if (!code || !ctx.redis) return { enrolled: true, ok: false };
|
|
30
|
+
const verify = await verifyMfaFactor(row, code, { redis: ctx.redis, userId });
|
|
31
|
+
return { enrolled: true, ok: verify.ok && verify.method === "totp" };
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -49,10 +49,12 @@ const stubNav: NavApi = {
|
|
|
49
49
|
route: undefined,
|
|
50
50
|
navigate: () => {},
|
|
51
51
|
replace: () => {},
|
|
52
|
-
hrefFor: (target) =>
|
|
53
|
-
target
|
|
52
|
+
hrefFor: (target) => {
|
|
53
|
+
if (!("screenId" in target)) return "";
|
|
54
|
+
return target.entityId !== undefined
|
|
54
55
|
? `/${target.screenId}/${target.entityId}`
|
|
55
|
-
: `/${target.screenId}
|
|
56
|
+
: `/${target.screenId}`;
|
|
57
|
+
},
|
|
56
58
|
searchParams: {},
|
|
57
59
|
setSearchParams: () => {},
|
|
58
60
|
};
|