@cosmicdrift/kumiko-bundled-features 0.200.0 → 0.201.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/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/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/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.201.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.201.0",
|
|
130
|
+
"@cosmicdrift/kumiko-framework": "0.201.0",
|
|
131
|
+
"@cosmicdrift/kumiko-headless": "0.201.0",
|
|
132
|
+
"@cosmicdrift/kumiko-renderer": "0.201.0",
|
|
133
|
+
"@cosmicdrift/kumiko-renderer-web": "0.201.0",
|
|
134
|
+
"@cosmicdrift/kumiko-types": "0.201.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 });
|
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
|
+
}
|
|
@@ -37,11 +37,12 @@ import { userSessionEntity } from "../../sessions/schema/user-session";
|
|
|
37
37
|
import { createTenantFeature } from "../../tenant";
|
|
38
38
|
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
39
39
|
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
40
|
-
import { USER_STATUS } from "../../user";
|
|
40
|
+
import { USER_STATUS, UserHandlers, UserQueries } from "../../user";
|
|
41
41
|
import { createUserFeature } from "../../user/feature";
|
|
42
42
|
import { userEntity, userTable } from "../../user/schema/user";
|
|
43
|
-
import { PatHandlers, PatQueries } from "../constants";
|
|
43
|
+
import { PAT_DEFAULT_EXPIRES_IN_DAYS, PatErrors, PatHandlers, PatQueries } from "../constants";
|
|
44
44
|
import { createPersonalAccessTokensFeature } from "../feature";
|
|
45
|
+
import { revokeAllPatTokensForUser } from "../revoke-for-user";
|
|
45
46
|
import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
|
|
46
47
|
import type { PatScopeConfig } from "../scopes";
|
|
47
48
|
|
|
@@ -66,14 +67,21 @@ const SCOPES: PatScopeConfig = {
|
|
|
66
67
|
|
|
67
68
|
async function mintToken(
|
|
68
69
|
actor: SessionUser,
|
|
69
|
-
opts?: {
|
|
70
|
+
opts?: {
|
|
71
|
+
scopes?: string[];
|
|
72
|
+
expiresInDays?: number;
|
|
73
|
+
currentPassword?: string;
|
|
74
|
+
mfaCode?: string;
|
|
75
|
+
},
|
|
70
76
|
): Promise<string> {
|
|
71
77
|
const res = await stack.http.writeOk<{ id: string; token: string }>(
|
|
72
78
|
PatHandlers.create,
|
|
73
79
|
{
|
|
74
80
|
name: "test",
|
|
75
81
|
scopes: opts?.scopes ?? ["tokens:read"],
|
|
82
|
+
currentPassword: opts?.currentPassword ?? "pw",
|
|
76
83
|
...(opts?.expiresInDays ? { expiresInDays: opts.expiresInDays } : {}),
|
|
84
|
+
...(opts?.mfaCode ? { mfaCode: opts.mfaCode } : {}),
|
|
77
85
|
},
|
|
78
86
|
actor,
|
|
79
87
|
);
|
|
@@ -91,7 +99,12 @@ beforeAll(async () => {
|
|
|
91
99
|
createTenantFeature(),
|
|
92
100
|
createAuthEmailPasswordFeature(),
|
|
93
101
|
createSessionsFeature(),
|
|
94
|
-
createPersonalAccessTokensFeature({
|
|
102
|
+
createPersonalAccessTokensFeature({
|
|
103
|
+
scopes: SCOPES,
|
|
104
|
+
// Closure over `stack` is fine here: the hook only invokes this at
|
|
105
|
+
// fire-time, well after setupTestStack below has assigned it.
|
|
106
|
+
autoRevokeOnPasswordChange: (userId) => revokeAllPatTokensForUser(stack.db, userId),
|
|
107
|
+
}),
|
|
95
108
|
authFoundationFeature,
|
|
96
109
|
],
|
|
97
110
|
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
@@ -253,3 +266,157 @@ describe("PAT with active KMS (#820): token name is userOwned PII", () => {
|
|
|
253
266
|
}
|
|
254
267
|
});
|
|
255
268
|
});
|
|
269
|
+
|
|
270
|
+
describe("PAT create: re-auth (#security)", () => {
|
|
271
|
+
test("missing currentPassword → rejected, no token minted", async () => {
|
|
272
|
+
const actor = await actorFor("reauth-missing@example.com");
|
|
273
|
+
const err = await stack.http.writeErr(
|
|
274
|
+
PatHandlers.create,
|
|
275
|
+
{ name: "test", scopes: ["tokens:read"] },
|
|
276
|
+
actor,
|
|
277
|
+
);
|
|
278
|
+
expect(err.httpStatus).toBeGreaterThanOrEqual(400);
|
|
279
|
+
const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
|
|
280
|
+
expect(rows).toHaveLength(0);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("wrong currentPassword → rejected, no token minted", async () => {
|
|
284
|
+
const actor = await actorFor("reauth-wrong@example.com");
|
|
285
|
+
const err = await stack.http.writeErr(
|
|
286
|
+
PatHandlers.create,
|
|
287
|
+
{ name: "test", scopes: ["tokens:read"], currentPassword: "not-the-password" },
|
|
288
|
+
actor,
|
|
289
|
+
);
|
|
290
|
+
expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
|
|
291
|
+
const rows = await stack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
|
|
292
|
+
expect(rows).toHaveLength(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("expiresInDays omitted → defaults to ~90 days, not never-expiring", async () => {
|
|
296
|
+
const actor = await actorFor("reauth-expiry@example.com");
|
|
297
|
+
await mintToken(actor);
|
|
298
|
+
const rows = await stack.http.queryOk<Array<{ id: string; expiresAt: string | null }>>(
|
|
299
|
+
PatQueries.mine,
|
|
300
|
+
{},
|
|
301
|
+
actor,
|
|
302
|
+
);
|
|
303
|
+
expect(rows[0]?.expiresAt).not.toBeNull();
|
|
304
|
+
const expiresAt = Temporal.Instant.from(rows[0]?.expiresAt as string);
|
|
305
|
+
const expected = Temporal.Now.instant().add({ hours: 24 * PAT_DEFAULT_EXPIRES_IN_DAYS });
|
|
306
|
+
const driftHours = Math.abs(expiresAt.since(expected).total({ unit: "hours" }));
|
|
307
|
+
expect(driftHours).toBeLessThan(1);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe("PAT create: MFA re-auth gate", () => {
|
|
312
|
+
let mfaStack: TestStack;
|
|
313
|
+
let mfaH: ReturnType<typeof makeSessionHelpers>;
|
|
314
|
+
|
|
315
|
+
beforeAll(async () => {
|
|
316
|
+
const encryption = createTestEnvelopeCipher(encryptionKey);
|
|
317
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
318
|
+
mfaStack = await setupTestStack({
|
|
319
|
+
features: [
|
|
320
|
+
createConfigFeature(),
|
|
321
|
+
createUserFeature(),
|
|
322
|
+
createTenantFeature(),
|
|
323
|
+
createAuthEmailPasswordFeature(),
|
|
324
|
+
createSessionsFeature(),
|
|
325
|
+
createPersonalAccessTokensFeature({
|
|
326
|
+
scopes: SCOPES,
|
|
327
|
+
// Stub verifier: always enrolled, code never accepted — exercises
|
|
328
|
+
// the real gate over HTTP without depending on auth-mfa's TOTP
|
|
329
|
+
// machinery (same shape as sessions' massRevokeSpy tests).
|
|
330
|
+
mfaVerifier: async () => ({ enrolled: true, ok: false }),
|
|
331
|
+
}),
|
|
332
|
+
authFoundationFeature,
|
|
333
|
+
],
|
|
334
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
335
|
+
authConfig: {
|
|
336
|
+
membershipQuery: "tenant:query:memberships",
|
|
337
|
+
loginHandler: AuthHandlers.login,
|
|
338
|
+
tokenVerifier: (raw: string) =>
|
|
339
|
+
resolveTokenVerifier({ db: mfaStack.db, registry: mfaStack.registry }, raw),
|
|
340
|
+
patRateLimiter: createInMemoryLoginRateLimiter(10, 60_000),
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
mfaH = makeSessionHelpers(mfaStack, TENANT);
|
|
344
|
+
await unsafeCreateEntityTable(mfaStack.db, userEntity);
|
|
345
|
+
await unsafeCreateEntityTable(mfaStack.db, tenantEntity);
|
|
346
|
+
await unsafeCreateEntityTable(mfaStack.db, userSessionEntity);
|
|
347
|
+
await unsafeCreateEntityTable(mfaStack.db, apiTokenEntity);
|
|
348
|
+
await unsafePushTables(mfaStack.db, { configValuesTable, tenantMembershipsTable });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
afterAll(async () => {
|
|
352
|
+
await mfaStack.cleanup();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("MFA-enrolled user without mfaCode → rejected, no token minted", async () => {
|
|
356
|
+
const { userId } = await mfaH.seedUser("mfa-missing-code@example.com", "pw");
|
|
357
|
+
const actor: SessionUser = { id: userId, tenantId: TENANT, roles: ["User"] };
|
|
358
|
+
const err = await mfaStack.http.writeErr(
|
|
359
|
+
PatHandlers.create,
|
|
360
|
+
{ name: "test", scopes: ["tokens:read"], currentPassword: "pw" },
|
|
361
|
+
actor,
|
|
362
|
+
);
|
|
363
|
+
expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
|
|
364
|
+
const rows = await mfaStack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
|
|
365
|
+
expect(rows).toHaveLength(0);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("MFA-enrolled user with wrong mfaCode → rejected, no token minted", async () => {
|
|
369
|
+
const { userId } = await mfaH.seedUser("mfa-wrong-code@example.com", "pw");
|
|
370
|
+
const actor: SessionUser = { id: userId, tenantId: TENANT, roles: ["User"] };
|
|
371
|
+
const err = await mfaStack.http.writeErr(
|
|
372
|
+
PatHandlers.create,
|
|
373
|
+
{ name: "test", scopes: ["tokens:read"], currentPassword: "pw", mfaCode: "000000" },
|
|
374
|
+
actor,
|
|
375
|
+
);
|
|
376
|
+
expect(err.details).toMatchObject({ reason: PatErrors.reauthRequired });
|
|
377
|
+
const rows = await mfaStack.http.queryOk<Array<{ id: string }>>(PatQueries.mine, {}, actor);
|
|
378
|
+
expect(rows).toHaveLength(0);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("PAT revoke on password change (#security)", () => {
|
|
383
|
+
test("changing password revokes all of the user's live PAT tokens", async () => {
|
|
384
|
+
const actor = await actorFor("pat-revoke-pwchange@example.com");
|
|
385
|
+
const token = await mintToken(actor);
|
|
386
|
+
const preCheck = await h.authedPost("/api/query", token, {
|
|
387
|
+
type: PatQueries.mine,
|
|
388
|
+
payload: {},
|
|
389
|
+
});
|
|
390
|
+
expect(preCheck.status).toBe(200);
|
|
391
|
+
|
|
392
|
+
await stack.http.writeOk(
|
|
393
|
+
AuthHandlers.changePassword,
|
|
394
|
+
{ oldPassword: "pw", newPassword: "NewPassw0rd!42" },
|
|
395
|
+
actor,
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const rows = await selectMany<{ revokedAt: string | null }>(stack.db, apiTokenTable, {
|
|
399
|
+
userId: actor.id,
|
|
400
|
+
});
|
|
401
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
402
|
+
expect(rows.every((row) => row.revokedAt !== null)).toBe(true);
|
|
403
|
+
|
|
404
|
+
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
405
|
+
expect(res.status).toBe(401);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("editing a non-password field does NOT revoke PAT tokens", async () => {
|
|
409
|
+
const actor = await actorFor("pat-no-revoke-other-field@example.com");
|
|
410
|
+
const token = await mintToken(actor);
|
|
411
|
+
|
|
412
|
+
const me = await stack.http.queryOk<{ version: number }>(UserQueries.me, {}, actor);
|
|
413
|
+
await stack.http.writeOk(
|
|
414
|
+
UserHandlers.update,
|
|
415
|
+
{ id: actor.id, version: me.version, changes: { displayName: "New Name" } },
|
|
416
|
+
actor,
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const res = await h.authedPost("/api/query", token, { type: PatQueries.mine, payload: {} });
|
|
420
|
+
expect(res.status).toBe(200);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
@@ -6,8 +6,16 @@ export const PAT_FEATURE = "personal-access-tokens";
|
|
|
6
6
|
// Snake_case reason strings (Error-Reasons guard: no colons/dashes).
|
|
7
7
|
export const PatErrors = {
|
|
8
8
|
ownershipDenied: "ownership_denied",
|
|
9
|
+
// Uniform for wrong-password AND missing/wrong MFA code — a distinct
|
|
10
|
+
// reason per case would leak whether the account has MFA enrolled.
|
|
11
|
+
reauthRequired: "reauth_required",
|
|
9
12
|
} as const;
|
|
10
13
|
|
|
14
|
+
// expiresInDays default when omitted at mint time. A silent "never expires"
|
|
15
|
+
// default is a standing-credential risk; the existing 3650-day cap still
|
|
16
|
+
// lets callers who genuinely want a long-lived token pass it explicitly.
|
|
17
|
+
export const PAT_DEFAULT_EXPIRES_IN_DAYS = 90;
|
|
18
|
+
|
|
11
19
|
// Dormant custom-screen id (r.screen) — the app places it via r.nav. The client
|
|
12
20
|
// maps it to the PatTokensScreen component.
|
|
13
21
|
export const PAT_SCREEN_ID = "api-tokens";
|
|
@@ -7,7 +7,7 @@ import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
|
|
|
7
7
|
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PAT_SCREEN_ID, type PatRateLimit } from "./constants";
|
|
9
9
|
import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
|
|
10
|
-
import {
|
|
10
|
+
import { type CreatePatOptions, createPatCreateHandler } from "./handlers/create.write";
|
|
11
11
|
import { listPatQuery } from "./handlers/list.query";
|
|
12
12
|
import { revokePatWrite } from "./handlers/revoke.write";
|
|
13
13
|
import { PAT_FEATURE_I18N } from "./i18n";
|
|
@@ -15,6 +15,36 @@ import { createPatResolver } from "./resolver";
|
|
|
15
15
|
import { apiTokenEntity } from "./schema/api-token";
|
|
16
16
|
import type { PatScopeConfig } from "./scopes";
|
|
17
17
|
|
|
18
|
+
// Password-change is the only field-level trigger — see the postSave hook
|
|
19
|
+
// below. MFA-enable/disable is wired separately (auth-mfa's
|
|
20
|
+
// revokeAllPatTokens callback, late-bound at app-composition time) since
|
|
21
|
+
// that's a different entity ("user-mfa") this feature doesn't own or
|
|
22
|
+
// require.
|
|
23
|
+
const PAT_REVOKE_TRIGGERING_FIELDS = ["passwordHash"] as const;
|
|
24
|
+
|
|
25
|
+
export type BindPatAutoRevokeOnPasswordChange = (
|
|
26
|
+
revoker: (userId: string) => Promise<number>,
|
|
27
|
+
) => void;
|
|
28
|
+
|
|
29
|
+
// Reads the late-bind setter off a mounted personal-access-tokens feature's
|
|
30
|
+
// exports — run{Prod,Dev}App call this once a concrete db is available,
|
|
31
|
+
// mirrors sessions' own bindAutoRevokeFromFeature/bindAutoRevokeOnPasswordChange.
|
|
32
|
+
export function bindPatAutoRevokeOnPasswordChangeFromFeature(
|
|
33
|
+
feature: FeatureDefinition,
|
|
34
|
+
): BindPatAutoRevokeOnPasswordChange | undefined {
|
|
35
|
+
const exports = feature.exports;
|
|
36
|
+
if (exports && typeof exports === "object" && "bindAutoRevokeOnPasswordChange" in exports) {
|
|
37
|
+
const { bindAutoRevokeOnPasswordChange } = exports as {
|
|
38
|
+
bindAutoRevokeOnPasswordChange: unknown;
|
|
39
|
+
};
|
|
40
|
+
if (typeof bindAutoRevokeOnPasswordChange === "function") {
|
|
41
|
+
// @cast-boundary exports-walk — feature.exports is untyped by design
|
|
42
|
+
return bindAutoRevokeOnPasswordChange as BindPatAutoRevokeOnPasswordChange;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
18
48
|
export type PersonalAccessTokensOptions = {
|
|
19
49
|
// The scopes this deployment offers. Each is a named bundle of QN globs a PAT
|
|
20
50
|
// may be granted (a scope can span features). Closed over by available-scopes
|
|
@@ -28,10 +58,20 @@ export type PersonalAccessTokensOptions = {
|
|
|
28
58
|
* { default: false } for fail-closed gating (feature off until a tier grants
|
|
29
59
|
* it). Omit to keep PAT always-on (default). */
|
|
30
60
|
readonly toggleable?: { readonly default: boolean };
|
|
61
|
+
// Opt-in MFA re-auth gate for minting a token — wired via
|
|
62
|
+
// mfaVerifierFromFeature (auth-mfa/feature.ts) at app-composition time. No
|
|
63
|
+
// hard dependency on the optional auth-mfa feature.
|
|
64
|
+
readonly mfaVerifier?: CreatePatOptions["mfaVerifier"];
|
|
65
|
+
// Password-change revoker — same constructor-option-or-late-bind duality as
|
|
66
|
+
// sessions' own autoRevokeOnPasswordChange (bindAutoRevokeOnPasswordChange
|
|
67
|
+
// below wins only if this is unset). Lets tests pass a revoker directly
|
|
68
|
+
// instead of needing a post-setupTestStack bind call.
|
|
69
|
+
readonly autoRevokeOnPasswordChange?: (userId: string) => Promise<number>;
|
|
31
70
|
};
|
|
32
71
|
|
|
33
72
|
export type PatFeatureExports = {
|
|
34
73
|
readonly rateLimit: PatRateLimit;
|
|
74
|
+
readonly bindAutoRevokeOnPasswordChange: BindPatAutoRevokeOnPasswordChange;
|
|
35
75
|
};
|
|
36
76
|
|
|
37
77
|
// Personal Access Tokens — long-lived, revocable bearer credentials for the
|
|
@@ -73,8 +113,31 @@ export function createPersonalAccessTokensFeature(
|
|
|
73
113
|
piiEncryptedOnWrite: true,
|
|
74
114
|
});
|
|
75
115
|
|
|
116
|
+
// Password-change auto-revoke — mirrors sessions' own
|
|
117
|
+
// autoRevokeOnPasswordChange postSave hook, including WHY it's a
|
|
118
|
+
// late-bind callback rather than direct ctx.db use: user:write:user:update
|
|
119
|
+
// is r.systemScope()'d (the user aggregate is a systemStream, framework
|
|
120
|
+
// #497), so a postSave hook on "user" gets a poisoned ctx.db here. The
|
|
121
|
+
// concrete revoker is bound once run{Prod,Dev}App has a real db handle.
|
|
122
|
+
let autoRevokeOnPasswordChange = options.autoRevokeOnPasswordChange;
|
|
123
|
+
r.hook("postSave", { allOf: "user" }, async (result) => {
|
|
124
|
+
// skip: nothing bound — same late-bind pattern as sessions/feature.ts
|
|
125
|
+
if (!autoRevokeOnPasswordChange) return;
|
|
126
|
+
// skip: brand-new user, no PATs can exist yet
|
|
127
|
+
if (result.isNew) return;
|
|
128
|
+
// skip: handler didn't touch any revoke-triggering field
|
|
129
|
+
if (!PAT_REVOKE_TRIGGERING_FIELDS.some((field) => result.changes[field] !== undefined)) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
await autoRevokeOnPasswordChange(String(result.id));
|
|
133
|
+
});
|
|
134
|
+
const bindAutoRevokeOnPasswordChange: BindPatAutoRevokeOnPasswordChange = (revoker) => {
|
|
135
|
+
// explicit constructor option wins over the runtime binding
|
|
136
|
+
autoRevokeOnPasswordChange ??= revoker;
|
|
137
|
+
};
|
|
138
|
+
|
|
76
139
|
const handlers = {
|
|
77
|
-
create: r.writeHandler(
|
|
140
|
+
create: r.writeHandler(createPatCreateHandler({ mfaVerifier: options.mfaVerifier })),
|
|
78
141
|
revoke: r.writeHandler(revokePatWrite),
|
|
79
142
|
};
|
|
80
143
|
const queries = {
|
|
@@ -102,6 +165,7 @@ export function createPersonalAccessTokensFeature(
|
|
|
102
165
|
return {
|
|
103
166
|
handlers,
|
|
104
167
|
queries,
|
|
168
|
+
bindAutoRevokeOnPasswordChange,
|
|
105
169
|
rateLimit: options.rateLimit ?? PAT_DEFAULT_RATE_LIMIT,
|
|
106
170
|
} satisfies { handlers: unknown; queries: unknown } & PatFeatureExports;
|
|
107
171
|
});
|
|
@@ -1,47 +1,105 @@
|
|
|
1
1
|
import { insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createSystemUser,
|
|
4
|
+
defineWriteHandler,
|
|
5
|
+
type HandlerContext,
|
|
6
|
+
type TenantId,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
9
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
10
|
import { Temporal } from "temporal-polyfill";
|
|
5
11
|
import { z } from "zod";
|
|
6
|
-
import { encryptForDirectWrite } from "../../shared";
|
|
12
|
+
import { encryptForDirectWrite, verifyPassword } from "../../shared";
|
|
13
|
+
import { UserQueries } from "../../user";
|
|
14
|
+
import { PAT_DEFAULT_EXPIRES_IN_DAYS, PatErrors } from "../constants";
|
|
7
15
|
import { mintPatToken } from "../hash";
|
|
8
16
|
import { apiTokenEntity, apiTokenTable } from "../schema/api-token";
|
|
9
17
|
|
|
18
|
+
export type PatMfaVerifyResult = { readonly enrolled: boolean; readonly ok: boolean };
|
|
19
|
+
|
|
20
|
+
export type CreatePatOptions = {
|
|
21
|
+
// Opt-in MFA re-auth gate — auth-mfa (if mounted) wires this in at
|
|
22
|
+
// app-composition time via mfaVerifierFromFeature. Deliberately generic
|
|
23
|
+
// here, same decoupling as login.write.ts's mfaStatusChecker: this
|
|
24
|
+
// feature must not import auth-mfa's types, only this shape.
|
|
25
|
+
readonly mfaVerifier?: (
|
|
26
|
+
ctx: HandlerContext,
|
|
27
|
+
userId: string,
|
|
28
|
+
tenantId: TenantId,
|
|
29
|
+
code: string | undefined,
|
|
30
|
+
) => Promise<PatMfaVerifyResult>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function reauthFailed() {
|
|
34
|
+
return writeFailure(
|
|
35
|
+
new UnprocessableError(PatErrors.reauthRequired, {
|
|
36
|
+
i18nKey: "errors.reauthRequired",
|
|
37
|
+
}),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
10
41
|
// Mint a PAT for the calling user in their active tenant. The plaintext token
|
|
11
42
|
// is returned ONCE (data.token) and never again — only the hash is stored.
|
|
12
43
|
// `scopes` are granted scope names; unknown names simply grant nothing at
|
|
13
44
|
// resolve time (fail-closed), so no cross-check against the app config here.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
45
|
+
//
|
|
46
|
+
// Minting a long-lived bearer credential re-verifies the caller's password
|
|
47
|
+
// (and MFA code, if enrolled) even though the request is already
|
|
48
|
+
// session-authed — a stolen/leaked session cookie must not be enough to
|
|
49
|
+
// stand up a durable API credential.
|
|
50
|
+
export function createPatCreateHandler(opts: CreatePatOptions = {}) {
|
|
51
|
+
return defineWriteHandler({
|
|
52
|
+
name: "create",
|
|
53
|
+
schema: z.object({
|
|
54
|
+
name: z.string().min(1).max(120),
|
|
55
|
+
scopes: z.array(z.string().min(1)).min(1),
|
|
56
|
+
expiresInDays: z.number().int().positive().max(3650).optional(),
|
|
57
|
+
currentPassword: z.string().min(1),
|
|
58
|
+
mfaCode: z.string().optional(),
|
|
59
|
+
}),
|
|
60
|
+
access: { openToAll: true },
|
|
61
|
+
handler: async (event, ctx) => {
|
|
62
|
+
const systemUser = createSystemUser(event.user.tenantId);
|
|
63
|
+
const me = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
|
|
64
|
+
id: event.user.id,
|
|
65
|
+
})) as { passwordHash: string | null } | null; // @cast-boundary db-runner
|
|
66
|
+
if (!me?.passwordHash) return reauthFailed();
|
|
67
|
+
const passwordOk = await verifyPassword(me.passwordHash, event.payload.currentPassword);
|
|
68
|
+
if (!passwordOk) return reauthFailed();
|
|
69
|
+
|
|
70
|
+
if (opts.mfaVerifier) {
|
|
71
|
+
const mfa = await opts.mfaVerifier(
|
|
72
|
+
ctx,
|
|
73
|
+
event.user.id,
|
|
74
|
+
event.user.tenantId,
|
|
75
|
+
event.payload.mfaCode,
|
|
76
|
+
);
|
|
77
|
+
if (mfa.enrolled && !mfa.ok) return reauthFailed();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { raw, hash, prefix } = mintPatToken();
|
|
81
|
+
const now = Temporal.Now.instant();
|
|
82
|
+
const id = generateId();
|
|
83
|
+
const row = await encryptForDirectWrite(
|
|
84
|
+
apiTokenEntity,
|
|
85
|
+
{
|
|
86
|
+
id,
|
|
87
|
+
userId: event.user.id,
|
|
88
|
+
tenantId: event.user.tenantId,
|
|
89
|
+
name: event.payload.name,
|
|
90
|
+
tokenHash: hash,
|
|
91
|
+
prefix,
|
|
92
|
+
scopes: JSON.stringify(event.payload.scopes),
|
|
93
|
+
createdAt: now,
|
|
94
|
+
expiresAt: now.add({
|
|
95
|
+
hours: 24 * (event.payload.expiresInDays ?? PAT_DEFAULT_EXPIRES_IN_DAYS),
|
|
96
|
+
}),
|
|
97
|
+
revokedAt: null,
|
|
98
|
+
},
|
|
99
|
+
"pat:create",
|
|
100
|
+
);
|
|
101
|
+
await insertOne(ctx.db, apiTokenTable, row);
|
|
102
|
+
return { isSuccess: true, data: { id, token: raw, prefix } };
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -3,10 +3,15 @@ import { PAT_DEFAULT_RATE_LIMIT, type PatRateLimit } from "./constants";
|
|
|
3
3
|
|
|
4
4
|
export type { PatRateLimit } from "./constants";
|
|
5
5
|
export { PAT_DEFAULT_RATE_LIMIT, PAT_FEATURE, PatHandlers, PatQueries } from "./constants";
|
|
6
|
-
export type { PersonalAccessTokensOptions } from "./feature";
|
|
7
|
-
export {
|
|
6
|
+
export type { BindPatAutoRevokeOnPasswordChange, PersonalAccessTokensOptions } from "./feature";
|
|
7
|
+
export {
|
|
8
|
+
bindPatAutoRevokeOnPasswordChangeFromFeature,
|
|
9
|
+
createPersonalAccessTokensFeature,
|
|
10
|
+
} from "./feature";
|
|
11
|
+
export type { CreatePatOptions, PatMfaVerifyResult } from "./handlers/create.write";
|
|
8
12
|
export { hashPatToken, mintPatToken } from "./hash";
|
|
9
13
|
export { createPatResolver } from "./resolver";
|
|
14
|
+
export { revokeAllPatTokensForUser } from "./revoke-for-user";
|
|
10
15
|
export { apiTokenEntity, apiTokenTable } from "./schema/api-token";
|
|
11
16
|
export type { PatScopeConfig, PatScopeDef } from "./scopes";
|
|
12
17
|
export { expandScopes } from "./scopes";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import { Temporal } from "temporal-polyfill";
|
|
4
|
+
import { apiTokenTable } from "./schema/api-token";
|
|
5
|
+
|
|
6
|
+
// Cross-tenant revoke: password-change and MFA-enable/disable are account-
|
|
7
|
+
// level security events, not scoped to one tenant. Mirrors sessions'
|
|
8
|
+
// sessionMassRevoker (session-callbacks.ts) which passes the boot-time
|
|
9
|
+
// DbConnection directly — not ctx.db, which would be tenant-scoped in a hook.
|
|
10
|
+
export async function revokeAllPatTokensForUser(db: DbConnection, userId: string): Promise<number> {
|
|
11
|
+
const updated = await updateMany<{ id: string }>(
|
|
12
|
+
db,
|
|
13
|
+
apiTokenTable,
|
|
14
|
+
{ revokedAt: Temporal.Now.instant() },
|
|
15
|
+
{ userId, revokedAt: null },
|
|
16
|
+
);
|
|
17
|
+
return updated.length;
|
|
18
|
+
}
|
|
@@ -17,6 +17,10 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
17
17
|
"pat.create.submit": "Token erstellen",
|
|
18
18
|
"pat.create.needName": "Bitte einen Namen vergeben.",
|
|
19
19
|
"pat.create.needScope": "Bitte mindestens eine Berechtigung wählen.",
|
|
20
|
+
"pat.create.needPassword": "Bitte dein Passwort zur Bestätigung eingeben.",
|
|
21
|
+
"pat.create.currentPassword": "Passwort (zur Bestätigung)",
|
|
22
|
+
"pat.create.mfaCode": "2FA-Code (falls aktiviert)",
|
|
23
|
+
"pat.create.mfaCodePlaceholder": "6-stelliger Code",
|
|
20
24
|
"pat.expiry.30d": "30 Tage",
|
|
21
25
|
"pat.expiry.90d": "90 Tage",
|
|
22
26
|
"pat.expiry.1y": "1 Jahr",
|
|
@@ -49,6 +53,10 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
49
53
|
"pat.create.submit": "Create token",
|
|
50
54
|
"pat.create.needName": "Please enter a name.",
|
|
51
55
|
"pat.create.needScope": "Please select at least one permission.",
|
|
56
|
+
"pat.create.needPassword": "Please enter your password to confirm.",
|
|
57
|
+
"pat.create.currentPassword": "Password (to confirm)",
|
|
58
|
+
"pat.create.mfaCode": "2FA code (if enabled)",
|
|
59
|
+
"pat.create.mfaCodePlaceholder": "6-digit code",
|
|
52
60
|
"pat.expiry.30d": "30 days",
|
|
53
61
|
"pat.expiry.90d": "90 days",
|
|
54
62
|
"pat.expiry.1y": "1 year",
|
|
@@ -57,6 +57,8 @@ export function PatTokensScreen({
|
|
|
57
57
|
const [name, setName] = useState("");
|
|
58
58
|
const [levels, setLevels] = useState<Readonly<Record<string, Level>>>({});
|
|
59
59
|
const [expiry, setExpiry] = useState<ExpiryKey>("90d");
|
|
60
|
+
const [currentPassword, setCurrentPassword] = useState("");
|
|
61
|
+
const [mfaCode, setMfaCode] = useState("");
|
|
60
62
|
const [minted, setMinted] = useState<{ token: string } | null>(null);
|
|
61
63
|
const [copied, setCopied] = useState(false);
|
|
62
64
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -80,15 +82,21 @@ export function PatTokensScreen({
|
|
|
80
82
|
if (name.trim() === "") return setError(t("pat.create.needName"));
|
|
81
83
|
const scopes = grants();
|
|
82
84
|
if (scopes.length === 0) return setError(t("pat.create.needScope"));
|
|
85
|
+
if (currentPassword === "") return setError(t("pat.create.needPassword"));
|
|
83
86
|
setBusy(true);
|
|
84
87
|
setError(null);
|
|
85
88
|
const days = EXPIRY_DAYS[expiry];
|
|
86
89
|
const res = await dispatcher.write(PatHandlers.create, {
|
|
87
90
|
name: name.trim(),
|
|
88
91
|
scopes,
|
|
92
|
+
currentPassword,
|
|
89
93
|
...(days !== undefined ? { expiresInDays: days } : {}),
|
|
94
|
+
...(mfaCode.trim() !== "" ? { mfaCode: mfaCode.trim() } : {}),
|
|
90
95
|
});
|
|
91
96
|
setBusy(false);
|
|
97
|
+
// Never leave a submitted password/MFA code sitting in state, success or not.
|
|
98
|
+
setCurrentPassword("");
|
|
99
|
+
setMfaCode("");
|
|
92
100
|
if (!res.isSuccess) return setError(t("pat.error.generic"));
|
|
93
101
|
setMinted({ token: (res.data as { token: string }).token });
|
|
94
102
|
setCopied(false);
|
|
@@ -219,6 +227,29 @@ export function PatTokensScreen({
|
|
|
219
227
|
disabled={busy}
|
|
220
228
|
/>
|
|
221
229
|
</Field>
|
|
230
|
+
|
|
231
|
+
<Field id="pat-current-password" label={t("pat.create.currentPassword")} required>
|
|
232
|
+
<Input
|
|
233
|
+
kind="password"
|
|
234
|
+
id="pat-current-password"
|
|
235
|
+
name="currentPassword"
|
|
236
|
+
value={currentPassword}
|
|
237
|
+
onChange={setCurrentPassword}
|
|
238
|
+
disabled={busy}
|
|
239
|
+
/>
|
|
240
|
+
</Field>
|
|
241
|
+
|
|
242
|
+
<Field id="pat-mfa-code" label={t("pat.create.mfaCode")}>
|
|
243
|
+
<Input
|
|
244
|
+
kind="text"
|
|
245
|
+
id="pat-mfa-code"
|
|
246
|
+
name="mfaCode"
|
|
247
|
+
value={mfaCode}
|
|
248
|
+
onChange={setMfaCode}
|
|
249
|
+
placeholder={t("pat.create.mfaCodePlaceholder")}
|
|
250
|
+
disabled={busy}
|
|
251
|
+
/>
|
|
252
|
+
</Field>
|
|
222
253
|
</Form>
|
|
223
254
|
|
|
224
255
|
<div className="flex flex-col gap-3">
|
|
@@ -18,6 +18,13 @@ export function reservedMembershipRoleError(role: string): AccessDeniedError {
|
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function unassignableMembershipRoleError(role: string): AccessDeniedError {
|
|
22
|
+
return new AccessDeniedError({
|
|
23
|
+
message: `role "${role}" cannot be assigned by this inviter`,
|
|
24
|
+
details: { reason: "unassignable_membership_role", role },
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
export function assertAssignableMembershipRoles(roles: readonly string[]): void {
|
|
22
29
|
const forbidden = findForbiddenMembershipRole(roles);
|
|
23
30
|
if (forbidden !== undefined) throw reservedMembershipRoleError(forbidden);
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
35
35
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
36
|
import { createConfigFeature } from "../../config";
|
|
37
|
-
import { createConfigResolver } from "../../config/resolver";
|
|
37
|
+
import { buildEnvConfigOverrides, createConfigResolver } from "../../config/resolver";
|
|
38
38
|
import { configValueEntity } from "../../config/table";
|
|
39
39
|
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
40
40
|
import { createSessionsFeature } from "../../sessions";
|
|
@@ -147,6 +147,28 @@ describe("tenant-model config resolution (seam)", () => {
|
|
|
147
147
|
});
|
|
148
148
|
expect(model).toBe("multi-user");
|
|
149
149
|
});
|
|
150
|
+
|
|
151
|
+
test("TENANT_MODEL env var bridges through the real registry to resolveAppTenantModel", async () => {
|
|
152
|
+
// Mirrors the cascade.integration.test.ts env seam test, but pins it at
|
|
153
|
+
// the real tenantModel key: registry → buildEnvConfigOverrides →
|
|
154
|
+
// resolveAppTenantModel, so a key-qualification mismatch between the
|
|
155
|
+
// feature's `env` declaration and the bridge would fail here, not just
|
|
156
|
+
// on a stub registry.
|
|
157
|
+
const keyDef = stack.registry.getConfigKey(TENANT_MODEL_CONFIG_KEY);
|
|
158
|
+
expect(keyDef).toBeDefined();
|
|
159
|
+
expect(keyDef?.env).toBe("TENANT_MODEL");
|
|
160
|
+
|
|
161
|
+
const overrides = buildEnvConfigOverrides(stack.registry, { TENANT_MODEL: "single-user" });
|
|
162
|
+
expect(overrides.get(TENANT_MODEL_CONFIG_KEY)).toBe("single-user");
|
|
163
|
+
|
|
164
|
+
const model = await resolveAppTenantModel({
|
|
165
|
+
registry: stack.registry,
|
|
166
|
+
configResolver: createConfigResolver({ appOverrides: overrides }),
|
|
167
|
+
db: stack.db,
|
|
168
|
+
userId: SYSTEM_USER_ID,
|
|
169
|
+
});
|
|
170
|
+
expect(model).toBe("single-user");
|
|
171
|
+
});
|
|
150
172
|
});
|
|
151
173
|
|
|
152
174
|
describe("forget pipeline honours the effective tenant model", () => {
|