@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/auth-mfa. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# auth-mfa
|
|
4
|
+
|
|
5
|
+
createAuthMfaFeature — TOTP/2FA enrollment, login challenge, and recovery codes wired into auth-email-password.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Sample: Auth MFA (TOTP/2FA)
|
|
10
|
+
|
|
11
|
+
**I want users to be able to enable TOTP-based 2FA, and login to become a two-step challenge once they have.**
|
|
12
|
+
|
|
13
|
+
## What this sample shows
|
|
14
|
+
|
|
15
|
+
The production wiring shape: mount `createAuthMfaFeature()` next to `createAuthEmailPasswordFeature()`, thread its status-checker into the login handler, and give `auth: { mfaVerifyHandler: ... }` to `buildServer`. From there the framework handles the rest — a password-only login for an MFA-enrolled user returns a challenge token instead of a JWT, and `POST /auth/mfa/verify` completes it with a TOTP or recovery code.
|
|
16
|
+
|
|
17
|
+
## The wiring in 4 sentences
|
|
18
|
+
|
|
19
|
+
1. `createAuthMfaFeature({ setupTokenSecret, challengeTokenSecret, issuer })` builds the feature; `mfaStatusCheckerFromFeature(mfaFeature)` reads its login-status callback back off.
|
|
20
|
+
2. That callback goes into `createAuthEmailPasswordFeature({ mfaStatusChecker: ... })` — `login.write.ts` calls it after the password check to decide whether to mint a JWT or a challenge.
|
|
21
|
+
3. `buildServer({ auth: { mfaVerifyHandler: AuthMfaHandlers.verify } })` mounts `POST /auth/mfa/verify`, a framework route (not a dispatcher write-handler) with its own rate limiter, since it runs before a JWT exists.
|
|
22
|
+
4. If `sessions` is mounted too, `bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers)` wires "enable/disable/regenerate signs out every other session."
|
|
23
|
+
|
|
24
|
+
## What to copy into your app
|
|
25
|
+
|
|
26
|
+
```ts illustration
|
|
27
|
+
const mfaFeature = createAuthMfaFeature({
|
|
28
|
+
setupTokenSecret: config.mfaSetupSecret,
|
|
29
|
+
challengeTokenSecret: config.mfaChallengeSecret,
|
|
30
|
+
issuer: "Your App",
|
|
31
|
+
});
|
|
32
|
+
const sessionCallbacks = createSessionCallbacks({ db });
|
|
33
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
|
|
34
|
+
sessionCallbacks.sessionRevokeAllOthers,
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const server = buildServer({
|
|
38
|
+
registry: buildRegistry([
|
|
39
|
+
createAuthEmailPasswordFeature({
|
|
40
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature),
|
|
41
|
+
}),
|
|
42
|
+
createSessionsFeature({
|
|
43
|
+
autoRevokeOnPasswordChange: sessionCallbacks.sessionMassRevoker,
|
|
44
|
+
}),
|
|
45
|
+
mfaFeature,
|
|
46
|
+
// ... other features
|
|
47
|
+
]),
|
|
48
|
+
context: { db, /* ... */ },
|
|
49
|
+
jwtSecret: process.env.JWT_SECRET,
|
|
50
|
+
auth: {
|
|
51
|
+
membershipQuery: "tenant:query:memberships",
|
|
52
|
+
loginHandler: "auth-email-password:[REDACTED:API key param]",
|
|
53
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
54
|
+
sessionCreator: sessionCallbacks.sessionCreator,
|
|
55
|
+
sessionRevoker: sessionCallbacks.sessionRevoker,
|
|
56
|
+
sessionChecker: sessionCallbacks.sessionChecker,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Apps built via `runDevApp`/`runProdApp` (the dev-server bootstrap used by `create-kumiko-app`) get all of this automatically — just add `createAuthMfaFeature(...)` to `APP_FEATURES`. `composeFeatures` threads the status-checker and the bootstrap wires the session-revoke bind and the verify route for you. This sample shows the raw contract those helpers implement.
|
|
62
|
+
|
|
63
|
+
## Design rules
|
|
64
|
+
|
|
65
|
+
1. **Enrollment is stateless until confirmed.** `enable-start` returns a signed setup token (QR + secret + 8 recovery codes) — nothing is persisted until `enable-confirm` proves the user scanned it with a valid TOTP code.
|
|
66
|
+
2. **The TOTP secret is envelope-encrypted at rest**, same `MasterKeyProvider` as `secrets`/`config` — no separate crypto story to maintain.
|
|
67
|
+
3. **`/auth/mfa/verify` is a framework route, not a dispatcher handler.** It runs before a JWT exists, so it gets its own IP-keyed rate limiter (`mfaVerifyRateLimit`), independent of the login rate limiter.
|
|
68
|
+
4. **Recovery codes are single-use.** Each of the 8 generated codes completes exactly one login challenge, then it's burned.
|
|
69
|
+
5. **MFA state changes revoke every OTHER live session** (stolen-session defense) — never the session that made the change.
|
|
70
|
+
6. **Enforcement is opt-in.** Without a tenant enabling `auth-mfa:config:required`, users choose for themselves whether to enroll.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
- **Baseline:** a user without MFA enrolled still gets a JWT straight from `/auth/login`.
|
|
75
|
+
- **End-to-end:** enroll via `enable-start`/`enable-confirm`, log in (gets a challenge, no JWT), complete via `/auth/mfa/verify` (gets a JWT) — copies the `buildServer({ auth: ... })` block from the sample comment verbatim.
|
|
76
|
+
|
|
77
|
+
## Source files
|
|
78
|
+
|
|
79
|
+
### src/__tests__/feature.integration.test.ts
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// Auth MFA Sample — Integration Test
|
|
83
|
+
//
|
|
84
|
+
// Drives the wiring from the sample's comment snippet end-to-end against
|
|
85
|
+
// real HTTP + DB. If a reader copies the buildServer({...}) block verbatim,
|
|
86
|
+
// the resulting app behaves like this test asserts: login is two-step once
|
|
87
|
+
// MFA is enabled, and a valid TOTP code completes it.
|
|
88
|
+
|
|
89
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
90
|
+
import {
|
|
91
|
+
AuthHandlers,
|
|
92
|
+
createAuthEmailPasswordFeature,
|
|
93
|
+
hashPassword,
|
|
94
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
95
|
+
import {
|
|
96
|
+
configValuesTable,
|
|
97
|
+
createConfigFeature,
|
|
98
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
99
|
+
import {
|
|
100
|
+
createTenantFeature,
|
|
101
|
+
tenantEntity,
|
|
102
|
+
tenantMembershipsTable,
|
|
103
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
104
|
+
import { seedTenantMembership } from "@cosmicdrift/kumiko-bundled-features/tenant/seeding";
|
|
105
|
+
import {
|
|
106
|
+
createUserFeature,
|
|
107
|
+
UserHandlers,
|
|
108
|
+
userEntity,
|
|
109
|
+
userTable,
|
|
110
|
+
} from "@cosmicdrift/kumiko-bundled-features/user";
|
|
111
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
112
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
113
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
114
|
+
import {
|
|
115
|
+
setupTestStack,
|
|
116
|
+
type TestStack,
|
|
117
|
+
TestUsers,
|
|
118
|
+
testTenantId,
|
|
119
|
+
unsafeCreateEntityTable,
|
|
120
|
+
unsafePushTables,
|
|
121
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
122
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
123
|
+
import {
|
|
124
|
+
AuthMfaHandlers,
|
|
125
|
+
base32Decode,
|
|
126
|
+
createAuthMfaFeature,
|
|
127
|
+
currentTotpCode,
|
|
128
|
+
mfaStatusCheckerFromFeature,
|
|
129
|
+
userMfaEntity,
|
|
130
|
+
} from "../feature";
|
|
131
|
+
|
|
132
|
+
const TENANT: TenantId = testTenantId(1);
|
|
133
|
+
const SETUP_TOKEN_SECRET = "test-mfa-setup-secret-at-least-32-bytes-long!!";
|
|
134
|
+
const CHALLENGE_TOKEN_SECRET = "test-mfa-challenge-secret-at-least-32-bytes!!";
|
|
135
|
+
|
|
136
|
+
let stack: TestStack;
|
|
137
|
+
|
|
138
|
+
beforeAll(async () => {
|
|
139
|
+
configureEntityFieldEncryption(createTestEnvelopeCipher());
|
|
140
|
+
|
|
141
|
+
const mfaFeature = createAuthMfaFeature({
|
|
142
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
143
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
144
|
+
issuer: "Kumiko Sample",
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
stack = await setupTestStack({
|
|
148
|
+
features: [
|
|
149
|
+
createConfigFeature(),
|
|
150
|
+
createUserFeature(),
|
|
151
|
+
createTenantFeature(),
|
|
152
|
+
createAuthEmailPasswordFeature({
|
|
153
|
+
mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature),
|
|
154
|
+
}),
|
|
155
|
+
mfaFeature,
|
|
156
|
+
],
|
|
157
|
+
authConfig: {
|
|
158
|
+
membershipQuery: "tenant:query:memberships",
|
|
159
|
+
loginHandler: AuthHandlers.login,
|
|
160
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
165
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
166
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
167
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
afterAll(async () => {
|
|
171
|
+
await stack.cleanup();
|
|
172
|
+
configureEntityFieldEncryption(undefined);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
beforeEach(async () => {
|
|
176
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
177
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("auth-mfa sample wiring", () => {
|
|
181
|
+
test("login without MFA enrolled returns a JWT directly", async () => {
|
|
182
|
+
const hash = await hashPassword("super-long-password");
|
|
183
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
184
|
+
UserHandlers.create,
|
|
185
|
+
{ email: "plain@example.com", passwordHash: hash, displayName: "Plain" },
|
|
186
|
+
TestUsers.systemAdmin,
|
|
187
|
+
);
|
|
188
|
+
await seedTenantMembership(stack.db, { userId: created.id, tenantId: TENANT, roles: ["User"] });
|
|
189
|
+
|
|
190
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
191
|
+
email: "plain@example.com",
|
|
192
|
+
password: "super-long-password",
|
|
193
|
+
});
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
const body = (await res.json()) as { token?: string; mfaRequired?: boolean };
|
|
196
|
+
expect(body.token).toBeTypeOf("string");
|
|
197
|
+
expect(body.mfaRequired).toBeUndefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("login with MFA enrolled challenges, then verify mints the JWT", async () => {
|
|
201
|
+
const hash = await hashPassword("super-long-password");
|
|
202
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
203
|
+
UserHandlers.create,
|
|
204
|
+
{ email: "mfa@example.com", passwordHash: hash, displayName: "Mfa" },
|
|
205
|
+
TestUsers.systemAdmin,
|
|
206
|
+
);
|
|
207
|
+
await seedTenantMembership(stack.db, { userId: created.id, tenantId: TENANT, roles: ["User"] });
|
|
208
|
+
const user = { ...TestUsers.user, id: created.id, tenantId: TENANT };
|
|
209
|
+
|
|
210
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
211
|
+
AuthMfaHandlers.enableStart,
|
|
212
|
+
{ accountLabel: "mfa@example.com" },
|
|
213
|
+
user,
|
|
214
|
+
);
|
|
215
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
216
|
+
const secret = base32Decode(secretParam);
|
|
217
|
+
await stack.http.writeOk(
|
|
218
|
+
AuthMfaHandlers.enableConfirm,
|
|
219
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
220
|
+
user,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
224
|
+
email: "mfa@example.com",
|
|
225
|
+
password: "super-long-password",
|
|
226
|
+
});
|
|
227
|
+
expect(loginRes.status).toBe(200);
|
|
228
|
+
const challenge = (await loginRes.json()) as {
|
|
229
|
+
token?: string;
|
|
230
|
+
mfaRequired?: boolean;
|
|
231
|
+
challengeToken?: string;
|
|
232
|
+
};
|
|
233
|
+
expect(challenge.token).toBeUndefined();
|
|
234
|
+
expect(challenge.mfaRequired).toBe(true);
|
|
235
|
+
expect(challenge.challengeToken).toBeTypeOf("string");
|
|
236
|
+
|
|
237
|
+
const verifyRes = await stack.http.raw("POST", "/api/auth/mfa/verify", {
|
|
238
|
+
challengeToken: challenge.challengeToken,
|
|
239
|
+
code: currentTotpCode(secret),
|
|
240
|
+
});
|
|
241
|
+
expect(verifyRes.status).toBe(200);
|
|
242
|
+
const { token } = (await verifyRes.json()) as { token: string };
|
|
243
|
+
expect(token).toBeTypeOf("string");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### src/feature.ts
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// Auth MFA (TOTP/2FA) Sample
|
|
252
|
+
//
|
|
253
|
+
// Shows the production-wiring shape: mount `createAuthMfaFeature()` next to
|
|
254
|
+
// `createAuthEmailPasswordFeature()`, thread its status-checker into the
|
|
255
|
+
// login handler, and (if `sessions` is mounted) bind its revoke-all-other-
|
|
256
|
+
// sessions callback. From there the framework handles the rest — login
|
|
257
|
+
// returns a challenge instead of a JWT when MFA is enabled, POST
|
|
258
|
+
// /auth/mfa/verify completes it, and every OTHER live session gets signed
|
|
259
|
+
// out whenever the account's MFA state changes (enable/disable/regenerate).
|
|
260
|
+
//
|
|
261
|
+
// What to copy into your own app:
|
|
262
|
+
//
|
|
263
|
+
// const mfaFeature = createAuthMfaFeature({
|
|
264
|
+
// setupTokenSecret: config.mfaSetupSecret,
|
|
265
|
+
// challengeTokenSecret: config.mfaChallengeSecret,
|
|
266
|
+
// issuer: "Your App",
|
|
267
|
+
// });
|
|
268
|
+
// const sessionCallbacks = createSessionCallbacks({ db });
|
|
269
|
+
// bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(
|
|
270
|
+
// sessionCallbacks.sessionRevokeAllOthers,
|
|
271
|
+
// );
|
|
272
|
+
//
|
|
273
|
+
// const server = buildServer({
|
|
274
|
+
// registry: buildRegistry([
|
|
275
|
+
// createAuthEmailPasswordFeature({
|
|
276
|
+
// mfaStatusChecker: mfaStatusCheckerFromFeature(mfaFeature),
|
|
277
|
+
// }),
|
|
278
|
+
// createSessionsFeature({
|
|
279
|
+
// autoRevokeOnPasswordChange: sessionCallbacks.sessionMassRevoker,
|
|
280
|
+
// }),
|
|
281
|
+
// mfaFeature,
|
|
282
|
+
// // ... other features
|
|
283
|
+
// ]),
|
|
284
|
+
// context: { db, ... },
|
|
285
|
+
// jwtSecret: config.jwtSecret,
|
|
286
|
+
// auth: {
|
|
287
|
+
// membershipQuery: "tenant:query:memberships",
|
|
288
|
+
// loginHandler: "auth-email-password:[REDACTED:API key param]",
|
|
289
|
+
// mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
290
|
+
// sessionCreator: sessionCallbacks.sessionCreator,
|
|
291
|
+
// sessionRevoker: sessionCallbacks.sessionRevoker,
|
|
292
|
+
// sessionChecker: sessionCallbacks.sessionChecker,
|
|
293
|
+
// },
|
|
294
|
+
// });
|
|
295
|
+
//
|
|
296
|
+
// Apps built via runDevApp/runProdApp (the dev-server bootstrap) get all of
|
|
297
|
+
// this for free just by adding `createAuthMfaFeature(...)` to APP_FEATURES —
|
|
298
|
+
// composeFeatures threads the status-checker and the bootstrap wraps the
|
|
299
|
+
// session-revoke bind and the /auth/mfa/verify route automatically. This
|
|
300
|
+
// sample shows the raw wiring contract those helpers implement.
|
|
301
|
+
//
|
|
302
|
+
// Design rules the sample demonstrates:
|
|
303
|
+
//
|
|
304
|
+
// 1. Login becomes two-step once MFA is enabled. `POST /auth/login` returns
|
|
305
|
+
// `{ mfaRequired: true, challengeToken }` instead of a JWT; the client
|
|
306
|
+
// completes with `POST /auth/mfa/verify`.
|
|
307
|
+
// 2. Enrollment is stateless until confirmed. `enable-start` returns a
|
|
308
|
+
// signed setup token (QR + recovery codes) — nothing is persisted until
|
|
309
|
+
// `enable-confirm` proves the user scanned it with a valid TOTP code.
|
|
310
|
+
// 3. Recovery codes are single-use. Each of the 8 generated codes can
|
|
311
|
+
// complete exactly one login challenge, then it's burned.
|
|
312
|
+
// 4. Every MFA state change signs out every OTHER live session (stolen-
|
|
313
|
+
// session defense) — but never the session that made the change.
|
|
314
|
+
export {
|
|
315
|
+
AuthMfaHandlers,
|
|
316
|
+
base32Decode,
|
|
317
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
318
|
+
createAuthMfaFeature,
|
|
319
|
+
mfaStatusCheckerFromFeature,
|
|
320
|
+
userMfaEntity,
|
|
321
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
322
|
+
export { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
|
|
323
|
+
```
|