@cosmicdrift/kumiko-framework 0.157.2 → 0.159.1
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 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +66 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +4 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +83 -17
- package/src/api/index.ts +8 -4
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +34 -2
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +92 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +10 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +2 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/define-handler.ts +94 -0
- package/src/engine/types/entity-handlers.ts +30 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +45 -0
- package/src/engine/types/fields.ts +19 -31
- package/src/engine/types/handlers.ts +7 -26
- package/src/engine/types/hooks.ts +1 -15
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +34 -9
- package/src/engine/types/ownership.ts +83 -0
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +0 -46
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/entrypoint/index.ts +2 -2
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/event-store.ts +28 -32
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/types.ts +22 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/dispatch-shared.ts +12 -2
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +13 -39
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/ui-types/index.ts +7 -7
|
@@ -17,9 +17,9 @@ export const CSRF_COOKIE_NAME = "kumiko_csrf";
|
|
|
17
17
|
export const CSRF_HEADER_NAME = "X-CSRF-Token";
|
|
18
18
|
|
|
19
19
|
// Prefix that marks a bearer token as a long-lived Personal Access Token
|
|
20
|
-
// rather than a JWT session. The
|
|
21
|
-
//
|
|
22
|
-
// here so both sides import the same literal.
|
|
20
|
+
// rather than a JWT session. The personal-access-tokens feature mints tokens
|
|
21
|
+
// with this prefix and declares it as its tokenVerifier shape (kind:
|
|
22
|
+
// "prefix") — kept here so both sides import the same literal.
|
|
23
23
|
export const PAT_TOKEN_PREFIX = "kpat_";
|
|
24
24
|
|
|
25
25
|
// Which wire the current request authenticated over. Downstream
|
|
@@ -47,13 +47,13 @@ export type AuthSessionChecker = (
|
|
|
47
47
|
expectedUserId: string,
|
|
48
48
|
) => Promise<AuthSessionStatus>;
|
|
49
49
|
|
|
50
|
-
// Resolves a raw
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
export type
|
|
50
|
+
// Resolves a raw bearer token into a SessionUser, or null when no registered
|
|
51
|
+
// provider claims it (or the claiming provider rejects it as unknown/revoked/
|
|
52
|
+
// expired). Wired generically from the auth-foundation `tokenVerifier`
|
|
53
|
+
// extension-point registry — the middleware has no PAT/JWT-specific
|
|
54
|
+
// knowledge, it just consults whatever the app wired in and short-circuits
|
|
55
|
+
// the JWT path on a hit.
|
|
56
|
+
export type TokenVerifier = (rawToken: string) => Promise<SessionUser | null>;
|
|
57
57
|
|
|
58
58
|
export type TenantLifecycleStatusResolver = (
|
|
59
59
|
tenantId: TenantId,
|
|
@@ -64,15 +64,13 @@ export type AuthMiddlewareOptions = {
|
|
|
64
64
|
// reports anything other than "live", the request is rejected with 401.
|
|
65
65
|
// Omit to run in stateless-JWT mode (any valid JWT is accepted).
|
|
66
66
|
readonly sessionChecker?: AuthSessionChecker;
|
|
67
|
-
// Called for bearer tokens
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
// TTL. Has no effect when sessionChecker is undefined.
|
|
75
|
-
readonly strictMode?: boolean;
|
|
67
|
+
// Called for bearer tokens, BEFORE jwt.verify. On a hit the middleware
|
|
68
|
+
// sets the returned SessionUser and skips the JWT path entirely; on a
|
|
69
|
+
// miss (null) falls through to jwt.verify. Wired from the auth-foundation
|
|
70
|
+
// `tokenVerifier` extension-point registry — generic across providers
|
|
71
|
+
// (PAT, future JWT-provider, ...), the middleware itself has no
|
|
72
|
+
// provider-specific knowledge. Omit to disable non-JWT bearer auth.
|
|
73
|
+
readonly tokenVerifier?: TokenVerifier;
|
|
76
74
|
// Opt-in: when set, requests without a JWT are treated as anonymous
|
|
77
75
|
// callers instead of being rejected with 401. The middleware synthesises
|
|
78
76
|
// a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
|
|
@@ -222,13 +220,7 @@ function extractToken(
|
|
|
222
220
|
}
|
|
223
221
|
|
|
224
222
|
export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
|
|
225
|
-
const {
|
|
226
|
-
sessionChecker,
|
|
227
|
-
strictMode = false,
|
|
228
|
-
anonymousAccess,
|
|
229
|
-
patResolver,
|
|
230
|
-
resolveTenantLifecycleStatus,
|
|
231
|
-
} = options;
|
|
223
|
+
const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus } = options;
|
|
232
224
|
|
|
233
225
|
// Fail loud at boot, not silently at request time: a tenantResolver
|
|
234
226
|
// without a declared resolverTrust is an ambiguous trust decision no
|
|
@@ -282,11 +274,17 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
282
274
|
}
|
|
283
275
|
const { token, transport } = extracted;
|
|
284
276
|
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
277
|
+
// Generic bearer-verifier path: try the wired tokenVerifier (PAT, future
|
|
278
|
+
// JWT-provider, ...) BEFORE jwt.verify. A hit short-circuits the JWT path
|
|
279
|
+
// entirely; a miss (null — no provider's shape matched, or the matching
|
|
280
|
+
// provider rejected the token) falls through to the normal JWT flow below,
|
|
281
|
+
// which rejects it uniformly as invalid_token. Cookie transport is never
|
|
282
|
+
// routed here (the browser holds the JWT).
|
|
283
|
+
if (tokenVerifier && transport === "bearer") {
|
|
284
|
+
const verifiedUser = await tokenVerifier(token);
|
|
285
|
+
if (verifiedUser) {
|
|
286
|
+
return await handleVerifiedBearerUser(c, verifiedUser, next, resolveTenantLifecycleStatus);
|
|
287
|
+
}
|
|
290
288
|
}
|
|
291
289
|
|
|
292
290
|
let payload: Awaited<ReturnType<JwtHelper["verify"]>>;
|
|
@@ -302,15 +300,16 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
302
300
|
}
|
|
303
301
|
|
|
304
302
|
// Session liveness check — only when both a checker is wired AND the
|
|
305
|
-
// token carries a sid.
|
|
306
|
-
//
|
|
303
|
+
// token carries a sid.
|
|
304
|
+
// A checker wired without a sid on the token means the token predates
|
|
305
|
+
// session tracking (or the JWT was forged) — reject.
|
|
307
306
|
if (sessionChecker) {
|
|
308
307
|
if (payload.jti) {
|
|
309
308
|
const status = await sessionChecker(payload.jti, payload.sub);
|
|
310
309
|
if (status !== "live") {
|
|
311
310
|
return sessionInvalid(c, status);
|
|
312
311
|
}
|
|
313
|
-
} else
|
|
312
|
+
} else {
|
|
314
313
|
return sessionInvalid(c, "no_sid");
|
|
315
314
|
}
|
|
316
315
|
}
|
|
@@ -360,49 +359,39 @@ export function getAuthTransport(c: Context): AuthTransport | undefined {
|
|
|
360
359
|
return c.get(AUTH_TRANSPORT_KEY) as AuthTransport | undefined;
|
|
361
360
|
}
|
|
362
361
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
async function
|
|
362
|
+
// Verified-bearer request flow. `user` was already resolved by the wired
|
|
363
|
+
// tokenVerifier (PAT today, future JWT-provider). Apply the same
|
|
364
|
+
// X-Tenant-mismatch guard as the JWT path before continuing. Structured like
|
|
365
|
+
// handleAnonymous so authMiddleware stays flat.
|
|
366
|
+
async function handleVerifiedBearerUser(
|
|
368
367
|
c: Context,
|
|
369
|
-
|
|
370
|
-
token: string,
|
|
368
|
+
user: SessionUser,
|
|
371
369
|
next: Next,
|
|
372
370
|
resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
|
|
373
371
|
): Promise<Response | undefined> {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return middlewareReject(c, {
|
|
377
|
-
code: "invalid_token",
|
|
378
|
-
status: 401,
|
|
379
|
-
message: "personal access token invalid, revoked or expired",
|
|
380
|
-
i18nKey: "auth.errors.invalidToken",
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
// The PAT carries its own tenant; an X-Tenant header pointing elsewhere is a
|
|
384
|
-
// confused client — reject loudly, same stance as the JWT path.
|
|
372
|
+
// The token carries its own tenant; an X-Tenant header pointing elsewhere is
|
|
373
|
+
// a confused client — reject loudly, same stance as the JWT path.
|
|
385
374
|
const headerTenant = c.req.header(TENANT_HEADER_NAME);
|
|
386
|
-
if (headerTenant !== undefined && headerTenant !==
|
|
375
|
+
if (headerTenant !== undefined && headerTenant !== user.tenantId) {
|
|
387
376
|
return middlewareReject(c, {
|
|
388
377
|
code: "tenant_mismatch",
|
|
389
378
|
status: 400,
|
|
390
|
-
message: "
|
|
379
|
+
message: "token tenantId and X-Tenant header disagree",
|
|
391
380
|
i18nKey: "auth.errors.tenantMismatch",
|
|
392
|
-
details: {
|
|
381
|
+
details: { tokenTenantId: user.tenantId, headerTenantId: headerTenant },
|
|
393
382
|
});
|
|
394
383
|
}
|
|
395
|
-
c.set(USER_KEY,
|
|
384
|
+
c.set(USER_KEY, user);
|
|
396
385
|
c.set(AUTH_TRANSPORT_KEY, "bearer");
|
|
397
386
|
const lifecycleReject = await rejectIfTenantTeardown(
|
|
398
387
|
c,
|
|
399
|
-
|
|
388
|
+
user.tenantId,
|
|
400
389
|
resolveTenantLifecycleStatus,
|
|
401
390
|
);
|
|
402
391
|
if (lifecycleReject) return lifecycleReject;
|
|
403
392
|
await next();
|
|
404
|
-
// skip:
|
|
405
|
-
// Response|undefined union honest (same as handleAnonymous).
|
|
393
|
+
// skip: verified-bearer path completed — next() ran; explicit return keeps
|
|
394
|
+
// the Response|undefined union honest (same as handleAnonymous).
|
|
406
395
|
return;
|
|
407
396
|
}
|
|
408
397
|
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { deleteCookie, setCookie } from "hono/cookie";
|
|
4
|
+
import type Redis from "ioredis";
|
|
4
5
|
import { z } from "zod";
|
|
5
6
|
import { buildSessionRoles } from "../engine/membership-roles";
|
|
6
7
|
import { createSystemUser } from "../engine/system-user";
|
|
@@ -15,17 +16,11 @@ import {
|
|
|
15
16
|
type AuthSessionStatus,
|
|
16
17
|
CSRF_COOKIE_NAME,
|
|
17
18
|
getUser,
|
|
18
|
-
type
|
|
19
|
+
type TokenVerifier,
|
|
19
20
|
} from "./auth-middleware";
|
|
20
21
|
import type { JwtHelper } from "./jwt";
|
|
21
22
|
import { generateToken } from "./tokens";
|
|
22
23
|
|
|
23
|
-
// Cookie lifetime must track the JWT's exp claim — both are issued together,
|
|
24
|
-
// both reference the same session. jwt.ts's createJwtHelper hardcodes
|
|
25
|
-
// setExpirationTime("24h"); if that ever becomes configurable this constant
|
|
26
|
-
// follows it.
|
|
27
|
-
const JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
28
|
-
|
|
29
24
|
// Resolves the Secure cookie flag. Locked off in dev/test so Playwright
|
|
30
25
|
// against http://localhost:… can actually receive the cookie. Production
|
|
31
26
|
// flips it on — browsers drop Secure cookies on http, so a misconfigured
|
|
@@ -47,6 +42,10 @@ function setAuthCookies(
|
|
|
47
42
|
csrfToken: string;
|
|
48
43
|
sameSite: "lax" | "strict";
|
|
49
44
|
domain?: string | undefined;
|
|
45
|
+
// Cookie lifetime must track the JWT's exp claim — both are issued
|
|
46
|
+
// together, both reference the same session. Callers pass jwt.ttlSeconds
|
|
47
|
+
// so the two never drift apart.
|
|
48
|
+
ttlSeconds: number;
|
|
50
49
|
},
|
|
51
50
|
): void {
|
|
52
51
|
const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
|
|
@@ -54,7 +53,7 @@ function setAuthCookies(
|
|
|
54
53
|
secure: cookieSecure(),
|
|
55
54
|
sameSite,
|
|
56
55
|
path: "/",
|
|
57
|
-
maxAge:
|
|
56
|
+
maxAge: opts.ttlSeconds,
|
|
58
57
|
...(opts.domain !== undefined && { domain: opts.domain }),
|
|
59
58
|
} as const;
|
|
60
59
|
|
|
@@ -261,14 +260,11 @@ export type AuthRoutesConfig = {
|
|
|
261
260
|
// at login, check it here on every request. Leaving this empty disables
|
|
262
261
|
// the revocation path — old JWTs stay valid until they expire naturally.
|
|
263
262
|
sessionChecker?: SessionChecker;
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
// SessionUser, consulted BEFORE jwt.verify. Wired by the
|
|
270
|
-
// personal-access-tokens feature; unwired = PAT auth disabled.
|
|
271
|
-
patResolver?: PatResolver;
|
|
263
|
+
// Resolves bearer tokens (any registered auth-foundation `tokenVerifier`
|
|
264
|
+
// provider — PAT today, future JWT-provider), consulted BEFORE jwt.verify.
|
|
265
|
+
// Wired by run-prod-app/run-dev-app when at least one provider feature is
|
|
266
|
+
// mounted; unwired = no non-JWT bearer auth.
|
|
267
|
+
tokenVerifier?: TokenVerifier;
|
|
272
268
|
// Per-token request-rate limiter for PAT-authenticated requests, keyed by
|
|
273
269
|
// the token id (SessionUser.pat.tokenId). Cookie/JWT requests are unaffected.
|
|
274
270
|
// Reuses the LoginRateLimiter shape (a generic keyed check/reset limiter).
|
|
@@ -287,6 +283,12 @@ export type AuthRoutesConfig = {
|
|
|
287
283
|
passwordReset?: PasswordResetConfig;
|
|
288
284
|
// Email-verification flow. Symmetric to passwordReset.
|
|
289
285
|
emailVerification?: EmailVerificationConfig;
|
|
286
|
+
// Account-unlock flow (#1266). When wired, POST
|
|
287
|
+
// /auth/request-account-unlock + /auth/confirm-account-unlock are
|
|
288
|
+
// mounted as public routes. Confirm needs no extra body field
|
|
289
|
+
// (token-only, like email-verification) — the handler only clears the
|
|
290
|
+
// Redis lockout state, no entity write.
|
|
291
|
+
accountUnlock?: AccountUnlockConfig;
|
|
290
292
|
// Self-Signup (Magic-Link). Wenn wired, mountet POST
|
|
291
293
|
// /auth/signup-request + /auth/signup-confirm. Confirm returnt JWT-
|
|
292
294
|
// Cookie + Session-Body wie login.
|
|
@@ -357,6 +359,13 @@ export type EmailVerificationConfig = {
|
|
|
357
359
|
confirmHandler: string;
|
|
358
360
|
};
|
|
359
361
|
|
|
362
|
+
export type AccountUnlockConfig = {
|
|
363
|
+
requestHandler: string;
|
|
364
|
+
// Token-only body (mirrors EmailVerificationConfig) — no entity write, so
|
|
365
|
+
// there's no newPassword-equivalent field.
|
|
366
|
+
confirmHandler: string;
|
|
367
|
+
};
|
|
368
|
+
|
|
360
369
|
// Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
|
|
361
370
|
// - acceptHandler: logged-in User akzeptiert via JWT (Branch 1)
|
|
362
371
|
// - acceptWithLoginHandler: anon User mit existing email (Branch 2)
|
|
@@ -461,6 +470,37 @@ export function createInMemoryLoginRateLimiter(
|
|
|
461
470
|
};
|
|
462
471
|
}
|
|
463
472
|
|
|
473
|
+
// Redis-backed sibling of createInMemoryLoginRateLimiter — same fixed-window
|
|
474
|
+
// semantics (count <= maxAttempts allows, window anchored on first hit), but
|
|
475
|
+
// shared across replicas via INCR/PEXPIRE instead of an in-process Map.
|
|
476
|
+
// runProdApp defaults to this: an in-memory limiter only rate-limits within
|
|
477
|
+
// a single instance, so a multi-replica prod deployment would silently give
|
|
478
|
+
// each replica its own bucket. namespace separates the login-key keyspace
|
|
479
|
+
// from the mfa-verify one (they share the same LoginRateLimiter shape but
|
|
480
|
+
// key on different values).
|
|
481
|
+
export function createRedisLoginRateLimiter(
|
|
482
|
+
redis: Redis,
|
|
483
|
+
maxAttempts = 10,
|
|
484
|
+
windowMs = 5 * 60_000,
|
|
485
|
+
namespace = "login",
|
|
486
|
+
): LoginRateLimiter {
|
|
487
|
+
const prefix = `kumiko:auth:ratelimit:${namespace}:`;
|
|
488
|
+
|
|
489
|
+
return {
|
|
490
|
+
async check(key) {
|
|
491
|
+
const redisKey = `${prefix}${key}`;
|
|
492
|
+
const count = await redis.incr(redisKey);
|
|
493
|
+
if (count === 1) {
|
|
494
|
+
await redis.pexpire(redisKey, windowMs);
|
|
495
|
+
}
|
|
496
|
+
return count <= maxAttempts;
|
|
497
|
+
},
|
|
498
|
+
async reset(key) {
|
|
499
|
+
await redis.del(`${prefix}${key}`);
|
|
500
|
+
},
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
464
504
|
export function createAuthRoutes(
|
|
465
505
|
dispatcher: Dispatcher,
|
|
466
506
|
jwt: JwtHelper,
|
|
@@ -486,7 +526,13 @@ export function createAuthRoutes(
|
|
|
486
526
|
}
|
|
487
527
|
const token = await jwt.sign(sessionForJwt);
|
|
488
528
|
const csrfToken = generateToken();
|
|
489
|
-
setAuthCookies(c, {
|
|
529
|
+
setAuthCookies(c, {
|
|
530
|
+
token,
|
|
531
|
+
csrfToken,
|
|
532
|
+
sameSite: cookieSameSite,
|
|
533
|
+
domain: cookieDomain,
|
|
534
|
+
ttlSeconds: jwt.ttlSeconds,
|
|
535
|
+
});
|
|
490
536
|
return token;
|
|
491
537
|
}
|
|
492
538
|
|
|
@@ -697,6 +743,26 @@ export function createAuthRoutes(
|
|
|
697
743
|
});
|
|
698
744
|
}
|
|
699
745
|
|
|
746
|
+
// Account-unlock mirrors email-verification (token-only confirm body) —
|
|
747
|
+
// clears the Redis lockout state instead of an entity field, see
|
|
748
|
+
// confirm-account-unlock.write.ts.
|
|
749
|
+
if (config.accountUnlock) {
|
|
750
|
+
const au = config.accountUnlock;
|
|
751
|
+
registerTokenRequestRoute({
|
|
752
|
+
api,
|
|
753
|
+
dispatcher,
|
|
754
|
+
path: Routes.authRequestAccountUnlock,
|
|
755
|
+
requestHandler: au.requestHandler,
|
|
756
|
+
});
|
|
757
|
+
registerTokenConfirmRoute({
|
|
758
|
+
api,
|
|
759
|
+
dispatcher,
|
|
760
|
+
path: Routes.authConfirmAccountUnlock,
|
|
761
|
+
confirmHandler: au.confirmHandler,
|
|
762
|
+
schema: VerifyEmailBody,
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
|
|
700
766
|
// Self-Signup (Magic-Link). Request mountet wie reset/verify den
|
|
701
767
|
// silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
|
|
702
768
|
// SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login
|
package/src/api/index.ts
CHANGED
|
@@ -5,10 +5,10 @@ export type {
|
|
|
5
5
|
AuthMiddlewareOptions,
|
|
6
6
|
AuthSessionChecker,
|
|
7
7
|
AuthSessionStatus,
|
|
8
|
-
PatResolver,
|
|
9
8
|
TenantExists,
|
|
10
9
|
TenantLifecycleStatusResolver,
|
|
11
10
|
TenantResolver,
|
|
11
|
+
TokenVerifier,
|
|
12
12
|
} from "./auth-middleware";
|
|
13
13
|
export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
|
|
14
14
|
export type {
|
|
@@ -19,7 +19,11 @@ export type {
|
|
|
19
19
|
SessionMetadata,
|
|
20
20
|
SessionRevoker,
|
|
21
21
|
} from "./auth-routes";
|
|
22
|
-
export {
|
|
22
|
+
export {
|
|
23
|
+
createAuthRoutes,
|
|
24
|
+
createInMemoryLoginRateLimiter,
|
|
25
|
+
createRedisLoginRateLimiter,
|
|
26
|
+
} from "./auth-routes";
|
|
23
27
|
export type { CachedResponseInit, CachePolicy } from "./http-cache";
|
|
24
28
|
export {
|
|
25
29
|
cacheControlHeader,
|
|
@@ -30,8 +34,8 @@ export {
|
|
|
30
34
|
etagMatches,
|
|
31
35
|
parseIfNoneMatch,
|
|
32
36
|
} from "./http-cache";
|
|
33
|
-
export type { JwtHelper, JwtPayload } from "./jwt";
|
|
34
|
-
export { createJwtHelper } from "./jwt";
|
|
37
|
+
export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
|
|
38
|
+
export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
|
|
35
39
|
export { patAllows, qnMatches } from "./pat-scope";
|
|
36
40
|
export { type RequestContextData, requestContext } from "./request-context";
|
|
37
41
|
export { requestIdMiddleware } from "./request-id-middleware";
|
package/src/api/jwt.ts
CHANGED
|
@@ -23,10 +23,82 @@ export type JwtPayload = {
|
|
|
23
23
|
export type JwtHelper = {
|
|
24
24
|
sign(user: SessionUser): Promise<string>;
|
|
25
25
|
verify(token: string): Promise<JwtPayload>;
|
|
26
|
+
// The TTL this helper signs tokens with, in seconds — the single source for
|
|
27
|
+
// callers (e.g. the auth-cookie's maxAge) that must stay coupled to the JWT's exp.
|
|
28
|
+
readonly ttlSeconds: number;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
// kid → secret. All entries verify; `signKid` picks the sign-key. Rotation:
|
|
32
|
+
// add the new kid, flip signKid, keep the old kid around until in-flight
|
|
33
|
+
// tokens expire.
|
|
34
|
+
export type JwtKeyring = {
|
|
35
|
+
readonly keys: Readonly<Record<string, string>>;
|
|
36
|
+
readonly signKid: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type NormalizedKeyring = {
|
|
40
|
+
readonly verifyKeys: ReadonlyMap<string, Uint8Array>;
|
|
41
|
+
readonly signKid: string | undefined;
|
|
42
|
+
readonly signKey: Uint8Array;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function normalizeKeyring(secretOrKeyring: string | JwtKeyring): NormalizedKeyring {
|
|
46
|
+
if (typeof secretOrKeyring === "string") {
|
|
47
|
+
const key = new TextEncoder().encode(secretOrKeyring);
|
|
48
|
+
return { verifyKeys: new Map(), signKid: undefined, signKey: key };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const verifyKeys = new Map<string, Uint8Array>();
|
|
52
|
+
for (const [kid, secret] of Object.entries(secretOrKeyring.keys)) {
|
|
53
|
+
verifyKeys.set(kid, new TextEncoder().encode(secret));
|
|
54
|
+
}
|
|
55
|
+
const signKey = verifyKeys.get(secretOrKeyring.signKid);
|
|
56
|
+
if (!signKey) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`createJwtHelper: signKid "${secretOrKeyring.signKid}" is not present in the keyring`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return { verifyKeys, signKid: secretOrKeyring.signKid, signKey };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Tokens carry `kid` in the protected header when signed from a keyring — pick the
|
|
65
|
+
// matching verify-key directly. Tokens without `kid` (single-secret form, or in-flight
|
|
66
|
+
// tokens signed before a rotation) fall back to trying every verify-key.
|
|
67
|
+
async function verifyWithKeyring(token: string, keyring: NormalizedKeyring, issuer: string) {
|
|
68
|
+
const { kid } = jose.decodeProtectedHeader(token);
|
|
69
|
+
if (typeof kid === "string" && keyring.verifyKeys.size > 0) {
|
|
70
|
+
const key = keyring.verifyKeys.get(kid);
|
|
71
|
+
if (!key) {
|
|
72
|
+
throw new Error(`JWT verification failed: unknown kid "${kid}"`);
|
|
73
|
+
}
|
|
74
|
+
return jose.jwtVerify(token, key, { issuer });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ponytail: tries every key in the ring (O(keys) per legacy-token verify) — fine for a
|
|
78
|
+
// rotation window of a handful of keys, revisit if the keyring ever grows large.
|
|
79
|
+
const candidates =
|
|
80
|
+
keyring.verifyKeys.size > 0 ? [...keyring.verifyKeys.values()] : [keyring.signKey];
|
|
81
|
+
let lastError: unknown;
|
|
82
|
+
for (const key of candidates) {
|
|
83
|
+
try {
|
|
84
|
+
return await jose.jwtVerify(token, key, { issuer });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
lastError = err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw lastError instanceof Error
|
|
90
|
+
? lastError
|
|
91
|
+
: new Error("JWT verification failed: no matching key");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const DEFAULT_JWT_TTL_SECONDS = 24 * 60 * 60;
|
|
95
|
+
|
|
96
|
+
export function createJwtHelper(
|
|
97
|
+
secretOrKeyring: string | JwtKeyring,
|
|
98
|
+
issuer = "kumiko",
|
|
99
|
+
ttlSeconds = DEFAULT_JWT_TTL_SECONDS,
|
|
100
|
+
): JwtHelper {
|
|
101
|
+
const keyring = normalizeKeyring(secretOrKeyring);
|
|
30
102
|
|
|
31
103
|
return {
|
|
32
104
|
async sign(user) {
|
|
@@ -36,19 +108,27 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
36
108
|
};
|
|
37
109
|
if (user.claims) body.claims = { ...user.claims };
|
|
38
110
|
|
|
111
|
+
const header: jose.JWTHeaderParameters = keyring.signKid
|
|
112
|
+
? { alg: "HS256", kid: keyring.signKid }
|
|
113
|
+
: { alg: "HS256" };
|
|
114
|
+
|
|
115
|
+
// iat/exp share one `now` — jose's setIssuedAt()/setExpirationTime(Date)
|
|
116
|
+
// each read the clock separately, letting `exp - iat` drift by a
|
|
117
|
+
// second and making TTL-precision tests flaky.
|
|
118
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
39
119
|
const builder = new jose.SignJWT(body)
|
|
40
|
-
.setProtectedHeader(
|
|
120
|
+
.setProtectedHeader(header)
|
|
41
121
|
.setSubject(String(user.id))
|
|
42
122
|
.setIssuer(issuer)
|
|
43
|
-
.setIssuedAt()
|
|
44
|
-
.setExpirationTime(
|
|
123
|
+
.setIssuedAt(nowSec)
|
|
124
|
+
.setExpirationTime(nowSec + ttlSeconds);
|
|
45
125
|
if (user.sid) builder.setJti(user.sid);
|
|
46
126
|
|
|
47
|
-
return builder.sign(
|
|
127
|
+
return builder.sign(keyring.signKey);
|
|
48
128
|
},
|
|
49
129
|
|
|
50
130
|
async verify(token) {
|
|
51
|
-
const { payload } = await
|
|
131
|
+
const { payload } = await verifyWithKeyring(token, keyring, issuer);
|
|
52
132
|
|
|
53
133
|
// defence-in-depth: valid sig ≠ well-formed claims; malformed payload → throw → 401
|
|
54
134
|
const tenantId = parseTenantId(payload["tenantId"]);
|
|
@@ -84,5 +164,66 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
|
|
|
84
164
|
}
|
|
85
165
|
return result;
|
|
86
166
|
},
|
|
167
|
+
ttlSeconds,
|
|
87
168
|
};
|
|
88
169
|
}
|
|
170
|
+
|
|
171
|
+
const JWT_KEY_VAR_PATTERN = /^JWT_SECRET_V(\d+)$/;
|
|
172
|
+
const JWT_CURRENT_VERSION_VAR = "JWT_SECRET_CURRENT_VERSION";
|
|
173
|
+
// Mirrors authEmailPasswordEnvSchema's JWT_SECRET.min(32) — HS256 minimum.
|
|
174
|
+
// JWT_SECRET_V<n> bypasses that zod schema entirely (it only validates the
|
|
175
|
+
// plain JWT_SECRET name), so this loader is the only gate for the rotation path.
|
|
176
|
+
const MIN_JWT_SECRET_LENGTH = 32;
|
|
177
|
+
|
|
178
|
+
function assertMinLength(name: string, value: string): void {
|
|
179
|
+
if (value.length < MIN_JWT_SECRET_LENGTH) {
|
|
180
|
+
throw new Error(`[jwt] ${name} must be ≥${MIN_JWT_SECRET_LENGTH} chars (HS256 minimum)`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Env-loader for createJwtHelper's secret-or-keyring param, analog to
|
|
185
|
+
// secrets' loadKeyring: JWT_SECRET_V<n> (+ JWT_SECRET_CURRENT_VERSION picking
|
|
186
|
+
// the active signKid) for rotation, falling back to plain JWT_SECRET when no
|
|
187
|
+
// JWT_SECRET_V<n> is set — so a non-rotating deployment needs no new env vars.
|
|
188
|
+
export function loadJwtSecretOrKeyring(
|
|
189
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
190
|
+
): string | JwtKeyring {
|
|
191
|
+
const keys: Record<string, string> = {};
|
|
192
|
+
for (const [name, value] of Object.entries(env)) {
|
|
193
|
+
const match = name.match(JWT_KEY_VAR_PATTERN);
|
|
194
|
+
if (!match || !value) continue;
|
|
195
|
+
assertMinLength(name, value);
|
|
196
|
+
// biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
|
|
197
|
+
keys[`v${match[1]!}`] = value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// skip: no JWT_SECRET_V<n> found — single-secret fallback.
|
|
201
|
+
if (Object.keys(keys).length === 0) {
|
|
202
|
+
const secret = env["JWT_SECRET"];
|
|
203
|
+
if (!secret) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
"[jwt] JWT_SECRET not set — set JWT_SECRET for a single key, or " +
|
|
206
|
+
"JWT_SECRET_V1 (+ JWT_SECRET_CURRENT_VERSION=1) for a rotatable keyring.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
assertMinLength("JWT_SECRET", secret);
|
|
210
|
+
return secret;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const currentRaw = env[JWT_CURRENT_VERSION_VAR];
|
|
214
|
+
if (!currentRaw) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR} not set — explicit current-version required ` +
|
|
217
|
+
"so adding a new JWT_SECRET_V<n> doesn't auto-promote it to the sign key.",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const signKid = `v${currentRaw}`;
|
|
221
|
+
if (!keys[signKid]) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`[jwt] ${JWT_CURRENT_VERSION_VAR}="${currentRaw}" not present in the keyring ` +
|
|
224
|
+
`(have versions: ${Object.keys(keys).sort().join(", ")}). ` +
|
|
225
|
+
`Check JWT_SECRET_V${currentRaw} is set.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return { keys, signKid };
|
|
229
|
+
}
|
|
@@ -2,7 +2,10 @@ import type { MiddlewareHandler } from "hono";
|
|
|
2
2
|
import { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
|
|
3
3
|
|
|
4
4
|
const isProductionEnv = () => process.env["NODE_ENV"] === "production";
|
|
5
|
-
|
|
5
|
+
// Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
|
|
6
|
+
// older/decrypt-only format version still present in unmigrated rows.
|
|
7
|
+
const CIPHERTEXT_MARKER = "kumiko-pii:v";
|
|
8
|
+
const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
|
|
6
9
|
|
|
7
10
|
// A PII subject ciphertext never belongs in an API response — its presence
|
|
8
11
|
// means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
|
|
@@ -19,7 +22,7 @@ export function piiCiphertextResponseGuard(): MiddlewareHandler {
|
|
|
19
22
|
if (!contentType.includes("application/json")) return;
|
|
20
23
|
const text = await c.res.clone().text();
|
|
21
24
|
// skip: clean response — the common case
|
|
22
|
-
if (!text.includes(
|
|
25
|
+
if (!text.includes(CIPHERTEXT_MARKER)) return;
|
|
23
26
|
|
|
24
27
|
const detail =
|
|
25
28
|
`[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
|
package/src/api/server.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { PUBLIC_API_PATHS } from "./api-constants";
|
|
|
50
50
|
import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
|
|
51
51
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
52
52
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
53
|
-
import { createJwtHelper, type JwtHelper } from "./jwt";
|
|
53
|
+
import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
54
54
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
55
55
|
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
56
56
|
import { piiCiphertextResponseGuard } from "./pii-leak-guard";
|
|
@@ -69,8 +69,13 @@ import { createSseRoute } from "./sse-route";
|
|
|
69
69
|
export type ServerOptions = {
|
|
70
70
|
registry: Registry;
|
|
71
71
|
context: AppContext;
|
|
72
|
-
jwtSecret: string;
|
|
72
|
+
jwtSecret: string | JwtKeyring;
|
|
73
73
|
jwtIssuer?: string;
|
|
74
|
+
// JWT lifetime in seconds. Explicit always wins. When omitted, the default
|
|
75
|
+
// depends on `auth.sessionChecker`: wired (revocation possible) keeps the
|
|
76
|
+
// long-lived 24h default; unwired (stateless JWTs, no revocation) drops to
|
|
77
|
+
// 1h so a leaked stateless token has a much smaller exposure window.
|
|
78
|
+
jwtTtl?: number;
|
|
74
79
|
dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
|
|
75
80
|
systemHooks?: SystemHooks;
|
|
76
81
|
eventDedup?: EventDedup;
|
|
@@ -94,6 +99,8 @@ export type ServerOptions = {
|
|
|
94
99
|
pollIntervalMs?: number;
|
|
95
100
|
batchSize?: number;
|
|
96
101
|
maxAttempts?: number;
|
|
102
|
+
rearmCooldownMs?: number;
|
|
103
|
+
maxRearmCount?: number;
|
|
97
104
|
// Opt out of building the dispatcher even if consumers exist — e.g. ops
|
|
98
105
|
// runs a dedicated dispatcher process, or a test needs to control the
|
|
99
106
|
// consumer lifecycle manually.
|
|
@@ -238,7 +245,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
238
245
|
);
|
|
239
246
|
}
|
|
240
247
|
|
|
241
|
-
|
|
248
|
+
// Stateless JWTs (no sessionChecker → no revocation) default to a shorter
|
|
249
|
+
// TTL than session-backed ones, since a leaked stateless token can't be
|
|
250
|
+
// revoked and stays valid until it expires. Explicit jwtTtl always wins.
|
|
251
|
+
const defaultJwtTtl = options.auth?.sessionChecker ? 24 * 60 * 60 : 60 * 60;
|
|
252
|
+
const jwt = createJwtHelper(
|
|
253
|
+
options.jwtSecret,
|
|
254
|
+
options.jwtIssuer,
|
|
255
|
+
options.jwtTtl ?? defaultJwtTtl,
|
|
256
|
+
);
|
|
242
257
|
const sseBroker = options.sseBroker ?? createSseBroker();
|
|
243
258
|
|
|
244
259
|
// Resolve the per-process instance identifier. Prefer explicit
|
|
@@ -566,8 +581,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
566
581
|
// middleware can reject revoked sids on every request.
|
|
567
582
|
const jwtGuard = authMiddleware(jwt, {
|
|
568
583
|
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
569
|
-
...(options.auth?.
|
|
570
|
-
...(options.auth?.patResolver ? { patResolver: options.auth.patResolver } : {}),
|
|
584
|
+
...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
|
|
571
585
|
...(options.auth?.resolveTenantLifecycleStatus
|
|
572
586
|
? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
|
|
573
587
|
: {}),
|