@bojanrajkovic/mcp-paprika 1.1.0 → 1.2.0-beta.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.
Files changed (65) hide show
  1. package/README.md +159 -30
  2. package/dist/auth/allowlist.d.ts +37 -0
  3. package/dist/auth/allowlist.js +69 -0
  4. package/dist/auth/auth-code-store.d.ts +41 -0
  5. package/dist/auth/auth-code-store.js +56 -0
  6. package/dist/auth/auth-request-store.d.ts +25 -0
  7. package/dist/auth/auth-request-store.js +27 -0
  8. package/dist/auth/build.d.ts +15 -0
  9. package/dist/auth/build.js +84 -0
  10. package/dist/auth/cleanup.d.ts +56 -0
  11. package/dist/auth/cleanup.js +127 -0
  12. package/dist/auth/client-registration.d.ts +82 -0
  13. package/dist/auth/client-registration.js +176 -0
  14. package/dist/auth/dcr-validator.d.ts +47 -0
  15. package/dist/auth/dcr-validator.js +232 -0
  16. package/dist/auth/errors.d.ts +108 -0
  17. package/dist/auth/errors.js +175 -0
  18. package/dist/auth/metadata.d.ts +28 -0
  19. package/dist/auth/metadata.js +47 -0
  20. package/dist/auth/oidc-client.d.ts +128 -0
  21. package/dist/auth/oidc-client.js +181 -0
  22. package/dist/auth/presets.d.ts +83 -0
  23. package/dist/auth/presets.js +131 -0
  24. package/dist/auth/provider.d.ts +41 -0
  25. package/dist/auth/provider.js +142 -0
  26. package/dist/auth/routes.d.ts +84 -0
  27. package/dist/auth/routes.js +336 -0
  28. package/dist/auth/token-store.d.ts +129 -0
  29. package/dist/auth/token-store.js +273 -0
  30. package/dist/auth/tokens.d.ts +60 -0
  31. package/dist/auth/tokens.js +82 -0
  32. package/dist/auth/ttl-store.d.ts +54 -0
  33. package/dist/auth/ttl-store.js +80 -0
  34. package/dist/auth/types.d.ts +444 -0
  35. package/dist/auth/types.js +206 -0
  36. package/dist/cache/disk-cache.d.ts +41 -3
  37. package/dist/cache/disk-cache.js +286 -173
  38. package/dist/cache/pantry-store.d.ts +12 -0
  39. package/dist/cache/pantry-store.js +41 -0
  40. package/dist/cache/recipe-store.d.ts +12 -0
  41. package/dist/cache/recipe-store.js +41 -0
  42. package/dist/features/discover-feature.js +2 -1
  43. package/dist/features/vector-store.js +2 -3
  44. package/dist/index.js +4 -5
  45. package/dist/paprika/client.js +38 -3
  46. package/dist/paprika/sync.js +78 -16
  47. package/dist/server/app-context.d.ts +3 -0
  48. package/dist/server/build.js +16 -5
  49. package/dist/tools/create.js +5 -1
  50. package/dist/tools/delete.js +5 -1
  51. package/dist/tools/helpers.d.ts +8 -1
  52. package/dist/tools/helpers.js +30 -3
  53. package/dist/tools/pantry-add.js +6 -1
  54. package/dist/tools/pantry-delete.js +5 -1
  55. package/dist/tools/pantry-helpers.js +24 -4
  56. package/dist/tools/pantry-update.js +5 -1
  57. package/dist/tools/update.js +5 -1
  58. package/dist/transport/http.js +90 -4
  59. package/dist/transport/stdio.js +2 -3
  60. package/dist/utils/config.d.ts +175 -4
  61. package/dist/utils/config.js +163 -3
  62. package/dist/utils/log.d.ts +27 -0
  63. package/dist/utils/log.js +31 -0
  64. package/dist/utils/xdg.js +23 -6
  65. package/package.json +16 -13
