+ Connected {new Date(grant.createdAt).toLocaleDateString()}
+ {grant.lastUsedAt
+ ? ` · last used ${new Date(grant.lastUsedAt).toLocaleDateString()}`
+ : ""}
+
+
+
+
+ ))}
+
+
+ )}
+
+
+ Access tokens
+
+ for clients you configure by hand, such as Claude Code
+
+
+
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 1d0d099..872e94f 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -16,6 +16,17 @@ const PUBLIC_ROUTES = [
"/invite",
"/reset",
"/share",
+ // OAuth discovery documents for the MCP server. These have to be readable
+ // without a session - an MCP client fetches them before it has any
+ // credential, and `authorized` would otherwise answer with an HTML redirect
+ // to /login, which the client cannot parse and reports as "couldn't reach
+ // the server". Being a prefix match, this makes any future /.well-known/*
+ // route public too, so put nothing there that isn't meant to be.
+ //
+ // Note /oauth/authorize is deliberately NOT here: the consent screen must
+ // sit behind the session, and the redirect to /login is exactly what should
+ // happen when a client sends an unauthenticated user to it.
+ "/.well-known",
]
// Email and password only. Google and GitHub were configured but never had
diff --git a/src/lib/mcp/authorize-request.ts b/src/lib/mcp/authorize-request.ts
new file mode 100644
index 0000000..16d4e80
--- /dev/null
+++ b/src/lib/mcp/authorize-request.ts
@@ -0,0 +1,139 @@
+import { prisma } from "@/lib/prisma"
+import { mcpResourceUrl } from "@/lib/mcp/config"
+import { findMatchingRedirectUri, parseScopes } from "@/lib/mcp/oauth"
+
+/**
+ * Validation for an /oauth/authorize request, shared by the consent page and the
+ * approve endpoint so the two cannot drift.
+ *
+ * Order matters and is the whole point of the two-stage result below. Until the
+ * client and its redirect URI are known good there is nowhere safe to send an
+ * error, because an unvalidated redirect_uri is an open redirect. Only after
+ * both check out do errors become redirects, as RFC 6749 requires.
+ */
+
+export interface AuthorizeParams {
+ clientId: string
+ redirectUri: string
+ state: string | null
+ scopes: string[]
+ codeChallenge: string
+ resource: string | null
+}
+
+export interface ResolvedClient {
+ id: string
+ clientId: string
+ clientName: string
+ clientUri: string | null
+}
+
+export type AuthorizeValidation =
+ /** Nothing may be redirected. Render this on our own page. */
+ | { kind: "fatal"; message: string }
+ /** Client and redirect are trustworthy; report this back to the client. */
+ | {
+ kind: "redirectable"
+ error: string
+ description: string
+ redirectUri: string
+ state: string | null
+ }
+ | { kind: "ok"; params: AuthorizeParams; client: ResolvedClient }
+
+export async function validateAuthorizeRequest(
+ search: URLSearchParams
+): Promise {
+ const clientId = search.get("client_id")
+ const redirectUri = search.get("redirect_uri")
+
+ if (!clientId) return { kind: "fatal", message: "Missing client_id." }
+ if (!redirectUri) return { kind: "fatal", message: "Missing redirect_uri." }
+
+ const client = await prisma.oAuthClient.findUnique({
+ where: { clientId },
+ select: {
+ id: true,
+ clientId: true,
+ clientName: true,
+ clientUri: true,
+ redirectUris: true,
+ },
+ })
+ if (!client) return { kind: "fatal", message: "Unknown client_id." }
+
+ // Port-agnostic for loopback, exact otherwise. Returns the registered form.
+ const matched = findMatchingRedirectUri(client.redirectUris, redirectUri)
+ if (!matched) {
+ return {
+ kind: "fatal",
+ message: "The redirect_uri does not match any registered for this client.",
+ }
+ }
+
+ // From here on the redirect target is trusted, so problems go back to it.
+ const state = search.get("state")
+ const fail = (error: string, description: string): AuthorizeValidation => ({
+ kind: "redirectable",
+ error,
+ description,
+ redirectUri,
+ state,
+ })
+
+ if (search.get("response_type") !== "code") {
+ return fail("unsupported_response_type", "Only response_type=code is supported.")
+ }
+
+ const codeChallenge = search.get("code_challenge")
+ if (!codeChallenge) {
+ return fail("invalid_request", "PKCE is required: code_challenge is missing.")
+ }
+ const method = search.get("code_challenge_method")
+ if (method !== "S256") {
+ return fail(
+ "invalid_request",
+ "code_challenge_method must be S256; plain is not accepted."
+ )
+ }
+
+ // RFC 8707. If a client names an audience it must be this MCP server, or a
+ // token minted here could be replayed somewhere it was never meant for.
+ const resource = search.get("resource")
+ if (resource && resource.replace(/\/$/, "") !== mcpResourceUrl()) {
+ return fail(
+ "invalid_target",
+ `Unknown resource. This server issues tokens for ${mcpResourceUrl()}.`
+ )
+ }
+
+ return {
+ kind: "ok",
+ params: {
+ clientId,
+ redirectUri,
+ state,
+ scopes: parseScopes(search.get("scope")),
+ codeChallenge,
+ resource: resource ?? null,
+ },
+ client: {
+ id: client.id,
+ clientId: client.clientId,
+ clientName: client.clientName,
+ clientUri: client.clientUri,
+ },
+ }
+}
+
+/** Builds the URL to send the user back to, preserving `state`. */
+export function buildRedirect(
+ redirectUri: string,
+ params: Record
+): string {
+ const url = new URL(redirectUri)
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== null) url.searchParams.set(key, value)
+ }
+ return url.toString()
+}
diff --git a/src/lib/mcp/oauth.ts b/src/lib/mcp/oauth.ts
new file mode 100644
index 0000000..99299be
--- /dev/null
+++ b/src/lib/mcp/oauth.ts
@@ -0,0 +1,276 @@
+import { createHash, randomBytes, timingSafeEqual } from "crypto"
+import { NextResponse } from "next/server"
+import { prisma } from "@/lib/prisma"
+import { generateMcpToken } from "@/lib/mcp/tokens"
+import { MCP_SCOPES, type McpScope } from "@/lib/mcp/config"
+
+/**
+ * OAuth 2.1 authorization server primitives.
+ *
+ * This app is both the authorization server and the resource server, and it
+ * serves public clients only - no client secrets are stored anywhere, so there
+ * is nothing here to leak. Clients authenticate at the token endpoint with PKCE,
+ * which Claude always sends with S256.
+ */
+
+/** Access tokens are short; the refresh token is what carries the session. */
+export const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000
+export const REFRESH_TOKEN_TTL_MS = 90 * 24 * 60 * 60 * 1000
+/** Long enough for a redirect and a token call, short enough to be useless if leaked. */
+export const AUTH_CODE_TTL_MS = 60 * 1000
+
+export const OFFLINE_ACCESS = "offline_access"
+
+/** Advertised in discovery metadata and offered on the consent screen. */
+export const SUPPORTED_SCOPES: string[] = [...MCP_SCOPES, OFFLINE_ACCESS]
+
+export const SCOPE_DESCRIPTIONS: Record = {
+ "drinks:read": "See your drinks, ratings, wishlist and taste preferences",
+ "drinks:write": "Add, edit and delete drinks, ratings and wishlist entries",
+ "bar:read": "See your bar inventory and saved recipes",
+ "bar:write": "Add, edit and delete bar items and recipes",
+ [OFFLINE_ACCESS]: "Stay connected without asking you to sign in again",
+}
+
+// ─── Opaque secrets ──────────────────────────────────────────────
+
+export function sha256(value: string): string {
+ return createHash("sha256").update(value).digest("hex")
+}
+
+export function generateAuthCode(): { code: string; hash: string } {
+ const code = randomBytes(32).toString("base64url")
+ return { code, hash: sha256(code) }
+}
+
+export function generateRefreshToken(): { token: string; hash: string } {
+ const token = randomBytes(32).toString("base64url")
+ return { token, hash: sha256(token) }
+}
+
+export function generateClientId(): string {
+ return `dtc_${randomBytes(16).toString("hex")}`
+}
+
+// ─── Scopes ──────────────────────────────────────────────────────
+
+/**
+ * Narrow a requested scope string to what this server actually grants. Unknown
+ * scopes are dropped rather than rejected: an unrecognised scope is not an error
+ * per RFC 6749, and rejecting would break clients that request extras.
+ */
+export function parseScopes(requested: string | null | undefined): string[] {
+ const asked = (requested ?? "").split(/\s+/).filter(Boolean)
+ const granted = asked.filter((s) => SUPPORTED_SCOPES.includes(s))
+ // Default to read-only when nothing usable was asked for.
+ if (granted.filter((s) => s !== OFFLINE_ACCESS).length === 0) {
+ return ["drinks:read", "bar:read", ...granted.filter((s) => s === OFFLINE_ACCESS)]
+ }
+ return granted
+}
+
+/** The scopes actually stored on an access token: everything except the marker. */
+export function accessScopes(scopes: string[]): McpScope[] {
+ return scopes.filter((s): s is McpScope =>
+ (MCP_SCOPES as readonly string[]).includes(s)
+ )
+}
+
+export function coversScopes(granted: string[], requested: string[]): boolean {
+ return requested.every((s) => granted.includes(s))
+}
+
+// ─── PKCE ────────────────────────────────────────────────────────
+
+/** RFC 7636 unreserved set, 43-128 characters. */
+const VERIFIER_RE = /^[A-Za-z0-9\-._~]{43,128}$/
+
+export function verifyPkce(verifier: string, challenge: string): boolean {
+ if (!VERIFIER_RE.test(verifier)) return false
+ const computed = createHash("sha256").update(verifier).digest("base64url")
+ const a = Buffer.from(computed)
+ const b = Buffer.from(challenge)
+ // Length check first: timingSafeEqual throws on a mismatch rather than
+ // returning false.
+ if (a.length !== b.length) return false
+ return timingSafeEqual(a, b)
+}
+
+// ─── Redirect URIs ───────────────────────────────────────────────
+
+function isLoopback(url: URL): boolean {
+ return (
+ url.protocol === "http:" &&
+ (url.hostname === "localhost" ||
+ url.hostname === "127.0.0.1" ||
+ url.hostname === "[::1]" ||
+ url.hostname === "::1")
+ )
+}
+
+/**
+ * A redirect URI is acceptable at registration if it is https, or an RFC 8252
+ * loopback address. Fragments are forbidden by RFC 6749.
+ */
+export function isRegisterableRedirectUri(value: string): boolean {
+ let url: URL
+ try {
+ url = new URL(value)
+ } catch {
+ return false
+ }
+ if (url.hash) return false
+ return url.protocol === "https:" || isLoopback(url)
+}
+
+/**
+ * Matching a redirect at authorize time.
+ *
+ * Exact string equality, except for loopback addresses where the port is
+ * ignored. This is the single most common cause of a silently broken native
+ * client: Claude Code registers http://localhost/callback and
+ * http://127.0.0.1/callback, then redirects to an ephemeral port such as
+ * http://localhost:51234/callback. RFC 8252 section 7.3 requires the port to be
+ * ignored for the IP-literal form; the same is applied to localhost so Claude
+ * Code works at all.
+ */
+export function redirectUriMatches(registered: string, requested: string): boolean {
+ if (registered === requested) return true
+
+ let a: URL, b: URL
+ try {
+ a = new URL(registered)
+ b = new URL(requested)
+ } catch {
+ return false
+ }
+ if (!isLoopback(a) || !isLoopback(b)) return false
+ return (
+ a.protocol === b.protocol &&
+ a.hostname === b.hostname &&
+ a.pathname === b.pathname
+ )
+}
+
+export function findMatchingRedirectUri(
+ registered: string[],
+ requested: string
+): string | null {
+ return registered.find((uri) => redirectUriMatches(uri, requested)) ?? null
+}
+
+// ─── Errors ──────────────────────────────────────────────────────
+
+/**
+ * RFC 6749 error body. `invalid_grant` in particular is load-bearing: Claude
+ * keys off exactly that code to decide a refresh token is dead and it should
+ * restart the whole flow. Any other code leaves the connection stuck.
+ */
+export function oauthError(
+ error: string,
+ description?: string,
+ status = 400
+): NextResponse {
+ return NextResponse.json(
+ { error, ...(description ? { error_description: description } : {}) },
+ { status, headers: { "Cache-Control": "no-store", Pragma: "no-cache" } }
+ )
+}
+
+// ─── Token issuance ──────────────────────────────────────────────
+
+export interface IssuedTokens {
+ access_token: string
+ token_type: "Bearer"
+ expires_in: number
+ scope: string
+ refresh_token?: string
+}
+
+/**
+ * Mints an access token against a grant.
+ *
+ * Refresh tokens are deliberately NOT handled here. The two call sites need
+ * different behaviour - the code exchange creates the first one, a refresh
+ * rotates the existing one and has to record the previous hash for replay
+ * detection - and doing it in here once meant the rotation was silently
+ * overwritten and the token handed back matched nothing on file.
+ *
+ * The access token is an ordinary McpAccessToken row with source "oauth", the
+ * same shape a hand-minted one has, so verifyMcpToken needs no OAuth awareness.
+ * Previously outstanding tokens on the grant are revoked, so a refresh replaces
+ * rather than accumulates.
+ */
+export async function issueAccessToken(
+ grantId: string,
+ userId: string,
+ scopes: string[],
+ clientName: string
+): Promise {
+ const access = generateMcpToken()
+
+ await prisma.$transaction(async (tx) => {
+ await tx.mcpAccessToken.updateMany({
+ where: { grantId, revokedAt: null },
+ data: { revokedAt: new Date() },
+ })
+ await tx.mcpAccessToken.create({
+ data: {
+ userId,
+ tokenHash: access.hash,
+ prefix: access.prefix,
+ name: clientName,
+ scopes: accessScopes(scopes),
+ source: "oauth",
+ grantId,
+ expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL_MS),
+ },
+ })
+ await tx.oAuthGrant.update({
+ where: { id: grantId },
+ data: { scopes, lastUsedAt: new Date() },
+ })
+ })
+
+ return {
+ access_token: access.raw,
+ token_type: "Bearer",
+ expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
+ scope: scopes.join(" "),
+ }
+}
+
+/** Stores a freshly minted refresh token on the grant, keeping the old hash for replay detection. */
+export async function storeRefreshToken(
+ grantId: string,
+ hash: string,
+ previousHash: string | null
+): Promise {
+ await prisma.oAuthGrant.update({
+ where: { id: grantId },
+ data: {
+ refreshHash: hash,
+ refreshPrevHash: previousHash,
+ refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
+ },
+ })
+}
+
+/** Kills a grant and every access token it issued. */
+export async function revokeGrant(grantId: string): Promise {
+ const now = new Date()
+ await prisma.$transaction([
+ prisma.mcpAccessToken.updateMany({
+ where: { grantId, revokedAt: null },
+ data: { revokedAt: now },
+ }),
+ prisma.oAuthGrant.update({
+ where: { id: grantId },
+ data: {
+ revokedAt: now,
+ refreshHash: null,
+ refreshPrevHash: null,
+ },
+ }),
+ ])
+}