@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.
- package/README.md +159 -30
- package/dist/auth/allowlist.d.ts +37 -0
- package/dist/auth/allowlist.js +69 -0
- package/dist/auth/auth-code-store.d.ts +41 -0
- package/dist/auth/auth-code-store.js +56 -0
- package/dist/auth/auth-request-store.d.ts +25 -0
- package/dist/auth/auth-request-store.js +27 -0
- package/dist/auth/build.d.ts +15 -0
- package/dist/auth/build.js +84 -0
- package/dist/auth/cleanup.d.ts +56 -0
- package/dist/auth/cleanup.js +127 -0
- package/dist/auth/client-registration.d.ts +82 -0
- package/dist/auth/client-registration.js +176 -0
- package/dist/auth/dcr-validator.d.ts +47 -0
- package/dist/auth/dcr-validator.js +232 -0
- package/dist/auth/errors.d.ts +108 -0
- package/dist/auth/errors.js +175 -0
- package/dist/auth/metadata.d.ts +28 -0
- package/dist/auth/metadata.js +47 -0
- package/dist/auth/oidc-client.d.ts +128 -0
- package/dist/auth/oidc-client.js +181 -0
- package/dist/auth/presets.d.ts +83 -0
- package/dist/auth/presets.js +131 -0
- package/dist/auth/provider.d.ts +41 -0
- package/dist/auth/provider.js +142 -0
- package/dist/auth/routes.d.ts +84 -0
- package/dist/auth/routes.js +336 -0
- package/dist/auth/token-store.d.ts +129 -0
- package/dist/auth/token-store.js +273 -0
- package/dist/auth/tokens.d.ts +60 -0
- package/dist/auth/tokens.js +82 -0
- package/dist/auth/ttl-store.d.ts +54 -0
- package/dist/auth/ttl-store.js +80 -0
- package/dist/auth/types.d.ts +444 -0
- package/dist/auth/types.js +206 -0
- package/dist/cache/disk-cache.d.ts +41 -3
- package/dist/cache/disk-cache.js +286 -173
- package/dist/cache/pantry-store.d.ts +12 -0
- package/dist/cache/pantry-store.js +41 -0
- package/dist/cache/recipe-store.d.ts +12 -0
- package/dist/cache/recipe-store.js +41 -0
- package/dist/features/discover-feature.js +2 -1
- package/dist/features/vector-store.js +2 -3
- package/dist/index.js +4 -5
- package/dist/paprika/client.js +38 -3
- package/dist/paprika/sync.js +78 -16
- package/dist/server/app-context.d.ts +3 -0
- package/dist/server/build.js +16 -5
- package/dist/tools/create.js +5 -1
- package/dist/tools/delete.js +5 -1
- package/dist/tools/helpers.d.ts +8 -1
- package/dist/tools/helpers.js +30 -3
- package/dist/tools/pantry-add.js +6 -1
- package/dist/tools/pantry-delete.js +5 -1
- package/dist/tools/pantry-helpers.js +24 -4
- package/dist/tools/pantry-update.js +5 -1
- package/dist/tools/update.js +5 -1
- package/dist/transport/http.js +90 -4
- package/dist/transport/stdio.js +2 -3
- package/dist/utils/config.d.ts +175 -4
- package/dist/utils/config.js +163 -3
- package/dist/utils/log.d.ts +27 -0
- package/dist/utils/log.js +31 -0
- package/dist/utils/xdg.js +23 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
### Step 4 — Configure the allowlist
|
|
60
106
|
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
70
|
-
# →
|
|
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
|
|
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:
|
|
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
|
-
|
|
87
|
-
|
|
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:
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
-
|
|
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
|
-
- **[
|
|
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
|
+
}
|