diff --git a/README.md b/README.md index d499183..99ce412 100644 --- a/README.md +++ b/README.md @@ -231,20 +231,72 @@ prompt, never reaching the app. ## Connecting Claude Code -In a future Phase, we'll publish a one-line Claude Code config snippet. For -Phase 1, follow the [MCP authorization flow][mcp-auth]: +Two paths, in order of preference: -1. Add the MCP server to Claude Code's config, pointing at - `https://memory.dnspegasus.net/api/mcp`. -2. On first connection, the server returns 401 with `WWW-Authenticate` - pointing at `/.well-known/oauth-protected-resource`. -3. Claude Code reads the protected-resource metadata, follows the link to - Authentik's discovery doc, and runs the OAuth 2.1 PKCE flow. -4. You'll be prompted in your browser to authenticate with Authentik. -5. Claude Code stores the access token and uses it on subsequent requests. +### A. OAuth flow (recommended — picks up your IdP credentials) -If Authentik refuses the redirect URI Claude Code attempts to use, copy the -URI from the error and add it under the MCP provider's **Redirect URIs**. +```bash +claude mcp add --transport http --scope user \ + --client-id \ + --callback-port 33418 \ + shared-memory https://memory.dnspegasus.net/api/mcp +``` + +What happens: + +1. Claude Code hits `/api/mcp`, gets 401 with our `WWW-Authenticate` header +2. It reads `/.well-known/oauth-protected-resource`, finds your OIDC issuer +3. It opens an authorize URL in your browser and starts a local listener + on the `--callback-port` you specified +4. You authenticate with your IdP in the browser +5. The IdP redirects back to `http://localhost:33418/callback?code=…`, + Claude Code's listener catches it, exchanges the code for an access + token, and stores it + +`--callback-port` is required because your IdP only accepts pre-registered +redirect URIs. Pick any free port; just make sure the matching URI is in +your MCP client's **Redirect URIs** list. Authentik users with the regex +pattern from the setup step (`^http://(127\.0\.0\.1|localhost):\d+/.*$`) +can use any port without re-registering. + +### B. Manual-paste fallback (when loopback isn't reachable) + +Sealed containers, devboxes without port forwarding, etc. The redirect URI +in this case is hosted by *this* server: + +```bash +claude mcp add --transport http --scope user \ + --client-id \ + --callback-port 0 \ + shared-memory https://memory.dnspegasus.net/api/mcp +``` + +When the loopback listener times out, Claude Code prompts you to paste the +callback URL. Open the authorize URL Claude Code printed in your browser, +sign in, and your IdP redirects to +`https://memory.dnspegasus.net/auth/cli-callback?code=…`. That page shows +the `code` and the full URL with copy buttons — paste either back into +Claude Code's prompt to complete the flow. + +The manual-fallback URI must be registered on your MCP client too: +`https://memory.dnspegasus.net/auth/cli-callback`. + +### C. Static bearer token (no browser at all) + +For fully headless / CI scenarios, mint a long-lived HMAC token at +`https://memory.dnspegasus.net/connect` and pass it via `--header`. See +the `/connect` page for the exact `claude mcp add` command it generates +for you. + +### Why no zero-config plugin yet + +Claude Code plugins can ship an MCP server entry that handles OAuth +without any flags — but only when the auth server supports Dynamic Client +Registration (RFC 7591). Authentik is tracking DCR in +[goauthentik/authentik#8751](https://github.com/goauthentik/authentik/issues/8751); +once it ships we'll publish a plugin so the entire flow above collapses +to `/plugin install shared-memory`. Other IdPs that already support DCR +(Asana-style) can wire this up sooner. [mcp-auth]: https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization diff --git a/apps/web/app/auth/cli-callback/copy-button.tsx b/apps/web/app/auth/cli-callback/copy-button.tsx new file mode 100644 index 0000000..cfd4f7a --- /dev/null +++ b/apps/web/app/auth/cli-callback/copy-button.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useState } from "react"; + +interface Props { + value: string; + label: string; +} + +export default function CopyButton({ value, label }: Props) { + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Fallback for old browsers / non-secure contexts: select the next + //
 and let the user hit ⌘C themselves.
+    }
+  }
+
+  return (
+    
+  );
+}
diff --git a/apps/web/app/auth/cli-callback/page.tsx b/apps/web/app/auth/cli-callback/page.tsx
new file mode 100644
index 0000000..ec286ed
--- /dev/null
+++ b/apps/web/app/auth/cli-callback/page.tsx
@@ -0,0 +1,136 @@
+import Link from "next/link";
+import CopyButton from "./copy-button";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * /auth/cli-callback — OAuth redirect target for clients that can't open a
+ * loopback port (e.g. Claude Code in a sealed container).
+ *
+ * The page is intentionally unauthenticated: the user arrives here as part
+ * of an in-progress OAuth flow, before any session exists. The code is
+ * single-use and proof of possession (PKCE on the client side) is still
+ * required to exchange it. Showing it on this page does NOT grant access
+ * by itself.
+ */
+
+interface SearchParams {
+  code?: string;
+  state?: string;
+  error?: string;
+  error_description?: string;
+  iss?: string;
+}
+
+export default async function CliCallbackPage({
+  searchParams,
+}: {
+  searchParams: Promise;
+}) {
+  const params = await searchParams;
+
+  if (params.error) {
+    return (
+      
+

Sign-in failed

+

+ {params.error} + {params.error_description ? <> — {params.error_description} : null} +

+

+ Switch back to your terminal, cancel the in-progress prompt, and + retry the claude mcp add command. If the error + persists, check that the redirect URI matches what your OIDC + provider has registered. +

+

+ ← home +

+
+ ); + } + + if (!params.code) { + return ( +
+

OAuth callback

+

+ This page is the manual-fallback redirect target for the + shared-memory MCP server. It only does something useful in the + middle of an OAuth sign-in flow that couldn't reach a + loopback callback on your machine. +

+

+ If you're trying to connect an MCP client, start over from + your terminal with the claude mcp add command shown + in the README. +

+

+ ← home +

+
+ ); + } + + const fullUrl = `?code=${encodeURIComponent(params.code)}${ + params.state ? `&state=${encodeURIComponent(params.state)}` : "" + }${params.iss ? `&iss=${encodeURIComponent(params.iss)}` : ""}`; + + return ( +
+

Sign-in complete

+

+ Switch back to your terminal where Claude Code (or whichever MCP + client) is waiting, and paste one of the values below. +

+ +

Authorization code

+

+ Most clients ask for just the code: +

+ +
+        {params.code}
+      
+ +

Full callback URL

+

+ Some clients ask you to paste the entire URL their loopback timed + out on: +

+ +
+        {fullUrl}
+      
+ + {params.state ? ( + <> +

State (verification)

+

+ Your terminal client may show its expected state; it should + match this value. If it doesn't, stop and start over — + something is wrong with the flow. +

+
{params.state}
+ + ) : null} + +

+ The code is single-use and expires in a few minutes. If you take + too long, retry the claude mcp add command. +

+
+ ); +}