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.
