diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 088933a..20813ec 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,7 +1,8 @@ "use client" -import { useState } from "react" +import { Suspense, useState } from "react" import { signIn } from "next-auth/react" +import { useSearchParams } from "next/navigation" import Link from "next/link" import { Beer, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" @@ -9,7 +10,49 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +/** + * Only same-origin destinations are honoured. Without this check the + * `?callbackUrl=` parameter - which anyone can put in a link - would make the + * login page an open redirect onto an attacker's site, wearing our domain. + * + * Both forms have to be accepted. A relative path is what /oauth/authorize + * builds, while the middleware's own redirect writes an absolute URL, and + * rejecting that one would quietly drop people on /dashboard instead of the + * consent screen they were sent to sign in for. + * + * A leading `//` is rejected because `//evil.com` is protocol-relative, which + * the browser resolves as absolute. + */ +function safeCallbackUrl(value: string | null): string { + if (!value) return "/dashboard" + + if (value.startsWith("/")) { + return value.startsWith("//") ? "/dashboard" : value + } + + try { + const target = new URL(value) + if (target.origin === window.location.origin) { + return `${target.pathname}${target.search}${target.hash}` + } + } catch { + // Not a URL at all. + } + return "/dashboard" +} + export default function LoginPage() { + // useSearchParams needs a Suspense boundary; this page prerenders statically. + return ( + + + + ) +} + +function LoginForm() { + const searchParams = useSearchParams() + const callbackUrl = safeCallbackUrl(searchParams.get("callbackUrl")) const [email, setEmail] = useState("") const [password, setPassword] = useState("") const [error, setError] = useState("") @@ -24,7 +67,7 @@ export default function LoginPage() { const result = await signIn("credentials", { email, password, - callbackUrl: "/dashboard", + callbackUrl, redirect: false, }) diff --git a/src/app/.well-known/oauth-authorization-server/route.ts b/src/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 0000000..2574ef1 --- /dev/null +++ b/src/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server" +import { mcpBaseUrl } from "@/lib/mcp/config" +import { SUPPORTED_SCOPES } from "@/lib/mcp/oauth" + +/** + * RFC 8414 authorization server metadata. + * + * `issuer` must equal the base URL this document was discovered at, so it is + * derived from the same NEXTAUTH_URL everything else uses rather than hardcoded. + */ +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +function metadata() { + const base = mcpBaseUrl() + return { + issuer: base, + authorization_endpoint: `${base}/oauth/authorize`, + token_endpoint: `${base}/api/oauth/token`, + registration_endpoint: `${base}/api/oauth/register`, + revocation_endpoint: `${base}/api/oauth/revoke`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token"], + // Claude always sends a PKCE challenge and checks that S256 is advertised + // before starting the flow. `plain` is not accepted. + code_challenge_methods_supported: ["S256"], + // Public clients only. No client secret is ever issued or stored, so there + // is none to leak from this deployment. + token_endpoint_auth_methods_supported: ["none"], + revocation_endpoint_auth_methods_supported: ["none"], + scopes_supported: SUPPORTED_SCOPES, + service_documentation: `${base}/settings`, + // + // Deliberately absent: client_id_metadata_document_supported. + // + // Claude picks CIMD only when this document advertises BOTH that flag and + // "none" in token_endpoint_auth_methods_supported; omitting it makes both + // Claude and ChatGPT fall back to dynamic registration cleanly. CIMD would + // mean fetching a client-supplied URL server-side, from a host that also + // reaches MinIO on 127.0.0.1:9000, Postgres on 5432 and the Switchboard + // gateway on the LAN. That is an SSRF pivot in exchange for nothing here; + // registration is a POST to our own endpoint with no outbound traffic. + } +} + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", +} + +export function GET(): NextResponse { + return NextResponse.json(metadata(), { headers: CORS }) +} + +export function OPTIONS(): Response { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Max-Age": "86400", + }, + }) +} diff --git a/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts b/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts new file mode 100644 index 0000000..599e8a2 --- /dev/null +++ b/src/app/.well-known/oauth-protected-resource/api/mcp/route.ts @@ -0,0 +1,15 @@ +/** + * RFC 9728 path-inserted form of the protected resource metadata. + * + * For a resource at https://host/api/mcp, a client probes + * /.well-known/oauth-protected-resource/api/mcp before falling back to the bare + * /.well-known/oauth-protected-resource. Both are served so discovery works + * whichever a given client tries, and whether or not it read the + * WWW-Authenticate challenge that names the URL explicitly. + */ +export { GET, OPTIONS } from "../../route" + +// Declared literally rather than re-exported: Next reads these statically and +// cannot follow a re-export. +export const runtime = "nodejs" +export const dynamic = "force-dynamic" diff --git a/src/app/.well-known/oauth-protected-resource/route.ts b/src/app/.well-known/oauth-protected-resource/route.ts new file mode 100644 index 0000000..981fe21 --- /dev/null +++ b/src/app/.well-known/oauth-protected-resource/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" +import { generateProtectedResourceMetadata } from "mcp-handler" +import { mcpBaseUrl, mcpResourceUrl } from "@/lib/mcp/config" +import { SUPPORTED_SCOPES } from "@/lib/mcp/oauth" + +/** + * RFC 9728 protected resource metadata - how a client discovers where to + * authenticate for /api/mcp. + * + * Two constraints worth stating, because breaking either fails silently as + * "couldn't reach the MCP server": + * + * - `resource` must match the URL the user typed into their client exactly, + * which is why both it and the issuer come from NEXTAUTH_URL rather than the + * request. See src/lib/mcp/config.ts. + * - Claude reads only the FIRST entry of authorization_servers and does not + * fall back to later ones, so there is exactly one. + * + * This path sits outside /api, so it is caught by the middleware matcher and + * needs "/.well-known" in PUBLIC_ROUTES - otherwise it answers with an HTML + * redirect to /login and discovery never starts. + */ +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** + * `scopes_supported` is not decoration. When a client's 401 challenge carries no + * explicit scope, Claude requests exactly the scopes advertised here - so + * omitting it makes every connection fall back to the read-only default and the + * user is left wondering why the assistant cannot add a drink. The consent + * screen is what actually gates the write scopes. + */ +export function GET(): Response { + const metadata = generateProtectedResourceMetadata({ + authServerUrls: [mcpBaseUrl()], + resourceUrl: mcpResourceUrl(), + additionalMetadata: { + scopes_supported: SUPPORTED_SCOPES, + resource_name: "DrinkTracker", + bearer_methods_supported: ["header"], + }, + }) + + return NextResponse.json(metadata, { + headers: { + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=3600", + }, + }) +} + +export function OPTIONS(): Response { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Max-Age": "86400", + }, + }) +} diff --git a/src/app/api/oauth/authorize/route.ts b/src/app/api/oauth/authorize/route.ts new file mode 100644 index 0000000..0246c21 --- /dev/null +++ b/src/app/api/oauth/authorize/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { requireUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { + buildRedirect, + validateAuthorizeRequest, +} from "@/lib/mcp/authorize-request" +import { AUTH_CODE_TTL_MS, generateAuthCode } from "@/lib/mcp/oauth" + +/** + * The user's decision on the consent screen. + * + * Everything is re-validated from the query string rather than trusted from the + * request body: the body is just a carrier for the original parameters, and + * treating any of it as already-checked would let a crafted POST mint a code for + * a client and redirect the page never approved. + */ +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const bodySchema = z.object({ + decision: z.enum(["allow", "deny"]), + search: z.string().max(4096), +}) + +export async function POST(request: Request) { + const session = await requireUser() + if (session instanceof NextResponse) return session + + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json({ error: "invalid_request" }, { status: 400 }) + } + + const search = new URLSearchParams(parsed.data.search) + const validation = await validateAuthorizeRequest(search) + + if (validation.kind === "fatal") { + return NextResponse.json( + { error: "invalid_request", error_description: validation.message }, + { status: 400 } + ) + } + if (validation.kind === "redirectable") { + return NextResponse.json({ + redirectTo: buildRedirect(validation.redirectUri, { + error: validation.error, + error_description: validation.description, + state: validation.state, + }), + }) + } + + const { params, client } = validation + const userId = session.user.id + + if (parsed.data.decision === "deny") { + return NextResponse.json({ + redirectTo: buildRedirect(params.redirectUri, { + error: "access_denied", + error_description: "The user declined the request.", + state: params.state, + }), + }) + } + + // One standing grant per (client, user). Re-approving widens the scopes and + // clears a previous revocation rather than piling up rows. + const grant = await prisma.oAuthGrant.upsert({ + where: { clientId_userId: { clientId: client.id, userId } }, + update: { scopes: params.scopes, revokedAt: null }, + create: { clientId: client.id, userId, scopes: params.scopes }, + select: { id: true }, + }) + + const { code, hash } = generateAuthCode() + await prisma.oAuthAuthCode.create({ + data: { + codeHash: hash, + clientId: client.id, + userId, + redirectUri: params.redirectUri, + resource: params.resource, + scopes: params.scopes, + codeChallenge: params.codeChallenge, + codeChallengeMethod: "S256", + expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS), + }, + }) + + console.log( + `[oauth] granted user=${userId} client=${client.clientId} scopes=${params.scopes.join(",")} grant=${grant.id}` + ) + + return NextResponse.json({ + redirectTo: buildRedirect(params.redirectUri, { + code, + state: params.state, + }), + }) +} diff --git a/src/app/api/oauth/register/route.ts b/src/app/api/oauth/register/route.ts new file mode 100644 index 0000000..462c133 --- /dev/null +++ b/src/app/api/oauth/register/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { prisma } from "@/lib/prisma" +import { rateLimit } from "@/lib/rate-limit" +import { + generateClientId, + isRegisterableRedirectUri, + oauthError, +} from "@/lib/mcp/oauth" + +/** + * RFC 7591 dynamic client registration. + * + * Unauthenticated by specification, which makes it the one endpoint on this + * deployment that a stranger can create rows through. Hence the rate limit, the + * global cap, and the pruning of registrations that never went on to be used. + */ +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +/** Beyond this many registrations in a day, something is wrong. */ +const DAILY_REGISTRATION_CAP = 500 +const PRUNE_AFTER_MS = 7 * 24 * 60 * 60 * 1000 + +const registerSchema = z.object({ + client_name: z.string().min(1).max(200).optional(), + redirect_uris: z.array(z.string().min(1)).min(1).max(10), + grant_types: z.array(z.string()).optional(), + response_types: z.array(z.string()).optional(), + token_endpoint_auth_method: z.string().optional(), + scope: z.string().max(500).optional(), + client_uri: z.string().max(2048).optional(), + logo_uri: z.string().max(2048).optional(), + software_id: z.string().max(200).optional(), +}) + +export async function POST(request: Request) { + const forwarded = request.headers.get("x-forwarded-for") + const hops = forwarded?.split(",").map((h) => h.trim()).filter(Boolean) ?? [] + // Last hop, not first: everything before it is client-supplied and can be + // forged for a fresh bucket. Same reasoning as the register route. + const ip = hops.length > 0 ? hops[hops.length - 1] : "unknown" + + const rl = rateLimit(`dcr:${ip}`, 5, 60 * 60 * 1000) + if (!rl.success) { + return oauthError( + "temporarily_unavailable", + "Too many registration attempts.", + 429 + ) + } + + const body = await request.json().catch(() => null) + if (!body) return oauthError("invalid_client_metadata", "Body must be JSON.") + + const parsed = registerSchema.safeParse(body) + if (!parsed.success) { + return oauthError("invalid_client_metadata", parsed.error.issues[0]?.message) + } + const data = parsed.data + + // Public clients only. Accepting a confidential client would mean storing a + // secret this server has no need for and no way to protect better than the + // PKCE it already requires. + const authMethod = data.token_endpoint_auth_method ?? "none" + if (authMethod !== "none") { + return oauthError( + "invalid_client_metadata", + "This server issues public clients only; token_endpoint_auth_method must be \"none\"." + ) + } + + const bad = data.redirect_uris.filter((u) => !isRegisterableRedirectUri(u)) + if (bad.length) { + return oauthError( + "invalid_redirect_uri", + `Redirect URIs must be https or a loopback address, with no fragment: ${bad.join(", ")}` + ) + } + + const since = new Date(Date.now() - 24 * 60 * 60 * 1000) + const recent = await prisma.oAuthClient.count({ + where: { createdAt: { gte: since } }, + }) + if (recent >= DAILY_REGISTRATION_CAP) { + return oauthError( + "temporarily_unavailable", + "Registration is temporarily closed.", + 503 + ) + } + + const clientId = generateClientId() + const client = await prisma.oAuthClient.create({ + data: { + clientId, + clientName: data.client_name ?? "Unnamed client", + redirectUris: data.redirect_uris, + grantTypes: data.grant_types ?? ["authorization_code", "refresh_token"], + responseTypes: data.response_types ?? ["code"], + tokenEndpointAuthMethod: "none", + scope: data.scope ?? null, + clientUri: data.client_uri ?? null, + logoUri: data.logo_uri ?? null, + softwareId: data.software_id ?? null, + }, + }) + + pruneUnusedClients() + + return NextResponse.json( + { + client_id: client.clientId, + client_id_issued_at: Math.floor(client.createdAt.getTime() / 1000), + client_name: client.clientName, + redirect_uris: client.redirectUris, + grant_types: client.grantTypes, + response_types: client.responseTypes, + token_endpoint_auth_method: "none", + ...(client.scope ? { scope: client.scope } : {}), + }, + { status: 201, headers: { "Cache-Control": "no-store" } } + ) +} + +/** + * Registrations that never led to a grant are abandoned handshakes. Cleared + * opportunistically rather than on a schedule, because there is no scheduler. + */ +function pruneUnusedClients(): void { + prisma.oAuthClient + .deleteMany({ + where: { + createdAt: { lt: new Date(Date.now() - PRUNE_AFTER_MS) }, + grants: { none: {} }, + }, + }) + .then(({ count }) => { + if (count > 0) console.log(`[oauth] pruned ${count} unused client(s)`) + }) + .catch((error) => console.warn("[oauth] client prune failed:", error)) +} diff --git a/src/app/api/oauth/revoke/route.ts b/src/app/api/oauth/revoke/route.ts new file mode 100644 index 0000000..e7a6a72 --- /dev/null +++ b/src/app/api/oauth/revoke/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { revokeGrant, sha256 } from "@/lib/mcp/oauth" + +/** + * RFC 7009 token revocation. + * + * Always answers 200, even for a token that does not exist. That is the spec's + * requirement and it is also what stops this endpoint from becoming an oracle + * for guessing valid tokens. + */ +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +export async function POST(request: Request) { + const contentType = request.headers.get("content-type") ?? "" + const form = contentType.includes("application/json") + ? new URLSearchParams( + Object.entries((await request.json().catch(() => ({}))) as Record) + .map(([k, v]) => [k, String(v)]) + ) + : new URLSearchParams(await request.text()) + + const token = form.get("token") + const ok = NextResponse.json({}, { headers: { "Cache-Control": "no-store" } }) + if (!token) return ok + + const hash = sha256(token) + + try { + // A refresh token identifies the whole grant, so revoking it ends the + // connection outright. + const grant = await prisma.oAuthGrant.findFirst({ + where: { OR: [{ refreshHash: hash }, { refreshPrevHash: hash }] }, + select: { id: true }, + }) + if (grant) { + await revokeGrant(grant.id) + return ok + } + + // An access token revokes only itself; the client can still refresh. That + // matches RFC 7009's guidance that revoking an access token need not + // invalidate the refresh token. + await prisma.mcpAccessToken.updateMany({ + where: { tokenHash: hash, revokedAt: null }, + data: { revokedAt: new Date() }, + }) + } catch (error) { + // Still a 200 - the caller's intent was for the token to stop working, and + // reporting an internal failure here tells them nothing useful. + console.error("[oauth] revoke failed:", error) + } + + return ok +} diff --git a/src/app/api/oauth/token/route.ts b/src/app/api/oauth/token/route.ts new file mode 100644 index 0000000..b9da3db --- /dev/null +++ b/src/app/api/oauth/token/route.ts @@ -0,0 +1,199 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { + OFFLINE_ACCESS, + generateRefreshToken, + issueAccessToken, + oauthError, + redirectUriMatches, + revokeGrant, + sha256, + storeRefreshToken, + verifyPkce, +} from "@/lib/mcp/oauth" + +/** + * RFC 6749 token endpoint. + * + * Bodies arrive as application/x-www-form-urlencoded - required by the spec and + * what Claude sends for both the initial exchange and every refresh. JSON is + * accepted as well, since some clients send it and there is no reason to be + * strict about it. + */ +export const runtime = "nodejs" +export const dynamic = "force-dynamic" + +const NO_STORE = { "Cache-Control": "no-store", Pragma: "no-cache" } + +async function readForm(request: Request): Promise { + const contentType = request.headers.get("content-type") ?? "" + if (contentType.includes("application/json")) { + const body = await request.json().catch(() => ({})) + return new URLSearchParams( + Object.entries(body as Record).map(([k, v]) => [k, String(v)]) + ) + } + return new URLSearchParams(await request.text()) +} + +export async function POST(request: Request) { + const form = await readForm(request) + const grantType = form.get("grant_type") + + if (grantType === "authorization_code") return exchangeCode(form) + if (grantType === "refresh_token") return refresh(form) + + return oauthError( + "unsupported_grant_type", + "Supported grant types are authorization_code and refresh_token." + ) +} + +async function exchangeCode(form: URLSearchParams) { + const code = form.get("code") + const clientId = form.get("client_id") + const redirectUri = form.get("redirect_uri") + const verifier = form.get("code_verifier") + + if (!code || !clientId || !redirectUri || !verifier) { + return oauthError( + "invalid_request", + "code, client_id, redirect_uri and code_verifier are all required." + ) + } + + const row = await prisma.oAuthAuthCode.findUnique({ + where: { codeHash: sha256(code) }, + include: { + client: { select: { id: true, clientId: true, clientName: true } }, + user: { select: { id: true, status: true } }, + }, + }) + if (!row) return oauthError("invalid_grant", "Unknown or expired code.") + + // Consume before verifying anything else. One guarded statement, one row + // lock: whoever wins gets count 1 and everyone else gets 0, so a replayed + // code cannot mint a second token even under concurrency. A code that fails + // verification below stays consumed, which is the intent - it has been seen. + const { count } = await prisma.oAuthAuthCode.updateMany({ + where: { + codeHash: row.codeHash, + consumedAt: null, + expiresAt: { gt: new Date() }, + }, + data: { consumedAt: new Date() }, + }) + if (count !== 1) { + return oauthError("invalid_grant", "This code has already been used or has expired.") + } + + if (row.client.clientId !== clientId) { + return oauthError("invalid_grant", "This code was issued to a different client.") + } + // Exact match against what was recorded at authorize time, not a fresh + // registration lookup - the pairing is what is being verified. + if (!redirectUriMatches(row.redirectUri, redirectUri)) { + return oauthError("invalid_grant", "redirect_uri does not match the authorization request.") + } + + const grant = await prisma.oAuthGrant.findUnique({ + where: { clientId_userId: { clientId: row.client.id, userId: row.userId } }, + select: { id: true, revokedAt: true }, + }) + if (!grant || grant.revokedAt) { + return oauthError("invalid_grant", "This authorization is no longer valid.") + } + + if (!verifyPkce(verifier, row.codeChallenge)) { + // A verifier that does not match means whoever is holding this code is + // probably not who the code was issued to. Burn the whole grant, not just + // the code. + await revokeGrant(grant.id) + console.warn( + `[oauth] PKCE mismatch for client=${row.client.clientId} user=${row.userId}; grant revoked` + ) + return oauthError("invalid_grant", "PKCE verification failed.") + } + + if (row.user.status !== "ACTIVE") { + return oauthError("invalid_grant", "This account is not active.") + } + + const tokens = await issueAccessToken( + grant.id, + row.userId, + row.scopes, + row.client.clientName + ) + + // A refresh token only exists if offline_access was actually granted. + let refreshToken: string | undefined + if (row.scopes.includes(OFFLINE_ACCESS)) { + const minted = generateRefreshToken() + await storeRefreshToken(grant.id, minted.hash, null) + refreshToken = minted.token + } + + console.log( + `[oauth] token issued user=${row.userId} client=${row.client.clientId} grant=${grant.id}` + ) + return NextResponse.json( + { ...tokens, ...(refreshToken ? { refresh_token: refreshToken } : {}) }, + { headers: NO_STORE } + ) +} + +async function refresh(form: URLSearchParams) { + const token = form.get("refresh_token") + if (!token) return oauthError("invalid_request", "refresh_token is required.") + + const hash = sha256(token) + + const grant = await prisma.oAuthGrant.findFirst({ + where: { OR: [{ refreshHash: hash }, { refreshPrevHash: hash }] }, + include: { + client: { select: { clientId: true, clientName: true } }, + user: { select: { status: true } }, + }, + }) + if (!grant) return oauthError("invalid_grant", "Unknown refresh token.") + + // Presenting the previous generation means the current one was rotated out + // from under this caller - either a replay of a stolen token or a client + // holding a stale copy. Either way the safe move is to end the grant and make + // everyone re-authorize. + if (grant.refreshPrevHash === hash && grant.refreshHash !== hash) { + await revokeGrant(grant.id) + console.warn( + `[oauth] refresh token replay for client=${grant.client.clientId} user=${grant.userId}; grant revoked` + ) + return oauthError("invalid_grant", "This refresh token has already been used.") + } + + if (grant.revokedAt) return oauthError("invalid_grant", "This authorization was revoked.") + if (grant.refreshExpiresAt && grant.refreshExpiresAt <= new Date()) { + return oauthError("invalid_grant", "This refresh token has expired.") + } + if (grant.user.status !== "ACTIVE") { + return oauthError("invalid_grant", "This account is not active.") + } + + const tokens = await issueAccessToken( + grant.id, + grant.userId, + grant.scopes, + grant.client.clientName + ) + + // Rotate. OAuth 2.1 requires it for public clients, and keeping the old hash + // in refreshPrevHash is what makes the replay check above possible. Done + // after the access token is minted so a failure there leaves the caller's + // existing refresh token still usable. + const next = generateRefreshToken() + await storeRefreshToken(grant.id, next.hash, hash) + + return NextResponse.json( + { ...tokens, refresh_token: next.token }, + { headers: NO_STORE } + ) +} diff --git a/src/app/api/settings/mcp-grants/[id]/route.ts b/src/app/api/settings/mcp-grants/[id]/route.ts new file mode 100644 index 0000000..78f37b4 --- /dev/null +++ b/src/app/api/settings/mcp-grants/[id]/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" +import { requireUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" +import { revokeGrant } from "@/lib/mcp/oauth" + +/** + * Disconnect an app. Cascades to every access token the grant issued, so the + * connection dies on the next request rather than at the end of the current + * access token's hour. + */ +export async function DELETE( + _request: Request, + { params }: { params: { id: string } } +) { + const session = await requireUser() + if (session instanceof NextResponse) return session + + // Scoped to the caller, 404 rather than 403 - the convention across the API. + const grant = await prisma.oAuthGrant.findFirst({ + where: { id: params.id, userId: session.user.id, revokedAt: null }, + select: { id: true }, + }) + if (!grant) return NextResponse.json({ error: "Not found" }, { status: 404 }) + + await revokeGrant(grant.id) + return NextResponse.json({ success: true }) +} diff --git a/src/app/api/settings/mcp-grants/route.ts b/src/app/api/settings/mcp-grants/route.ts new file mode 100644 index 0000000..0496cd1 --- /dev/null +++ b/src/app/api/settings/mcp-grants/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server" +import { requireUser } from "@/lib/authz" +import { prisma } from "@/lib/prisma" + +/** Apps the user has connected through OAuth, for the Settings list. */ +export async function GET() { + const session = await requireUser() + if (session instanceof NextResponse) return session + + const grants = await prisma.oAuthGrant.findMany({ + where: { userId: session.user.id, revokedAt: null }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + scopes: true, + createdAt: true, + lastUsedAt: true, + client: { select: { clientName: true, clientUri: true } }, + }, + }) + + return NextResponse.json({ + grants: grants.map((g) => ({ + id: g.id, + clientName: g.client.clientName, + clientUri: g.client.clientUri, + scopes: g.scopes, + createdAt: g.createdAt, + lastUsedAt: g.lastUsedAt, + })), + }) +} diff --git a/src/app/oauth/authorize/page.tsx b/src/app/oauth/authorize/page.tsx new file mode 100644 index 0000000..82b6a19 --- /dev/null +++ b/src/app/oauth/authorize/page.tsx @@ -0,0 +1,183 @@ +import { redirect } from "next/navigation" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" +import { Beer, ShieldCheck } from "lucide-react" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { ConsentForm } from "@/components/oauth/consent-form" +import { + buildRedirect, + validateAuthorizeRequest, +} from "@/lib/mcp/authorize-request" +import { + AUTH_CODE_TTL_MS, + SCOPE_DESCRIPTIONS, + coversScopes, + generateAuthCode, +} from "@/lib/mcp/oauth" + +/** + * The consent screen. + * + * Not in PUBLIC_ROUTES, so the middleware bounces an unauthenticated visitor to + * /login for us - and now that login honours ?callbackUrl they come straight + * back here afterwards. The explicit session check below is for the callbackUrl + * construction, not for access control. + * + * Deliberately outside the (app) route group: this is a credential prompt, and + * it should not be wrapped in the sidebar and bottom nav of the signed-in app. + */ +export const dynamic = "force-dynamic" + +type SearchParams = Record + +function toParams(searchParams: SearchParams): URLSearchParams { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(searchParams)) { + if (typeof value === "string") params.set(key, value) + else if (Array.isArray(value) && value[0]) params.set(key, value[0]) + } + return params +} + +export default async function AuthorizePage({ + searchParams, +}: { + searchParams: SearchParams +}) { + const search = toParams(searchParams) + const validation = await validateAuthorizeRequest(search) + + if (validation.kind === "fatal") { + return + } + + if (validation.kind === "redirectable") { + redirect( + buildRedirect(validation.redirectUri, { + error: validation.error, + error_description: validation.description, + state: validation.state, + }) + ) + } + + const { params, client } = validation + + const session = await auth() + if (!session?.user?.id) { + redirect(`/login?callbackUrl=${encodeURIComponent(`/oauth/authorize?${search}`)}`) + } + + // A session already implies an ACTIVE user - the jwt callback in lib/auth.ts + // returns null for anyone suspended or deleted, which clears the cookie. + const userId = session.user.id + + // Already approved for these scopes? Skip the screen. This is what makes a + // re-connection after an expired refresh token silent rather than another + // round of clicking Allow. + const existing = await prisma.oAuthGrant.findUnique({ + where: { clientId_userId: { clientId: client.id, userId } }, + select: { id: true, scopes: true, revokedAt: true }, + }) + + if (existing && !existing.revokedAt && coversScopes(existing.scopes, params.scopes)) { + const code = await createAuthCode(client.id, userId, params) + redirect(buildRedirect(params.redirectUri, { code, state: params.state })) + } + + return ( +
+ + +
+ +
+ + Connect {client.clientName} to DrinkTracker? + + + Signed in as {session.user.email ?? session.user.name} + +
+ +
+

It will be able to:

+
    + {params.scopes.map((scope) => ( +
  • + + {SCOPE_DESCRIPTIONS[scope] ?? scope} +
  • + ))} +
+
+ + {client.clientUri && ( +

+ {client.clientUri} +

+ )} + +

+ It will be sent back to {params.redirectUri}. + You can disconnect it any time from Settings. +

+ + +
+
+
+ ) +} + +async function createAuthCode( + clientRowId: string, + userId: string, + params: { + redirectUri: string + scopes: string[] + codeChallenge: string + resource: string | null + } +): Promise { + const { code, hash } = generateAuthCode() + await prisma.oAuthAuthCode.create({ + data: { + codeHash: hash, + clientId: clientRowId, + userId, + redirectUri: params.redirectUri, + resource: params.resource, + scopes: params.scopes, + codeChallenge: params.codeChallenge, + codeChallengeMethod: "S256", + expiresAt: new Date(Date.now() + AUTH_CODE_TTL_MS), + }, + }) + return code +} + +function ErrorCard({ title, detail }: { title: string; detail: string }) { + return ( +
+ + + {title} + {detail} + + +

+ Nothing was connected. Try starting the connection again from the app + you were using. +

+
+
+
+ ) +} diff --git a/src/components/oauth/consent-form.tsx b/src/components/oauth/consent-form.tsx new file mode 100644 index 0000000..a768536 --- /dev/null +++ b/src/components/oauth/consent-form.tsx @@ -0,0 +1,71 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Loader2 } from "lucide-react" + +/** + * Approve or deny. + * + * The endpoint answers with JSON containing the URL to go to, and the browser + * navigates itself, rather than the POST replying with a 302 to claude.ai. The + * CSP sets `form-action 'self'`, and browsers have historically disagreed about + * whether that also constrains the redirect a form submission lands on. A + * client-side navigation sidesteps the question and gives somewhere to show an + * error if the request fails. + */ +export function ConsentForm({ search }: { search: string }) { + const [busy, setBusy] = useState<"allow" | "deny" | null>(null) + const [error, setError] = useState("") + + async function decide(decision: "allow" | "deny") { + setBusy(decision) + setError("") + try { + const res = await fetch("/api/oauth/authorize", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ decision, search }), + }) + const body = await res.json() + if (!res.ok || !body.redirectTo) { + setError(body.error_description ?? body.error ?? "Something went wrong.") + setBusy(null) + return + } + window.location.assign(body.redirectTo) + } catch { + setError("Could not reach the server. Please try again.") + setBusy(null) + } + } + + return ( +
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+ ) +} diff --git a/src/components/settings/mcp-tokens-card.tsx b/src/components/settings/mcp-tokens-card.tsx index 1469602..5ccd2ea 100644 --- a/src/components/settings/mcp-tokens-card.tsx +++ b/src/components/settings/mcp-tokens-card.tsx @@ -14,6 +14,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" import { Select, SelectOption } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" import { Check, Copy, Loader2, Plug, Trash2 } from "lucide-react" interface McpToken { @@ -35,6 +36,15 @@ interface CreatedToken extends McpToken { secret: string } +interface McpGrant { + id: string + clientName: string + clientUri: string | null + scopes: string[] + createdAt: string + lastUsedAt: string | null +} + const ACCESS_OPTIONS = [ { value: "read", label: "Read only" }, { value: "read-write", label: "Read and write" }, @@ -103,6 +113,30 @@ export function McpTokensCard() { }, }) + // Apps connected through OAuth rather than a pasted token: claude.ai and + // ChatGPT arrive this way. + const { data: grantData } = useQuery<{ grants: McpGrant[] }>({ + queryKey: ["mcp-grants"], + queryFn: async () => { + const res = await fetch("/api/settings/mcp-grants") + if (!res.ok) throw new Error("Failed to load connected apps") + return res.json() + }, + }) + + const disconnect = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/settings/mcp-grants/${id}`, { + method: "DELETE", + }) + if (!res.ok) throw new Error("Failed to disconnect") + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["mcp-grants"] }) + queryClient.invalidateQueries({ queryKey: ["mcp-tokens"] }) + }, + }) + const serverUrl = data?.serverUrl ?? "" async function copy(key: string, value: string) { @@ -188,6 +222,52 @@ export function McpTokensCard() { )} + {!!grantData?.grants.length && ( +
+

Connected apps

+ {grantData.grants.map((grant) => ( +
+
+
+ {grant.clientName} + + {grant.scopes.some((s) => s.endsWith(":write")) + ? "read/write" + : "read only"} + +
+

+ 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, + }, + }), + ]) +}