@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.
Files changed (29) hide show
  1. package/package.json +7 -7
  2. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +127 -0
  3. package/src/auth-email-password/handlers/invite-create.write.ts +9 -0
  4. package/src/auth-mfa/__tests__/session-auto-revoke.integration.test.ts +18 -6
  5. package/src/auth-mfa/feature.ts +69 -2
  6. package/src/auth-mfa/handlers/disable.write.ts +7 -0
  7. package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +7 -0
  8. package/src/auth-mfa/handlers/enable-confirm.write.ts +7 -0
  9. package/src/auth-mfa/index.ts +4 -0
  10. package/src/auth-mfa/mfa-code-verifier.ts +33 -0
  11. package/src/jobs/web/__tests__/job-runs-screen.test.tsx +5 -3
  12. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +171 -4
  13. package/src/personal-access-tokens/constants.ts +8 -0
  14. package/src/personal-access-tokens/feature.ts +66 -2
  15. package/src/personal-access-tokens/handlers/create.write.ts +94 -36
  16. package/src/personal-access-tokens/index.ts +7 -2
  17. package/src/personal-access-tokens/revoke-for-user.ts +18 -0
  18. package/src/personal-access-tokens/web/i18n.ts +8 -0
  19. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +31 -0
  20. package/src/sessions/__tests__/role-rederivation.integration.test.ts +137 -0
  21. package/src/sessions/__tests__/sessions.integration.test.ts +3 -1
  22. package/src/sessions/session-callbacks.ts +44 -13
  23. package/src/sessions/session-checker-fail-open.test.ts +74 -0
  24. package/src/step-dispatcher/webhook-runner.ts +15 -0
  25. package/src/tenant/handlers/remove-member.write.ts +13 -12
  26. package/src/tenant/handlers/update-member-roles.write.ts +13 -12
  27. package/src/tenant/membership-roles.ts +7 -0
  28. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +23 -1
  29. package/src/user-data-rights/feature.ts +1 -0
@@ -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?: { scopes?: string[]; expiresInDays?: number },
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({ scopes: SCOPES }),
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 { createPatWrite } from "./handlers/create.write";
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(createPatWrite),
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 { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
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
- export const createPatWrite = defineWriteHandler({
15
- name: "create",
16
- schema: z.object({
17
- name: z.string().min(1).max(120),
18
- scopes: z.array(z.string().min(1)).min(1),
19
- expiresInDays: z.number().int().positive().max(3650).optional(),
20
- }),
21
- access: { openToAll: true },
22
- handler: async (event, ctx) => {
23
- const { raw, hash, prefix } = mintPatToken();
24
- const now = Temporal.Now.instant();
25
- const id = generateId();
26
- const row = await encryptForDirectWrite(
27
- apiTokenEntity,
28
- {
29
- id,
30
- userId: event.user.id,
31
- tenantId: event.user.tenantId,
32
- name: event.payload.name,
33
- tokenHash: hash,
34
- prefix,
35
- scopes: JSON.stringify(event.payload.scopes),
36
- createdAt: now,
37
- expiresAt: event.payload.expiresInDays
38
- ? now.add({ hours: 24 * event.payload.expiresInDays })
39
- : null,
40
- revokedAt: null,
41
- },
42
- "pat:create",
43
- );
44
- await insertOne(ctx.db, apiTokenTable, row);
45
- return { isSuccess: true, data: { id, token: raw, prefix } };
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 { createPersonalAccessTokensFeature } from "./feature";
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">