feat(mcp): add an OAuth 2.1 authorization-server bridge for remote connectors

Let a remote MCP client (Claude.ai, ChatGPT) self-register and connect to
the `/mcp` endpoint over OAuth 2.1 + PKCE, bridged onto the existing `hlk_`
Bearer model — no parallel identity store.

- Discovery: RFC 9728 Protected Resource Metadata and RFC 8414 Authorization
  Server Metadata at `/.well-known/oauth-protected-resource` and
  `/.well-known/oauth-authorization-server` (added to the proxy allowlist).
- `/api/mcp/oauth/authorize` — consent gate backed by the existing session;
  a code is only issued on an authenticated POST decision, never on a GET.
- `/api/mcp/oauth/token` — authorization-code and refresh-token grants with
  mandatory PKCE S256, single-use codes, and rotating refresh tokens. The
  access token is an ordinary short-lived `hlk_` token scoped `health:read`.
- `/api/mcp/oauth/register` — RFC 7591 DCR fallback; CIMD (SEP-991) is the
  preferred path and resolves client metadata through `safeFetch`.
- Audience binding (RFC 8707): every token is bound to the canonical `/mcp`
  resource URI; a mismatched `resource` is rejected at authorize and token.
- The grant artifacts (code, refresh, DCR client id) are HMAC-signed,
  self-describing, and domain-separated; the central log redactors scrub
  them plus the PKCE verifier.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 14:27:09 +02:00
1 parent 03d41bc5c0
commit 7ec3bf0dad
18 files changed
+2186 -3

No files matched your search

+10 -3
View File
@@ -23,6 +23,12 @@ export interface IssueTokenOptions {
permissions?: string[];
/** Lifetime in days. */
expiresInDays?: number;
/**
* Lifetime in minutes. When set it WINS over `expiresInDays` — used for the
* short-lived OAuth access tokens the MCP bridge mints (the refresh token
* carries continuity). Ignored when `expiresInDays` alone is supplied.
*/
expiresInMinutes?: number;
}
/**
@@ -35,9 +41,10 @@ export async function issueApiToken(
): Promise<IssuedToken> {
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
const expiresAt = new Date(
Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000,
);
const expiresAt =
opts.expiresInMinutes !== undefined
? new Date(Date.now() + opts.expiresInMinutes * 60 * 1000)
: new Date(Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000);
const created = await prisma.apiToken.create({
data: {