diff --git a/.env.example b/.env.example index fe71790..2e73886 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,16 @@ # ADMIN_KEY= # enable multi-tenant projects (manage via /v1/admin) # CORS_ORIGIN= # comma-separated browser origin allowlist (unset = open) +# Verifiable end-user identity (JWT/OIDC). When set, a caller may present a token +# (Authorization: Bearer , X-User-Token, or ?token=) → verified as req.user, +# echoed at /v1/me, used as a trusted realtime presence identity, and (on an +# API_KEY-locked instance) accepted in place of the key. Use a shared secret OR a +# JWKS URL — not both. Unset = identity disabled. +# AUTH_JWT_SECRET= # HS256 shared secret +# AUTH_JWKS_URL= # OIDC JWKS endpoint (RS/ES), e.g. https://issuer/.well-known/jwks.json +# AUTH_JWT_ISSUER= # required `iss` (optional but recommended) +# AUTH_JWT_AUDIENCE= # required `aud` (optional but recommended) + # --- limits --- # MAX_UPLOAD_BYTES=52428800 # 50 MB # RATE_LIMIT_MAX=600 # requests per window per IP (0 disables) diff --git a/README.md b/README.md index 12e2657..58bc242 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,25 @@ secret and comparing to `X-Zero-Signature`. Management is key-gated (and per-pro in multi-tenant); since webhooks make outbound requests, run with a key and restrict egress if the instance is internet-exposed. +### Verifiable identity (JWT / OIDC) + +Optionally verify an end-user token so calls carry a **trusted identity**. Configure +either a shared HS256 secret (`AUTH_JWT_SECRET`) or an OIDC JWKS endpoint +(`AUTH_JWKS_URL`, with `AUTH_JWT_ISSUER` / `AUTH_JWT_AUDIENCE`). A caller presents the +token as `Authorization: Bearer `, an `X-User-Token` header (so it can sit +beside a project key), or `?token=` (for websockets): + +```bash +curl localhost:3737/v1/me -H "Authorization: Bearer $JWT" # -> { user: { sub, claims } } +``` + +A verified token is **echoed at `/v1/me`**, becomes the **trusted realtime presence +identity** (overriding `?as=`), and on an `API_KEY`-locked instance **stands in for +the key** — so end-users authenticate with their own IdP tokens instead of sharing a +secret. `Zero(base, { token })` sends it for you. An invalid/expired token is a +`401`; identity is off until configured. _(Row-level per-user authorization is the +natural next step and isn't included yet.)_ + ## Multi-tenant projects By default the server runs in **open mode**: one implicit project, no auth — exactly @@ -475,6 +494,9 @@ curl -X POST localhost:3737/v1/import \ | `API_KEY` | _(unset)_ | If set, locks the whole instance with one bearer key | | `ADMIN_KEY` | _(unset)_ | If set, enables multi-tenant projects (manage via `/v1/admin`) | | `CORS_ORIGIN` | _(unset → `*`)_ | Comma-separated browser origin allowlist; unset = open CORS | +| `AUTH_JWT_SECRET` | _(unset)_ | HS256 secret to verify end-user identity tokens (`req.user`, `/v1/me`) | +| `AUTH_JWKS_URL` | _(unset)_ | OIDC JWKS endpoint (RS/ES) — alternative to `AUTH_JWT_SECRET` | +| `AUTH_JWT_ISSUER` / `AUTH_JWT_AUDIENCE` | _(unset)_ | Required `iss` / `aud` enforced on tokens (recommended) | | `LOG_LEVEL` | `info` | Log verbosity: `trace`…`fatal`, or `silent` | ### Data store — SQLite (default) or Postgres @@ -615,9 +637,12 @@ alongside the SQLite + disk defaults; **declared indexed fields** (built JSON Schema** validation (opt-in); **cursor pagination** for stable iteration; **per-project public collections + live feeds** via a non-secret project id; realtime **presence** (join/leave/typing) with an optional **Redis** fan-out, -**Redis-backed rate limiting**, and presence TTLs for multi-process scale; and -**observability** — Prometheus `/metrics` + a `/ready` probe. - -**Next (larger):** verifiable identity (JWT/OIDC) for per-user ACL + trusted -presence; outbound webhooks on data changes; per-project AI provider config; -publish to npm + GHCR (the release workflow is wired; the first tag publishes). +**Redis-backed rate limiting**, and presence TTLs for multi-process scale; +**observability** — Prometheus `/metrics` + a `/ready` probe; **outbound webhooks** +(persisted, retried, HMAC-signed); **per-project AI provider** config; and +**verifiable identity** (JWT/OIDC) — trusted `req.user` / `/v1/me`, trusted presence, +and token auth for locked instances. + +**Next:** row-level per-user authorization (own-your-rows ACL) built on the new +verified identity; publish to npm + GHCR (the release workflow is wired; the first +tag publishes). diff --git a/package-lock.json b/package-lock.json index 4d9741e..5313c20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", "ioredis": "^5.11.1", + "jose": "^5.10.0", "pg": "^8.21.0", "prom-client": "^15.1.3" }, @@ -2236,6 +2237,15 @@ "node": ">= 10" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", diff --git a/package.json b/package.json index ab13f4f..e38d40b 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "better-sqlite3": "^12.10.0", "fastify": "^5.8.5", "ioredis": "^5.11.1", + "jose": "^5.10.0", "pg": "^8.21.0", "prom-client": "^15.1.3" }, diff --git a/public/docs.html b/public/docs.html index 5ecccb8..cdd4976 100644 --- a/public/docs.html +++ b/public/docs.html @@ -103,6 +103,7 @@