package/README.md CHANGED
@@ -20,10 +20,7 @@ An [MCP](https://modelcontextprotocol.io/) server for [Paprika](https://www.papr
20
20
  | `stdio` | yes | Local CLI clients: Claude Code, Claude Desktop, Cursor, mcp-cli |
21
21
  | `http` | no | Streamable HTTP for Claude Mobile and other HTTP-based MCP clients, or self-hosting |
22
22
 
23
- > **HTTP transport has no built-in authentication.** Do not expose port 3000 directly
24
- > to the public internet. Put it behind Cloudflare Access, Tailscale Serve, an OAuth2
25
- > proxy, or your reverse proxy of choice. OAuth 2.1 support is planned as a follow-up
26
- > — until then, network-trust is the supported deployment model.
23
+ The HTTP transport ships with **OAuth 2.1** (authorization code + PKCE, RFC 7591 dynamic client registration). See the [HTTP transport quick start](#quick-start--http-transport) below.
27
24
 
28
25
  ## Quick start — stdio (Claude Desktop / Claude Code / Cursor)
29
26
 
@@ -46,45 +43,155 @@ Add to your MCP client config:
46
43
 
47
44
  ## Quick start — HTTP transport
48
45
 
49
- Run with env vars set:
46
+ The HTTP transport uses **OAuth 2.1 with OIDC delegation**: `mcp-paprika` acts as the OAuth authorization server toward MCP clients, and delegates authentication to an upstream identity provider (IdP) of your choice.
47
+
48
+ ### Step 1 — Choose an upstream IdP
49
+
50
+ Pick a preset or supply a raw discovery URL:
51
+
52
+ | Preset value | IdP | Notes |
53
+ | ------------ | ----------------------------- | ------------------------------------------------------------- |
54
+ | `google` | Google | Discovery URL built-in |
55
+ | `entra` | Microsoft Entra ID (Azure AD) | Tenant-bound — requires `MCP_OIDC_DISCOVERY_URL` |
56
+ | `okta` | Okta | Tenant-bound — requires `MCP_OIDC_DISCOVERY_URL` |
57
+ | `auth0` | Auth0 | Tenant-bound — requires `MCP_OIDC_DISCOVERY_URL` |
58
+ | `keycloak` | Keycloak | Tenant-bound — requires `MCP_OIDC_DISCOVERY_URL` |
59
+ | _(none)_ | Custom | Set `MCP_OIDC_DISCOVERY_URL` directly; omit `MCP_OIDC_PRESET` |
60
+
61
+ ### Step 2 — Register one OAuth client in your IdP
62
+
63
+ In your IdP's developer console (e.g., Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client IDs), create a **single** OAuth 2.0 client with:
64
+
65
+ - **Application type:** Web application
66
+ - **Redirect URI:** `https://<your-MCP_PUBLIC_URL>/oauth/callback`
67
+
68
+ Copy the resulting client ID and client secret — these become `MCP_OIDC_CLIENT_ID` and `MCP_OIDC_CLIENT_SECRET`.
69
+
70
+ > **Tenant-bound presets (entra, okta, auth0, keycloak):** also copy the tenant-specific discovery URL from your IdP. For Entra this is `https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration`.
71
+
72
+ ### Step 3 — Configure and start the server
73
+
74
+ Full env-var reference:
75
+
76
+ | Env var | Required? | Description |
77
+ | -------------------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
78
+ | `MCP_TRANSPORT` | yes | Set to `http` |
79
+ | `MCP_PUBLIC_URL` | yes | Canonical `https://` URL of this server; used as OAuth issuer. No trailing slash. |
80
+ | `MCP_OIDC_PRESET` | one of preset or discoveryUrl | `google`, `entra`, `okta`, `auth0`, or `keycloak` |
81
+ | `MCP_OIDC_DISCOVERY_URL` | one of preset or discoveryUrl | Raw OIDC discovery URL; required for tenant-bound presets |
82
+ | `MCP_OIDC_CLIENT_ID` | yes | Client ID from upstream IdP |
83
+ | `MCP_OIDC_CLIENT_SECRET` | yes | Client secret from upstream IdP |
84
+ | `MCP_ALLOWED_EMAILS` | one of emails or subs | Comma-separated list of allowed email addresses |
85
+ | `MCP_ALLOWED_SUBS` | one of emails or subs | Comma-separated list of allowed subject IDs |
86
+ | `MCP_OIDC_SCOPES` | no | Override preset's scope list (comma-separated; default `openid email profile`) |
87
+ | `MCP_OIDC_EMAIL_VERIFIED_POLICY` | no | `strict` (default), `skip`, or `if-present` |
88
+ | `MCP_OIDC_ALLOWED_ALGS` | no | Override preset's allowed id_token signing algorithms (comma-separated) |
89
+ | `MCP_TRUST_PROXY` | no | `true` to trust `X-Forwarded-For` / `CF-Connecting-IP` for the DCR rate-limit key. Default `false` (safe for direct exposure). Set `true` only behind a sanitizing reverse proxy (k8s ingress, Tailscale Funnel, Cloudflare). |
90
+
91
+ Example startup command:
50
92
 
51
93
  ```bash
52
94
  MCP_TRANSPORT=http \
53
- MCP_HTTP_PORT=3000 \
95
+ MCP_PUBLIC_URL=https://mcp.example.com \
96
+ MCP_OIDC_PRESET=google \
97
+ MCP_OIDC_CLIENT_ID=123456789-abc.apps.googleusercontent.com \
98
+ MCP_OIDC_CLIENT_SECRET=GOCSPX-... \
99
+ MCP_ALLOWED_EMAILS=you@example.com \
54
100
  PAPRIKA_EMAIL=you@example.com \
55
101
  PAPRIKA_PASSWORD=your-password \
56
102
  npx -y @bojanrajkovic/mcp-paprika
57
103
  ```
58
104
 
59
- The server then exposes:
105
+ ### Step 4 — Configure the allowlist
60
106
 
61
- - `POST /mcp` MCP JSON-RPC over Streamable HTTP (single endpoint that multiplexes initialize, tools/list, tools/call, etc.)
62
- - `GET /mcp` — long-lived SSE channel for server→client notifications (resource list changed, log messages)
63
- - `DELETE /mcp` — session termination
64
- - `GET /healthz` — liveness probe returning `{ "ok": true, "sessions": <n> }`
107
+ The allowlist uses **OR semantics**: access is granted if the authenticated user's email is in `MCP_ALLOWED_EMAILS` OR their subject ID is in `MCP_ALLOWED_SUBS`. At least one list must be non-empty.
65
108
 
66
- Verify locally:
109
+ - **`MCP_ALLOWED_EMAILS`** — comma-separated email addresses. Subject to `MCP_OIDC_EMAIL_VERIFIED_POLICY`:
110
+ - `strict` (default): email must be present and `email_verified = true`
111
+ - `skip`: email is accepted without checking `email_verified`
112
+ - `if-present`: if `email_verified` is in the id_token, it must be `true`; if absent, the email is accepted
113
+ - **`MCP_ALLOWED_SUBS`** — comma-separated subject IDs (stable per-user opaque identifiers from the IdP). Useful when you want to allow access regardless of email verification status.
114
+
115
+ ### Step 5 — Add as a Claude connector
116
+
117
+ 1. Open [claude.ai](https://claude.ai) → Settings → Connectors
118
+ 2. Click "Add custom connector"
119
+ 3. Enter your server URL: `https://<MCP_PUBLIC_URL>/mcp`
120
+ 4. Claude will redirect your browser to the upstream IdP for authentication
121
+ 5. After sign-in, you are redirected back and the connector is authorized
122
+
123
+ ### Verify the OAuth metadata
67
124
 
68
125
  ```bash
69
- curl -sf http://127.0.0.1:3000/healthz
70
- # → {"ok":true,"sessions":0}
126
+ curl -sf https://<MCP_PUBLIC_URL>/.well-known/oauth-authorization-server | jq .issuer
127
+ # → "https://<MCP_PUBLIC_URL>"
71
128
  ```
72
129
 
130
+ The server also exposes:
131
+
132
+ - `POST /mcp` — MCP JSON-RPC over Streamable HTTP
133
+ - `GET /mcp` — long-lived SSE channel for server→client notifications
134
+ - `DELETE /mcp` — session termination
135
+ - `GET /healthz` — liveness probe returning `{ "ok": true, "sessions": <n> }`
136
+
73
137
  ## Quick start — container
74
138
 
139
+ The image defaults to `MCP_TRANSPORT=http`, so a container run needs the same
140
+ OAuth environment that the [HTTP transport quick start](#quick-start--http-transport)
141
+ walks through — `MCP_PUBLIC_URL`, an OIDC preset (or discovery URL), upstream
142
+ client credentials, and a non-empty allowlist. Without those, the server exits
143
+ during config validation.
144
+
145
+ Pull the published image (multi-arch: `linux/amd64`, `linux/arm64`):
146
+
75
147
  ```bash
76
- docker build -t mcp-paprika:dev .
148
+ docker pull ghcr.io/bojanrajkovic/mcp-paprika:latest
77
149
 
78
150
  docker run --rm \
79
151
  -e PAPRIKA_EMAIL=you@example.com \
80
152
  -e PAPRIKA_PASSWORD=your-password \
153
+ -e MCP_PUBLIC_URL=https://mcp.example.com \
154
+ -e MCP_OIDC_PRESET=google \
155
+ -e MCP_OIDC_CLIENT_ID=123456789-abc.apps.googleusercontent.com \
156
+ -e MCP_OIDC_CLIENT_SECRET=GOCSPX-... \
157
+ -e MCP_ALLOWED_EMAILS=you@example.com \
81
158
  -v "$(pwd)/data:/data" \
82
159
  -p 3000:3000 \
83
- mcp-paprika:dev
160
+ ghcr.io/bojanrajkovic/mcp-paprika:latest
161
+ ```
162
+
163
+ The image is signed with [sigstore/cosign](https://github.com/sigstore/cosign) keyless OIDC and ships SLSA build provenance + an SPDX SBOM as OCI attestations. Verify both before running in untrusted environments — `gh attestation verify` without `--predicate-type` only validates the default (provenance) attestation, so the SBOM needs its own verification:
164
+
165
+ ```bash
166
+ # SLSA build provenance
167
+ gh attestation verify oci://ghcr.io/bojanrajkovic/mcp-paprika:latest \
168
+ --owner bojanrajkovic \
169
+ --predicate-type https://slsa.dev/provenance/v1
170
+
171
+ # SPDX SBOM
172
+ gh attestation verify oci://ghcr.io/bojanrajkovic/mcp-paprika:latest \
173
+ --owner bojanrajkovic \
174
+ --predicate-type https://spdx.dev/Document/v2.3
84
175
  ```
85
176
 
86
- The image defaults to `MCP_TRANSPORT=http`, binds on `0.0.0.0:3000`, and persists the
87
- disk cache and vector index under `/data` (the documented mount point). Both `/data`
177
+ Contributors building from source can use `docker build -t mcp-paprika:dev .` and substitute `mcp-paprika:dev` for the image reference below.
178
+
179
+ For a one-shot smoke test that just verifies the image launches (no OAuth, no
180
+ remote clients), override the transport to `stdio` — note that this turns the
181
+ container into a CLI process that speaks MCP on stdin/stdout, so the port
182
+ mapping isn't used:
183
+
184
+ ```bash
185
+ docker run --rm -i \
186
+ -e MCP_TRANSPORT=stdio \
187
+ -e PAPRIKA_EMAIL=you@example.com \
188
+ -e PAPRIKA_PASSWORD=your-password \
189
+ -v "$(pwd)/data:/data" \
190
+ ghcr.io/bojanrajkovic/mcp-paprika:latest
191
+ ```
192
+
193
+ The HTTP-mode image binds on `0.0.0.0:3000` and persists the disk cache and
194
+ vector index under `/data` (the documented mount point). Both `/data`
88
195
  sub-directories (`config/`, `cache/`) are pre-created with `nonroot` (UID 65532)
89
196
  ownership in the image so writes work the first time even on a fresh bind-mount.
90
197
 
@@ -101,7 +208,7 @@ docker run --rm \
101
208
  -e PAPRIKA_EMAIL=... -e PAPRIKA_PASSWORD=... \
102
209
  -v mcp-paprika-data:/data \
103
210
  -p 3000:3000 \
104
- mcp-paprika:dev
211
+ ghcr.io/bojanrajkovic/mcp-paprika:latest
105
212
  ```
106
213
 
107
214
  The image also declares a `HEALTHCHECK` that hits `GET /healthz`; verify with:
@@ -113,17 +220,39 @@ docker inspect --format '{{.State.Health.Status}}' <container>
113
220
 
114
221
  ## Deployment patterns (HTTP transport)
115
222
 
116
- Because the HTTP transport ships without authentication, the supported deployment is
117
- "behind a network-trust boundary." Some good options:
223
+ The HTTP transport ships with OAuth 2.1 built in. The primary remaining concerns are TLS termination and, optionally, additional network-layer controls.
224
+
225
+ **TLS termination** is required — `MCP_PUBLIC_URL` must be `https://` and OAuth requires encrypted connections end-to-end. Recommended options:
226
+
227
+ - **Reverse proxy with TLS** (nginx / Caddy) — terminates TLS, passes `X-Forwarded-For` headers (required for rate limiting), and forwards to the container on a private port.
228
+ - **Cloudflare Tunnel** — no inbound port exposed; Cloudflare terminates TLS. Works well with Cloudflare Access for an additional authentication layer if desired.
229
+ - **Tailscale HTTPS** — Tailscale's built-in HTTPS cert provisioning; suitable for homelab setups where all clients are on your tailnet.
230
+
231
+ **Container deployment with Docker Compose:**
232
+
233
+ ```yaml
234
+ services:
235
+ mcp-paprika:
236
+ image: ghcr.io/bojanrajkovic/mcp-paprika:latest
237
+ environment:
238
+ MCP_TRANSPORT: http
239
+ MCP_PUBLIC_URL: https://mcp.example.com
240
+ MCP_OIDC_PRESET: google
241
+ MCP_OIDC_CLIENT_ID: "<your-client-id>"
242
+ MCP_OIDC_CLIENT_SECRET: "<your-client-secret>"
243
+ MCP_ALLOWED_EMAILS: "you@example.com"
244
+ PAPRIKA_EMAIL: "you@example.com"
245
+ PAPRIKA_PASSWORD: "<your-paprika-password>"
246
+ volumes:
247
+ - mcp-paprika-data:/data
248
+ ports:
249
+ - "127.0.0.1:3000:3000"
250
+
251
+ volumes:
252
+ mcp-paprika-data:
253
+ ```
118
254
 
119
- - **Cloudflare Tunnel + Cloudflare Access** (recommended for public reachability) a
120
- zero-trust front door with SSO/IdP integration, no inbound ports exposed.
121
- - **Tailscale Serve** — exposes the container only over your tailnet; perfect for
122
- homelab / single-user setups.
123
- - **OAuth2 proxy** (e.g. [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy))
124
- in front of the container.
125
- - **Reverse proxy basic-auth** (nginx / Caddy `basic_auth`) for the simplest setup
126
- when you really just need a password gate.
255
+ OAuth provides authentication-level controls; the reverse proxy provides TLS, rate limiting, and any additional network-level restrictions the deployment requires.
127
256
 
128
257
  ## Documentation
129
258
 
@@ -131,7 +260,7 @@ Because the HTTP transport ships without authentication, the supported deploymen
131
260
  - **[Tools reference](docs/tools/)** — every tool with parameters and examples
132
261
  - **[Embedding providers](docs/embedding-providers.md)** — set up semantic search with Ollama, OpenAI, OpenRouter, etc.
133
262
  - **[Architecture](docs/architecture.md)** — how it works under the hood
134
- - **[Verified MCP SDK API](docs/verified-api.md)** — the authoritative reference for SDK import paths and the Streamable HTTP wiring
263
+ - **[Releasing](docs/releasing.md)** — maintainer-facing release model, prerelease validation, attestation verification
135
264
 
136
265
  ## License
137
266
 
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Identity verification against allowlists with email_verified policy enforcement.
3
+ *
4
+ * Pure function — no I/O, no side effects. Returns Result<VerifiedIdentity, OAuthAllowlistDenialError>.
5
+ *
6
+ * Algorithm (email-precedence):
7
+ * 1. If email would be admitted (email in list AND policy allows), return with source=email
8
+ * 2. If email is in list but policy denies, return emailNotVerified error (blocks sub fallback)
9
+ * 3. Else if sub is in allowlist, return with source=sub
10
+ * 4. Else return notAllowlisted error
11
+ *
12
+ * Policy semantics:
13
+ * - strict: require email_verified === true; undefined or false both deny
14
+ * - skip: ignore email_verified entirely
15
+ * - if-present: deny only if email_verified === false; undefined (missing) is OK
16
+ */
17
+ import { type Result } from "neverthrow";
18
+ import { OAuthAllowlistDenialError } from "./errors.js";
19
+ import type { IdTokenPayload, EmailVerifiedPolicy } from "./types.js";
20
+ export interface AllowlistInput {
21
+ readonly emails: ReadonlySet<string>;
22
+ readonly subs: ReadonlySet<string>;
23
+ }
24
+ export interface VerifiedIdentity {
25
+ readonly email: string | null;
26
+ readonly sub: string;
27
+ readonly source: "email" | "sub";
28
+ }
29
+ /**
30
+ * Verifies an identity against email and sub allowlists with email_verified policy enforcement.
31
+ *
32
+ * @param payload The id_token payload from upstream OIDC provider
33
+ * @param policy The email verification policy (strict | skip | if-present)
34
+ * @param allowlist Email and sub allowlists as ReadonlySets
35
+ * @returns Ok(VerifiedIdentity) if admitted; Err(OAuthAllowlistDenialError) if denied
36
+ */
37
+ export declare function verifyIdentity(payload: IdTokenPayload, policy: EmailVerifiedPolicy, allowlist: AllowlistInput): Result<VerifiedIdentity, OAuthAllowlistDenialError>;
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Identity verification against allowlists with email_verified policy enforcement.
3
+ *
4
+ * Pure function — no I/O, no side effects. Returns Result<VerifiedIdentity, OAuthAllowlistDenialError>.
5
+ *
6
+ * Algorithm (email-precedence):
7
+ * 1. If email would be admitted (email in list AND policy allows), return with source=email
8
+ * 2. If email is in list but policy denies, return emailNotVerified error (blocks sub fallback)
9
+ * 3. Else if sub is in allowlist, return with source=sub
10
+ * 4. Else return notAllowlisted error
11
+ *
12
+ * Policy semantics:
13
+ * - strict: require email_verified === true; undefined or false both deny
14
+ * - skip: ignore email_verified entirely
15
+ * - if-present: deny only if email_verified === false; undefined (missing) is OK
16
+ */
17
+ import { ok, err } from "neverthrow";
18
+ import { OAuthAllowlistDenialError } from "./errors.js";
19
+ /**
20
+ * Determines if a policy allows the given email_verified claim.
21
+ *
22
+ * @param verified The email_verified claim from the id_token (true | false | undefined)
23
+ * @param policy The email verification policy (strict | skip | if-present)
24
+ * @returns true if policy allows; false if policy denies
25
+ */
26
+ function policyAllows(verified, policy) {
27
+ switch (policy) {
28
+ case "strict":
29
+ // Only true is allowed; false and undefined both deny
30
+ return verified === true;
31
+ case "skip":
32
+ // Always allow, ignore verified entirely
33
+ return true;
34
+ case "if-present":
35
+ // Allow if verified is true or undefined; deny only if false
36
+ return verified !== false;
37
+ }
38
+ }
39
+ /**
40
+ * Verifies an identity against email and sub allowlists with email_verified policy enforcement.
41
+ *
42
+ * @param payload The id_token payload from upstream OIDC provider
43
+ * @param policy The email verification policy (strict | skip | if-present)
44
+ * @param allowlist Email and sub allowlists as ReadonlySets
45
+ * @returns Ok(VerifiedIdentity) if admitted; Err(OAuthAllowlistDenialError) if denied
46
+ */
47
+ export function verifyIdentity(payload, policy, allowlist) {
48
+ const email = payload.email;
49
+ const sub = payload.sub;
50
+ const verified = payload.email_verified;
51
+ // Step 1: Resolve email match — would email be admitted by policy?
52
+ const emailWouldAdmit = email && allowlist.emails.has(email) && policyAllows(verified, policy);
53
+ // Step 2: If email would be admitted, return with source=email
54
+ if (emailWouldAdmit) {
55
+ return ok({ email, sub, source: "email" });
56
+ }
57
+ // Step 3: Else if sub is in allowlist, return with source=sub
58
+ // (sub does not depend on email_verified, so this is a fallback)
59
+ if (sub && allowlist.subs.has(sub)) {
60
+ return ok({ email: email ?? null, sub, source: "sub" });
61
+ }
62
+ // Step 4: Else if email is in list but policy denies it, return emailNotVerified error
63
+ // (This is only reached if sub did not match, ensuring email takes precedence)
64
+ if (email && allowlist.emails.has(email) && !policyAllows(verified, policy)) {
65
+ return err(OAuthAllowlistDenialError.emailNotVerified(email, policy));
66
+ }
67
+ // Step 5: Neither email nor sub admitted the identity
68
+ return err(OAuthAllowlistDenialError.notAllowlisted(email ?? null, sub));
69
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * In-memory TTL store for OAuth 2.1 post-callback state (authorization code).
3
+ *
4
+ * Keyed by `our_auth_code` (our unique authorization code). Default TTL: 60 seconds.
5
+ * Consume-on-read: calling `consume()` atomically deletes the entry for single-use replay protection.
6
+ * Lazy TTL eviction: expired entries are deleted on `consume()`, `peek()`, or `sweepExpired()`.
7
+ *
8
+ * Identity is populated by the `/oauth/callback` handler after verifying the
9
+ * upstream id_token and checking the allowlist. The store does not enrich; it stores
10
+ * whatever is handed to `put()`.
11
+ *
12
+ * No persistence across restart (in-memory only). Auth codes do NOT survive process restart,
13
+ * which aligns with OAuth 2.1 short-lived code semantics and prevents code-reuse across restarts.
14
+ */
15
+ import type { AuthCodeState } from "./types.js";
16
+ import { TtlStore } from "./ttl-store.js";
17
+ export declare class AuthCodeStore extends TtlStore<AuthCodeState> {
18
+ /**
19
+ * Constructor with optional TTL and clock injection for testing.
20
+ *
21
+ * @param opts.ttlMs - TTL in milliseconds (default: AUTH_CODE_TTL_SECONDS * 1000)
22
+ * @param opts.now - Clock function returning milliseconds (default: Date.now)
23
+ */
24
+ constructor(opts?: {
25
+ readonly ttlMs?: number;
26
+ readonly now?: () => number;
27
+ });
28
+ /**
29
+ * Retrieve WITHOUT consuming an authorization code state.
30
+ *
31
+ * Used by the provider's challengeForAuthorizationCode which runs BEFORE
32
+ * exchangeAuthorizationCode in the same /token request. The actual consume
33
+ * happens later in exchangeAuthorizationCode.
34
+ *
35
+ * Still evicts expired entries on peek (lazy eviction).
36
+ *
37
+ * @param authCode - The authorization code to peek at
38
+ * @returns The AuthCodeState, or null if not found or expired
39
+ */
40
+ peek(authCode: string): AuthCodeState | null;
41
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * In-memory TTL store for OAuth 2.1 post-callback state (authorization code).
3
+ *
4
+ * Keyed by `our_auth_code` (our unique authorization code). Default TTL: 60 seconds.
5
+ * Consume-on-read: calling `consume()` atomically deletes the entry for single-use replay protection.
6
+ * Lazy TTL eviction: expired entries are deleted on `consume()`, `peek()`, or `sweepExpired()`.
7
+ *
8
+ * Identity is populated by the `/oauth/callback` handler after verifying the
9
+ * upstream id_token and checking the allowlist. The store does not enrich; it stores
10
+ * whatever is handed to `put()`.
11
+ *
12
+ * No persistence across restart (in-memory only). Auth codes do NOT survive process restart,
13
+ * which aligns with OAuth 2.1 short-lived code semantics and prevents code-reuse across restarts.
14
+ */
15
+ import { AUTH_CODE_TTL_SECONDS } from "./tokens.js";
16
+ import { TtlStore } from "./ttl-store.js";
17
+ export class AuthCodeStore extends TtlStore {
18
+ /**
19
+ * Constructor with optional TTL and clock injection for testing.
20
+ *
21
+ * @param opts.ttlMs - TTL in milliseconds (default: AUTH_CODE_TTL_SECONDS * 1000)
22
+ * @param opts.now - Clock function returning milliseconds (default: Date.now)
23
+ */
24
+ constructor(opts) {
25
+ super({
26
+ ttlMs: opts?.ttlMs ?? AUTH_CODE_TTL_SECONDS * 1000,
27
+ ...(opts?.now !== undefined ? { now: opts.now } : {}),
28
+ });
29
+ }
30
+ /**
31
+ * Retrieve WITHOUT consuming an authorization code state.
32
+ *
33
+ * Used by the provider's challengeForAuthorizationCode which runs BEFORE
34
+ * exchangeAuthorizationCode in the same /token request. The actual consume
35
+ * happens later in exchangeAuthorizationCode.
36
+ *
37
+ * Still evicts expired entries on peek (lazy eviction).
38
+ *
39
+ * @param authCode - The authorization code to peek at
40
+ * @returns The AuthCodeState, or null if not found or expired
41
+ */
42
+ peek(authCode) {
43
+ const entry = this._entries.get(authCode);
44
+ if (entry === undefined)
45
+ return null;
46
+ // Check TTL: entry.createdAt is in seconds, _ttlMs is in milliseconds, _now() is in milliseconds
47
+ const expiresAt = entry.createdAt + this._ttlMs / 1000;
48
+ const now = Math.floor(this._now() / 1000);
49
+ if (expiresAt < now) {
50
+ // Still evict expired entries on peek
51
+ this._entries.delete(authCode);
52
+ return null; // expired
53
+ }
54
+ return entry;
55
+ }
56
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * In-memory TTL store for OAuth 2.1 pre-callback state (OIDC auth_request).
3
+ *
4
+ * Keyed by `our_state` (CSRF token). Default TTL: 5 minutes.
5
+ * Consume-on-read: calling `consume()` atomically deletes the entry for single-use semantics.
6
+ * Lazy TTL eviction: expired entries are deleted on `consume()` or `sweepExpired()`.
7
+ *
8
+ * No persistence across restart (in-memory only). Not used in normal production cleanup
9
+ * as the entries typically consume within seconds of creation. `sweepExpired()` is called
10
+ * periodically by `AuthCleanup` for memory hygiene.
11
+ */
12
+ import type { AuthRequestState } from "./types.js";
13
+ import { TtlStore } from "./ttl-store.js";
14
+ export declare class AuthRequestStore extends TtlStore<AuthRequestState> {
15
+ /**
16
+ * Constructor with optional TTL and clock injection for testing.
17
+ *
18
+ * @param opts.ttlMs - TTL in milliseconds (default: AUTH_REQUEST_TTL_SECONDS * 1000)
19
+ * @param opts.now - Clock function returning milliseconds (default: Date.now)
20
+ */
21
+ constructor(opts?: {
22
+ readonly ttlMs?: number;
23
+ readonly now?: () => number;
24
+ });
25
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * In-memory TTL store for OAuth 2.1 pre-callback state (OIDC auth_request).
3
+ *
4
+ * Keyed by `our_state` (CSRF token). Default TTL: 5 minutes.
5
+ * Consume-on-read: calling `consume()` atomically deletes the entry for single-use semantics.
6
+ * Lazy TTL eviction: expired entries are deleted on `consume()` or `sweepExpired()`.
7
+ *
8
+ * No persistence across restart (in-memory only). Not used in normal production cleanup
9
+ * as the entries typically consume within seconds of creation. `sweepExpired()` is called
10
+ * periodically by `AuthCleanup` for memory hygiene.
11
+ */
12
+ import { AUTH_REQUEST_TTL_SECONDS } from "./tokens.js";
13
+ import { TtlStore } from "./ttl-store.js";
14
+ export class AuthRequestStore extends TtlStore {
15
+ /**
16
+ * Constructor with optional TTL and clock injection for testing.
17
+ *
18
+ * @param opts.ttlMs - TTL in milliseconds (default: AUTH_REQUEST_TTL_SECONDS * 1000)
19
+ * @param opts.now - Clock function returning milliseconds (default: Date.now)
20
+ */
21
+ constructor(opts) {
22
+ super({
23
+ ttlMs: opts?.ttlMs ?? AUTH_REQUEST_TTL_SECONDS * 1000,
24
+ ...(opts?.now !== undefined ? { now: opts.now } : {}),
25
+ });
26
+ }
27
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * buildAuthContext — constructs the OAuth 2.1 runtime for HTTP mode.
3
+ *
4
+ * Returns null when transport !== "http" (stdio mode needs no auth).
5
+ * Throws on misconfiguration or upstream discovery failure — this is a
6
+ * fail-fast startup operation; there is no value running HTTP mode if
7
+ * the OAuth stack can't authenticate anyone.
8
+ *
9
+ * Called once per process from buildAppContext (src/server/build.ts)
10
+ * after cache.init() completes.
11
+ */
12
+ import type { DiskCache } from "../cache/disk-cache.js";
13
+ import type { PaprikaConfig } from "../utils/config.js";
14
+ import type { AuthContext } from "./types.js";
15
+ export declare function buildAuthContext(config: PaprikaConfig, cache: DiskCache): Promise<AuthContext | null>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * buildAuthContext — constructs the OAuth 2.1 runtime for HTTP mode.
3
+ *
4
+ * Returns null when transport !== "http" (stdio mode needs no auth).
5
+ * Throws on misconfiguration or upstream discovery failure — this is a
6
+ * fail-fast startup operation; there is no value running HTTP mode if
7
+ * the OAuth stack can't authenticate anyone.
8
+ *
9
+ * Called once per process from buildAppContext (src/server/build.ts)
10
+ * after cache.init() completes.
11
+ */
12
+ import { resolvePreset } from "./presets.js";
13
+ import { loadDiscovery, createJwksFor } from "./oidc-client.js";
14
+ import { DiskClientRegistrationStore } from "./client-registration.js";
15
+ import { TokenStore } from "./token-store.js";
16
+ import { AuthRequestStore } from "./auth-request-store.js";
17
+ import { AuthCodeStore } from "./auth-code-store.js";
18
+ import { MintingOAuthServerProvider } from "./provider.js";
19
+ import { AuthCleanup } from "./cleanup.js";
20
+ import { MAX_REGISTERED_CLIENTS } from "./routes.js";
21
+ export async function buildAuthContext(config, cache) {
22
+ if (config.transport !== "http")
23
+ return null;
24
+ if (config.oauth === undefined) {
25
+ // The root-level superRefine in src/utils/config.ts catches this case before
26
+ // we get here, but defensively re-check so downstream code can rely on non-null.
27
+ throw new Error("OAuth config required for HTTP transport (should have failed at config load)");
28
+ }
29
+ const presetOverrides = {};
30
+ if (config.oauth.discoveryUrl !== undefined)
31
+ presetOverrides.discoveryUrl = config.oauth.discoveryUrl;
32
+ if (config.oauth.scopes !== undefined)
33
+ presetOverrides.scopes = config.oauth.scopes;
34
+ if (config.oauth.emailVerifiedPolicy !== undefined)
35
+ presetOverrides.emailVerifiedPolicy = config.oauth.emailVerifiedPolicy;
36
+ if (config.oauth.allowedAlgs !== undefined)
37
+ presetOverrides.allowedAlgs = config.oauth.allowedAlgs;
38
+ const resolveResult = resolvePreset(config.oauth.preset, presetOverrides);
39
+ const presetResult = resolveResult.match((r) => r, (e) => {
40
+ throw e; // fail-fast at startup
41
+ });
42
+ // Assemble full ResolvedOAuthConfig by merging the preset result with
43
+ // the deployment-specific fields validated by superRefine.
44
+ // These three fields are guaranteed non-undefined when transport === "http"
45
+ // by superRefine in src/utils/config.ts. The invariant guards below make
46
+ // that contract explicit and narrow the types without `as string` casts.
47
+ if (config.oauth.publicUrl === undefined) {
48
+ throw new Error("invariant: oauth.publicUrl must be set when transport=http");
49
+ }
50
+ if (config.oauth.clientId === undefined) {
51
+ throw new Error("invariant: oauth.clientId must be set when transport=http");
52
+ }
53
+ if (config.oauth.clientSecret === undefined) {
54
+ throw new Error("invariant: oauth.clientSecret must be set when transport=http");
55
+ }
56
+ const resolved = {
57
+ ...presetResult,
58
+ publicUrl: config.oauth.publicUrl,
59
+ clientId: config.oauth.clientId,
60
+ clientSecret: config.oauth.clientSecret,
61
+ trustProxy: config.oauth.trustProxy,
62
+ allowlist: config.oauth.allowlist,
63
+ };
64
+ // Fetch + validate upstream discovery document (rejects http:// endpoints; checks alg overlap)
65
+ const discovery = await loadDiscovery(resolved.discoveryUrl, resolved.allowedAlgs);
66
+ const jwks = createJwksFor(discovery);
67
+ const clientStore = new DiskClientRegistrationStore(cache, resolved.publicUrl, MAX_REGISTERED_CLIENTS);
68
+ const tokenStore = new TokenStore(cache);
69
+ const requestStore = new AuthRequestStore();
70
+ const codeStore = new AuthCodeStore();
71
+ const provider = new MintingOAuthServerProvider(clientStore, tokenStore, requestStore, codeStore, discovery, resolved, resolved.publicUrl);
72
+ const cleanup = new AuthCleanup(clientStore, tokenStore, cache, requestStore, codeStore);
73
+ return {
74
+ provider,
75
+ config: resolved,
76
+ discovery,
77
+ jwks,
78
+ authRequests: requestStore,
79
+ authCodes: codeStore,
80
+ tokenStore,
81
+ clientStore,
82
+ cleanup,
83
+ };
84
+ }