@bojanrajkovic/mcp-paprika 1.0.4 → 1.2.0-beta.1

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 (89) hide show
  1. package/README.md +232 -7
  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 +46 -3
  37. package/dist/cache/disk-cache.js +298 -119
  38. package/dist/cache/pantry-store.d.ts +32 -0
  39. package/dist/cache/pantry-store.js +122 -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.d.ts +28 -4
  43. package/dist/features/discover-feature.js +27 -14
  44. package/dist/features/vector-store.js +2 -3
  45. package/dist/index.js +17 -87
  46. package/dist/paprika/client.d.ts +4 -1
  47. package/dist/paprika/client.js +41 -1
  48. package/dist/paprika/dates.d.ts +25 -0
  49. package/dist/paprika/dates.js +57 -0
  50. package/dist/paprika/sync.d.ts +2 -2
  51. package/dist/paprika/sync.js +125 -32
  52. package/dist/paprika/types.d.ts +119 -10
  53. package/dist/paprika/types.js +64 -4
  54. package/dist/resources/pantry.d.ts +3 -0
  55. package/dist/resources/pantry.js +32 -0
  56. package/dist/server/app-context.d.ts +35 -0
  57. package/dist/server/app-context.js +1 -0
  58. package/dist/server/build.d.ts +45 -0
  59. package/dist/server/build.js +175 -0
  60. package/dist/server/notifier.d.ts +47 -0
  61. package/dist/server/notifier.js +76 -0
  62. package/dist/tools/create.js +2 -1
  63. package/dist/tools/delete.js +2 -1
  64. package/dist/tools/helpers.d.ts +8 -1
  65. package/dist/tools/helpers.js +31 -4
  66. package/dist/tools/pantry-add.d.ts +3 -0
  67. package/dist/tools/pantry-add.js +72 -0
  68. package/dist/tools/pantry-delete.d.ts +3 -0
  69. package/dist/tools/pantry-delete.js +45 -0
  70. package/dist/tools/pantry-get.d.ts +3 -0
  71. package/dist/tools/pantry-get.js +39 -0
  72. package/dist/tools/pantry-helpers.d.ts +20 -0
  73. package/dist/tools/pantry-helpers.js +82 -0
  74. package/dist/tools/pantry-list.d.ts +3 -0
  75. package/dist/tools/pantry-list.js +23 -0
  76. package/dist/tools/pantry-update.d.ts +3 -0
  77. package/dist/tools/pantry-update.js +72 -0
  78. package/dist/tools/update.js +2 -1
  79. package/dist/transport/http.d.ts +29 -0
  80. package/dist/transport/http.js +232 -0
  81. package/dist/transport/stdio.d.ts +10 -0
  82. package/dist/transport/stdio.js +55 -0
  83. package/dist/types/server-context.d.ts +16 -10
  84. package/dist/utils/config.d.ts +193 -1
  85. package/dist/utils/config.js +186 -3
  86. package/dist/utils/log.d.ts +27 -0
  87. package/dist/utils/log.js +31 -0
  88. package/dist/utils/xdg.js +23 -6
  89. package/package.json +22 -16