Auth & modes

GET /v1 reports the active mode and capability map; GET /v1/stats adds version, uptime, usage counts, and the AI provider.

+

Verifiable identity (optional). Configure AUTH_JWT_SECRET (HS256) or AUTH_JWKS_URL (OIDC) and a caller may present a token — Authorization: Bearer <jwt>, X-User-Token, or ?token= — verified into req.user, echoed at /v1/me, used as the trusted realtime presence identity, and accepted in place of API_KEY on a locked instance. Zero(base, { token }) sends it.

Request & response headers

@@ -323,6 +324,7 @@

Meta & health

+
GET/metricsPrometheus exposition — request rate/latency, memory, realtime connections. No per-tenant labels; restrict at the proxy if needed.
GET/v1Capabilities + endpoint map + mode + AI provider.
GET/v1/statsVersion, uptime, usage counts, AI provider readiness.
GET/v1/meThe verified end-user identity { user: { sub, claims } | null } (when JWT/OIDC auth is configured).
GET/sdk.js · /sdk.d.ts · /llms.txtThe browser client, its types, and the AI-readable guide.
@@ -356,6 +358,7 @@

Core & access

ADMIN_KEY—Enable multi-tenant projects CORS_ORIGIN* (open)Comma-separated browser origin allowlist REDIS_URL—Horizontal scale: cross-process realtime (messages + presence) + a shared per-IP rate-limit window + AUTH_JWT_SECRET / AUTH_JWKS_URL—Verify end-user identity tokens (HS256 secret, or OIDC JWKS) → req.user / /v1/me LOG_LEVELinfotrace … fatal, or silent diff --git a/public/sdk.d.ts b/public/sdk.d.ts index f1a5133..824ce27 100644 --- a/public/sdk.d.ts +++ b/public/sdk.d.ts @@ -20,6 +20,16 @@ declare namespace Zero { * the `X-Project-Id` header (and `?project=` on file URLs). */ project?: string; + /** + * A verified end-user identity token (JWT) for an instance with identity + * configured. Sent as `X-User-Token` (and `?token=` on websockets). + */ + token?: string; + } + + interface Identity { + sub: string; + claims: Record; } type Operator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in' | 'nin'; @@ -299,6 +309,8 @@ declare namespace Zero { interface Client { baseUrl: string; health(): Promise<{ ok: boolean; uptime: number }>; + /** The verified end-user identity for the configured token, or null. */ + me(): Promise; info(): Promise>; stats(): Promise; collections(): Promise<{ name: string; count: number }[]>; diff --git a/public/sdk.js b/public/sdk.js index 4cd1745..ae5ca09 100644 --- a/public/sdk.js +++ b/public/sdk.js @@ -41,11 +41,15 @@ // A non-secret project id for anonymous access to a project's PUBLIC // collections in multi-tenant mode — no secret key embedded in the page. var project = opts.project || null; + // A verified end-user identity token (JWT) — sent as X-User-Token so it can + // coexist with a project key, and as ?token= on websockets. + var token = opts.token || null; function headers(extra) { var h = Object.assign({}, extra || {}); if (apiKey) h['Authorization'] = 'Bearer ' + apiKey; if (project) h['X-Project-Id'] = project; + if (token) h['X-User-Token'] = token; return h; } @@ -301,6 +305,7 @@ var params = []; if (apiKey) params.push('key=' + encodeURIComponent(apiKey)); else if (project) params.push('project=' + encodeURIComponent(project)); + if (token) params.push('token=' + encodeURIComponent(token)); // verified identity if (opts.as != null) { params.push('as=' + encodeURIComponent(typeof opts.as === 'string' ? opts.as : JSON.stringify(opts.as))); } @@ -385,6 +390,8 @@ return { baseUrl: base, health: function () { return request('GET', '/health'); }, + // The verified end-user identity for the configured token, or null. + me: function () { return request('GET', '/v1/me').then(function (r) { return r.user; }); }, info: function () { return request('GET', '/v1'); }, // Operational stats: version, uptime, and usage counts for your project. stats: function () { return request('GET', '/v1/stats'); }, diff --git a/src/auth/identity.ts b/src/auth/identity.ts new file mode 100644 index 0000000..1c977d5 --- /dev/null +++ b/src/auth/identity.ts @@ -0,0 +1,67 @@ +import { jwtVerify, createRemoteJWKSet, type JWTPayload } from 'jose'; +import { config } from '../config.js'; + +/** A verified caller identity from a JWT. */ +export interface Identity { + sub: string; + claims: JWTPayload; +} + +export type AuthConfig = typeof config.auth; + +/** Verifies a token, returning the identity, or null if it's invalid/expired. */ +export type Verifier = (token: string) => Promise; + +/** Whether end-user identity is configured (a secret or a JWKS URL is set). */ +export function identityEnabled(auth: AuthConfig = config.auth): boolean { + return !!(auth.jwtSecret || auth.jwksUrl); +} + +function toIdentity(payload: JWTPayload): Identity | null { + return typeof payload.sub === 'string' && payload.sub.length > 0 ? { sub: payload.sub, claims: payload } : null; +} + +/** + * Build a token verifier from the auth config. Prefers a JWKS URL (OIDC, RS/ES) + * when set, else a shared HS256 secret. Issuer/audience are enforced when + * configured. Returns a verifier that resolves null for any invalid token + * (bad signature, wrong iss/aud, expired, or missing `sub`) — never throws. + */ +export function createVerifier(auth: AuthConfig = config.auth): Verifier { + const opts = { + ...(auth.issuer ? { issuer: auth.issuer } : {}), + ...(auth.audience ? { audience: auth.audience } : {}), + }; + + if (auth.jwksUrl) { + const jwks = createRemoteJWKSet(new URL(auth.jwksUrl)); + return async (token) => { + try { + const { payload } = await jwtVerify(token, jwks, opts); + return toIdentity(payload); + } catch { + return null; + } + }; + } + + if (auth.jwtSecret) { + const secret = new TextEncoder().encode(auth.jwtSecret); + return async (token) => { + try { + const { payload } = await jwtVerify(token, secret, opts); + return toIdentity(payload); + } catch { + return null; + } + }; + } + + return async () => null; // identity disabled +} + +/** Looks like a compact JWS/JWT: three non-empty dot-separated segments. */ +export function looksLikeJwt(token: string): boolean { + const parts = token.split('.'); + return parts.length === 3 && parts.every((p) => p.length > 0); +} diff --git a/src/config.ts b/src/config.ts index c2b551c..d27f3bd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -46,6 +46,17 @@ export const config = { // (authenticated with this key). Unset = open single-tenant mode (default). adminKey: process.env.ADMIN_KEY ?? null, + // Optional verifiable end-user identity (JWT/OIDC). When configured, a caller + // may present a token (Authorization: Bearer , X-User-Token, or ?token=) + // that the server verifies → req.user. Either a shared HS256 secret, or a + // JWKS URL for an OIDC issuer (RS/ES). Unset = identity disabled. + auth: { + jwtSecret: process.env.AUTH_JWT_SECRET ?? null, + jwksUrl: process.env.AUTH_JWKS_URL ?? null, + issuer: process.env.AUTH_JWT_ISSUER ?? null, + audience: process.env.AUTH_JWT_AUDIENCE ?? null, + }, + // Allowed CORS origins. Unset = open (`*`), so arbitrary AI-generated sites // can call the API cross-origin — the zero-config default. In production, set // a comma-separated allowlist (e.g. "https://app.example,https://admin.example") diff --git a/src/modules/realtime/routes.ts b/src/modules/realtime/routes.ts index 37f1bbe..8e6c782 100644 --- a/src/modules/realtime/routes.ts +++ b/src/modules/realtime/routes.ts @@ -50,8 +50,11 @@ export async function realtimeRoutes(app: FastifyInstance): Promise { app.get('/v1/realtime/:channel', { websocket: true }, (socket: WebSocket, req) => { const { channel } = req.params as { channel: string }; const ch = key(req.projectId, channel); + // A verified end-user identity (JWT) is a trusted presence label and wins + // over the client-supplied ?as=; otherwise fall back to ?as=. const asRaw = (req.query as { as?: string } | undefined)?.as; - const member = { cid: newId(), identity: typeof asRaw === 'string' && asRaw ? asRaw : null }; + const identity = req.user?.sub ?? (typeof asRaw === 'string' && asRaw ? asRaw : null); + const member = { cid: newId(), identity }; hub.join(ch, socket); diff --git a/src/server.ts b/src/server.ts index 75f50cd..00650dd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,6 +25,7 @@ import { webhookRoutes } from './modules/webhooks/routes.js'; import { AclStore } from './modules/acl/store.js'; import { SchemaStore, SchemaValidationError } from './modules/data/schema.js'; import { AiConfigStore } from './modules/ai/config-store.js'; +import { createVerifier, identityEnabled, looksLikeJwt, type AuthConfig } from './auth/identity.js'; import { getBlobStore } from './modules/files/blob.js'; import { newId } from './lib/id.js'; @@ -86,6 +87,8 @@ export interface BuildOptions { corsOrigin?: string | null; /** Override the webhook max delivery attempts (used by the test suite). */ webhookMaxAttempts?: number; + /** Override the JWT/OIDC identity config (used by the test suite). */ + auth?: AuthConfig; } /** Resolve the @fastify/cors `origin` option: an allowlist array, or `true` (open). */ @@ -144,6 +147,13 @@ export async function buildServer(opts: BuildOptions = {}): Promise (only when + // it looks like a JWT, so it never clashes with an api/project key), the + // X-User-Token header, or ?token= (websockets can't set headers). + const userToken = (req: { headers: Record; query: unknown }): string | null => { + const authz = req.headers['authorization']; + if (typeof authz === 'string' && authz.startsWith('Bearer ')) { + const t = authz.slice(7); + if (looksLikeJwt(t)) return t; + } + const h = req.headers['x-user-token']; + if (typeof h === 'string' && h) return h; + const q = (req.query as { token?: string } | undefined)?.token; + return typeof q === 'string' && q ? q : null; + }; + app.addHook('onRequest', async (req, reply) => { req.projectId = DEFAULT_PROJECT; const url = req.url.split('?')[0] ?? ''; if (url === '/health') return; + // Verify an end-user identity token if one is presented (any mode). A + // presented-but-invalid token is a 401; a valid one populates req.user. + if (idEnabled) { + const token = userToken(req); + if (token) { + const identity = await verifyUser(token); + if (!identity) return reply.code(401).send({ error: 'invalid_token', message: 'Invalid or expired identity token.' }); + req.user = identity; + } + } + // The identity echo endpoint is always reachable (reports req.user or null). + if (url === '/v1/me') return; + if (url === '/v1/admin' || url.startsWith('/v1/admin/')) { if (!adminKey) { return reply.code(403).send({ error: 'admin_disabled', message: 'Set ADMIN_KEY to manage projects.' }); @@ -233,9 +271,12 @@ export async function buildServer(opts: BuildOptions = {}): Promise