fest-gateway 0.1.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/LICENSE +21 -0
- package/README.md +274 -0
- package/dist/cli/claude.js +326 -0
- package/dist/cli/claude.js.map +1 -0
- package/dist/cli/config.js +59 -0
- package/dist/cli/config.js.map +1 -0
- package/dist/cli/login.js +130 -0
- package/dist/cli/login.js.map +1 -0
- package/dist/cli/logout.js +20 -0
- package/dist/cli/logout.js.map +1 -0
- package/dist/cli/whoami.js +34 -0
- package/dist/cli/whoami.js.map +1 -0
- package/dist/server/adapters/anthropic.js +74 -0
- package/dist/server/adapters/anthropic.js.map +1 -0
- package/dist/server/adapters/fireworks.js +54 -0
- package/dist/server/adapters/fireworks.js.map +1 -0
- package/dist/server/adapters/index.js +16 -0
- package/dist/server/adapters/index.js.map +1 -0
- package/dist/server/adapters/registry.js +38 -0
- package/dist/server/adapters/registry.js.map +1 -0
- package/dist/server/adapters/rewrite.js +152 -0
- package/dist/server/adapters/rewrite.js.map +1 -0
- package/dist/server/adapters/url.js +24 -0
- package/dist/server/adapters/url.js.map +1 -0
- package/dist/server/api/auth.js +213 -0
- package/dist/server/api/auth.js.map +1 -0
- package/dist/server/api/live.js +108 -0
- package/dist/server/api/live.js.map +1 -0
- package/dist/server/api/models.js +129 -0
- package/dist/server/api/models.js.map +1 -0
- package/dist/server/api/oauth.js +300 -0
- package/dist/server/api/oauth.js.map +1 -0
- package/dist/server/api/routes.js +164 -0
- package/dist/server/api/routes.js.map +1 -0
- package/dist/server/api/routing-view.js +47 -0
- package/dist/server/api/routing-view.js.map +1 -0
- package/dist/server/auth/accounts.js +111 -0
- package/dist/server/auth/accounts.js.map +1 -0
- package/dist/server/auth/gateway-401.js +85 -0
- package/dist/server/auth/gateway-401.js.map +1 -0
- package/dist/server/auth/guard.js +74 -0
- package/dist/server/auth/guard.js.map +1 -0
- package/dist/server/auth/oauth.js +43 -0
- package/dist/server/auth/oauth.js.map +1 -0
- package/dist/server/auth/password.js +104 -0
- package/dist/server/auth/password.js.map +1 -0
- package/dist/server/auth/posture.js +145 -0
- package/dist/server/auth/posture.js.map +1 -0
- package/dist/server/auth/session.js +161 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/bin/fest.js +589 -0
- package/dist/server/bin/fest.js.map +1 -0
- package/dist/server/config.js +109 -0
- package/dist/server/config.js.map +1 -0
- package/dist/server/credentials/provider.js +70 -0
- package/dist/server/credentials/provider.js.map +1 -0
- package/dist/server/credentials/resolve.js +89 -0
- package/dist/server/credentials/resolve.js.map +1 -0
- package/dist/server/http/body.js +66 -0
- package/dist/server/http/body.js.map +1 -0
- package/dist/server/http/errors.js +144 -0
- package/dist/server/http/errors.js.map +1 -0
- package/dist/server/http/headers.js +189 -0
- package/dist/server/http/headers.js.map +1 -0
- package/dist/server/http/pipe.js +97 -0
- package/dist/server/http/pipe.js.map +1 -0
- package/dist/server/http/server.js +226 -0
- package/dist/server/http/server.js.map +1 -0
- package/dist/server/http/sse.js +167 -0
- package/dist/server/http/sse.js.map +1 -0
- package/dist/server/http/static.js +138 -0
- package/dist/server/http/static.js.map +1 -0
- package/dist/server/ingest/live-bus.js +67 -0
- package/dist/server/ingest/live-bus.js.map +1 -0
- package/dist/server/ingest/sink.js +141 -0
- package/dist/server/ingest/sink.js.map +1 -0
- package/dist/server/log.js +31 -0
- package/dist/server/log.js.map +1 -0
- package/dist/server/pipeline/count-tokens.js +48 -0
- package/dist/server/pipeline/count-tokens.js.map +1 -0
- package/dist/server/pipeline/dispatch.js +138 -0
- package/dist/server/pipeline/dispatch.js.map +1 -0
- package/dist/server/pipeline/passthrough.js +392 -0
- package/dist/server/pipeline/passthrough.js.map +1 -0
- package/dist/server/pipeline/preflight.js +66 -0
- package/dist/server/pipeline/preflight.js.map +1 -0
- package/dist/server/pipeline/record.js +49 -0
- package/dist/server/pipeline/record.js.map +1 -0
- package/dist/server/pipeline/substitute.js +287 -0
- package/dist/server/pipeline/substitute.js.map +1 -0
- package/dist/server/routes/resolve.js +176 -0
- package/dist/server/routes/resolve.js.map +1 -0
- package/dist/server/routes/table.js +247 -0
- package/dist/server/routes/table.js.map +1 -0
- package/dist/server/routes/watch.js +101 -0
- package/dist/server/routes/watch.js.map +1 -0
- package/dist/server/secret/fingerprint.js +107 -0
- package/dist/server/secret/fingerprint.js.map +1 -0
- package/dist/server/secret/non-persistable.js +69 -0
- package/dist/server/secret/non-persistable.js.map +1 -0
- package/dist/server/store/audit.js +53 -0
- package/dist/server/store/audit.js.map +1 -0
- package/dist/server/store/bootstrap.js +53 -0
- package/dist/server/store/bootstrap.js.map +1 -0
- package/dist/server/store/db.js +157 -0
- package/dist/server/store/db.js.map +1 -0
- package/dist/server/store/ids.js +14 -0
- package/dist/server/store/ids.js.map +1 -0
- package/dist/server/store/migrations/001-init.sql +213 -0
- package/dist/server/store/migrations/002-feed-and-error-indexes.sql +26 -0
- package/dist/server/store/migrations/003-routing.sql +42 -0
- package/dist/server/store/migrations/004-admin-auth.sql +72 -0
- package/dist/server/store/migrations/005-notional-cost.sql +28 -0
- package/dist/server/store/queries.js +904 -0
- package/dist/server/store/queries.js.map +1 -0
- package/dist/server/store/retention.js +79 -0
- package/dist/server/store/retention.js.map +1 -0
- package/dist/server/store/seed.js +203 -0
- package/dist/server/store/seed.js.map +1 -0
- package/dist/server/store/tokens.js +133 -0
- package/dist/server/store/tokens.js.map +1 -0
- package/dist/server/store/write.js +330 -0
- package/dist/server/store/write.js.map +1 -0
- package/dist/server/usage/accumulator.js +209 -0
- package/dist/server/usage/accumulator.js.map +1 -0
- package/dist/server/usage/cost.js +57 -0
- package/dist/server/usage/cost.js.map +1 -0
- package/dist/server/usage/prices/catalog.js +136 -0
- package/dist/server/usage/prices/catalog.js.map +1 -0
- package/dist/server/usage/prices/refresh.js +125 -0
- package/dist/server/usage/prices/refresh.js.map +1 -0
- package/dist/server/usage/prices/snapshot.json.gz +0 -0
- package/dist/server/usage/prices/table.js +102 -0
- package/dist/server/usage/prices/table.js.map +1 -0
- package/dist/server/usage/pricing.js +103 -0
- package/dist/server/usage/pricing.js.map +1 -0
- package/dist/shared/api.js +19 -0
- package/dist/shared/api.js.map +1 -0
- package/dist/shared/base-models.js +28 -0
- package/dist/shared/base-models.js.map +1 -0
- package/dist/shared/demotion-vars.js +17 -0
- package/dist/shared/demotion-vars.js.map +1 -0
- package/dist/shared/series.js +50 -0
- package/dist/shared/series.js.map +1 -0
- package/dist/shared/types.js +40 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/web/dist/assets/index-8HlKsJqt.css +2 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js +56 -0
- package/dist/web/dist/assets/index-DQ7fyPVm.js.map +1 -0
- package/dist/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/dist/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/dist/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-BLAVimhd.woff +0 -0
- package/dist/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
- package/dist/web/dist/index.html +44 -0
- package/dist/web/dist/nearform-icon.svg +4 -0
- package/package.json +82 -0
- package/routes.example.json +22 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"accounts.js","sourceRoot":"","sources":["../../../server/auth/accounts.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The message a developer sees when Fest cannot serve them.
|
|
3
|
+
*
|
|
4
|
+
* This is genuinely UI: Claude Code renders `error.message` verbatim, mid-task,
|
|
5
|
+
* to someone who was trying to get work done. An error that says "unauthorized"
|
|
6
|
+
* is a support ticket. One that says which variable to set is a fix.
|
|
7
|
+
*
|
|
8
|
+
* Two constraints learned by watching it render, both of which shape every
|
|
9
|
+
* message here:
|
|
10
|
+
*
|
|
11
|
+
* 1. **The client TRUNCATES the message.** It is shown on roughly one line, so
|
|
12
|
+
* the first sentence has to carry the whole actionable point. Detail on
|
|
13
|
+
* later lines is a bonus that may never be seen.
|
|
14
|
+
* 2. **The client RETRIES.** Its predicate is
|
|
15
|
+
* `x-should-retry` first, then 408/409/429/5xx. A permanent configuration
|
|
16
|
+
* problem returned as a retryable status produces ten escalating retries of
|
|
17
|
+
* a request that cannot possibly succeed — the developer waits a minute to
|
|
18
|
+
* be told something that was true immediately. So every failure here
|
|
19
|
+
* carries `retryable`, and callers must send `x-should-retry: false`
|
|
20
|
+
* accordingly.
|
|
21
|
+
*
|
|
22
|
+
* The messages differ by POSTURE because the remedies are opposite, and telling
|
|
23
|
+
* a subscription user to set `ANTHROPIC_AUTH_TOKEN` would be actively harmful —
|
|
24
|
+
* that variable is exactly what disables their subscription.
|
|
25
|
+
*/
|
|
26
|
+
const DO_NOT_SET = "Do not set ANTHROPIC_API_KEY or ANTHROPIC_AUTH_TOKEN alongside it: either one makes " +
|
|
27
|
+
"Claude Code stop using your Claude subscription and bill an API key instead.";
|
|
28
|
+
/** No identity token at all, on a deployment that requires one. */
|
|
29
|
+
export function noIdentity(baseUrl) {
|
|
30
|
+
return {
|
|
31
|
+
status: 401,
|
|
32
|
+
type: "authentication_error",
|
|
33
|
+
retryable: false,
|
|
34
|
+
message: `Fest: no identity token — ask an admin for one, then set ANTHROPIC_BASE_URL=${baseUrl}/t/<your-token>.\n` +
|
|
35
|
+
`Ask an admin for a token, then either:\n` +
|
|
36
|
+
` • point your base URL at it — ANTHROPIC_BASE_URL=${baseUrl}/t/<your-token> (keeps your Claude subscription)\n` +
|
|
37
|
+
` • or set ANTHROPIC_AUTH_TOKEN=<your-token> (uses this gateway's own credentials instead)\n` +
|
|
38
|
+
`The first is preferred: it bills your own subscription rather than the team's.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** A token was presented and did not resolve. */
|
|
42
|
+
export function badIdentity() {
|
|
43
|
+
return {
|
|
44
|
+
status: 401,
|
|
45
|
+
type: "authentication_error",
|
|
46
|
+
retryable: false,
|
|
47
|
+
message: "Fest: that identity token is not valid (unknown, revoked, or expired). " +
|
|
48
|
+
"Ask an admin for a new one, then update whichever of ANTHROPIC_BASE_URL or " +
|
|
49
|
+
"ANTHROPIC_AUTH_TOKEN you set it in.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Key posture, identity fine, but nothing can pay for the request.
|
|
54
|
+
*
|
|
55
|
+
* This is an OPERATOR problem surfacing to a developer, so the message says so
|
|
56
|
+
* explicitly. Otherwise the developer spends their afternoon re-checking their
|
|
57
|
+
* own token, which is the one thing here that is not wrong.
|
|
58
|
+
*/
|
|
59
|
+
export function noUpstreamCredential(model) {
|
|
60
|
+
const what = model === null ? "this model" : model;
|
|
61
|
+
return {
|
|
62
|
+
// NOT 401. A 401 invites the client's token-refresh-and-retry path, and
|
|
63
|
+
// nothing about refreshing a token fixes "the server has no credential
|
|
64
|
+
// configured" — the developer just waits through ten escalating retries to
|
|
65
|
+
// be told what was true at the first attempt.
|
|
66
|
+
status: 400,
|
|
67
|
+
type: "invalid_request_error",
|
|
68
|
+
retryable: false,
|
|
69
|
+
// First sentence carries it: the message is truncated in the client.
|
|
70
|
+
message: `Fest has no credential configured for ${what} — pick a different model, or ask whoever runs Fest to add a route for it. ` +
|
|
71
|
+
`(Your identity token is fine; this is a server configuration problem. ` +
|
|
72
|
+
`To use your own Claude subscription instead, unset ANTHROPIC_AUTH_TOKEN and set ANTHROPIC_BASE_URL=<fest>/t/<your-token>.)`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Subscription posture with no bearer — rare, and usually a stale login. */
|
|
76
|
+
export function noSubscriptionCredential() {
|
|
77
|
+
return {
|
|
78
|
+
status: 401,
|
|
79
|
+
type: "authentication_error",
|
|
80
|
+
retryable: false,
|
|
81
|
+
message: "Fest: no Claude credential arrived with this request. Run `claude /login` to refresh your " +
|
|
82
|
+
`subscription, and check your environment. ${DO_NOT_SET}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=gateway-401.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gateway-401.js","sourceRoot":"","sources":["../../../server/auth/gateway-401.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAaH,MAAM,UAAU,GACd,sFAAsF;IACtF,8EAA8E,CAAC;AAEjF,mEAAmE;AACnE,MAAM,UAAU,UAAU,CAAC,OAAe;IACxC,OAAO;QACL,MAAM,EAAE,GAAG;QACX,IAAI,EAAE,sBAAsB;QAC5B,SAAS,EAAE,KAAK;QAChB,OAAO,EACL,+EAA+E,OAAO,oBAAoB;YAC1G,0CAA0C;YAC1C,sDAAsD,OAAO,sDAAsD;YACnH,gIAAgI;YAChI,gFAAgF;KACnF,CAAC;AACJ,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,WAAW;IACzB,OAAO;QACL,MAAM,EAAE,GAAG;QACX,IAAI,EAAE,sBAAsB;QAC5B,SAAS,EAAE,KAAK;QAChB,OAAO,EACL,yEAAyE;YACzE,6EAA6E;YAC7E,qCAAqC;KACxC,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAoB;IACvD,MAAM,IAAI,GAAG,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,KAAK,CAAC;IACnD,OAAO;QACL,wEAAwE;QACxE,uEAAuE;QACvE,2EAA2E;QAC3E,8CAA8C;QAC9C,MAAM,EAAE,GAAG;QACX,IAAI,EAAE,uBAAuB;QAC7B,SAAS,EAAE,KAAK;QAChB,qEAAqE;QACrE,OAAO,EACL,yCAAyC,IAAI,6EAA6E;YAC1H,wEAAwE;YACxE,4HAA4H;KAC/H,CAAC;AACJ,CAAC;AAED,6EAA6E;AAC7E,MAAM,UAAU,wBAAwB;IACtC,OAAO;QACL,MAAM,EAAE,GAAG;QACX,IAAI,EAAE,sBAAsB;QAC5B,SAAS,EAAE,KAAK;QAChB,OAAO,EACL,4FAA4F;YAC5F,6CAA6C,UAAU,EAAE;KAC5D,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may call the dashboard API.
|
|
3
|
+
*
|
|
4
|
+
* This guards `/api/*` only. The proxy path (`/v1/*`) authenticates completely
|
|
5
|
+
* differently — an identity token presented by a developer's Claude Code — and
|
|
6
|
+
* must never require a browser session. Conflating them would mean a developer
|
|
7
|
+
* has to log in to a web page before their editor works.
|
|
8
|
+
*
|
|
9
|
+
* ── The unclaimed state ──────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* Before anyone has run `fest admin create`, there is no account to sign in
|
|
12
|
+
* with. Rather than ship a well-known default password, Fest serves the
|
|
13
|
+
* dashboard open *only while bound to loopback*, and refuses to serve it at all
|
|
14
|
+
* on any other interface. So the single-developer trial still works with no
|
|
15
|
+
* setup, and the failure mode "I bound it to 0.0.0.0 and forgot to add a
|
|
16
|
+
* password" cannot happen quietly — it cannot happen at all.
|
|
17
|
+
*/
|
|
18
|
+
import { parseCookies, cookieName, resolveSession } from "./session.js";
|
|
19
|
+
import { hasAnyOwner } from "./accounts.js";
|
|
20
|
+
const LOOPBACK = new Set(["127.0.0.1", "::1", "localhost"]);
|
|
21
|
+
export function isLoopbackHost(host) {
|
|
22
|
+
return LOOPBACK.has(host.trim().toLowerCase());
|
|
23
|
+
}
|
|
24
|
+
export function sessionFromRequest(store, req, secureCookies) {
|
|
25
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
26
|
+
return resolveSession(store, cookies[cookieName(secureCookies)] ?? null);
|
|
27
|
+
}
|
|
28
|
+
/** Decide whether this `/api/*` request may proceed. */
|
|
29
|
+
export function authorizeApi(req, deps) {
|
|
30
|
+
const session = sessionFromRequest(deps.store, req, deps.secureCookies);
|
|
31
|
+
if (session !== null)
|
|
32
|
+
return { allow: true, session, unclaimed: false };
|
|
33
|
+
if (!hasAnyOwner(deps.store, deps.orgId)) {
|
|
34
|
+
if (isLoopbackHost(deps.bindHost))
|
|
35
|
+
return { allow: true, session: null, unclaimed: true };
|
|
36
|
+
return {
|
|
37
|
+
allow: false,
|
|
38
|
+
status: 503,
|
|
39
|
+
error: "Fest has no owner account yet, and refuses to serve an unauthenticated dashboard " +
|
|
40
|
+
"off loopback. Run: fest admin create <email>",
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return { allow: false, status: 401, error: "authentication required" };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Cross-site request forgery, handled by origin rather than by token.
|
|
47
|
+
*
|
|
48
|
+
* A double-submit token would add a second secret to manage for no gain here:
|
|
49
|
+
* the session cookie is already `SameSite=Strict`, so a browser will not attach
|
|
50
|
+
* it to a cross-site request at all, and this is the belt to that's braces. A
|
|
51
|
+
* form POSTed from another origin cannot control `Origin`.
|
|
52
|
+
*
|
|
53
|
+
* A missing `Origin` is rejected rather than waved through. Browsers always
|
|
54
|
+
* send it on POST; the only callers it inconveniences are scripts, which can
|
|
55
|
+
* set it, and that is the right way round — waving it through would restore the
|
|
56
|
+
* exact hole this closes for any browser that omits it.
|
|
57
|
+
*/
|
|
58
|
+
export function originAllowed(req) {
|
|
59
|
+
const origin = req.headers.origin;
|
|
60
|
+
const host = req.headers.host;
|
|
61
|
+
if (typeof origin !== "string" || origin === "" || typeof host !== "string")
|
|
62
|
+
return false;
|
|
63
|
+
try {
|
|
64
|
+
return new URL(origin).host === host;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Best-effort client address, for the audit trail only — never for access. */
|
|
71
|
+
export function clientIp(req) {
|
|
72
|
+
return req.socket.remoteAddress ?? "";
|
|
73
|
+
}
|
|
74
|
+
//# sourceMappingURL=guard.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"guard.js","sourceRoot":"","sources":["../../../server/auth/guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACxE,OAAO,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAM5C,MAAM,QAAQ,GAAG,IAAI,GAAG,CAAC,CAAC,WAAW,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC;AAE5D,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,OAAO,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,CAAC;AACjD,CAAC;AAED,MAAM,UAAU,kBAAkB,CAChC,KAAY,EACZ,GAAoB,EACpB,aAAsB;IAEtB,MAAM,OAAO,GAAG,YAAY,CAAC,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IACjD,OAAO,cAAc,CAAC,KAAK,EAAE,OAAO,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC;AAC3E,CAAC;AASD,wDAAwD;AACxD,MAAM,UAAU,YAAY,CAAC,GAAoB,EAAE,IAAe;IAChE,MAAM,OAAO,GAAG,kBAAkB,CAAC,IAAI,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;IACxE,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;IAExE,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;QACzC,IAAI,cAAc,CAAC,IAAI,CAAC,QAAQ,CAAC;YAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;QAC1F,OAAO;YACL,KAAK,EAAE,KAAK;YACZ,MAAM,EAAE,GAAG;YACX,KAAK,EACH,mFAAmF;gBACnF,8CAA8C;SACjD,CAAC;IACJ,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC;AACzE,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,aAAa,CAAC,GAAoB;IAChD,MAAM,MAAM,GAAG,GAAG,CAAC,OAAO,CAAC,MAAM,CAAC;IAClC,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC;IAC9B,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,KAAK,EAAE,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC1F,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC,IAAI,KAAK,IAAI,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,QAAQ,CAAC,GAAoB;IAC3C,OAAO,GAAG,CAAC,MAAM,CAAC,aAAa,IAAI,EAAE,CAAC;AACxC,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google/GitHub OAuth provider construction.
|
|
3
|
+
*
|
|
4
|
+
* Returns null rather than throwing when a provider's client id/secret are
|
|
5
|
+
* unset — a deployment may intentionally run with only one provider
|
|
6
|
+
* configured, or with neither and password-only dashboard login. Same shape
|
|
7
|
+
* as an unconfigured routing upstream (Phase 4): a feature that refuses to
|
|
8
|
+
* activate, not a boot failure.
|
|
9
|
+
*/
|
|
10
|
+
import { Google, GitHub } from "arctic";
|
|
11
|
+
export function oauthRedirectUri(cfg, provider) {
|
|
12
|
+
return `${cfg.publicUrl}/api/auth/oauth/${provider}/callback`;
|
|
13
|
+
}
|
|
14
|
+
export function googleProvider(cfg) {
|
|
15
|
+
if (cfg.googleClientId === null || cfg.googleClientSecret === null)
|
|
16
|
+
return null;
|
|
17
|
+
return new Google(cfg.googleClientId, cfg.googleClientSecret, oauthRedirectUri(cfg, "google"));
|
|
18
|
+
}
|
|
19
|
+
export function githubProvider(cfg) {
|
|
20
|
+
if (cfg.githubClientId === null || cfg.githubClientSecret === null)
|
|
21
|
+
return null;
|
|
22
|
+
return new GitHub(cfg.githubClientId, cfg.githubClientSecret, oauthRedirectUri(cfg, "github"));
|
|
23
|
+
}
|
|
24
|
+
export function configuredProviders(cfg) {
|
|
25
|
+
const out = [];
|
|
26
|
+
if (googleProvider(cfg) !== null)
|
|
27
|
+
out.push("google");
|
|
28
|
+
if (githubProvider(cfg) !== null)
|
|
29
|
+
out.push("github");
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Empty allow-list refuses every OAuth login, deliberately. Self-service token
|
|
34
|
+
* minting spends server-held credentials on the substitute path, so "nobody
|
|
35
|
+
* configured a domain" must mean "OAuth login is off", not "everyone is in".
|
|
36
|
+
*/
|
|
37
|
+
export function emailDomainAllowed(cfg, email) {
|
|
38
|
+
if (cfg.allowedEmailDomains.length === 0)
|
|
39
|
+
return false;
|
|
40
|
+
const domain = email.split("@")[1]?.toLowerCase() ?? "";
|
|
41
|
+
return cfg.allowedEmailDomains.includes(domain);
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=oauth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauth.js","sourceRoot":"","sources":["../../../server/auth/oauth.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAKxC,MAAM,UAAU,gBAAgB,CAAC,GAAe,EAAE,QAAuB;IACvE,OAAO,GAAG,GAAG,CAAC,SAAS,mBAAmB,QAAQ,WAAW,CAAC;AAChE,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,GAAe;IAC5C,IAAI,GAAG,CAAC,cAAc,KAAK,IAAI,IAAI,GAAG,CAAC,kBAAkB,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAChF,OAAO,IAAI,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,GAAG,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC;AACjG,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,GAAe;IAC5C,IAAI,GAAG,CAAC,cAAc,KAAK,IAAI,IAAI,GAAG,CAAC,kBAAkB,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IAChF,OAAO,IAAI,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,GAAG,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC;AACjG,CAAC;AAED,MAAM,UAAU,mBAAmB,CAAC,GAAe;IACjD,MAAM,GAAG,GAAoB,EAAE,CAAC;IAChC,IAAI,cAAc,CAAC,GAAG,CAAC,KAAK,IAAI;QAAE,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACrD,IAAI,cAAc,CAAC,GAAG,CAAC,KAAK,IAAI;QAAE,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACrD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,GAAe,EAAE,KAAa;IAC/D,IAAI,GAAG,CAAC,mBAAmB,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IACvD,MAAM,MAAM,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;IACxD,OAAO,GAAG,CAAC,mBAAmB,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;AAClD,CAAC"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard passwords: scrypt, with the parameters stored alongside the hash.
|
|
3
|
+
*
|
|
4
|
+
* Why scrypt here and sha256 for identity tokens (see store/tokens.ts): a
|
|
5
|
+
* 32-byte random token has no dictionary to attack, so a slow KDF buys nothing
|
|
6
|
+
* and costs every proxied request. A human-chosen password is the opposite on
|
|
7
|
+
* both counts — it is guessable, and it is verified once per login, off the hot
|
|
8
|
+
* path entirely.
|
|
9
|
+
*
|
|
10
|
+
* The encoded form is `scrypt$N$r$p$salt$hash`, all base64url. Storing the
|
|
11
|
+
* parameters rather than assuming them means today's cost factor can be raised
|
|
12
|
+
* later without invalidating existing passwords: an old hash still verifies
|
|
13
|
+
* with the parameters it was written with, and `needsRehash` says when to
|
|
14
|
+
* upgrade it on next successful login.
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
const scrypt = promisify(scryptCb);
|
|
19
|
+
/** ~100ms on a 2024 laptop, and within Node's default maxmem at r=8. */
|
|
20
|
+
export const CURRENT_PARAMS = { N: 16_384, r: 8, p: 1 };
|
|
21
|
+
const KEY_BYTES = 32;
|
|
22
|
+
const SALT_BYTES = 16;
|
|
23
|
+
/** N=16384,r=8 needs ~16MB; the headroom covers a future raise of N. */
|
|
24
|
+
const MAXMEM = 128 * 1024 * 1024;
|
|
25
|
+
const b64 = (b) => b.toString("base64url");
|
|
26
|
+
/**
|
|
27
|
+
* Minimum length only — no composition rules.
|
|
28
|
+
*
|
|
29
|
+
* Character-class requirements measurably push people toward `Password1!` and
|
|
30
|
+
* are not what stands between this database and an attacker; a slow KDF and a
|
|
31
|
+
* non-guessable length are. `fest admin create` generates a random password by
|
|
32
|
+
* default, which is the path most operators should take.
|
|
33
|
+
*/
|
|
34
|
+
export const MIN_PASSWORD_LENGTH = 12;
|
|
35
|
+
export function validatePassword(password) {
|
|
36
|
+
if (password.length < MIN_PASSWORD_LENGTH) {
|
|
37
|
+
return `password must be at least ${MIN_PASSWORD_LENGTH} characters`;
|
|
38
|
+
}
|
|
39
|
+
if (password.length > 1024) {
|
|
40
|
+
// Not a policy, a denial-of-service bound: scrypt cost scales with input.
|
|
41
|
+
return "password must be at most 1024 characters";
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export async function hashPassword(password, params = CURRENT_PARAMS) {
|
|
46
|
+
const salt = randomBytes(SALT_BYTES);
|
|
47
|
+
const key = await scrypt(password, salt, KEY_BYTES, { ...params, maxmem: MAXMEM });
|
|
48
|
+
return ["scrypt", params.N, params.r, params.p, b64(salt), b64(key)].join("$");
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Verify a password against an encoded hash.
|
|
52
|
+
*
|
|
53
|
+
* Returns false rather than throwing for a malformed stored value: a corrupted
|
|
54
|
+
* row must read as "this password does not match", not as a 500 that tells an
|
|
55
|
+
* attacker they found something interesting.
|
|
56
|
+
*/
|
|
57
|
+
export async function verifyPassword(password, encoded) {
|
|
58
|
+
const parts = encoded.split("$");
|
|
59
|
+
if (parts.length !== 6 || parts[0] !== "scrypt")
|
|
60
|
+
return false;
|
|
61
|
+
const N = Number(parts[1]);
|
|
62
|
+
const r = Number(parts[2]);
|
|
63
|
+
const p = Number(parts[3]);
|
|
64
|
+
if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p))
|
|
65
|
+
return false;
|
|
66
|
+
// A hostile row could otherwise name parameters that hang the process.
|
|
67
|
+
if (N > 1 << 20 || r > 32 || p > 16)
|
|
68
|
+
return false;
|
|
69
|
+
let salt;
|
|
70
|
+
let expected;
|
|
71
|
+
try {
|
|
72
|
+
salt = Buffer.from(parts[4] ?? "", "base64url");
|
|
73
|
+
expected = Buffer.from(parts[5] ?? "", "base64url");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (salt.length === 0 || expected.length === 0)
|
|
79
|
+
return false;
|
|
80
|
+
let actual;
|
|
81
|
+
try {
|
|
82
|
+
actual = await scrypt(password, salt, expected.length, { N, r, p, maxmem: MAXMEM });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
88
|
+
}
|
|
89
|
+
/** True when a stored hash was written with weaker parameters than today's. */
|
|
90
|
+
export function needsRehash(encoded, params = CURRENT_PARAMS) {
|
|
91
|
+
const parts = encoded.split("$");
|
|
92
|
+
if (parts.length !== 6 || parts[0] !== "scrypt")
|
|
93
|
+
return true;
|
|
94
|
+
return Number(parts[1]) < params.N || Number(parts[2]) < params.r;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A password nobody has to invent. ~103 bits of entropy in a shape that
|
|
98
|
+
* survives being read aloud once and pasted into a password manager.
|
|
99
|
+
*/
|
|
100
|
+
export function generatePassword() {
|
|
101
|
+
const raw = randomBytes(16).toString("base64url").replace(/[-_]/g, "");
|
|
102
|
+
return (raw.match(/.{1,6}/g) ?? [raw]).slice(0, 4).join("-");
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=password.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"password.js","sourceRoot":"","sources":["../../../server/auth/password.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound credential posture and identity detection.
|
|
3
|
+
*
|
|
4
|
+
* Every request carries up to TWO independent credentials, and conflating them
|
|
5
|
+
* is the bug that breaks subscription auth:
|
|
6
|
+
*
|
|
7
|
+
* 1. The developer's *upstream* credential — their own Claude Max/Team OAuth
|
|
8
|
+
* bearer (`Authorization: Bearer sk-ant-oat…`) or an API key (`x-api-key`,
|
|
9
|
+
* or `Authorization: Bearer sk-ant-api…`). This is forwarded to Anthropic.
|
|
10
|
+
* 2. Fest's own *identity* token — "which developer is this?" — which is never
|
|
11
|
+
* forwarded upstream. It arrives as a URL path prefix (`/t/<token>/…`) or as
|
|
12
|
+
* an `X-Fest-Token` header.
|
|
13
|
+
*
|
|
14
|
+
* On the SUBSCRIPTION posture the identity token cannot ride in
|
|
15
|
+
* `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN`: setting either of those makes
|
|
16
|
+
* Claude Code silently abandon subscription auth, so the developer's Max token
|
|
17
|
+
* never arrives and we bill the org instead. Hence the two out-of-band
|
|
18
|
+
* carriers.
|
|
19
|
+
*
|
|
20
|
+
* On the KEY posture that objection does not apply — there is no subscription
|
|
21
|
+
* left to protect — so a Fest token may also arrive as
|
|
22
|
+
* `Authorization: Bearer fest_…`. Whichever position it arrives in, a Fest
|
|
23
|
+
* token is IDENTITY and is never forwarded upstream. Relaying our own bearer to
|
|
24
|
+
* Anthropic would authenticate nothing and disclose a credential that can
|
|
25
|
+
* impersonate a developer on this gateway.
|
|
26
|
+
*/
|
|
27
|
+
import { classifyCredential, fingerprint } from "../secret/fingerprint.js";
|
|
28
|
+
/**
|
|
29
|
+
* Header positions that may hold the *upstream* credential, in preference order.
|
|
30
|
+
*
|
|
31
|
+
* `x-fest-token` is deliberately absent even though it is a secret header
|
|
32
|
+
* (see SECRET_HEADERS): it is Fest's identity token, not Anthropic's, and
|
|
33
|
+
* forwarding it upstream would authenticate nothing and leak our own bearer.
|
|
34
|
+
*/
|
|
35
|
+
const UPSTREAM_CREDENTIAL_HEADERS = ["authorization", "x-api-key"];
|
|
36
|
+
/** Path prefixes that carry an identity token. `/t/` is the short form. */
|
|
37
|
+
const IDENTITY_PATH_PREFIX = /^\/(?:t|fest)\/([^/?#]+)(\/[^?#]*)?(\?.*|#.*)?$/;
|
|
38
|
+
/**
|
|
39
|
+
* Split a leading `/t/<token>` or `/fest/<token>` segment off the request URL.
|
|
40
|
+
*
|
|
41
|
+
* The query string belongs to the remainder, because Claude Code sends
|
|
42
|
+
* `POST /v1/messages?beta=true` and dropping `?beta=true` changes the upstream
|
|
43
|
+
* request shape. A URL with no identity prefix is returned untouched — note the
|
|
44
|
+
* anchored regex, so a normal `/v1/messages` can never be mistaken for a token
|
|
45
|
+
* (its first segment is not `t` or `fest`).
|
|
46
|
+
*/
|
|
47
|
+
export function parseIdentityPath(url) {
|
|
48
|
+
const match = IDENTITY_PATH_PREFIX.exec(url);
|
|
49
|
+
if (!match)
|
|
50
|
+
return { token: null, remainder: url };
|
|
51
|
+
const token = match[1] ?? "";
|
|
52
|
+
if (token.length === 0)
|
|
53
|
+
return { token: null, remainder: url };
|
|
54
|
+
// `/t/<tok>` with nothing after it still has to address a path upstream.
|
|
55
|
+
const path = match[2] ?? "/";
|
|
56
|
+
const suffix = match[3] ?? "";
|
|
57
|
+
return { token, remainder: `${path === "" ? "/" : path}${suffix}` };
|
|
58
|
+
}
|
|
59
|
+
/** `Bearer xyz` -> `xyz`; anything else unchanged. */
|
|
60
|
+
function stripBearer(raw) {
|
|
61
|
+
const match = /^Bearer\s+(.*)$/i.exec(raw.trim());
|
|
62
|
+
return (match ? (match[1] ?? "") : raw).trim();
|
|
63
|
+
}
|
|
64
|
+
function headerValue(headers, name) {
|
|
65
|
+
// Node lowercases incoming header names, but this is also called with
|
|
66
|
+
// hand-built objects (tests, internal replays), so match case-insensitively.
|
|
67
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
68
|
+
if (key.toLowerCase() !== name)
|
|
69
|
+
continue;
|
|
70
|
+
const joined = Array.isArray(value) ? value.join(", ") : value;
|
|
71
|
+
if (joined === undefined)
|
|
72
|
+
return null;
|
|
73
|
+
return joined;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Classify a request's two credential positions in one pass.
|
|
79
|
+
*
|
|
80
|
+
* Posture is *detected*, never configured: it is a property of the developer's
|
|
81
|
+
* own client environment, which Fest does not control.
|
|
82
|
+
*/
|
|
83
|
+
export function detectInbound(url, headers) {
|
|
84
|
+
let upstreamCredential = null;
|
|
85
|
+
/**
|
|
86
|
+
* A Fest token found in an upstream-credential position.
|
|
87
|
+
*
|
|
88
|
+
* It is pulled OUT of that position rather than left there: everything
|
|
89
|
+
* downstream treats `upstreamCredential` as "the thing to forward", and our
|
|
90
|
+
* own identity token must never be forwarded. Recording it here keeps the
|
|
91
|
+
* loop's preference order intact while removing it from the forward path.
|
|
92
|
+
*/
|
|
93
|
+
let tokenFromAuthHeader = null;
|
|
94
|
+
for (const name of UPSTREAM_CREDENTIAL_HEADERS) {
|
|
95
|
+
const raw = headerValue(headers, name);
|
|
96
|
+
if (raw === null)
|
|
97
|
+
continue;
|
|
98
|
+
const info = classifyCredential(name, raw);
|
|
99
|
+
// An empty or whitespace-only header is the same as no credential; fall
|
|
100
|
+
// through so a present-but-blank `authorization` does not mask `x-api-key`.
|
|
101
|
+
if (info.kind === "EMPTY")
|
|
102
|
+
continue;
|
|
103
|
+
if (info.kind === "FEST_IDENTITY_TOKEN") {
|
|
104
|
+
// Identity, not an upstream credential. Keep scanning: a developer could
|
|
105
|
+
// conceivably present a Fest token AND a real provider key, and the key
|
|
106
|
+
// is still the thing to forward.
|
|
107
|
+
tokenFromAuthHeader ??= stripBearer(raw);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
upstreamCredential = info;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
const posture = upstreamCredential?.kind === "ANTHROPIC_OAUTH_SUBSCRIPTION" ? "subscription" : "key";
|
|
114
|
+
const fromPath = parseIdentityPath(url);
|
|
115
|
+
const fromHeader = headerValue(headers, "x-fest-token");
|
|
116
|
+
// Path wins: it is the form we hand developers (`ANTHROPIC_BASE_URL=…/t/<tok>`),
|
|
117
|
+
// so if both are present the URL is the deliberate one.
|
|
118
|
+
//
|
|
119
|
+
// Precedence: path, then X-Fest-Token, then the auth header. The path form is
|
|
120
|
+
// what we hand developers, so when several are present the URL is the
|
|
121
|
+
// deliberate one; the auth header is last because it is the only position a
|
|
122
|
+
// token can end up in by accident (a developer pasting a key into the wrong
|
|
123
|
+
// variable).
|
|
124
|
+
const headerToken = fromHeader !== null && fromHeader.trim() !== "" ? fromHeader.trim() : null;
|
|
125
|
+
const identityToken = fromPath.token ?? headerToken ?? tokenFromAuthHeader;
|
|
126
|
+
const carrier = fromPath.token !== null
|
|
127
|
+
? "path"
|
|
128
|
+
: headerToken !== null
|
|
129
|
+
? "header"
|
|
130
|
+
: tokenFromAuthHeader !== null
|
|
131
|
+
? "auth_header"
|
|
132
|
+
: "none";
|
|
133
|
+
const identity = {
|
|
134
|
+
carrier,
|
|
135
|
+
tokenFingerprint: identityToken === null ? null : fingerprint(identityToken),
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
posture,
|
|
139
|
+
upstreamCredential,
|
|
140
|
+
identity,
|
|
141
|
+
identityToken,
|
|
142
|
+
effectivePath: fromPath.remainder,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=posture.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"posture.js","sourceRoot":"","sources":["../../../server/auth/posture.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard sessions: an opaque cookie, hashed at rest.
|
|
3
|
+
*
|
|
4
|
+
* A cookie rather than a JWT. Fest already has a database on the cold path, and
|
|
5
|
+
* the property that matters for an admin console is *instant revocation* —
|
|
6
|
+
* "sign out everywhere" has to mean it. A stateless token cannot do that
|
|
7
|
+
* without a revocation table, at which point it is a session with extra steps
|
|
8
|
+
* and a signing key to lose.
|
|
9
|
+
*
|
|
10
|
+
* Two clocks, deliberately:
|
|
11
|
+
* - `last_seen_at` + IDLE_MS — an unattended console stops working.
|
|
12
|
+
* - `expires_at` (absolute) — a stolen cookie has a bounded life even if
|
|
13
|
+
* the thief keeps it warm.
|
|
14
|
+
*
|
|
15
|
+
* NOT ON THE HOT PATH. These are synchronous SQLite reads, which is only
|
|
16
|
+
* acceptable because the proxy path never calls them — see the guardrail
|
|
17
|
+
* comment in store/db.ts.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
20
|
+
import { newId } from "../store/ids.js";
|
|
21
|
+
const TOKEN_BYTES = 32;
|
|
22
|
+
export const IDLE_MS = 12 * 3_600_000;
|
|
23
|
+
export const ABSOLUTE_MS = 14 * 86_400_000;
|
|
24
|
+
/**
|
|
25
|
+
* `__Host-` is not cosmetic: it makes the browser refuse the cookie unless it
|
|
26
|
+
* is Secure, host-only and path `/`, which removes cookie-fixation from a
|
|
27
|
+
* sibling subdomain. It requires HTTPS, so plain-HTTP deployments (a loopback
|
|
28
|
+
* trial) get the unprefixed name.
|
|
29
|
+
*/
|
|
30
|
+
export const COOKIE_NAME = "fest_session";
|
|
31
|
+
export const SECURE_COOKIE_NAME = "__Host-fest_session";
|
|
32
|
+
const hash = (raw) => createHash("sha256").update(raw, "utf8").digest("hex");
|
|
33
|
+
export function cookieName(secure) {
|
|
34
|
+
return secure ? SECURE_COOKIE_NAME : COOKIE_NAME;
|
|
35
|
+
}
|
|
36
|
+
export function parseCookies(header) {
|
|
37
|
+
const out = {};
|
|
38
|
+
if (header === undefined)
|
|
39
|
+
return out;
|
|
40
|
+
for (const part of header.split(";")) {
|
|
41
|
+
const eq = part.indexOf("=");
|
|
42
|
+
if (eq === -1)
|
|
43
|
+
continue;
|
|
44
|
+
const k = part.slice(0, eq).trim();
|
|
45
|
+
if (k !== "")
|
|
46
|
+
out[k] = decodeURIComponent(part.slice(eq + 1).trim());
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export function serializeCookie(raw, opts) {
|
|
51
|
+
const parts = [
|
|
52
|
+
`${cookieName(opts.secure)}=${raw}`,
|
|
53
|
+
"Path=/",
|
|
54
|
+
"HttpOnly",
|
|
55
|
+
// Strict, not Lax. The dashboard is never linked to from elsewhere, so
|
|
56
|
+
// nothing legitimate arrives cross-site, and Strict is a second line behind
|
|
57
|
+
// the Origin check for unsafe methods.
|
|
58
|
+
"SameSite=Strict",
|
|
59
|
+
`Max-Age=${opts.maxAgeSeconds}`,
|
|
60
|
+
];
|
|
61
|
+
if (opts.secure)
|
|
62
|
+
parts.push("Secure");
|
|
63
|
+
return parts.join("; ");
|
|
64
|
+
}
|
|
65
|
+
export function clearedCookie(secure) {
|
|
66
|
+
const parts = [`${cookieName(secure)}=`, "Path=/", "HttpOnly", "SameSite=Strict", "Max-Age=0"];
|
|
67
|
+
if (secure)
|
|
68
|
+
parts.push("Secure");
|
|
69
|
+
return parts.join("; ");
|
|
70
|
+
}
|
|
71
|
+
export function createSession(store, args) {
|
|
72
|
+
const now = args.now ?? Date.now();
|
|
73
|
+
const raw = randomBytes(TOKEN_BYTES).toString("base64url");
|
|
74
|
+
const sessionId = newId("ses");
|
|
75
|
+
const expiresAt = now + ABSOLUTE_MS;
|
|
76
|
+
store.db
|
|
77
|
+
.prepare(`INSERT INTO sessions
|
|
78
|
+
(id, org_id, user_id, token_hash, created_at, expires_at, last_seen_at, user_agent, ip)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
80
|
+
.run(sessionId, args.orgId, args.userId, hash(raw), now, expiresAt, now, (args.userAgent ?? "").slice(0, 200), (args.ip ?? "").slice(0, 64));
|
|
81
|
+
return { sessionId, raw, expiresAt };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a presented cookie, rolling the idle clock forward.
|
|
85
|
+
*
|
|
86
|
+
* Returns null for absent, unknown, revoked, idle-expired and absolutely
|
|
87
|
+
* expired alike. The caller cannot tell them apart, and must not be able to:
|
|
88
|
+
* distinguishing "revoked" from "never existed" is an oracle for which sessions
|
|
89
|
+
* once existed.
|
|
90
|
+
*
|
|
91
|
+
* The role and email are read through a join rather than copied into the
|
|
92
|
+
* session at login, so a demotion or a disabled account takes effect on the
|
|
93
|
+
* next request instead of whenever the operator happens to sign out.
|
|
94
|
+
*/
|
|
95
|
+
export function resolveSession(store, raw, now = Date.now()) {
|
|
96
|
+
if (raw === null || raw === undefined || raw.length === 0)
|
|
97
|
+
return null;
|
|
98
|
+
const presented = hash(raw);
|
|
99
|
+
const row = store.db
|
|
100
|
+
.prepare(`SELECT s.id, s.org_id, s.user_id, s.token_hash, s.expires_at, s.last_seen_at, s.revoked_at,
|
|
101
|
+
u.email, u.role, u.disabled_at
|
|
102
|
+
FROM sessions s
|
|
103
|
+
JOIN users u ON u.id = s.user_id
|
|
104
|
+
WHERE s.token_hash = ?`)
|
|
105
|
+
.get(presented);
|
|
106
|
+
if (row === undefined)
|
|
107
|
+
return null;
|
|
108
|
+
const a = Buffer.from(presented, "hex");
|
|
109
|
+
const b = Buffer.from(row.token_hash, "hex");
|
|
110
|
+
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
111
|
+
return null;
|
|
112
|
+
if (row.revoked_at !== null)
|
|
113
|
+
return null;
|
|
114
|
+
if (row.disabled_at !== null)
|
|
115
|
+
return null;
|
|
116
|
+
if (row.expires_at <= now)
|
|
117
|
+
return null;
|
|
118
|
+
if (now - row.last_seen_at > IDLE_MS)
|
|
119
|
+
return null;
|
|
120
|
+
// Coarse: rewriting last_seen_at on every poll would turn a dashboard
|
|
121
|
+
// refreshing every few seconds into a steady write stream competing with the
|
|
122
|
+
// metering writer for the single SQLite writer.
|
|
123
|
+
if (now - row.last_seen_at > 60_000) {
|
|
124
|
+
store.db.prepare(`UPDATE sessions SET last_seen_at = ? WHERE id = ?`).run(now, row.id);
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
sessionId: row.id,
|
|
128
|
+
orgId: row.org_id,
|
|
129
|
+
userId: row.user_id,
|
|
130
|
+
email: row.email,
|
|
131
|
+
role: row.role,
|
|
132
|
+
expiresAt: row.expires_at,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
export function revokeSession(store, sessionId, now = Date.now()) {
|
|
136
|
+
const res = store.db
|
|
137
|
+
.prepare(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`)
|
|
138
|
+
.run(now, sessionId);
|
|
139
|
+
return Number(res.changes) > 0;
|
|
140
|
+
}
|
|
141
|
+
/** "Sign out everywhere" — and what a password change must do. */
|
|
142
|
+
export function revokeUserSessions(store, userId, now = Date.now()) {
|
|
143
|
+
const res = store.db
|
|
144
|
+
.prepare(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at IS NULL`)
|
|
145
|
+
.run(now, userId);
|
|
146
|
+
return Number(res.changes);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Delete sessions that can no longer authenticate anything.
|
|
150
|
+
*
|
|
151
|
+
* Rows are removed rather than left as tombstones: an expired session hash has
|
|
152
|
+
* no evidentiary value — the audit log holds the record of who signed in — and
|
|
153
|
+
* an unbounded table on a long-lived deployment is just a slower index.
|
|
154
|
+
*/
|
|
155
|
+
export function sweepSessions(store, now = Date.now()) {
|
|
156
|
+
const res = store.db
|
|
157
|
+
.prepare(`DELETE FROM sessions WHERE expires_at <= ? OR last_seen_at < ? OR revoked_at IS NOT NULL`)
|
|
158
|
+
.run(now, now - IDLE_MS);
|
|
159
|
+
return Number(res.changes);
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=session.js.map
|