@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
|
@@ -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">
|