package/README.md CHANGED
@@ -4,14 +4,27 @@ An [MCP](https://modelcontextprotocol.io/) server for [Paprika](https://www.papr
4
4
 
5
5
  ## Features
6
6
 
7
- - **10 tools** for recipe management — search, filter, CRUD, categories, pagination
7
+ - **14 tools** for recipe and pantry management — search, filter, CRUD, categories, pagination, pantry inventory
8
8
  - **Semantic search** via `discover_recipes` — find recipes by natural language description using any OpenAI-compatible embedding provider
9
9
  - **Background sync** — keeps your local cache in sync with Paprika's cloud
10
- - **MCP resources** — expose recipes as `paprika://recipe/{uid}` resources
10
+ - **MCP resources** — expose recipes as `paprika://recipe/{uid}` and pantry items as `paprika://pantry/{uid}` resources
11
+ - **Two transports** — stdio (default, for CLI clients) and Streamable HTTP (for mobile/web clients)
12
+ - **Container image** — `Dockerfile` ships a distroless runtime ready for self-hosting
11
13
 
12
- ## Quick start
14
+ ## Transports
13
15
 
14
- Add to your MCP client config (e.g. Claude Desktop):
16
+ `mcp-paprika` can speak the MCP protocol over two transports, selected via `MCP_TRANSPORT`:
17
+
18
+ | Transport | Default? | Use it for |
19
+ | --------- | -------- | ----------------------------------------------------------------------------------- |
20
+ | `stdio` | yes | Local CLI clients: Claude Code, Claude Desktop, Cursor, mcp-cli |
21
+ | `http` | no | Streamable HTTP for Claude Mobile and other HTTP-based MCP clients, or self-hosting |
22
+
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.
24
+
25
+ ## Quick start — stdio (Claude Desktop / Claude Code / Cursor)
26
+
27
+ Add to your MCP client config:
15
28
 
16
29
  ```json
17
30
  {
@@ -28,14 +41,226 @@ Add to your MCP client config (e.g. Claude Desktop):
28
41
  }
29
42
  ```
30
43
 
31
- See [configuration](docs/configuration.md) for all available options including background sync and semantic search.
44
+ ## Quick start HTTP transport
45
+
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:
92
+
93
+ ```bash
94
+ MCP_TRANSPORT=http \
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 \
100
+ PAPRIKA_EMAIL=you@example.com \
101
+ PAPRIKA_PASSWORD=your-password \
102
+ npx -y @bojanrajkovic/mcp-paprika
103
+ ```
104
+
105
+ ### Step 4 — Configure the allowlist
106
+
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.
108
+
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
124
+
125
+ ```bash
126
+ curl -sf https://<MCP_PUBLIC_URL>/.well-known/oauth-authorization-server | jq .issuer
127
+ # → "https://<MCP_PUBLIC_URL>"
128
+ ```
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
+
137
+ ## Quick start — container
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
+
147
+ ```bash
148
+ docker pull ghcr.io/bojanrajkovic/mcp-paprika:latest
149
+
150
+ docker run --rm \
151
+ -e PAPRIKA_EMAIL=you@example.com \
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 \
158
+ -v "$(pwd)/data:/data" \
159
+ -p 3000:3000 \
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
175
+ ```
176
+
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`
195
+ sub-directories (`config/`, `cache/`) are pre-created with `nonroot` (UID 65532)
196
+ ownership in the image so writes work the first time even on a fresh bind-mount.
197
+
198
+ If you bind-mount a host directory you created as root, pre-chown it:
199
+
200
+ ```bash
201
+ mkdir -p ./data && sudo chown -R 65532:65532 ./data
202
+ ```
203
+
204
+ Or use a named volume (Docker handles ownership automatically):
205
+
206
+ ```bash
207
+ docker run --rm \
208
+ -e PAPRIKA_EMAIL=... -e PAPRIKA_PASSWORD=... \
209
+ -v mcp-paprika-data:/data \
210
+ -p 3000:3000 \
211
+ ghcr.io/bojanrajkovic/mcp-paprika:latest
212
+ ```
213
+
214
+ The image also declares a `HEALTHCHECK` that hits `GET /healthz`; verify with:
215
+
216
+ ```bash
217
+ docker inspect --format '{{.State.Health.Status}}' <container>
218
+ # → healthy
219
+ ```
220
+
221
+ ## Deployment patterns (HTTP transport)
222
+
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
+ ```
254
+
255
+ OAuth provides authentication-level controls; the reverse proxy provides TLS, rate limiting, and any additional network-level restrictions the deployment requires.
32
256
 
33
257
  ## Documentation
34
258
 
35
- - **[Configuration](docs/configuration.md)** — env vars, config files, platform paths
36
- - **[Tools reference](docs/tools/)** — all 10 tools with parameters and examples
259
+ - **[Configuration](docs/configuration.md)** — env vars, config files, transport options, platform paths
260
+ - **[Tools reference](docs/tools/)** — every tool with parameters and examples
37
261
  - **[Embedding providers](docs/embedding-providers.md)** — set up semantic search with Ollama, OpenAI, OpenRouter, etc.
38
262
  - **[Architecture](docs/architecture.md)** — how it works under the hood
263
+ - **[Releasing](docs/releasing.md)** — maintainer-facing release model, prerelease validation, attestation verification
39
264
 
40
265
  ## License
41
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
+ }