registry
@automindzsheets 01–77shadcn 4.x
SHEET 70

MCP OAuth (Postgres)

item / server / mcp / auth

Postgres-backed OAuth 2.1 + PKCE provider for the MCP SDK's mcpAuthRouter: redirect-host allowlist, atomic single-use codes, constant-time PKCE, enforced expiry, rotating refresh tokens, scope enforcement, per-token rate limit. Nothing raw is stored.

type
item
files
3
registry deps
none
npm deps
none
env vars
MCP_ISSUER_URL, MCP_REDIRECT_HOSTS

installs

  • ~/lib/automindz/mcp-oauth.ts
  • ~/migrations/mcp_oauth.sql
  • ~/tests/mcp-oauth.test.mts
  • skipLocalPkceValidation is true on purpose — the SDK's own PKCE check is a string ===; the compare moves here, to timingSafeEqual
  • One statement spends a code: UPDATE … WHERE used_at IS NULL … RETURNING, so two racing exchanges cannot both win
  • Nothing raw is stored — codes and tokens are SHA-256 hex in the table, returned once, never logged

Apply migrations/mcp_oauth.sql (idempotent, three tables), then const provider = createMcpOAuthProvider({ store: postgresStore((sql, params) => pool.query(sql, params)), issuerUrl: process.env.MCP_ISSUER_URL, redirectHosts: process.env.MCP_REDIRECT_HOSTS.split(","), consent, oauthError }) and mount app.use(mcpAuthRouter({ provider, issuerUrl: provider.issuerUrl })) plus requireBearerAuth({ verifier: provider }) on /mcp. The SDK router already serves /authorize, /token, /register and the .well-known documents; this item is the half that decides whether a grant is safe, and it compiles with no SDK and no pg installed — SDK 1.29 shapes are mirrored in the file and the database arrives as query(sql, params). consent(ctx, res) is where you show a consent screen or bounce through your own login, then call provider.issueAuthorizationCode(client.client_id, { subject, scopes, redirectUri, codeChallenge }) and redirect back with code and state. oauthError(code, message) is required: it maps onto the SDK's error classes (InvalidGrantError, InvalidTokenError, InvalidRequestError, InvalidClientMetadataError) so a failed exchange is a 400/401 with a WWW-Authenticate challenge a connector can act on, not a 500 it retries forever. Tokens carry the RFC 8707 resource the code was issued for; a token request naming a different audience, or none, is refused, and verifyAccessToken returns it as AuthInfo.resource. skipLocalPkceValidation is true on purpose: the SDK's own PKCE check is a string ===; the compare happens here with timingSafeEqual. MCP_REDIRECT_HOSTS is comma-separated (claude.ai, *.example.com), https only except http loopback, and an empty list fails closed. Access tokens live 90 days, refresh tokens 365 and rotate on every use, codes 600 s and are spent by a single UPDATE … WHERE used_at IS NULL … RETURNING. Codes and tokens are stored only as SHA-256 hex. Pass postgresStore(query, { subjectColumn: "email" }) if your table names the subject differently. memoryStore() runs the same provider in your tests with no database. Run the shipped test with node --test tests/mcp-oauth.test.mts.

Variations

one state per section

No previews for this item yet.