Compare commits

...
Author SHA1 Message Date
shadowdaoandClaude Opus 5 60cfb19772 fix: advertise the audience scope so tokens actually carry aud
The OAuth path to /api/mcp has never worked end to end. Every access token
arrived without an `aud` claim and jwt.ts rejected it with
"claim invalid: aud" (401), even though the handshake, consent and PKCE all
succeeded. Only the CLI HMAC path worked, because cli-token.ts sets the
audience itself — which is why this went unnoticed.

Cause: Authentik evaluates a scope mapping only when the client REQUESTS
that scope by name. An MCP client learns which scopes to request from
`scopes_supported` in our RFC 9728 protected-resource metadata, and we only
advertised openid/profile/email. So the `aud-shared-memory` mapping was
attached to the provider but never evaluated.

Advertise the audience scope in that metadata. Name is derived as
`aud-<OIDC_AUDIENCE>` to match the README convention, overridable with the
new optional OIDC_AUDIENCE_SCOPE for deployments that named it differently.

Also documents that Claude Code's RFC 8707 `resource` parameter is ignored
by Authentik 2026.5, so it cannot be relied on for audience binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 05:58:56 -07:00
jknapp 2a1e81d80a Merge pull request 'chore: genericize plugin manifests for public release' (#10) from chore/genericize-plugin into main 2026-07-27 04:41:22 +00:00
shadowdaoandClaude Opus 5 8dff061faf chore: genericize plugin manifests for public release
main is now the shareable artifact: placeholder host and clientId, no
instance-specific hostnames, marketplace renamed to cybercove-labs.

The filled-in manifest for the live instance lives on branch
instance/dnspegasus and is installed with a #ref fragment, which
`claude plugin marketplace add` honors and persists even though it is
absent from --help (verified on Claude Code 2.1.220).

README: plugin install is now path A, documenting both the fork-and-edit
and the #ref branch approaches; remaining paths renumbered B/C/D.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:41:20 -07:00
jknapp 02fadb0955 Merge pull request 'chore: add MIT license' (#9) from chore/mit-license into main 2026-07-27 03:37:16 +00:00
shadowdaoandClaude Opus 5 063dc3ca00 chore: add MIT license
Prerequisite for making the repository public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:35:44 -07:00
jknapp 0cb749806c Merge pull request 'feat: add Claude Code plugin with pre-registered Authentik OAuth client' (#8) from feat/claude-code-plugin into main 2026-07-27 03:21:17 +00:00
8 changed files with 131 additions and 21 deletions
+5 -5
View File
@@ -1,16 +1,16 @@
{ {
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "dnspegasus", "name": "cybercove-labs",
"description": "Self-hosted Claude Code plugins for the dnspegasus.net infrastructure.", "description": "Open-source Claude Code plugins released by CyberCove Labs.",
"owner": { "owner": {
"name": "jknapp", "name": "CyberCove Labs",
"url": "https://repo.anhonesthost.net/jknapp/shared-memory" "url": "https://repo.anhonesthost.net/cybercove-labs/shared-memory"
}, },
"plugins": [ "plugins": [
{ {
"name": "shared-memory", "name": "shared-memory",
"source": "./plugin", "source": "./plugin",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC." "description": "Shared persistent memory and snippet library for Claude Code sessions, backed by your own shared-memory server and authenticated with OIDC."
} }
] ]
} }
+6
View File
@@ -35,6 +35,12 @@ OIDC_CLIENT_ID_WEB=replace-me
OIDC_CLIENT_SECRET_WEB=replace-me OIDC_CLIENT_SECRET_WEB=replace-me
OIDC_CLIENT_ID_MCP=replace-me OIDC_CLIENT_ID_MCP=replace-me
OIDC_AUDIENCE=shared-memory OIDC_AUDIENCE=shared-memory
# Scope whose IdP mapping emits `aud: <OIDC_AUDIENCE>`. Advertised in
# /.well-known/oauth-protected-resource so MCP clients request it — without
# that, Authentik never evaluates the mapping and every token 401s with
# "claim invalid: aud". Defaults to aud-<OIDC_AUDIENCE>; set only if you
# named the scope mapping something else.
#OIDC_AUDIENCE_SCOPE=aud-shared-memory
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Database (Postgres 16 + pgvector — pgvector/pgvector:pg16 image) # Database (Postgres 16 + pgvector — pgvector/pgvector:pg16 image)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Josh Knapp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE OTHER DEALINGS IN THE
SOFTWARE.
+74 -10
View File
@@ -144,6 +144,7 @@ Copy `.env.example` and fill in the values below.
| `OIDC_CLIENT_SECRET_WEB` | both | Client secret of the Web-UI client. | | `OIDC_CLIENT_SECRET_WEB` | both | Client secret of the Web-UI client. |
| `OIDC_CLIENT_ID_MCP` | both | Client ID of the MCP resource-server client in your IdP. | | `OIDC_CLIENT_ID_MCP` | both | Client ID of the MCP resource-server client in your IdP. |
| `OIDC_AUDIENCE` | both | Audience string the MCP access token must carry in its `aud` claim. Recommended: `shared-memory`. | | `OIDC_AUDIENCE` | both | Audience string the MCP access token must carry in its `aud` claim. Recommended: `shared-memory`. |
| `OIDC_AUDIENCE_SCOPE` | optional | Name of the IdP scope whose mapping emits that `aud` claim. Advertised in `scopes_supported` so clients request it. Defaults to `aud-<OIDC_AUDIENCE>`. |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | both | Local Postgres credentials. | | `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | both | Local Postgres credentials. |
| `NEXTAUTH_SECRET` | both | Session-cookie signing key. Generate with `openssl rand -base64 32`. | | `NEXTAUTH_SECRET` | both | Session-cookie signing key. Generate with `openssl rand -base64 32`. |
| `EMBEDDER_URL` | both | Phase 2 embedder sidecar. Leave empty in Phase 1. | | `EMBEDDER_URL` | both | Phase 2 embedder sidecar. Leave empty in Phase 1. |
@@ -297,16 +298,33 @@ The MCP endpoint requires the access token's `aud` claim to equal
reliable pattern: reliable pattern:
1. Create a **scope mapping** (Customisation → Property Mappings → Create → 1. Create a **scope mapping** (Customisation → Property Mappings → Create →
Scope Mapping) named `aud-shared-memory` with expression: Scope Mapping) named `aud-shared-memory`, **scope name** `aud-shared-memory`,
with expression:
```python ```python
return {"aud": "shared-memory"} return {"aud": "shared-memory"}
``` ```
2. On the MCP provider, add this scope mapping under **Scopes** and tick it 2. On the MCP provider, add this scope mapping under **Scopes**.
so it's emitted for the default scope. 3. Make sure the app advertises that scope name. It is derived automatically
as `aud-<OIDC_AUDIENCE>`; override with `OIDC_AUDIENCE_SCOPE` if you named
the mapping differently.
> If you skip this, the MCP route will return 401 with > **Attaching the mapping is not sufficient.** Authentik evaluates a scope
> `error_description="claim invalid: aud"`. Check `docker compose logs app` > mapping only when the client explicitly *requests* that scope, and an MCP
> for the exact failure. > client only requests the scopes listed in `scopes_supported` from
> `/.well-known/oauth-protected-resource`. If the audience scope isn't
> advertised there, the mapping silently never runs, the access token carries
> no `aud`, and every MCP call fails with 401
> `error_description="claim invalid: aud"` — even though the OAuth handshake,
> consent, and PKCE all succeeded. Verify with:
>
> ```bash
> curl -s https://memory.example.com/.well-known/oauth-protected-resource \
> | jq .scopes_supported # must include aud-<your audience>
> ```
>
> Note also that Claude Code sends an RFC 8707 `resource` parameter on the
> authorize request; Authentik 2026.5 ignores it, so it cannot be relied on
> for audience binding. The scope mapping is what sets `aud`.
Then create an **Application** for the MCP provider (same as Step A), slug Then create an **Application** for the MCP provider (same as Step A), slug
e.g. `shared-memory-mcp`. e.g. `shared-memory-mcp`.
@@ -322,9 +340,55 @@ prompt, never reaching the app.
## Connecting Claude Code ## Connecting Claude Code
Two paths, in order of preference: Three paths, in order of preference:
### A. OAuth flow (recommended — picks up your IdP credentials) ### A. Plugin (recommended — one command, no flags to remember)
This repo doubles as a Claude Code plugin marketplace. `plugin/.mcp.json` ships a
**pre-registered** OAuth client, so Claude Code never needs RFC 7591 Dynamic Client
Registration — which matters because most self-hosted IdPs (Authentik included, as of
2026.5) don't implement it.
`main` carries placeholders, so install from `main` only after pointing it at your own
instance. Fork or clone, then edit `plugin/.mcp.json`:
```json
{
"mcpServers": {
"shared-memory": {
"type": "http",
"url": "https://memory.example.com/api/mcp",
"oauth": {
"clientId": "<OIDC_CLIENT_ID_MCP>",
"callbackPort": 33418
}
}
}
}
```
`clientId` is the **Public** (PKCE) client from step B above — it is not a secret and is
meant to be committed. `callbackPort` must match a redirect URI your IdP accepts; with
the loopback regex from the setup step, any port works.
Then:
```bash
claude plugin marketplace add https://your-git-host/you/shared-memory.git
claude plugin install shared-memory@cybercove-labs
```
To keep a filled-in copy on a branch instead of forking, commit it to e.g.
`instance/<name>` and install with a `#ref` fragment:
```bash
claude plugin marketplace add https://your-git-host/you/shared-memory.git#instance/<name>
```
The `#ref` suffix is undocumented in `claude plugin marketplace add --help` but is
honored and persisted in `known_marketplaces.json` (verified on Claude Code 2.1.220).
### B. OAuth flow (manual, per-machine)
```bash ```bash
claude mcp add --transport http --scope user \ claude mcp add --transport http --scope user \
@@ -350,7 +414,7 @@ your MCP client's **Redirect URIs** list. Authentik users with the regex
pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`) pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`)
can use any port without re-registering. can use any port without re-registering.
### B. Manual-paste fallback (when loopback isn't reachable) ### C. Manual-paste fallback (when loopback isn't reachable)
Sealed containers, devboxes without port forwarding, etc. The redirect URI Sealed containers, devboxes without port forwarding, etc. The redirect URI
in this case is hosted by *this* server: in this case is hosted by *this* server:
@@ -372,7 +436,7 @@ Claude Code's prompt to complete the flow.
The manual-fallback URI must be registered on your MCP client too: The manual-fallback URI must be registered on your MCP client too:
`https://memory.example.com/auth/cli-callback`. `https://memory.example.com/auth/cli-callback`.
### C. Static bearer token (no browser at all) ### D. Static bearer token (no browser at all)
For fully headless / CI scenarios, mint a long-lived HMAC token at For fully headless / CI scenarios, mint a long-lived HMAC token at
`https://memory.example.com/connect` and pass it via `--header`. See `https://memory.example.com/connect` and pass it via `--header`. See
@@ -12,10 +12,18 @@ export const dynamic = "force-dynamic";
*/ */
export function GET() { export function GET() {
const resource = env().PUBLIC_URL.replace(/\/$/, ""); const resource = env().PUBLIC_URL.replace(/\/$/, "");
// The audience scope MUST be advertised. Authentik only evaluates a scope
// mapping when the client requests that scope by name, and the client only
// learns scope names from this document. Omit it and every access token
// arrives without `aud`, which jwt.ts rejects as "claim invalid: aud".
const audienceScope =
env().OIDC_AUDIENCE_SCOPE ?? `aud-${env().OIDC_AUDIENCE}`;
return NextResponse.json({ return NextResponse.json({
resource, resource,
authorization_servers: [env().OIDC_ISSUER], authorization_servers: [env().OIDC_ISSUER],
scopes_supported: ["openid", "profile", "email"], scopes_supported: ["openid", "profile", "email", audienceScope],
bearer_methods_supported: ["header"], bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`, resource_documentation: `${resource}/`,
}); });
+11
View File
@@ -18,6 +18,17 @@ const envSchema = z.object({
OIDC_CLIENT_ID_MCP: z.string().min(1), OIDC_CLIENT_ID_MCP: z.string().min(1),
OIDC_AUDIENCE: z.string().min(1), OIDC_AUDIENCE: z.string().min(1),
// Name of the IdP scope whose mapping emits `aud: <OIDC_AUDIENCE>`.
//
// Most IdPs (Authentik included) only evaluate a scope mapping when the
// client actually requests that scope. The MCP client learns which scopes
// to request from `scopes_supported` in our protected-resource metadata,
// so this name has to be advertised there or the mapping never runs and
// every token arrives without an `aud` claim (-> 401 "claim invalid: aud").
//
// Defaults to the `aud-<audience>` convention used in the README setup.
OIDC_AUDIENCE_SCOPE: z.string().min(1).optional(),
// Database // Database
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
+3 -3
View File
@@ -2,9 +2,9 @@
"$schema": "https://anthropic.com/claude-code/plugin.schema.json", "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "shared-memory", "name": "shared-memory",
"version": "0.1.0", "version": "0.1.0",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC.", "description": "Shared persistent memory and snippet library for Claude Code sessions, backed by your own shared-memory server and authenticated with OIDC.",
"author": { "author": {
"name": "jknapp" "name": "CyberCove Labs"
}, },
"homepage": "https://memory.dnspegasus.net" "homepage": "https://repo.anhonesthost.net/cybercove-labs/shared-memory"
} }
+2 -2
View File
@@ -2,9 +2,9 @@
"mcpServers": { "mcpServers": {
"shared-memory": { "shared-memory": {
"type": "http", "type": "http",
"url": "https://memory.dnspegasus.net/api/mcp", "url": "https://memory.example.com/api/mcp",
"oauth": { "oauth": {
"clientId": "5rkRS3rJhn3Ci9swWkxYMIrZ9OggsjOGy3cIOhYY", "clientId": "<OIDC_CLIENT_ID_MCP>",
"callbackPort": 33418 "callbackPort": 33418
} }
} }