Compare commits

...
Author SHA1 Message Date
jknapp d9306884d3 Merge pull request 'docs: instance branches should be orphan branches' (#18) from docs/instance-branch-note into main 2026-07-27 14:05:13 +00:00
shadowdaoandClaude Opus 5 d319f00227 docs: instance branches should be orphan branches
Records why, so the next person doesn't rebuild the trap: a branch off main
carries a full copy of the app it has no reason to have and drifts behind it,
and syncing it via `git merge origin/main` silently replaces the instance
manifests with main's placeholders — no conflict, because only main touches
those paths.

instance/dnspegasus has been converted accordingly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 07:05:12 -07:00
jknapp fc0cb453d3 Merge pull request 'fix: accept the issuer with or without a trailing slash' (#17) from fix/issuer-trailing-slash into main 2026-07-27 13:42:31 +00:00
shadowdaoandClaude Opus 5 ba9d8fbe60 fix: accept the issuer with or without a trailing slash
Regression introduced with OIDC_ISSUER_MCP. mcpIssuer() stripped the trailing
slash — right for building the JWKS URL, wrong for the `iss` claim check,
which jose compares by exact string. Authentik emits
`.../application/o/shared-memory-mcp/` with the slash, so verification failed
with "claim invalid: iss" even though issuer and audience were both correct.

Before OIDC_ISSUER_MCP the issuer was passed to jwtVerify unstripped and only
stripped when constructing the URL; collapsing both onto the stripped form is
what broke it.

mcpIssuer() now returns the value as configured, the JWKS URL strips locally,
and the claim check accepts both spellings so correctness doesn't hinge on
whether someone typed a trailing slash into an env var.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:42:30 -07:00
jknapp c12250fd11 Merge pull request 'docs: describe project sharing as shipped on the settings page' (#16) from docs/settings-groups-copy into main 2026-07-27 13:35:43 +00:00
shadowdaoandClaude Opus 5 8361a5b8e2 docs: describe project sharing as shipped on the settings page
The Groups card still called sharing "the upcoming sharing feature". It has
shipped — memory_visibility, groups, user_groups and project_shares are all
live — so the card now describes what group membership actually does:
read access for member groups, write access for read-write groups.

Authored on the fix/memory-list-project-filter branch on 2026-07-05 and never
committed; that branch is otherwise fully merged via PR #7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:35:42 -07:00
jknapp 04b964a866 Merge pull request 'feat: add the logo, and point people at the plugin before they mint a token' (#15) from feat/logo-and-tokens-copy into main 2026-07-27 13:22:52 +00:00
shadowdaoandClaude Opus 5 6b28e1d6c8 feat: add the logo, and point people at the plugin before they mint a token
Logo: the app had no icon at all — public/ held only .gitkeep and the page
emitted no <link rel="icon">, so browsers requested /favicon.ico, got a 404
and showed a blank tab. app/icon.svg is picked up automatically by the App
Router; public/logo.svg is a currentColor variant for in-app use.

The mark is three retrieval signals converging on a single memory, which is
what the search actually does (vector + full-text + tags fused by RRF) and
what the product does (many sessions, one store). Checked at 16px: the outer
strokes are held at equal opacity because asymmetry read as a rendering
artifact rather than as ranking.

Tokens page: reframed so a bearer token is the exception rather than the
default. A token is a credential to store and rotate; the plugin just signs
you in. The install hint renders only when PLUGIN_MARKETPLACE_URL is set —
a copyable command pointing nowhere is worse than no command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:22:51 -07:00
jknapp 73391a5823 Merge pull request 'fix: key MCP identity on the canonical issuer' (#14) from fix/identity-issuer-normalization into main 2026-07-27 13:19:18 +00:00
shadowdaoandClaude Opus 5 9486518832 fix: key MCP identity on the canonical issuer, not the token issuer
Verifying against OIDC_ISSUER_MCP fixed the 401, but would have introduced a
quieter bug. Identity is keyed on (oidc_iss, oidc_sub) and
userContextFromClaims UPSERTS rather than failing, so a token carrying the MCP
application's issuer would have created a SECOND user row for the same person:
MCP calls would succeed against an account holding none of their memories, and
nothing would appear broken.

Authentik's `sub` is `user.uid`, a user-level value that is identical across
providers (verified against the live instance), so the issuer is the only
differing component. Pin it to OIDC_ISSUER after verification.

No stray rows exist to clean up — verification failed before this path could
ever create one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:19:17 -07:00
jknapp b35a465303 Merge pull request 'fix: pass new optional env vars into the container' (#13) from fix/pass-new-env-vars into main 2026-07-27 13:14:18 +00:00
shadowdaoandClaude Opus 5 54c29d182d fix: actually pass the new optional env vars into the container
OIDC_ISSUER_MCP and OIDC_AUDIENCE_SCOPE were added to .env and read by the
app, but never reached it: the compose `environment:` block is an explicit
allow-list, not env_file, so anything not named there is silently dropped.
The aud scope only worked because its computed default happened to be right.

Also make optional vars tolerate the empty string. compose renders `${VAR:-}`
as "" rather than omitting the key, so an unset optional var would arrive as
"" and fail .url()/.min(1) validation — taking the app down at boot rather
than falling back to its default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:14:16 -07:00
jknapp 1728752ce1 Merge pull request 'fix: verify MCP tokens against the MCP application-s issuer' (#12) from fix/mcp-issuer into main 2026-07-27 13:10:37 +00:00
shadowdaoandClaude Opus 5 1fed65a187 fix: verify MCP tokens against the MCP application's issuer
Second failure on the same path. With the aud fix in place, tokens now carry
`aud: shared-memory` correctly but are still rejected — this time on `iss`.

The MCP endpoint is a separate application in the IdP from the Web UI, and
Authentik's default per_provider issuer mode stamps each token with its own
application slug. MCP tokens therefore carry
`.../application/o/shared-memory-mcp/` while OIDC_ISSUER points at
`.../application/o/shared-memory/`, so jwtVerify throws "claim invalid: iss".

Introduce OIDC_ISSUER_MCP (defaults to OIDC_ISSUER) and use it for both the
issuer check and the JWKS URL. The protected-resource metadata now advertises
that same issuer — previously it pointed clients at the Web UI's discovery
document while the tokens came from the MCP provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:10:35 -07:00
jknapp 7d4e8daaaf Merge pull request 'fix: advertise the audience scope so tokens actually carry aud' (#11) from fix/oauth-aud-scope into main 2026-07-27 12:58:57 +00:00
10 changed files with 219 additions and 8 deletions
+11
View File
@@ -31,10 +31,21 @@ ACME_EMAIL=you@example.com
# resource server). See README.md for exact provider settings. # resource server). See README.md for exact provider settings.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/ OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/
# Issuer of MCP access tokens, when the MCP endpoint is a separate application
# in your IdP (it usually is). Authentik stamps each token with its own app
# slug, so verifying MCP tokens against OIDC_ISSUER fails with
# "claim invalid: iss". Defaults to OIDC_ISSUER.
OIDC_ISSUER_MCP=https://auth.example.com/application/o/shared-memory-mcp/
OIDC_CLIENT_ID_WEB=replace-me 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
# Marketplace this instance's Claude Code plugin is published from. When set,
# the CLI tokens page shows the one-command plugin install so people only mint
# a bearer token when a browser sign-in genuinely isn't possible.
#PLUGIN_MARKETPLACE_URL=https://your-git-host/you/shared-memory.git
#PLUGIN_MARKETPLACE_NAME=shared-memory
# Scope whose IdP mapping emits `aud: <OIDC_AUDIENCE>`. Advertised in # Scope whose IdP mapping emits `aud: <OIDC_AUDIENCE>`. Advertised in
# /.well-known/oauth-protected-resource so MCP clients request it — without # /.well-known/oauth-protected-resource so MCP clients request it — without
# that, Authentik never evaluates the mapping and every token 401s with # that, Authentik never evaluates the mapping and every token 401s with
+41
View File
@@ -144,6 +144,9 @@ 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_ISSUER_MCP` | optional | Issuer of MCP access tokens when the MCP endpoint is a separate IdP application (Authentik stamps each app's tokens with its own slug). Defaults to `OIDC_ISSUER`. |
| `PLUGIN_MARKETPLACE_URL` | optional | Marketplace URL for this instance's plugin. Shown as a one-command install on the CLI tokens page. Hidden when unset. |
| `PLUGIN_MARKETPLACE_NAME` | optional | Marketplace name used in `shared-memory@<name>`. Defaults to `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>`. | | `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`. |
@@ -322,6 +325,25 @@ reliable pattern:
> | jq .scopes_supported # must include aud-<your audience> > | jq .scopes_supported # must include aud-<your audience>
> ``` > ```
> >
> **Second trap, same failure surface:** the MCP endpoint is a *separate
> application* from the Web UI, and Authentik's default `per_provider` issuer
> mode stamps each token with its own application slug. So MCP tokens carry
> `iss: .../application/o/shared-memory-mcp/` while `OIDC_ISSUER` points at
> `.../application/o/shared-memory/`, and verification fails with
> `claim invalid: iss` even once `aud` is correct. Set `OIDC_ISSUER_MCP` to the
> MCP application's issuer. Confirm which one your tokens actually carry:
>
> ```bash
> curl -s https://auth.example.com/application/o/shared-memory-mcp/.well-known/openid-configuration | jq .issuer
> ```
>
> **Identity note:** the app verifies MCP tokens against `OIDC_ISSUER_MCP` but
> keys the user record on `OIDC_ISSUER`. Authentik's `sub` is `user.uid`, which
> is stable across providers, so the same person resolves to the same row
> whether they arrive via the Web UI or the MCP endpoint. Without that
> normalization the MCP path silently creates a second, empty account instead
> of failing visibly.
>
> Note also that Claude Code sends an RFC 8707 `resource` parameter on the > 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 > authorize request; Authentik 2026.5 ignores it, so it cannot be relied on
> for audience binding. The scope mapping is what sets `aud`. > for audience binding. The scope mapping is what sets `aud`.
@@ -388,6 +410,25 @@ claude plugin marketplace add https://your-git-host/you/shared-memory.git#instan
The `#ref` suffix is undocumented in `claude plugin marketplace add --help` but is 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). honored and persisted in `known_marketplaces.json` (verified on Claude Code 2.1.220).
**Make that an orphan branch, not a branch off `main`.** `marketplace add` reads
only the manifests, so the branch needs nothing else:
```bash
git checkout --orphan instance/<name>
git rm -rf --cached . && find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
# restore just .claude-plugin/marketplace.json, plugin/.claude-plugin/plugin.json,
# plugin/.mcp.json — fill in your URL and client ID
claude plugin validate . && git add -A && git commit && git push
```
A branch off `main` carries a full copy of the application it has no reason to
have, so it drifts and someone can cut a stale deploy from it. Worse, syncing it
means `git merge origin/main`, which **silently replaces those manifests** with
the placeholders below — no conflict is raised, because only `main` ever touches
those paths. With no shared history there is nothing to sync and nothing to
clobber; if the manifest format changes upstream, hand-edit the three files and
re-run `claude plugin validate .`.
### B. OAuth flow (manual, per-machine) ### B. OAuth flow (manual, per-machine)
```bash ```bash
+3 -1
View File
@@ -63,7 +63,9 @@ export default async function SettingsPage() {
</CardHeader> </CardHeader>
<CardBody className="text-sm text-fg-muted"> <CardBody className="text-sm text-fg-muted">
OIDC group memberships from your IdP, refreshed at sign-in. Used OIDC group memberships from your IdP, refreshed at sign-in. Used
by the upcoming sharing feature to scope project visibility. to scope project sharing &mdash; memories and snippets in a shared
project are readable by member groups and editable by read-write
groups.
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
+44 -1
View File
@@ -108,6 +108,44 @@ async function revokeTokenAction(formData: FormData) {
revalidatePath("/settings/tokens"); revalidatePath("/settings/tokens");
} }
/**
* Points people at the plugin before they mint a token they don't need.
*
* Rendered only when this instance knows which marketplace it's published
* from — showing a copyable command that points nowhere is worse than showing
* nothing.
*/
function PluginHint({
marketplaceUrl,
marketplaceName,
}: {
marketplaceUrl: string | undefined;
marketplaceName: string;
}) {
if (!marketplaceUrl) return null;
return (
<Card className="mb-6">
<CardHeader className="text-sm font-medium text-fg">
If this machine has a browser, install the plugin instead
</CardHeader>
<CardBody>
<p className="text-sm text-fg-muted mb-3">
The plugin signs you in through {" "}
<span className="text-fg">your usual login</span>, so there&apos;s no
token to copy, store, or rotate. Generate a token below only when a
browser sign-in isn&apos;t possible.
</p>
<pre className="text-xs !whitespace-pre-wrap !break-all select-all">
{[
`claude plugin marketplace add ${marketplaceUrl}`,
`claude plugin install shared-memory@${marketplaceName}`,
].join("\n")}
</pre>
</CardBody>
</Card>
);
}
export default async function TokensPage() { export default async function TokensPage() {
const session = await auth(); const session = await auth();
const userId = session!.user.id; const userId = session!.user.id;
@@ -144,7 +182,12 @@ export default async function TokensPage() {
<Container className="pt-6 max-w-3xl"> <Container className="pt-6 max-w-3xl">
<PageHeader <PageHeader
title="CLI tokens" title="CLI tokens"
description={`Long-lived bearer tokens for MCP clients without browser access. ${ttlDays}-day expiry per token.`} description={`For machines that can't complete a browser sign-in — headless containers, CI runners, sealed devboxes. Tokens last ${ttlDays} days and can be revoked one at a time.`}
/>
<PluginHint
marketplaceUrl={env().PLUGIN_MARKETPLACE_URL}
marketplaceName={env().PLUGIN_MARKETPLACE_NAME}
/> />
<Card className="mb-6"> <Card className="mb-6">
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { mcpIssuer } from "@/lib/auth/jwt";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -22,7 +23,10 @@ export function GET() {
return NextResponse.json({ return NextResponse.json({
resource, resource,
authorization_servers: [env().OIDC_ISSUER], // The MCP application's issuer, which is not necessarily the Web UI's —
// see mcpIssuer(). Advertising the wrong one sends clients to a discovery
// document whose tokens this endpoint will then reject on `iss`.
authorization_servers: [mcpIssuer()], // as configured, slash and all
scopes_supported: ["openid", "profile", "email", audienceScope], scopes_supported: ["openid", "profile", "email", audienceScope],
bearer_methods_supported: ["header"], bearer_methods_supported: ["header"],
resource_documentation: `${resource}/`, resource_documentation: `${resource}/`,
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<rect width="64" height="64" rx="14" fill="#11151b"/>
<!--
Three retrieval signals - vector, full-text, tags - converging on a single
memory. The direct match runs straight through at full strength; the two
ranked neighbours fall back, which is the fusion the search actually does.
Opacity is held equal on the outer pair so the mark stays balanced at 16px.
-->
<g fill="none" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 26 32 35 32" stroke="#0092fd" opacity=".55"/>
<path d="M13 32H35" stroke="#49a9ff"/>
<path d="M13 49C25 49 26 32 35 32" stroke="#0092fd" opacity=".55"/>
</g>
<circle cx="45" cy="32" r="7.5" fill="#76c0ff"/>
</svg>

After

Width:  |  Height:  |  Size: 847 B

+47 -4
View File
@@ -23,13 +23,39 @@ type GlobalWithJwks = typeof globalThis & {
}; };
const g = globalThis as GlobalWithJwks; const g = globalThis as GlobalWithJwks;
/**
* Issuer of MCP access tokens. The MCP endpoint is a separate application in
* the IdP from the Web UI, and Authentik stamps each token with its own
* application slug, so this is NOT interchangeable with OIDC_ISSUER.
*/
export function mcpIssuer(): string {
return env().OIDC_ISSUER_MCP ?? env().OIDC_ISSUER;
}
/**
* Issuer values accepted for the `iss` claim.
*
* jose compares `iss` by exact string, and IdPs are inconsistent about the
* trailing slash: Authentik emits `.../application/o/<slug>/` while the same
* value is routinely configured without it. Normalizing to one form and
* comparing against that fails whenever the two disagree — which is exactly
* how this broke: the URL-safe (stripped) form was reused for the claim check
* against a token whose `iss` ended in a slash.
*
* Accept both spellings rather than making correctness depend on how someone
* typed an env var.
*/
function acceptedIssuers(): [string, string] {
const bare = mcpIssuer().replace(/\/$/, "");
return [bare, `${bare}/`];
}
function jwks() { function jwks() {
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks; if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`; // Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`. // the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
// Authentik canonically serves `${issuer}/jwks/`. // Authentik canonically serves `${issuer}/jwks/`.
const issuer = env().OIDC_ISSUER.replace(/\/$/, ""); const url = new URL(`${mcpIssuer().replace(/\/$/, "")}/jwks/`);
const url = new URL(`${issuer}/jwks/`);
g.__sharedMemoryJwks = createRemoteJWKSet(url, { g.__sharedMemoryJwks = createRemoteJWKSet(url, {
cacheMaxAge: 10 * 60 * 1000, // 10 min cacheMaxAge: 10 * 60 * 1000, // 10 min
cooldownDuration: 30 * 1000, cooldownDuration: 30 * 1000,
@@ -118,7 +144,7 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
} }
const { payload } = await jwtVerify(token, jwks(), { const { payload } = await jwtVerify(token, jwks(), {
issuer: env().OIDC_ISSUER, issuer: acceptedIssuers(),
audience: env().OIDC_AUDIENCE, audience: env().OIDC_AUDIENCE,
}); });
if (!payload.sub) { if (!payload.sub) {
@@ -127,7 +153,24 @@ export async function authenticateBearer(authHeader: string | null): Promise<Aut
buildWwwAuthenticate("invalid_token", "missing sub"), buildWwwAuthenticate("invalid_token", "missing sub"),
); );
} }
return { ...payload, groups: extractGroupsClaim(payload) } as AuthenticatedClaims; // Normalize the issuer for identity purposes.
//
// The token was just verified against mcpIssuer() — that check is done.
// But identity is keyed on (oidc_iss, oidc_sub), and the Web UI signs
// people in through a DIFFERENT application whose tokens carry
// OIDC_ISSUER. Authentik's `sub` is stable across providers (it is
// `user.uid`, a user-level value), so the only thing that differs is the
// issuer.
//
// Leave it un-normalized and userContextFromClaims — which UPSERTS rather
// than failing — quietly creates a SECOND user row for the same human:
// MCP writes would land in an account with none of their memories, and
// nothing would look broken. Pin identity to the canonical issuer.
return {
...payload,
iss: env().OIDC_ISSUER,
groups: extractGroupsClaim(payload),
} as AuthenticatedClaims;
} catch (err) { } catch (err) {
if (err instanceof UnauthorizedError) throw err; if (err instanceof UnauthorizedError) throw err;
const desc = const desc =
+32 -1
View File
@@ -4,6 +4,16 @@ const Bool = z
.union([z.boolean(), z.enum(["true", "false", "1", "0"])]) .union([z.boolean(), z.enum(["true", "false", "1", "0"])])
.transform((v) => v === true || v === "true" || v === "1"); .transform((v) => v === true || v === "true" || v === "1");
/**
* Treat an empty string as "not set".
*
* docker-compose renders `${VAR:-}` as an empty string rather than omitting
* the key, so an unset optional var arrives as "" and would otherwise fail
* `.url()` / `.min(1)` validation and take the whole app down at boot.
*/
const optional = <T extends z.ZodTypeAny>(schema: T) =>
z.preprocess((v) => (v === "" ? undefined : v), schema.optional());
const envSchema = z.object({ const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
@@ -13,6 +23,19 @@ const envSchema = z.object({
// Authentik OIDC // Authentik OIDC
OIDC_ISSUER: z.string().url(), OIDC_ISSUER: z.string().url(),
// Issuer of MCP *access tokens*, when it differs from OIDC_ISSUER.
//
// The Web UI and the MCP endpoint are two separate applications in the IdP,
// and Authentik's default `per_provider` issuer mode stamps each token with
// its own application slug. So the web app issues
// `.../application/o/<slug>/` while the MCP provider issues
// `.../application/o/<slug>-mcp/`, and verifying MCP tokens against
// OIDC_ISSUER fails with "claim invalid: iss".
//
// Set this to the MCP application's issuer. Defaults to OIDC_ISSUER for
// single-application setups.
OIDC_ISSUER_MCP: optional(z.string().url()),
OIDC_CLIENT_ID_WEB: z.string().min(1), OIDC_CLIENT_ID_WEB: z.string().min(1),
OIDC_CLIENT_SECRET_WEB: z.string().min(1), OIDC_CLIENT_SECRET_WEB: z.string().min(1),
OIDC_CLIENT_ID_MCP: z.string().min(1), OIDC_CLIENT_ID_MCP: z.string().min(1),
@@ -27,7 +50,7 @@ const envSchema = z.object({
// every token arrives without an `aud` claim (-> 401 "claim invalid: aud"). // every token arrives without an `aud` claim (-> 401 "claim invalid: aud").
// //
// Defaults to the `aud-<audience>` convention used in the README setup. // Defaults to the `aud-<audience>` convention used in the README setup.
OIDC_AUDIENCE_SCOPE: z.string().min(1).optional(), OIDC_AUDIENCE_SCOPE: optional(z.string().min(1)),
// Database // Database
DATABASE_URL: z.string().url(), DATABASE_URL: z.string().url(),
@@ -44,6 +67,13 @@ const envSchema = z.object({
// every issued CLI token at once. // every issued CLI token at once.
CLI_TOKEN_SECRET: z.string().min(32, "CLI_TOKEN_SECRET must be at least 32 chars"), CLI_TOKEN_SECRET: z.string().min(32, "CLI_TOKEN_SECRET must be at least 32 chars"),
// Plugin marketplace this instance is published from. When set, the CLI
// tokens page shows the one-command plugin install, so people only mint a
// bearer token when their machine genuinely can't complete a browser
// sign-in. Left unset, that hint is hidden rather than shown wrong.
PLUGIN_MARKETPLACE_URL: optional(z.string().url()),
PLUGIN_MARKETPLACE_NAME: z.string().min(1).default("shared-memory"),
// Behavior flags // Behavior flags
ALLOW_INSECURE_HTTP: Bool.optional().default(false), ALLOW_INSECURE_HTTP: Bool.optional().default(false),
}); });
@@ -87,6 +117,7 @@ function buildPhaseStub(): Env {
EMBEDDING_DIM: 384, EMBEDDING_DIM: 384,
NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx", NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
CLI_TOKEN_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx", CLI_TOKEN_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
PLUGIN_MARKETPLACE_NAME: "shared-memory",
ALLOW_INSECURE_HTTP: false, ALLOW_INSECURE_HTTP: false,
}; };
} }
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="shared-memory">
<title>shared-memory</title>
<!--
Transparent, currentColor variant of the mark for in-app use - inherits
the surrounding text color so it works on any surface. The tile version
used as the favicon lives at app/icon.svg.
-->
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="7">
<path d="M13 15C25 15 27 32 37 32" opacity=".45"/>
<path d="M13 32H37"/>
<path d="M13 49C25 49 27 32 37 32" opacity=".7"/>
</g>
<circle cx="43" cy="32" r="8" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 648 B

+6
View File
@@ -113,6 +113,12 @@ services:
OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required} OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required}
OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required} OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required} OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required}
# Optional. This block is an explicit allow-list, not env_file — a var
# added to .env but not listed here never reaches the container.
OIDC_ISSUER_MCP: ${OIDC_ISSUER_MCP:-}
OIDC_AUDIENCE_SCOPE: ${OIDC_AUDIENCE_SCOPE:-}
PLUGIN_MARKETPLACE_URL: ${PLUGIN_MARKETPLACE_URL:-}
PLUGIN_MARKETPLACE_NAME: ${PLUGIN_MARKETPLACE_NAME:-shared-memory}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}