@classytic/arc 1.1.0 → 2.1.2
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/README.md +247 -794
- package/bin/arc.js +91 -52
- package/dist/EventTransport-BD2U0BTc.d.mts +100 -0
- package/dist/EventTransport-BD2U0BTc.d.mts.map +1 -0
- package/dist/HookSystem-BsGV-j2l.mjs +405 -0
- package/dist/HookSystem-BsGV-j2l.mjs.map +1 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs +250 -0
- package/dist/ResourceRegistry-DsN4KJjV.mjs.map +1 -0
- package/dist/adapters/index.d.mts +5 -0
- package/dist/adapters/index.mjs +3 -0
- package/dist/audit/index.d.mts +82 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +276 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/audit/mongodb.d.mts +5 -0
- package/dist/audit/mongodb.mjs +3 -0
- package/dist/audited-C3T5DTUx.mjs +141 -0
- package/dist/audited-C3T5DTUx.mjs.map +1 -0
- package/dist/auth/index.d.mts +189 -0
- package/dist/auth/index.d.mts.map +1 -0
- package/dist/auth/index.mjs +1102 -0
- package/dist/auth/index.mjs.map +1 -0
- package/dist/auth/redis-session.d.mts +44 -0
- package/dist/auth/redis-session.d.mts.map +1 -0
- package/dist/auth/redis-session.mjs +76 -0
- package/dist/auth/redis-session.mjs.map +1 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs +250 -0
- package/dist/betterAuthOpenApi-BrHKeSAx.mjs.map +1 -0
- package/dist/cache/index.d.mts +146 -0
- package/dist/cache/index.d.mts.map +1 -0
- package/dist/cache/index.mjs +92 -0
- package/dist/cache/index.mjs.map +1 -0
- package/dist/caching-Bl28lYsR.mjs +94 -0
- package/dist/caching-Bl28lYsR.mjs.map +1 -0
- package/dist/chunk-C7Uep-_p.mjs +20 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs +1097 -0
- package/dist/circuitBreaker-DeY4FCjs.mjs.map +1 -0
- package/dist/cli/commands/describe.d.mts +19 -0
- package/dist/cli/commands/describe.d.mts.map +1 -0
- package/dist/cli/commands/describe.mjs +239 -0
- package/dist/cli/commands/describe.mjs.map +1 -0
- package/dist/cli/commands/docs.d.mts +14 -0
- package/dist/cli/commands/docs.d.mts.map +1 -0
- package/dist/cli/commands/docs.mjs +53 -0
- package/dist/cli/commands/docs.mjs.map +1 -0
- package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -1
- package/dist/cli/commands/generate.d.mts.map +1 -0
- package/dist/cli/commands/generate.mjs +358 -0
- package/dist/cli/commands/generate.mjs.map +1 -0
- package/dist/cli/commands/{init.d.ts → init.d.mts} +12 -8
- package/dist/cli/commands/init.d.mts.map +1 -0
- package/dist/cli/commands/{init.js → init.mjs} +807 -616
- package/dist/cli/commands/init.mjs.map +1 -0
- package/dist/cli/commands/introspect.d.mts +11 -0
- package/dist/cli/commands/introspect.d.mts.map +1 -0
- package/dist/cli/commands/introspect.mjs +76 -0
- package/dist/cli/commands/introspect.mjs.map +1 -0
- package/dist/cli/index.d.mts +17 -0
- package/dist/cli/index.d.mts.map +1 -0
- package/dist/cli/index.mjs +157 -0
- package/dist/cli/index.mjs.map +1 -0
- package/dist/constants-DdXFXQtN.mjs +85 -0
- package/dist/constants-DdXFXQtN.mjs.map +1 -0
- package/dist/core/index.d.mts +5 -0
- package/dist/core/index.mjs +4 -0
- package/dist/createApp-CUgNqegw.mjs +560 -0
- package/dist/createApp-CUgNqegw.mjs.map +1 -0
- package/dist/defineResource-k0_BDn8v.mjs +2197 -0
- package/dist/defineResource-k0_BDn8v.mjs.map +1 -0
- package/dist/discovery/index.d.mts +47 -0
- package/dist/discovery/index.d.mts.map +1 -0
- package/dist/discovery/index.mjs +110 -0
- package/dist/discovery/index.mjs.map +1 -0
- package/dist/docs/index.d.mts +163 -0
- package/dist/docs/index.d.mts.map +1 -0
- package/dist/docs/index.mjs +73 -0
- package/dist/docs/index.mjs.map +1 -0
- package/dist/elevation-BRy3yFWT.mjs +113 -0
- package/dist/elevation-BRy3yFWT.mjs.map +1 -0
- package/dist/elevation-B_2dRLVP.d.mts +88 -0
- package/dist/elevation-B_2dRLVP.d.mts.map +1 -0
- package/dist/errorHandler-BbcgBmIH.d.mts +73 -0
- package/dist/errorHandler-BbcgBmIH.d.mts.map +1 -0
- package/dist/errorHandler-C1okiriz.mjs +109 -0
- package/dist/errorHandler-C1okiriz.mjs.map +1 -0
- package/dist/errors-B9bZok84.mjs +212 -0
- package/dist/errors-B9bZok84.mjs.map +1 -0
- package/dist/errors-ChKiFz62.d.mts +125 -0
- package/dist/errors-ChKiFz62.d.mts.map +1 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts +125 -0
- package/dist/eventPlugin-CTrLH3mt.d.mts.map +1 -0
- package/dist/eventPlugin-DGR_B2on.mjs +230 -0
- package/dist/eventPlugin-DGR_B2on.mjs.map +1 -0
- package/dist/events/index.d.mts +54 -0
- package/dist/events/index.d.mts.map +1 -0
- package/dist/events/index.mjs +52 -0
- package/dist/events/index.mjs.map +1 -0
- package/dist/events/transports/redis-stream-entry.d.mts +2 -0
- package/dist/events/transports/redis-stream-entry.mjs +178 -0
- package/dist/events/transports/redis-stream-entry.mjs.map +1 -0
- package/dist/events/transports/redis.d.mts +77 -0
- package/dist/events/transports/redis.d.mts.map +1 -0
- package/dist/events/transports/redis.mjs +125 -0
- package/dist/events/transports/redis.mjs.map +1 -0
- package/dist/externalPaths-DlINfKbP.d.mts +51 -0
- package/dist/externalPaths-DlINfKbP.d.mts.map +1 -0
- package/dist/factory/index.d.mts +64 -0
- package/dist/factory/index.d.mts.map +1 -0
- package/dist/factory/index.mjs +3 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts +217 -0
- package/dist/fastifyAdapter-BkrGrlFi.d.mts.map +1 -0
- package/dist/fields-DyaDVX4J.d.mts +110 -0
- package/dist/fields-DyaDVX4J.d.mts.map +1 -0
- package/dist/fields-iagOozy0.mjs +115 -0
- package/dist/fields-iagOozy0.mjs.map +1 -0
- package/dist/hooks/index.d.mts +4 -0
- package/dist/hooks/index.mjs +3 -0
- package/dist/idempotency/index.d.mts +97 -0
- package/dist/idempotency/index.d.mts.map +1 -0
- package/dist/idempotency/index.mjs +320 -0
- package/dist/idempotency/index.mjs.map +1 -0
- package/dist/idempotency/mongodb.d.mts +2 -0
- package/dist/idempotency/mongodb.mjs +115 -0
- package/dist/idempotency/mongodb.mjs.map +1 -0
- package/dist/idempotency/redis.d.mts +2 -0
- package/dist/idempotency/redis.mjs +104 -0
- package/dist/idempotency/redis.mjs.map +1 -0
- package/dist/index.d.mts +261 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +105 -0
- package/dist/index.mjs.map +1 -0
- package/dist/integrations/event-gateway.d.mts +47 -0
- package/dist/integrations/event-gateway.d.mts.map +1 -0
- package/dist/integrations/event-gateway.mjs +44 -0
- package/dist/integrations/event-gateway.mjs.map +1 -0
- package/dist/integrations/index.d.mts +5 -0
- package/dist/integrations/index.mjs +1 -0
- package/dist/integrations/jobs.d.mts +104 -0
- package/dist/integrations/jobs.d.mts.map +1 -0
- package/dist/integrations/jobs.mjs +124 -0
- package/dist/integrations/jobs.mjs.map +1 -0
- package/dist/integrations/streamline.d.mts +61 -0
- package/dist/integrations/streamline.d.mts.map +1 -0
- package/dist/integrations/streamline.mjs +126 -0
- package/dist/integrations/streamline.mjs.map +1 -0
- package/dist/integrations/websocket.d.mts +83 -0
- package/dist/integrations/websocket.d.mts.map +1 -0
- package/dist/integrations/websocket.mjs +289 -0
- package/dist/integrations/websocket.mjs.map +1 -0
- package/dist/interface-B01JvPVc.d.mts +78 -0
- package/dist/interface-B01JvPVc.d.mts.map +1 -0
- package/dist/interface-CZe8IkMf.d.mts +55 -0
- package/dist/interface-CZe8IkMf.d.mts.map +1 -0
- package/dist/interface-Ch8HU9uM.d.mts +1098 -0
- package/dist/interface-Ch8HU9uM.d.mts.map +1 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs +54 -0
- package/dist/introspectionPlugin-rFdO8ZUa.mjs.map +1 -0
- package/dist/keys-BqNejWup.mjs +43 -0
- package/dist/keys-BqNejWup.mjs.map +1 -0
- package/dist/logger-Df2O2WsW.mjs +79 -0
- package/dist/logger-Df2O2WsW.mjs.map +1 -0
- package/dist/memory-cQgelFOj.mjs +144 -0
- package/dist/memory-cQgelFOj.mjs.map +1 -0
- package/dist/migrations/index.d.mts +157 -0
- package/dist/migrations/index.d.mts.map +1 -0
- package/dist/migrations/index.mjs +261 -0
- package/dist/migrations/index.mjs.map +1 -0
- package/dist/mongodb-BfJVlUJH.mjs +94 -0
- package/dist/mongodb-BfJVlUJH.mjs.map +1 -0
- package/dist/mongodb-CGzRbfAK.d.mts +119 -0
- package/dist/mongodb-CGzRbfAK.d.mts.map +1 -0
- package/dist/mongodb-JN-9JA7K.d.mts +72 -0
- package/dist/mongodb-JN-9JA7K.d.mts.map +1 -0
- package/dist/openapi-G3Cw7XuM.mjs +524 -0
- package/dist/openapi-G3Cw7XuM.mjs.map +1 -0
- package/dist/org/index.d.mts +69 -0
- package/dist/org/index.d.mts.map +1 -0
- package/dist/org/index.mjs +514 -0
- package/dist/org/index.mjs.map +1 -0
- package/dist/org/types.d.mts +83 -0
- package/dist/org/types.d.mts.map +1 -0
- package/dist/org/types.mjs +1 -0
- package/dist/permissions/index.d.mts +279 -0
- package/dist/permissions/index.d.mts.map +1 -0
- package/dist/permissions/index.mjs +579 -0
- package/dist/permissions/index.mjs.map +1 -0
- package/dist/plugins/index.d.mts +173 -0
- package/dist/plugins/index.d.mts.map +1 -0
- package/dist/plugins/index.mjs +523 -0
- package/dist/plugins/index.mjs.map +1 -0
- package/dist/plugins/response-cache.d.mts +88 -0
- package/dist/plugins/response-cache.d.mts.map +1 -0
- package/dist/plugins/response-cache.mjs +284 -0
- package/dist/plugins/response-cache.mjs.map +1 -0
- package/dist/plugins/tracing-entry.d.mts +2 -0
- package/dist/plugins/tracing-entry.mjs +186 -0
- package/dist/plugins/tracing-entry.mjs.map +1 -0
- package/dist/pluralize-CEweyOEm.mjs +87 -0
- package/dist/pluralize-CEweyOEm.mjs.map +1 -0
- package/dist/policies/{index.d.ts → index.d.mts} +204 -169
- package/dist/policies/index.d.mts.map +1 -0
- package/dist/policies/index.mjs +322 -0
- package/dist/policies/index.mjs.map +1 -0
- package/dist/presets/{index.d.ts → index.d.mts} +63 -131
- package/dist/presets/index.d.mts.map +1 -0
- package/dist/presets/index.mjs +144 -0
- package/dist/presets/index.mjs.map +1 -0
- package/dist/presets/multiTenant.d.mts +25 -0
- package/dist/presets/multiTenant.d.mts.map +1 -0
- package/dist/presets/multiTenant.mjs +114 -0
- package/dist/presets/multiTenant.mjs.map +1 -0
- package/dist/presets-BITljm96.mjs +120 -0
- package/dist/presets-BITljm96.mjs.map +1 -0
- package/dist/presets-DzSMwlKj.d.mts +58 -0
- package/dist/presets-DzSMwlKj.d.mts.map +1 -0
- package/dist/prisma-DJbMt3yf.mjs +628 -0
- package/dist/prisma-DJbMt3yf.mjs.map +1 -0
- package/dist/prisma-Dg9GoVdj.d.mts +275 -0
- package/dist/prisma-Dg9GoVdj.d.mts.map +1 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts +72 -0
- package/dist/queryCachePlugin-7THaI5mt.d.mts.map +1 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs +139 -0
- package/dist/queryCachePlugin-DMBnp2Q0.mjs.map +1 -0
- package/dist/redis-D-JAeLtm.d.mts +50 -0
- package/dist/redis-D-JAeLtm.d.mts.map +1 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts +104 -0
- package/dist/redis-stream-Bdh_vUU8.d.mts.map +1 -0
- package/dist/registry/index.d.mts +12 -0
- package/dist/registry/index.d.mts.map +1 -0
- package/dist/registry/index.mjs +4 -0
- package/dist/requestContext-QQD6ROJc.mjs +56 -0
- package/dist/requestContext-QQD6ROJc.mjs.map +1 -0
- package/dist/schemaConverter-BwrmWroW.mjs +99 -0
- package/dist/schemaConverter-BwrmWroW.mjs.map +1 -0
- package/dist/schemas/index.d.mts +64 -0
- package/dist/schemas/index.d.mts.map +1 -0
- package/dist/schemas/index.mjs +83 -0
- package/dist/schemas/index.mjs.map +1 -0
- package/dist/scope/index.d.mts +22 -0
- package/dist/scope/index.d.mts.map +1 -0
- package/dist/scope/index.mjs +66 -0
- package/dist/scope/index.mjs.map +1 -0
- package/dist/sessionManager-jPKLbHE0.d.mts +187 -0
- package/dist/sessionManager-jPKLbHE0.d.mts.map +1 -0
- package/dist/sse-B3c3_yZp.mjs +124 -0
- package/dist/sse-B3c3_yZp.mjs.map +1 -0
- package/dist/testing/index.d.mts +908 -0
- package/dist/testing/index.d.mts.map +1 -0
- package/dist/testing/index.mjs +1977 -0
- package/dist/testing/index.mjs.map +1 -0
- package/dist/tracing-Cc7vVQPp.d.mts +71 -0
- package/dist/tracing-Cc7vVQPp.d.mts.map +1 -0
- package/dist/typeGuards-DhMNLuvU.mjs +10 -0
- package/dist/typeGuards-DhMNLuvU.mjs.map +1 -0
- package/dist/types/index.d.mts +947 -0
- package/dist/types/index.d.mts.map +1 -0
- package/dist/types/index.mjs +15 -0
- package/dist/types/index.mjs.map +1 -0
- package/dist/types-Beqn1Un7.mjs +39 -0
- package/dist/types-Beqn1Un7.mjs.map +1 -0
- package/dist/types-CIgB7UUl.d.mts +446 -0
- package/dist/types-CIgB7UUl.d.mts.map +1 -0
- package/dist/types-aYB4V7uN.d.mts +87 -0
- package/dist/types-aYB4V7uN.d.mts.map +1 -0
- package/dist/utils/index.d.mts +748 -0
- package/dist/utils/index.d.mts.map +1 -0
- package/dist/utils/index.mjs +6 -0
- package/package.json +194 -68
- package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
- package/dist/adapters/index.d.ts +0 -237
- package/dist/adapters/index.js +0 -668
- package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
- package/dist/audit/index.d.ts +0 -195
- package/dist/audit/index.js +0 -319
- package/dist/auth/index.d.ts +0 -47
- package/dist/auth/index.js +0 -174
- package/dist/cli/commands/docs.d.ts +0 -11
- package/dist/cli/commands/docs.js +0 -474
- package/dist/cli/commands/generate.js +0 -334
- package/dist/cli/commands/introspect.d.ts +0 -8
- package/dist/cli/commands/introspect.js +0 -338
- package/dist/cli/index.d.ts +0 -4
- package/dist/cli/index.js +0 -3269
- package/dist/core/index.d.ts +0 -220
- package/dist/core/index.js +0 -2786
- package/dist/createApp-Ce9wl8W9.d.ts +0 -77
- package/dist/docs/index.d.ts +0 -166
- package/dist/docs/index.js +0 -658
- package/dist/errors-8WIxGS_6.d.ts +0 -122
- package/dist/events/index.d.ts +0 -117
- package/dist/events/index.js +0 -89
- package/dist/factory/index.d.ts +0 -38
- package/dist/factory/index.js +0 -1652
- package/dist/hooks/index.d.ts +0 -4
- package/dist/hooks/index.js +0 -199
- package/dist/idempotency/index.d.ts +0 -323
- package/dist/idempotency/index.js +0 -500
- package/dist/index-B4t03KQ0.d.ts +0 -1366
- package/dist/index.d.ts +0 -135
- package/dist/index.js +0 -4756
- package/dist/migrations/index.d.ts +0 -185
- package/dist/migrations/index.js +0 -274
- package/dist/org/index.d.ts +0 -129
- package/dist/org/index.js +0 -220
- package/dist/permissions/index.d.ts +0 -144
- package/dist/permissions/index.js +0 -103
- package/dist/plugins/index.d.ts +0 -46
- package/dist/plugins/index.js +0 -1069
- package/dist/policies/index.js +0 -196
- package/dist/presets/index.js +0 -384
- package/dist/presets/multiTenant.d.ts +0 -39
- package/dist/presets/multiTenant.js +0 -112
- package/dist/registry/index.d.ts +0 -16
- package/dist/registry/index.js +0 -253
- package/dist/testing/index.d.ts +0 -618
- package/dist/testing/index.js +0 -48020
- package/dist/types/index.d.ts +0 -4
- package/dist/types/index.js +0 -8
- package/dist/types-B99TBmFV.d.ts +0 -76
- package/dist/types-BvckRbs2.d.ts +0 -143
- package/dist/utils/index.d.ts +0 -679
- package/dist/utils/index.js +0 -931
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
import { t as AUTHENTICATED_SCOPE } from "../types-Beqn1Un7.mjs";
|
|
2
|
+
import { t as ArcError } from "../errors-B9bZok84.mjs";
|
|
3
|
+
import { requireOrgMembership, requireOrgRole, requireTeamMembership } from "../permissions/index.mjs";
|
|
4
|
+
import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-BrHKeSAx.mjs";
|
|
5
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
|
+
import fp from "fastify-plugin";
|
|
7
|
+
|
|
8
|
+
//#region src/auth/authPlugin.ts
|
|
9
|
+
/**
|
|
10
|
+
* Auth Plugin - Flexible, Database-Agnostic Authentication
|
|
11
|
+
*
|
|
12
|
+
* Arc provides JWT infrastructure and calls your authenticator.
|
|
13
|
+
* You control ALL authentication logic.
|
|
14
|
+
*
|
|
15
|
+
* Design principles:
|
|
16
|
+
* - Arc handles plumbing (JWT sign/verify utilities)
|
|
17
|
+
* - App handles business logic (how to authenticate, where users live)
|
|
18
|
+
* - Works with any database (Prisma, MongoDB, Postgres, none)
|
|
19
|
+
* - Supports multiple auth strategies (JWT, API keys, sessions, etc.)
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* // In createApp
|
|
24
|
+
* auth: {
|
|
25
|
+
* jwt: { secret: process.env.JWT_SECRET },
|
|
26
|
+
* authenticate: async (request, { jwt }) => {
|
|
27
|
+
* // Your auth logic - Arc never touches your database
|
|
28
|
+
* const token = request.headers.authorization?.split(' ')[1];
|
|
29
|
+
* if (!token) return null;
|
|
30
|
+
* const decoded = jwt.verify(token);
|
|
31
|
+
* return userRepo.findById(decoded.id);
|
|
32
|
+
* },
|
|
33
|
+
* }
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Parse expiration string to seconds
|
|
38
|
+
*/
|
|
39
|
+
function parseExpiresIn(input, defaultValue) {
|
|
40
|
+
if (!input) return defaultValue;
|
|
41
|
+
if (/^\d+$/.test(input)) return parseInt(input, 10);
|
|
42
|
+
const match = /^(\d+)\s*([smhd])$/i.exec(input);
|
|
43
|
+
if (!match) return defaultValue;
|
|
44
|
+
return parseInt(match[1], 10) * ({
|
|
45
|
+
s: 1,
|
|
46
|
+
m: 60,
|
|
47
|
+
h: 3600,
|
|
48
|
+
d: 86400
|
|
49
|
+
}[match[2].toLowerCase()] ?? 1);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Extract Bearer token from Authorization header
|
|
53
|
+
*/
|
|
54
|
+
function extractBearerToken(request) {
|
|
55
|
+
const auth = request.headers.authorization;
|
|
56
|
+
if (!auth?.startsWith("Bearer ")) return null;
|
|
57
|
+
return auth.slice(7);
|
|
58
|
+
}
|
|
59
|
+
const authPlugin = async (fastify, opts = {}) => {
|
|
60
|
+
const { jwt: jwtConfig, authenticate: appAuthenticator, onFailure, userProperty = "user", exposeAuthErrors = false } = opts;
|
|
61
|
+
let jwtContext = null;
|
|
62
|
+
if (jwtConfig?.secret) {
|
|
63
|
+
if (jwtConfig.secret.length < 32) throw new Error(`JWT secret must be at least 32 characters (current: ${jwtConfig.secret.length}).\nUse a strong random secret for production.`);
|
|
64
|
+
const jwtPlugin = await import("@fastify/jwt");
|
|
65
|
+
await fastify.register(jwtPlugin.default ?? jwtPlugin, {
|
|
66
|
+
secret: jwtConfig.secret,
|
|
67
|
+
sign: {
|
|
68
|
+
expiresIn: jwtConfig.expiresIn ?? "15m",
|
|
69
|
+
...jwtConfig.sign ?? {}
|
|
70
|
+
},
|
|
71
|
+
verify: { ...jwtConfig.verify ?? {} }
|
|
72
|
+
});
|
|
73
|
+
const fastifyWithJwt = fastify;
|
|
74
|
+
jwtContext = {
|
|
75
|
+
verify: (token) => {
|
|
76
|
+
return fastifyWithJwt.jwt.verify(token);
|
|
77
|
+
},
|
|
78
|
+
sign: (payload, options) => {
|
|
79
|
+
return fastifyWithJwt.jwt.sign(payload, options);
|
|
80
|
+
},
|
|
81
|
+
decode: (token) => {
|
|
82
|
+
try {
|
|
83
|
+
return fastifyWithJwt.jwt.decode(token);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
fastify.log.debug("Auth: JWT infrastructure enabled");
|
|
90
|
+
}
|
|
91
|
+
const authContext = {
|
|
92
|
+
jwt: jwtContext,
|
|
93
|
+
fastify
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Authenticate middleware
|
|
97
|
+
*
|
|
98
|
+
* Arc adds this to preHandler for non-public routes.
|
|
99
|
+
* Calls app's authenticator or falls back to default JWT verify.
|
|
100
|
+
*/
|
|
101
|
+
const authenticate = async (request, reply) => {
|
|
102
|
+
try {
|
|
103
|
+
let user = null;
|
|
104
|
+
if (appAuthenticator) user = await appAuthenticator(request, authContext);
|
|
105
|
+
else if (jwtContext) {
|
|
106
|
+
const token = extractBearerToken(request);
|
|
107
|
+
if (token) {
|
|
108
|
+
const decoded = jwtContext.verify(token);
|
|
109
|
+
if (decoded.type === "refresh") throw new Error("Refresh tokens cannot be used for authentication");
|
|
110
|
+
user = decoded;
|
|
111
|
+
}
|
|
112
|
+
} else throw new Error("No authenticator configured. Provide auth.authenticate function or auth.jwt.secret.");
|
|
113
|
+
if (!user) throw new Error("Authentication required");
|
|
114
|
+
const reqRecord = request;
|
|
115
|
+
reqRecord.user = user;
|
|
116
|
+
reqRecord[userProperty] = user;
|
|
117
|
+
if (!request.scope || request.scope.kind === "public") {
|
|
118
|
+
const userRecord = user;
|
|
119
|
+
if (userRecord.organizationId) request.scope = {
|
|
120
|
+
kind: "member",
|
|
121
|
+
organizationId: String(userRecord.organizationId),
|
|
122
|
+
orgRoles: Array.isArray(userRecord.orgRoles) ? userRecord.orgRoles : []
|
|
123
|
+
};
|
|
124
|
+
else request.scope = AUTHENTICATED_SCOPE;
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
128
|
+
if (onFailure) {
|
|
129
|
+
await onFailure(request, reply, error);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const message = exposeAuthErrors ? error.message : "Authentication required";
|
|
133
|
+
reply.code(401).send({
|
|
134
|
+
success: false,
|
|
135
|
+
error: "Unauthorized",
|
|
136
|
+
message
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Optional authenticate middleware
|
|
142
|
+
*
|
|
143
|
+
* Parses JWT if a Bearer token is present and populates request.user.
|
|
144
|
+
* Does NOT fail if no token or invalid token — treats as unauthenticated.
|
|
145
|
+
*
|
|
146
|
+
* Used on allowPublic() routes so that downstream middleware (e.g. multiTenant
|
|
147
|
+
* flexible filter) can apply org-scoped queries when a user IS authenticated.
|
|
148
|
+
*/
|
|
149
|
+
const optionalAuthenticate = async (request, _reply) => {
|
|
150
|
+
try {
|
|
151
|
+
let user = null;
|
|
152
|
+
if (appAuthenticator) user = await appAuthenticator(request, authContext);
|
|
153
|
+
else if (jwtContext) {
|
|
154
|
+
const token = extractBearerToken(request);
|
|
155
|
+
if (token) {
|
|
156
|
+
const decoded = jwtContext.verify(token);
|
|
157
|
+
if (decoded.type === "refresh") return;
|
|
158
|
+
user = decoded;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (user) {
|
|
162
|
+
const reqRecord = request;
|
|
163
|
+
reqRecord.user = user;
|
|
164
|
+
reqRecord[userProperty] = user;
|
|
165
|
+
if (!request.scope || request.scope.kind === "public") {
|
|
166
|
+
const userRecord = user;
|
|
167
|
+
if (userRecord.organizationId) request.scope = {
|
|
168
|
+
kind: "member",
|
|
169
|
+
organizationId: String(userRecord.organizationId),
|
|
170
|
+
orgRoles: Array.isArray(userRecord.orgRoles) ? userRecord.orgRoles : []
|
|
171
|
+
};
|
|
172
|
+
else request.scope = AUTHENTICATED_SCOPE;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch {}
|
|
176
|
+
};
|
|
177
|
+
const refreshSecret = jwtConfig?.refreshSecret ?? jwtConfig?.secret;
|
|
178
|
+
const accessExpiresIn = jwtConfig?.expiresIn ?? "15m";
|
|
179
|
+
const refreshExpiresIn = jwtConfig?.refreshExpiresIn ?? "7d";
|
|
180
|
+
/**
|
|
181
|
+
* Issue access + refresh tokens
|
|
182
|
+
* App calls this after validating credentials (login, OAuth, etc.)
|
|
183
|
+
*/
|
|
184
|
+
const issueTokens = (payload, options) => {
|
|
185
|
+
if (!jwtContext) throw new Error("JWT not configured. Provide auth.jwt.secret to use issueTokens.");
|
|
186
|
+
const accessTtl = options?.expiresIn ?? accessExpiresIn;
|
|
187
|
+
const refreshTtl = options?.refreshExpiresIn ?? refreshExpiresIn;
|
|
188
|
+
const accessToken = jwtContext.sign({
|
|
189
|
+
...payload,
|
|
190
|
+
type: "access"
|
|
191
|
+
}, { expiresIn: accessTtl });
|
|
192
|
+
const refreshPayload = payload.id ? {
|
|
193
|
+
id: payload.id,
|
|
194
|
+
type: "refresh"
|
|
195
|
+
} : payload._id ? {
|
|
196
|
+
id: payload._id,
|
|
197
|
+
type: "refresh"
|
|
198
|
+
} : {
|
|
199
|
+
...payload,
|
|
200
|
+
type: "refresh"
|
|
201
|
+
};
|
|
202
|
+
let refreshToken;
|
|
203
|
+
if (refreshSecret) refreshToken = fastify.jwt.sign(refreshPayload, {
|
|
204
|
+
expiresIn: refreshTtl,
|
|
205
|
+
...refreshSecret !== jwtConfig?.secret ? { key: refreshSecret } : {}
|
|
206
|
+
});
|
|
207
|
+
return {
|
|
208
|
+
accessToken,
|
|
209
|
+
refreshToken,
|
|
210
|
+
expiresIn: parseExpiresIn(accessTtl, 900),
|
|
211
|
+
refreshExpiresIn: refreshToken ? parseExpiresIn(refreshTtl, 604800) : void 0,
|
|
212
|
+
tokenType: "Bearer"
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Verify refresh token
|
|
217
|
+
* App calls this in refresh endpoint
|
|
218
|
+
*/
|
|
219
|
+
const verifyRefreshToken = (token) => {
|
|
220
|
+
if (!jwtContext) throw new Error("JWT not configured. Provide auth.jwt.secret to use verifyRefreshToken.");
|
|
221
|
+
const decoded = fastify.jwt.verify(token, { ...refreshSecret !== jwtConfig?.secret ? { key: refreshSecret } : {} });
|
|
222
|
+
if (decoded.type !== "refresh") throw new Error("Invalid token type: expected refresh token");
|
|
223
|
+
return decoded;
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* Authorize middleware factory
|
|
227
|
+
* Creates a middleware that checks if user has required roles
|
|
228
|
+
*
|
|
229
|
+
* @example
|
|
230
|
+
* preHandler: [fastify.authenticate, fastify.authorize('admin', 'superadmin')]
|
|
231
|
+
*/
|
|
232
|
+
const authorize = (...allowedRoles) => {
|
|
233
|
+
return async (request, reply) => {
|
|
234
|
+
const reqRecord = request;
|
|
235
|
+
const user = reqRecord[userProperty] ?? reqRecord.user;
|
|
236
|
+
if (!user) {
|
|
237
|
+
reply.code(401).send({
|
|
238
|
+
success: false,
|
|
239
|
+
error: "Unauthorized",
|
|
240
|
+
message: "No user context"
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const userRoles = user.roles ?? [];
|
|
245
|
+
if (allowedRoles.length === 1 && allowedRoles[0] === "*") return;
|
|
246
|
+
if (!allowedRoles.some((role) => userRoles.includes(role))) {
|
|
247
|
+
reply.code(403).send({
|
|
248
|
+
success: false,
|
|
249
|
+
error: "Forbidden",
|
|
250
|
+
message: `Requires one of: ${allowedRoles.join(", ")}`
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
const authHelpers = {
|
|
257
|
+
jwt: jwtContext,
|
|
258
|
+
issueTokens,
|
|
259
|
+
verifyRefreshToken
|
|
260
|
+
};
|
|
261
|
+
fastify.decorate("authenticate", authenticate);
|
|
262
|
+
fastify.decorate("optionalAuthenticate", optionalAuthenticate);
|
|
263
|
+
fastify.decorate("authorize", authorize);
|
|
264
|
+
fastify.decorate("auth", authHelpers);
|
|
265
|
+
fastify.log.debug(`Auth: Plugin registered (jwt=${!!jwtContext}, customAuth=${!!appAuthenticator})`);
|
|
266
|
+
};
|
|
267
|
+
var authPlugin_default = fp(authPlugin, {
|
|
268
|
+
name: "arc-auth",
|
|
269
|
+
fastify: "5.x"
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/auth/betterAuth.ts
|
|
274
|
+
/**
|
|
275
|
+
* Better Auth Adapter for Arc/Fastify
|
|
276
|
+
*
|
|
277
|
+
* Bridges Fastify <-> Better Auth's Fetch API (Request/Response).
|
|
278
|
+
* Better Auth is the USER's dependency -- Arc only provides this thin adapter.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* import { betterAuth } from 'better-auth';
|
|
282
|
+
* import { createBetterAuthAdapter } from '@classytic/arc/auth';
|
|
283
|
+
*
|
|
284
|
+
* const auth = betterAuth({ ... });
|
|
285
|
+
*
|
|
286
|
+
* const app = await createApp({
|
|
287
|
+
* auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth }) },
|
|
288
|
+
* });
|
|
289
|
+
*/
|
|
290
|
+
/**
|
|
291
|
+
* Convert a Fastify request into a Fetch API Request.
|
|
292
|
+
*
|
|
293
|
+
* Better Auth expects standard Web API Request objects.
|
|
294
|
+
* We reconstruct one from Fastify's request properties.
|
|
295
|
+
*/
|
|
296
|
+
function toFetchRequest(request) {
|
|
297
|
+
const url = `${request.protocol ?? "http"}://${request.hostname ?? "localhost"}${request.url}`;
|
|
298
|
+
const headers = new Headers();
|
|
299
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
300
|
+
if (value === void 0) continue;
|
|
301
|
+
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
|
302
|
+
else headers.set(key, value);
|
|
303
|
+
}
|
|
304
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
305
|
+
let body;
|
|
306
|
+
if (hasBody && request.body != null) {
|
|
307
|
+
const contentType = (request.headers["content-type"] ?? "").toLowerCase();
|
|
308
|
+
if (request.rawBody) body = request.rawBody;
|
|
309
|
+
else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
310
|
+
const params = new URLSearchParams();
|
|
311
|
+
for (const [k, v] of Object.entries(request.body)) if (v != null) params.set(k, String(v));
|
|
312
|
+
body = params.toString();
|
|
313
|
+
} else if (typeof request.body === "string") body = request.body;
|
|
314
|
+
else if (contentType.includes("application/json") || contentType.includes("text/") || !contentType) body = JSON.stringify(request.body);
|
|
315
|
+
else request.log?.warn?.("toFetchRequest: cannot reconstruct %s body without rawBody plugin", contentType);
|
|
316
|
+
}
|
|
317
|
+
return new Request(url, {
|
|
318
|
+
method: request.method,
|
|
319
|
+
headers,
|
|
320
|
+
body
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Pipe a Fetch API Response back into Fastify's reply.
|
|
325
|
+
*
|
|
326
|
+
* Transfers status code, all response headers, and the body.
|
|
327
|
+
* Handles both buffered (JSON) and streaming (SSE) responses.
|
|
328
|
+
*/
|
|
329
|
+
async function sendFetchResponse(response, reply) {
|
|
330
|
+
reply.status(response.status);
|
|
331
|
+
response.headers.forEach((value, key) => {
|
|
332
|
+
if (key.toLowerCase() === "transfer-encoding") return;
|
|
333
|
+
reply.header(key, value);
|
|
334
|
+
});
|
|
335
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
336
|
+
if (response.body && (contentType.includes("text/event-stream") || contentType.includes("application/octet-stream"))) await reply.send(response.body);
|
|
337
|
+
else {
|
|
338
|
+
const body = await response.text();
|
|
339
|
+
await reply.send(body);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Try to get session via Better Auth's direct JS API.
|
|
344
|
+
* Returns null if the API method is not available (older Better Auth versions).
|
|
345
|
+
*/
|
|
346
|
+
async function tryDirectGetSession(auth, headers) {
|
|
347
|
+
const api = auth.api;
|
|
348
|
+
if (!api || typeof api.getSession !== "function") return null;
|
|
349
|
+
try {
|
|
350
|
+
const result = await api.getSession({ headers });
|
|
351
|
+
if (result?.user) return result;
|
|
352
|
+
return null;
|
|
353
|
+
} catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Try to get active org member via direct JS API.
|
|
359
|
+
* Returns roles array or null if not available.
|
|
360
|
+
*/
|
|
361
|
+
async function tryDirectGetActiveMember(auth, headers) {
|
|
362
|
+
const getActiveMember = auth.api?.organization?.getActiveMember;
|
|
363
|
+
if (typeof getActiveMember !== "function") return null;
|
|
364
|
+
try {
|
|
365
|
+
const memberData = await getActiveMember({ headers });
|
|
366
|
+
if (memberData) return extractRolesFromMembership(memberData);
|
|
367
|
+
return null;
|
|
368
|
+
} catch {
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Look up member role by explicit organizationId (query param).
|
|
374
|
+
*
|
|
375
|
+
* Better Auth's `getActiveMemberRole` endpoint accepts an `organizationId`
|
|
376
|
+
* query parameter, bypassing the session's `activeOrganizationId`.
|
|
377
|
+
* This is essential for API key auth where the synthetic session has no
|
|
378
|
+
* active organization set — callers pass org context via `x-organization-id` header.
|
|
379
|
+
*/
|
|
380
|
+
async function tryDirectGetMemberRole(auth, headers, organizationId) {
|
|
381
|
+
const getActiveMemberRole = auth.api?.organization?.getActiveMemberRole;
|
|
382
|
+
if (typeof getActiveMemberRole !== "function") return null;
|
|
383
|
+
try {
|
|
384
|
+
const result = await getActiveMemberRole({
|
|
385
|
+
headers,
|
|
386
|
+
query: { organizationId }
|
|
387
|
+
});
|
|
388
|
+
if (result?.role) return parseRoles(result.role);
|
|
389
|
+
return null;
|
|
390
|
+
} catch {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Try to list teams via direct JS API.
|
|
396
|
+
*/
|
|
397
|
+
async function tryDirectListTeams(auth, headers) {
|
|
398
|
+
const listTeams = auth.api?.organization?.listTeams;
|
|
399
|
+
if (typeof listTeams !== "function") return null;
|
|
400
|
+
try {
|
|
401
|
+
const result = await listTeams({ headers });
|
|
402
|
+
const teams = Array.isArray(result) ? result : result?.teams;
|
|
403
|
+
return Array.isArray(teams) ? teams : null;
|
|
404
|
+
} catch {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Build a Headers object from Fastify request headers */
|
|
409
|
+
function buildHeaders(request) {
|
|
410
|
+
const headers = new Headers();
|
|
411
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
412
|
+
if (value === void 0) continue;
|
|
413
|
+
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
|
414
|
+
else headers.set(key, value);
|
|
415
|
+
}
|
|
416
|
+
return headers;
|
|
417
|
+
}
|
|
418
|
+
/** Normalize unknown ID-like values to comparable string form */
|
|
419
|
+
function normalizeId(value) {
|
|
420
|
+
if (value == null) return null;
|
|
421
|
+
if (typeof value === "string") return value;
|
|
422
|
+
if (typeof value === "number" || typeof value === "bigint") return String(value);
|
|
423
|
+
if (typeof value === "object") {
|
|
424
|
+
const obj = value;
|
|
425
|
+
const nested = obj._id ?? obj.id ?? obj.organizationId;
|
|
426
|
+
if (nested != null && nested !== value) return normalizeId(nested);
|
|
427
|
+
}
|
|
428
|
+
return String(value);
|
|
429
|
+
}
|
|
430
|
+
/** Parse role payload from Better Auth (string, csv, array) into normalized roles[] */
|
|
431
|
+
function parseRoles(value) {
|
|
432
|
+
if (Array.isArray(value)) return value.map((r) => String(r).trim()).filter(Boolean);
|
|
433
|
+
if (typeof value === "string") return value.split(",").map((r) => r.trim()).filter(Boolean);
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
/** Extract role field from heterogeneous org membership shapes */
|
|
437
|
+
function extractRolesFromMembership(membership) {
|
|
438
|
+
const direct = parseRoles(membership.role ?? membership.roles ?? membership.orgRole);
|
|
439
|
+
if (direct.length > 0) return direct;
|
|
440
|
+
const nestedMembership = membership.membership;
|
|
441
|
+
if (nestedMembership) {
|
|
442
|
+
const nested = parseRoles(nestedMembership.role ?? nestedMembership.roles);
|
|
443
|
+
if (nested.length > 0) return nested;
|
|
444
|
+
}
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
/** Match an organization membership entry against the active org id */
|
|
448
|
+
function membershipMatchesOrg(membership, activeOrgId) {
|
|
449
|
+
return [
|
|
450
|
+
normalizeId(membership.organizationId),
|
|
451
|
+
normalizeId(membership.orgId),
|
|
452
|
+
normalizeId(membership.id),
|
|
453
|
+
normalizeId(membership.organization?._id),
|
|
454
|
+
normalizeId(membership.organization?.id),
|
|
455
|
+
normalizeId(membership.organization?.organizationId)
|
|
456
|
+
].filter(Boolean).includes(activeOrgId);
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Resolve org roles with fallback chain:
|
|
460
|
+
* 1) GET /organization/get-active-member (requires activeOrganizationId in session)
|
|
461
|
+
* 2) GET /organization/get-active-member-role?organizationId=... (explicit org — works for API key auth)
|
|
462
|
+
* 3) GET /organization/list (fallback for type mismatch/legacy ID storage)
|
|
463
|
+
*/
|
|
464
|
+
async function resolveOrgRoles(auth, protocol, host, normalizedBase, headers, activeOrgId) {
|
|
465
|
+
const memberUrl = `${protocol}://${host}${normalizedBase}/organization/get-active-member`;
|
|
466
|
+
const memberRequest = new Request(memberUrl, {
|
|
467
|
+
method: "GET",
|
|
468
|
+
headers
|
|
469
|
+
});
|
|
470
|
+
const memberResponse = await auth.handler(memberRequest);
|
|
471
|
+
if (memberResponse.ok) {
|
|
472
|
+
const memberData = await memberResponse.json();
|
|
473
|
+
if (memberData) return extractRolesFromMembership(memberData);
|
|
474
|
+
}
|
|
475
|
+
const roleUrl = `${protocol}://${host}${normalizedBase}/organization/get-active-member-role?organizationId=${encodeURIComponent(activeOrgId)}`;
|
|
476
|
+
const roleRequest = new Request(roleUrl, {
|
|
477
|
+
method: "GET",
|
|
478
|
+
headers
|
|
479
|
+
});
|
|
480
|
+
const roleResponse = await auth.handler(roleRequest);
|
|
481
|
+
if (roleResponse.ok) {
|
|
482
|
+
const roleData = await roleResponse.json();
|
|
483
|
+
if (roleData?.role) return parseRoles(roleData.role);
|
|
484
|
+
}
|
|
485
|
+
const listUrl = `${protocol}://${host}${normalizedBase}/organization/list`;
|
|
486
|
+
const listRequest = new Request(listUrl, {
|
|
487
|
+
method: "GET",
|
|
488
|
+
headers
|
|
489
|
+
});
|
|
490
|
+
const listResponse = await auth.handler(listRequest);
|
|
491
|
+
if (!listResponse.ok) return null;
|
|
492
|
+
const listData = await listResponse.json();
|
|
493
|
+
const memberships = Array.isArray(listData) ? listData : listData?.organizations ?? listData?.data ?? [];
|
|
494
|
+
if (!Array.isArray(memberships)) return null;
|
|
495
|
+
const target = memberships.find((entry) => {
|
|
496
|
+
if (!entry || typeof entry !== "object") return false;
|
|
497
|
+
return membershipMatchesOrg(entry, activeOrgId);
|
|
498
|
+
});
|
|
499
|
+
if (!target) return null;
|
|
500
|
+
return extractRolesFromMembership(target);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Create a Better Auth adapter for Arc/Fastify.
|
|
504
|
+
*
|
|
505
|
+
* Returns a Fastify plugin (registers catch-all auth routes) and an
|
|
506
|
+
* `authenticate` preHandler that validates sessions via Better Auth.
|
|
507
|
+
*
|
|
508
|
+
* @example
|
|
509
|
+
* ```typescript
|
|
510
|
+
* import { betterAuth } from 'better-auth';
|
|
511
|
+
* import { createBetterAuthAdapter } from '@classytic/arc/auth';
|
|
512
|
+
*
|
|
513
|
+
* const auth = betterAuth({
|
|
514
|
+
* database: ...,
|
|
515
|
+
* emailAndPassword: { enabled: true },
|
|
516
|
+
* });
|
|
517
|
+
*
|
|
518
|
+
* const { plugin, authenticate } = createBetterAuthAdapter({ auth });
|
|
519
|
+
*
|
|
520
|
+
* // Register the plugin (catch-all auth routes)
|
|
521
|
+
* await fastify.register(plugin);
|
|
522
|
+
*
|
|
523
|
+
* // Use authenticate as a preHandler on protected routes
|
|
524
|
+
* fastify.get('/me', { preHandler: [authenticate] }, handler);
|
|
525
|
+
* ```
|
|
526
|
+
*/
|
|
527
|
+
function createBetterAuthAdapter(options) {
|
|
528
|
+
const { auth, basePath = "/api/auth", orgContext: orgContextOpt = false, openapi: openapiOpt = true, userFields, exposeAuthErrors = false } = options;
|
|
529
|
+
const normalizedBase = basePath.replace(/\/+$/, "");
|
|
530
|
+
const orgEnabled = !!orgContextOpt;
|
|
531
|
+
/**
|
|
532
|
+
* Validates the current session by forwarding cookies/headers
|
|
533
|
+
* to Better Auth's `GET /api/auth/get-session` endpoint.
|
|
534
|
+
*
|
|
535
|
+
* On success, sets `request.user` and `request.session`.
|
|
536
|
+
* When orgContext is enabled, also sets `request.scope` to
|
|
537
|
+
* `{ kind: 'member', organizationId, orgRoles, teamId? }`.
|
|
538
|
+
* On failure, replies with 401.
|
|
539
|
+
*/
|
|
540
|
+
const authenticate = async (request, reply) => {
|
|
541
|
+
try {
|
|
542
|
+
const protocol = request.protocol ?? "http";
|
|
543
|
+
const host = request.hostname ?? "localhost";
|
|
544
|
+
const headers = buildHeaders(request);
|
|
545
|
+
let sessionData = null;
|
|
546
|
+
sessionData = await tryDirectGetSession(auth, headers);
|
|
547
|
+
if (!sessionData) {
|
|
548
|
+
const sessionUrl = `${protocol}://${host}${normalizedBase}/get-session`;
|
|
549
|
+
const sessionRequest = new Request(sessionUrl, {
|
|
550
|
+
method: "GET",
|
|
551
|
+
headers
|
|
552
|
+
});
|
|
553
|
+
const sessionResponse = await auth.handler(sessionRequest);
|
|
554
|
+
if (!sessionResponse.ok) {
|
|
555
|
+
reply.code(401).send({
|
|
556
|
+
success: false,
|
|
557
|
+
error: "Unauthorized",
|
|
558
|
+
message: "Invalid or expired session"
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
sessionData = await sessionResponse.json();
|
|
563
|
+
}
|
|
564
|
+
if (!sessionData?.user) {
|
|
565
|
+
reply.code(401).send({
|
|
566
|
+
success: false,
|
|
567
|
+
error: "Unauthorized",
|
|
568
|
+
message: "No active session"
|
|
569
|
+
});
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const req = request;
|
|
573
|
+
req.user = sessionData.user;
|
|
574
|
+
req.session = sessionData.session;
|
|
575
|
+
req.scope = AUTHENTICATED_SCOPE;
|
|
576
|
+
if (orgEnabled) {
|
|
577
|
+
const session = sessionData.session;
|
|
578
|
+
const activeOrgId = session?.activeOrganizationId || request.headers["x-organization-id"];
|
|
579
|
+
if (activeOrgId) {
|
|
580
|
+
let orgRoles = await tryDirectGetActiveMember(auth, headers);
|
|
581
|
+
if (!orgRoles) orgRoles = await tryDirectGetMemberRole(auth, headers, activeOrgId);
|
|
582
|
+
if (!orgRoles) orgRoles = await resolveOrgRoles(auth, protocol, host, normalizedBase, headers, activeOrgId);
|
|
583
|
+
if (orgRoles) {
|
|
584
|
+
const scope = {
|
|
585
|
+
kind: "member",
|
|
586
|
+
organizationId: activeOrgId,
|
|
587
|
+
orgRoles
|
|
588
|
+
};
|
|
589
|
+
const activeTeamId = session?.activeTeamId;
|
|
590
|
+
if (activeTeamId) {
|
|
591
|
+
let teams = await tryDirectListTeams(auth, headers);
|
|
592
|
+
if (!teams) {
|
|
593
|
+
const teamsUrl = `${protocol}://${host}${normalizedBase}/organization/list-teams`;
|
|
594
|
+
const teamsRequest = new Request(teamsUrl, {
|
|
595
|
+
method: "GET",
|
|
596
|
+
headers
|
|
597
|
+
});
|
|
598
|
+
const teamsResponse = await auth.handler(teamsRequest);
|
|
599
|
+
if (teamsResponse.ok) {
|
|
600
|
+
const teamsData = await teamsResponse.json();
|
|
601
|
+
teams = Array.isArray(teamsData) ? teamsData : teamsData?.teams ?? [];
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (teams && teams.some((t) => t.id === activeTeamId)) scope.teamId = activeTeamId;
|
|
605
|
+
}
|
|
606
|
+
req.scope = scope;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
} catch (err) {
|
|
611
|
+
const message = exposeAuthErrors ? err instanceof Error ? err.message : String(err) : "Authentication required";
|
|
612
|
+
reply.code(401).send({
|
|
613
|
+
success: false,
|
|
614
|
+
error: "Unauthorized",
|
|
615
|
+
message
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
/**
|
|
620
|
+
* Silently resolves session without failing.
|
|
621
|
+
* Populates request.user + request.scope if a valid session exists.
|
|
622
|
+
* On failure or missing session, continues as unauthenticated (scope stays 'public').
|
|
623
|
+
*
|
|
624
|
+
* Used by allowPublic() routes so downstream middleware (e.g. multiTenant
|
|
625
|
+
* flexible filter) can apply org-scoped queries when a user IS authenticated.
|
|
626
|
+
*/
|
|
627
|
+
const optionalAuthenticate = async (request, _reply) => {
|
|
628
|
+
try {
|
|
629
|
+
const headers = buildHeaders(request);
|
|
630
|
+
let sessionData = null;
|
|
631
|
+
sessionData = await tryDirectGetSession(auth, headers);
|
|
632
|
+
if (!sessionData) {
|
|
633
|
+
const sessionUrl = `${request.protocol ?? "http"}://${request.hostname ?? "localhost"}${normalizedBase}/get-session`;
|
|
634
|
+
const sessionRequest = new Request(sessionUrl, {
|
|
635
|
+
method: "GET",
|
|
636
|
+
headers
|
|
637
|
+
});
|
|
638
|
+
const sessionResponse = await auth.handler(sessionRequest);
|
|
639
|
+
if (sessionResponse.ok) sessionData = await sessionResponse.json();
|
|
640
|
+
}
|
|
641
|
+
if (!sessionData?.user) return;
|
|
642
|
+
const req = request;
|
|
643
|
+
req.user = sessionData.user;
|
|
644
|
+
req.session = sessionData.session;
|
|
645
|
+
req.scope = AUTHENTICATED_SCOPE;
|
|
646
|
+
if (orgEnabled) {
|
|
647
|
+
const activeOrgId = sessionData.session?.activeOrganizationId || request.headers["x-organization-id"];
|
|
648
|
+
if (activeOrgId) {
|
|
649
|
+
let orgRoles = await tryDirectGetActiveMember(auth, headers);
|
|
650
|
+
if (!orgRoles) orgRoles = await tryDirectGetMemberRole(auth, headers, activeOrgId);
|
|
651
|
+
if (!orgRoles) orgRoles = await resolveOrgRoles(auth, request.protocol ?? "http", request.hostname ?? "localhost", normalizedBase, headers, activeOrgId);
|
|
652
|
+
if (orgRoles) req.scope = {
|
|
653
|
+
kind: "member",
|
|
654
|
+
organizationId: activeOrgId,
|
|
655
|
+
orgRoles
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
} catch {}
|
|
660
|
+
};
|
|
661
|
+
let extractedOpenApi;
|
|
662
|
+
if (openapiOpt === false) extractedOpenApi = void 0;
|
|
663
|
+
else if (typeof openapiOpt === "object") extractedOpenApi = openapiOpt;
|
|
664
|
+
const betterAuthPlugin = async (fastify) => {
|
|
665
|
+
fastify.all(`${normalizedBase}/*`, async (request, reply) => {
|
|
666
|
+
try {
|
|
667
|
+
const fetchRequest = toFetchRequest(request);
|
|
668
|
+
await sendFetchResponse(await auth.handler(fetchRequest), reply);
|
|
669
|
+
} catch (err) {
|
|
670
|
+
throw new ArcError("Authentication service error", {
|
|
671
|
+
code: "AUTH_SERVICE_ERROR",
|
|
672
|
+
statusCode: 500,
|
|
673
|
+
cause: err instanceof Error ? err : new Error(String(err))
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
if (!fastify.hasDecorator("authenticate")) fastify.decorate("authenticate", authenticate);
|
|
678
|
+
if (!fastify.hasDecorator("optionalAuthenticate")) fastify.decorate("optionalAuthenticate", optionalAuthenticate);
|
|
679
|
+
if (!extractedOpenApi && openapiOpt !== false && auth.api && typeof auth.api === "object") {
|
|
680
|
+
const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-BrHKeSAx.mjs").then((n) => n.t);
|
|
681
|
+
extractedOpenApi = extractBetterAuthOpenApi(auth.api, {
|
|
682
|
+
basePath,
|
|
683
|
+
userFields
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
if (extractedOpenApi) {
|
|
687
|
+
const arc = fastify.arc;
|
|
688
|
+
if (arc?.externalOpenApiPaths) arc.externalOpenApiPaths.push(extractedOpenApi);
|
|
689
|
+
}
|
|
690
|
+
fastify.log.debug(`Better Auth: Routes registered at ${normalizedBase}/*`);
|
|
691
|
+
};
|
|
692
|
+
return {
|
|
693
|
+
plugin: fp(betterAuthPlugin, {
|
|
694
|
+
name: "arc-better-auth",
|
|
695
|
+
fastify: "5.x"
|
|
696
|
+
}),
|
|
697
|
+
authenticate,
|
|
698
|
+
optionalAuthenticate,
|
|
699
|
+
permissions: {
|
|
700
|
+
requireOrgRole: (...roles) => requireOrgRole(roles),
|
|
701
|
+
requireOrgMembership: () => requireOrgMembership(),
|
|
702
|
+
requireTeamMembership: () => requireTeamMembership()
|
|
703
|
+
},
|
|
704
|
+
openapi: extractedOpenApi
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region src/auth/sessionManager.ts
|
|
710
|
+
/**
|
|
711
|
+
* Session Management for Arc
|
|
712
|
+
*
|
|
713
|
+
* Lightweight cookie-based session manager that coexists with JWT and Better Auth.
|
|
714
|
+
* Users pick their auth strategy — this is one option alongside authPlugin and
|
|
715
|
+
* createBetterAuthAdapter.
|
|
716
|
+
*
|
|
717
|
+
* Features:
|
|
718
|
+
* - Cookie-based session tokens (HMAC-signed)
|
|
719
|
+
* - Session refresh with throttling (updateAge)
|
|
720
|
+
* - Fresh session concept for sensitive operations (freshAge)
|
|
721
|
+
* - Session revocation (single, all, all-except-current)
|
|
722
|
+
* - Pluggable session stores (Memory, Redis, etc.)
|
|
723
|
+
*
|
|
724
|
+
* @example
|
|
725
|
+
* ```typescript
|
|
726
|
+
* import { createSessionManager, MemorySessionStore } from '@classytic/arc/auth';
|
|
727
|
+
*
|
|
728
|
+
* const sessions = createSessionManager({
|
|
729
|
+
* store: new MemorySessionStore(),
|
|
730
|
+
* secret: process.env.SESSION_SECRET,
|
|
731
|
+
* maxAge: 7 * 24 * 60 * 60, // 7 days
|
|
732
|
+
* updateAge: 24 * 60 * 60, // refresh every 24h
|
|
733
|
+
* freshAge: 10 * 60, // 10 min for sensitive ops
|
|
734
|
+
* });
|
|
735
|
+
*
|
|
736
|
+
* // Register plugin
|
|
737
|
+
* await fastify.register(sessions.plugin);
|
|
738
|
+
*
|
|
739
|
+
* // Protect sensitive routes
|
|
740
|
+
* fastify.post('/change-password', {
|
|
741
|
+
* preHandler: [fastify.authenticate, sessions.requireFresh],
|
|
742
|
+
* }, handler);
|
|
743
|
+
* ```
|
|
744
|
+
*/
|
|
745
|
+
/**
|
|
746
|
+
* Sign a session ID using HMAC-SHA256.
|
|
747
|
+
* Returns `sessionId.signature` format.
|
|
748
|
+
*/
|
|
749
|
+
function signSessionId(sessionId, secret) {
|
|
750
|
+
return `${sessionId}.${createHmac("sha256", secret).update(sessionId).digest("base64url")}`;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Verify and extract session ID from a signed cookie value.
|
|
754
|
+
* Returns the session ID if valid, null otherwise.
|
|
755
|
+
*/
|
|
756
|
+
function verifySessionId(signedValue, secret) {
|
|
757
|
+
const lastDotIndex = signedValue.lastIndexOf(".");
|
|
758
|
+
if (lastDotIndex === -1) return null;
|
|
759
|
+
const sessionId = signedValue.slice(0, lastDotIndex);
|
|
760
|
+
const signature = signedValue.slice(lastDotIndex + 1);
|
|
761
|
+
if (!sessionId || !signature) return null;
|
|
762
|
+
const expectedSignature = createHmac("sha256", secret).update(sessionId).digest("base64url");
|
|
763
|
+
const sigBuf = Buffer.from(signature);
|
|
764
|
+
const expectedBuf = Buffer.from(expectedSignature);
|
|
765
|
+
if (sigBuf.length !== expectedBuf.length) return null;
|
|
766
|
+
return timingSafeEqual(sigBuf, expectedBuf) ? sessionId : null;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Parse cookies from a Cookie header string.
|
|
770
|
+
* Returns a map of cookie name to value.
|
|
771
|
+
*/
|
|
772
|
+
function parseCookies(header) {
|
|
773
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
774
|
+
if (!header) return cookies;
|
|
775
|
+
const pairs = header.split(";");
|
|
776
|
+
for (const pair of pairs) {
|
|
777
|
+
const eqIndex = pair.indexOf("=");
|
|
778
|
+
if (eqIndex === -1) continue;
|
|
779
|
+
const name = pair.slice(0, eqIndex).trim();
|
|
780
|
+
const value = pair.slice(eqIndex + 1).trim();
|
|
781
|
+
if (name) try {
|
|
782
|
+
cookies.set(name, decodeURIComponent(value));
|
|
783
|
+
} catch {
|
|
784
|
+
cookies.set(name, value);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return cookies;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Build a Set-Cookie header value.
|
|
791
|
+
*/
|
|
792
|
+
function buildSetCookieHeader(name, value, maxAgeSeconds, options) {
|
|
793
|
+
const parts = [
|
|
794
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
795
|
+
`Max-Age=${maxAgeSeconds}`,
|
|
796
|
+
`Path=${options.path ?? "/"}`
|
|
797
|
+
];
|
|
798
|
+
if (options.httpOnly !== false) parts.push("HttpOnly");
|
|
799
|
+
if (options.secure ?? process.env.NODE_ENV === "production") parts.push("Secure");
|
|
800
|
+
parts.push(`SameSite=${capitalize(options.sameSite ?? "lax")}`);
|
|
801
|
+
if (options.domain) parts.push(`Domain=${options.domain}`);
|
|
802
|
+
return parts.join("; ");
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Build a Set-Cookie header that clears (expires) the cookie.
|
|
806
|
+
*/
|
|
807
|
+
function buildClearCookieHeader(name, options) {
|
|
808
|
+
return buildSetCookieHeader(name, "", 0, options);
|
|
809
|
+
}
|
|
810
|
+
function capitalize(s) {
|
|
811
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* In-memory session store for development and single-instance deployments.
|
|
815
|
+
* NOT suitable for multi-instance/clustered deployments — use Redis or similar.
|
|
816
|
+
*/
|
|
817
|
+
var MemorySessionStore = class {
|
|
818
|
+
sessions = /* @__PURE__ */ new Map();
|
|
819
|
+
/** Reverse index: userId -> Set<sessionId> for efficient bulk operations */
|
|
820
|
+
userIndex = /* @__PURE__ */ new Map();
|
|
821
|
+
cleanupInterval = null;
|
|
822
|
+
constructor(options = {}) {
|
|
823
|
+
const intervalMs = options.cleanupIntervalMs ?? 6e4;
|
|
824
|
+
this.cleanupInterval = setInterval(() => {
|
|
825
|
+
this.cleanup();
|
|
826
|
+
}, intervalMs);
|
|
827
|
+
if (this.cleanupInterval.unref) this.cleanupInterval.unref();
|
|
828
|
+
}
|
|
829
|
+
async get(sessionId) {
|
|
830
|
+
const session = this.sessions.get(sessionId);
|
|
831
|
+
if (!session) return null;
|
|
832
|
+
if (Date.now() > session.expiresAt) {
|
|
833
|
+
await this.delete(sessionId);
|
|
834
|
+
return null;
|
|
835
|
+
}
|
|
836
|
+
return session;
|
|
837
|
+
}
|
|
838
|
+
async set(sessionId, data) {
|
|
839
|
+
this.sessions.set(sessionId, data);
|
|
840
|
+
let userSessions = this.userIndex.get(data.userId);
|
|
841
|
+
if (!userSessions) {
|
|
842
|
+
userSessions = /* @__PURE__ */ new Set();
|
|
843
|
+
this.userIndex.set(data.userId, userSessions);
|
|
844
|
+
}
|
|
845
|
+
userSessions.add(sessionId);
|
|
846
|
+
}
|
|
847
|
+
async delete(sessionId) {
|
|
848
|
+
const session = this.sessions.get(sessionId);
|
|
849
|
+
if (session) {
|
|
850
|
+
const userSessions = this.userIndex.get(session.userId);
|
|
851
|
+
if (userSessions) {
|
|
852
|
+
userSessions.delete(sessionId);
|
|
853
|
+
if (userSessions.size === 0) this.userIndex.delete(session.userId);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
this.sessions.delete(sessionId);
|
|
857
|
+
}
|
|
858
|
+
async deleteAll(userId) {
|
|
859
|
+
const userSessions = this.userIndex.get(userId);
|
|
860
|
+
if (!userSessions) return;
|
|
861
|
+
for (const sessionId of userSessions) this.sessions.delete(sessionId);
|
|
862
|
+
this.userIndex.delete(userId);
|
|
863
|
+
}
|
|
864
|
+
async deleteAllExcept(userId, currentSessionId) {
|
|
865
|
+
const userSessions = this.userIndex.get(userId);
|
|
866
|
+
if (!userSessions) return;
|
|
867
|
+
for (const sessionId of userSessions) if (sessionId !== currentSessionId) this.sessions.delete(sessionId);
|
|
868
|
+
if (userSessions.has(currentSessionId)) this.userIndex.set(userId, new Set([currentSessionId]));
|
|
869
|
+
else this.userIndex.delete(userId);
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Close the store and clean up resources.
|
|
873
|
+
*/
|
|
874
|
+
close() {
|
|
875
|
+
if (this.cleanupInterval) {
|
|
876
|
+
clearInterval(this.cleanupInterval);
|
|
877
|
+
this.cleanupInterval = null;
|
|
878
|
+
}
|
|
879
|
+
this.sessions.clear();
|
|
880
|
+
this.userIndex.clear();
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Get current stats (for debugging/monitoring).
|
|
884
|
+
*/
|
|
885
|
+
getStats() {
|
|
886
|
+
return {
|
|
887
|
+
sessions: this.sessions.size,
|
|
888
|
+
users: this.userIndex.size
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Remove expired sessions.
|
|
893
|
+
*/
|
|
894
|
+
cleanup() {
|
|
895
|
+
const now = Date.now();
|
|
896
|
+
for (const [sessionId, session] of this.sessions) if (now > session.expiresAt) {
|
|
897
|
+
const userSessions = this.userIndex.get(session.userId);
|
|
898
|
+
if (userSessions) {
|
|
899
|
+
userSessions.delete(sessionId);
|
|
900
|
+
if (userSessions.size === 0) this.userIndex.delete(session.userId);
|
|
901
|
+
}
|
|
902
|
+
this.sessions.delete(sessionId);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
/**
|
|
907
|
+
* Create a session manager for Arc.
|
|
908
|
+
*
|
|
909
|
+
* Returns a Fastify plugin and a `requireFresh` preHandler.
|
|
910
|
+
*
|
|
911
|
+
* The plugin:
|
|
912
|
+
* - Parses session cookie on each request
|
|
913
|
+
* - Validates session against the store
|
|
914
|
+
* - Sets `request.user` and `request.session` from session data
|
|
915
|
+
* - Refreshes session token if older than `updateAge`
|
|
916
|
+
* - Provides `fastify.authenticate` decorator
|
|
917
|
+
* - Provides `fastify.sessionManager` decorator for session CRUD
|
|
918
|
+
*
|
|
919
|
+
* @example
|
|
920
|
+
* ```typescript
|
|
921
|
+
* import { createSessionManager, MemorySessionStore } from '@classytic/arc/auth';
|
|
922
|
+
*
|
|
923
|
+
* const sessions = createSessionManager({
|
|
924
|
+
* store: new MemorySessionStore(),
|
|
925
|
+
* secret: process.env.SESSION_SECRET!,
|
|
926
|
+
* maxAge: 7 * 24 * 60 * 60,
|
|
927
|
+
* updateAge: 24 * 60 * 60,
|
|
928
|
+
* freshAge: 10 * 60,
|
|
929
|
+
* });
|
|
930
|
+
*
|
|
931
|
+
* await fastify.register(sessions.plugin);
|
|
932
|
+
*
|
|
933
|
+
* // Login route
|
|
934
|
+
* fastify.post('/login', async (request, reply) => {
|
|
935
|
+
* const user = await authenticateUser(request.body);
|
|
936
|
+
* const { cookie } = await fastify.sessionManager.createSession(user.id);
|
|
937
|
+
* reply.header('Set-Cookie', cookie);
|
|
938
|
+
* return { success: true, user };
|
|
939
|
+
* });
|
|
940
|
+
*
|
|
941
|
+
* // Protected route
|
|
942
|
+
* fastify.get('/me', {
|
|
943
|
+
* preHandler: [fastify.authenticate],
|
|
944
|
+
* }, async (request) => {
|
|
945
|
+
* return { user: request.user };
|
|
946
|
+
* });
|
|
947
|
+
*
|
|
948
|
+
* // Sensitive route (requires fresh session)
|
|
949
|
+
* fastify.post('/change-password', {
|
|
950
|
+
* preHandler: [fastify.authenticate, sessions.requireFresh],
|
|
951
|
+
* }, handler);
|
|
952
|
+
* ```
|
|
953
|
+
*/
|
|
954
|
+
function createSessionManager(options) {
|
|
955
|
+
const { store, secret, maxAge: maxAgeSeconds = 10080 * 60, updateAge: updateAgeSeconds = 1440 * 60, freshAge: freshAgeSeconds = 600, cookieName = "arc.session", cookie: cookieOptions = {} } = options;
|
|
956
|
+
if (secret.length < 32) throw new Error(`Session secret must be at least 32 characters (current: ${secret.length}). Use a strong random secret for production.`);
|
|
957
|
+
const maxAgeMs = maxAgeSeconds * 1e3;
|
|
958
|
+
const updateAgeMs = updateAgeSeconds * 1e3;
|
|
959
|
+
const freshAgeMs = freshAgeSeconds * 1e3;
|
|
960
|
+
/**
|
|
961
|
+
* Create a new session and return the signed cookie value.
|
|
962
|
+
*/
|
|
963
|
+
async function createSession(userId, metadata) {
|
|
964
|
+
const sessionId = randomUUID();
|
|
965
|
+
const now = Date.now();
|
|
966
|
+
const sessionData = {
|
|
967
|
+
userId,
|
|
968
|
+
createdAt: now,
|
|
969
|
+
updatedAt: now,
|
|
970
|
+
expiresAt: now + maxAgeMs,
|
|
971
|
+
metadata
|
|
972
|
+
};
|
|
973
|
+
await store.set(sessionId, sessionData);
|
|
974
|
+
return {
|
|
975
|
+
sessionId,
|
|
976
|
+
cookie: buildSetCookieHeader(cookieName, signSessionId(sessionId, secret), maxAgeSeconds, cookieOptions)
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
/**
|
|
980
|
+
* Refresh a session: update the updatedAt timestamp and optionally extend expiry.
|
|
981
|
+
*/
|
|
982
|
+
async function refreshSession(sessionId) {
|
|
983
|
+
const session = await store.get(sessionId);
|
|
984
|
+
if (!session) return null;
|
|
985
|
+
const now = Date.now();
|
|
986
|
+
const updatedSession = {
|
|
987
|
+
...session,
|
|
988
|
+
updatedAt: now,
|
|
989
|
+
expiresAt: Math.max(session.expiresAt, now + maxAgeMs)
|
|
990
|
+
};
|
|
991
|
+
await store.set(sessionId, updatedSession);
|
|
992
|
+
return updatedSession;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* PreHandler that rejects requests if the session is not "fresh".
|
|
996
|
+
* A session is fresh if it was last updated within `freshAge` seconds.
|
|
997
|
+
* Use this for sensitive operations like password changes, email changes, etc.
|
|
998
|
+
*/
|
|
999
|
+
const requireFresh = async (request, reply) => {
|
|
1000
|
+
const session = request.session;
|
|
1001
|
+
if (!session) {
|
|
1002
|
+
reply.code(401).send({
|
|
1003
|
+
success: false,
|
|
1004
|
+
error: "Unauthorized",
|
|
1005
|
+
message: "Authentication required"
|
|
1006
|
+
});
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (Date.now() - session.updatedAt > freshAgeMs) {
|
|
1010
|
+
reply.code(403).send({
|
|
1011
|
+
success: false,
|
|
1012
|
+
error: "SessionNotFresh",
|
|
1013
|
+
message: "Session is not fresh. Please re-authenticate to perform this action.",
|
|
1014
|
+
code: "SESSION_NOT_FRESH"
|
|
1015
|
+
});
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
const sessionPlugin = async (fastify) => {
|
|
1020
|
+
const authenticate = async (request, reply) => {
|
|
1021
|
+
const cookieHeader = request.headers.cookie;
|
|
1022
|
+
const signedValue = parseCookies(typeof cookieHeader === "string" ? cookieHeader : void 0).get(cookieName);
|
|
1023
|
+
if (!signedValue) {
|
|
1024
|
+
reply.code(401).send({
|
|
1025
|
+
success: false,
|
|
1026
|
+
error: "Unauthorized",
|
|
1027
|
+
message: "No session cookie"
|
|
1028
|
+
});
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const sessionId = verifySessionId(signedValue, secret);
|
|
1032
|
+
if (!sessionId) {
|
|
1033
|
+
reply.header("Set-Cookie", buildClearCookieHeader(cookieName, cookieOptions));
|
|
1034
|
+
reply.code(401).send({
|
|
1035
|
+
success: false,
|
|
1036
|
+
error: "Unauthorized",
|
|
1037
|
+
message: "Invalid session"
|
|
1038
|
+
});
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
const session = await store.get(sessionId);
|
|
1042
|
+
if (!session) {
|
|
1043
|
+
reply.header("Set-Cookie", buildClearCookieHeader(cookieName, cookieOptions));
|
|
1044
|
+
reply.code(401).send({
|
|
1045
|
+
success: false,
|
|
1046
|
+
error: "Unauthorized",
|
|
1047
|
+
message: "Session expired or revoked"
|
|
1048
|
+
});
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (Date.now() > session.expiresAt) {
|
|
1052
|
+
await store.delete(sessionId);
|
|
1053
|
+
reply.header("Set-Cookie", buildClearCookieHeader(cookieName, cookieOptions));
|
|
1054
|
+
reply.code(401).send({
|
|
1055
|
+
success: false,
|
|
1056
|
+
error: "Unauthorized",
|
|
1057
|
+
message: "Session expired"
|
|
1058
|
+
});
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
request.user = {
|
|
1062
|
+
id: session.userId,
|
|
1063
|
+
...session.metadata
|
|
1064
|
+
};
|
|
1065
|
+
request.session = {
|
|
1066
|
+
...session,
|
|
1067
|
+
id: sessionId
|
|
1068
|
+
};
|
|
1069
|
+
if (Date.now() - session.updatedAt > updateAgeMs) {
|
|
1070
|
+
const updatedSession = await refreshSession(sessionId);
|
|
1071
|
+
if (updatedSession) {
|
|
1072
|
+
const newCookie = buildSetCookieHeader(cookieName, signSessionId(sessionId, secret), maxAgeSeconds, cookieOptions);
|
|
1073
|
+
reply.header("Set-Cookie", newCookie);
|
|
1074
|
+
request.session = {
|
|
1075
|
+
...updatedSession,
|
|
1076
|
+
id: sessionId
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
if (!fastify.hasDecorator("authenticate")) fastify.decorate("authenticate", authenticate);
|
|
1082
|
+
fastify.decorate("sessionManager", {
|
|
1083
|
+
createSession,
|
|
1084
|
+
revokeSession: (sessionId) => store.delete(sessionId),
|
|
1085
|
+
revokeAllSessions: (userId) => store.deleteAll(userId),
|
|
1086
|
+
revokeOtherSessions: (userId, currentSessionId) => store.deleteAllExcept(userId, currentSessionId),
|
|
1087
|
+
refreshSession
|
|
1088
|
+
});
|
|
1089
|
+
fastify.log.debug(`Session: Plugin registered (cookieName=${cookieName}, maxAge=${maxAgeSeconds}s, updateAge=${updateAgeSeconds}s, freshAge=${freshAgeSeconds}s)`);
|
|
1090
|
+
};
|
|
1091
|
+
return {
|
|
1092
|
+
plugin: fp(sessionPlugin, {
|
|
1093
|
+
name: "arc-session",
|
|
1094
|
+
fastify: "5.x"
|
|
1095
|
+
}),
|
|
1096
|
+
requireFresh
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
//#endregion
|
|
1101
|
+
export { MemorySessionStore, authPlugin_default as authPlugin, authPlugin as authPluginFn, createBetterAuthAdapter, createSessionManager, extractBetterAuthOpenApi };
|
|
1102
|
+
//# sourceMappingURL=index.mjs.map
|