davepi-plugin-mcp-oauth
v0.2.0
Published
OAuth 2.1 sign-in for dAvePi's MCP endpoint, so people connect Claude (or any MCP client) as themselves: protected-resource and authorization-server metadata, dynamic client registration, PKCE, consent on the app's own page, refresh-token rotation, grants
Maintainers
Readme
davepi-plugin-mcp-oauth
Lets people connect Claude (or any MCP client that follows the MCP authorization spec) to your dAvePi app as themselves, by signing in, instead of pasting an API key into a config file.
It turns the app into an OAuth 2.1 authorization server for its own POST /mcp endpoint. Once it's on:
- someone pastes
https://<your API>/mcpinto Claude's Settings › Connectors › Add custom connector; - Claude opens your app's sign-in;
- the person allows it on your consent page;
- Claude then calls your MCP tools as that person, with their roles, claims and ACL, and no more.
How it works
- The challenge. Claude calls
/mcpwith no token. It gets a401withWWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource/mcp"(RFC 9728). - Registration. Claude reads
/.well-known/oauth-authorization-server(RFC 8414) and registers itself atPOST /oauth/register(RFC 7591). It registers as a public client: no secret, with PKCE as the proof. - Sign-in. Claude sends the person's browser to
/oauth/authorizewith PKCE (S256) andresource=<issuer>/mcp(RFC 8707). The plugin checks the request, then redirects to your consent page (MCP_OAUTH_CONSENT_URL?request=<id>). - Consent. Your page, signed in as usual, calls:
GET /oauth/requests/:idfor the client's name, where it will send the person back, and the scopes;POST /oauth/requests/:id/decision { allow, scopes?, context? }, which answers{ redirectTo }. The page then sends the browser there.
- Tokens. Claude swaps the single-use code for an access token and a refresh token at
POST /oauth/token, then calls/mcpwith the access token.
Tokens
Access tokens are davepi JWTs (utils/tokens signGrantAccessToken) with four extra claims:
aud: the/mcpresource;grant: the grant they were issued under;scopes:readand/orwrite;client_id: the client that holds them.
Rules that follow from that:
- Only
/mcpaccepts them. REST and GraphQL refuse them, so a token handed to an assistant can't be replayed against the whole API. - Every request checks the grant. Revoking it, from your Connected apps screen or by the client, ends access on the next call.
- Scopes are enforced as for API keys. A read-only grant isn't offered the tools that change data.
- Your claims provider decides the claims. It's called with
{ grant: { id, clientId, scopes, context } }, so a token carries the claims for the context the person chose (a workspace, say). The grant claims, reserved claims and timing claims are re-applied after it, so it can't widen a token. - Roles are
user.roles, unless you say otherwise. If a person's roles depend on the context they chose (an owner in one workspace and a member in another), registergrantRoles(below) so a grant for the second workspace doesn't act as an owner there.
Refresh tokens rotate on every use. Presenting one that has already been rotated out revokes the whole grant, and so does reusing an authorization code. Codes and refresh tokens are stored only as SHA-256 hashes.
Configuration
| Variable | |
|---|---|
| MCP_OAUTH_ISSUER | Required. The API's public origin, e.g. https://api.example.com. The MCP resource is <issuer>/mcp. |
| MCP_OAUTH_CONSENT_URL | Required. Your app's consent page, e.g. https://app.example.com/connect. |
| MCP_OAUTH_PATH | Default /oauth. |
| MCP_OAUTH_ACCESS_TTL | Access-token lifetime in seconds, 60–3600. Default 900. |
| MCP_OAUTH_REFRESH_DAYS | Refresh-token lifetime in days, 1–90. Default 30. |
Without the two required variables the plugin logs a warning and stays dormant, and /mcp keeps answering as before. It needs a davepi with grant-token support (2.13.0 or later).
{ "davepi": { "plugins": ["davepi-plugin-mcp-oauth"] } }Hooks
const mcpOAuth = require('davepi-plugin-mcp-oauth');
// Extra data for your consent page: returned as `app` from GET /oauth/requests/:id.
mcpOAuth.describeRequest(async ({ user, client, scopes, req }) => ({ workspaces: await workspacesOf(user) }));
// Before a grant is made: throw a typed error to refuse, return the context to store.
mcpOAuth.authorizeGrant(async ({ user, client, scopes, context, req }) => {
const membership = await mustBeActiveMember(user, context);
return { membershipId: String(membership._id) };
});
// Which grants a person may list and revoke at GET/DELETE /oauth/grants (default: their own).
mcpOAuth.grantFilter(async ({ user }) => (isOwner(user) ? { 'context.workspaceId': user.workspaceId } : { userId: String(user.user_id) }));
// The roles a grant's tokens carry, worked out on every token and refresh
// (default: user.roles). Anything but a non-empty array of strings gives
// ['user']; a throw fails the token request. Needs davepi 2.14.0 or later.
mcpOAuth.grantRoles(async ({ user, grant }) => rolesIn(grant.context.membershipId));
// After grant.created / grant.revoked, e.g. to email a security notice.
mcpOAuth.onEvent(async ({ type, grant, userId }) => {});
// For your own code: a workspace switching connections off.
await mcpOAuth.revokeGrants({ 'context.workspaceId': id }, 'workspace-off');Only a person's own session can see a request, decide one, or manage grants. API keys, client-id callers and other OAuth tokens are refused.
Routes
| Route | Who |
|---|---|
| GET /.well-known/oauth-protected-resource[/mcp] | public |
| GET /.well-known/oauth-authorization-server | public |
| POST /oauth/register | public, rate-limited |
| GET /oauth/authorize | public, rate-limited |
| POST /oauth/token | public, rate-limited (form or JSON) |
| POST /oauth/revoke | public, rate-limited (RFC 7009) |
| GET /oauth/requests/:id | the person, signed in |
| POST /oauth/requests/:id/decision | the person, signed in |
| GET /oauth/grants | the person, signed in |
| DELETE /oauth/grants/:id | the person, signed in |
Behind a browser-based MCP client (rather than Claude, whose calls come from its servers), make sure your CORS settings allow the discovery, registration and token endpoints.
