Skip to content

Feature: skip OAuth Server consent screen for previously-approved (user, client_id, scope) tuples #823

@salmon-21

Description

@salmon-21

Summary

getAuthorize (src/controllers/oauthServerController.ts) currently renders the consent screen unconditionally for every authorization request, including for an OAuth client that the same user has already approved minutes earlier. There is no oauth_client_approvals (or equivalent) persistence anywhere in the codebase, and no skipApprovalScreen / preApproved / trustedClient config flag in OAuthServerConfig. Every Disconnect → Reconnect from claude.ai therefore reshows the same consent screen for the same (user, client_id, scope) tuple, even though the user just clicked Allow access for it.

This is a UX friction point rather than a security gap, but a repeatedly noisy consent screen is itself a soft cost: it trains users to click through without reading.

Spec context

  • MCP authorization spec (2025-06-18) defers entirely to OAuth 2.1 on consent display. It does not mandate showing the screen on every request, and its only consent-related MUST applies to proxy/confused-deputy scenarios that don't fit MCPHub's role as the authorization server itself.
  • OAuth 2.1 / RFC 6749 leave the policy to the implementation.
  • For public clients, MCP spec does require refresh token rotation, which MCPHub already implements correctly via @node-oauth/oauth2-server's default alwaysIssueNewRefreshToken: true + the revokeToken / saveToken model methods. This means a long-lived refresh token won't undermine consent enforcement — only the holder of the latest rotated refresh token is valid.

So consent skipping is a UX choice, not a spec choice.

Design dimensions

Persistence

Options for recording "this user approved this client":

Option Notes
(a) New oauth_client_approvals table(user_id, client_id, scope, approved_at, expires_at) Clean separation, easy to query / revoke / audit. Recommended if revocation UI lands.
(b) Column on oauth_clientsapprovedBy: Record<username, { scope, approvedAt, expiresAt }> JSON Less normalised, but requires no new table or DAO.
(c) Lean on existing oauth_tokens — treat the presence of a valid (user, client_id) token as implicit approval. No new persistence at all. The implicit coupling between token lifecycle and consent lifecycle is simple and arguably intuitive ("revoking the token also resets consent"), but it forecloses managing the two independently — e.g. expiring tokens aggressively while keeping consent stable. Worth considering if you want the minimal-schema option.

Expiration policy

Option Behaviour Implication
Permanent (no expiry) Once approved, skip consent forever until revoked from dashboard. Lowest friction. Closest to GitHub / Google OAuth Apps semantics.
Tied to refresh-token TTL (default 14 days, configurable) Approval expires when the issued refresh token expires. Forces a periodic re-confirmation that's aligned with the existing token lifecycle. Operators who tighten refreshTokenLifetime automatically tighten consent too.
Fixed period (e.g. 90 days) Independent of token TTL. Simple but introduces a second knob; the only operational reason to choose this over refresh-tied is if you want consent to expire while tokens stay valid.
Scope-upgrade re-prompt Approval skip only applies when the requested scope is a subset of the approved scope; new scopes re-show the screen. Should layer on top of any of the above. Matches GitHub's "this app requests additional permissions" prompt.

Revocation surface

  • Minimum: dashboard page listing "Authorized applications" for the current user (mirroring oauth_clients filtered by approved_by = self) with a Revoke button that drops the approval row and forces re-consent next time.
  • Optional: admin view of all approvals across users (useful for incident response — "this client was compromised, revoke everywhere").

Recommendation

Smallest cut that addresses the reported friction without painting into a corner:

  1. (a) oauth_client_approvals table as the persistence model. The schema cost is small and the row scopes the approval correctly to the user, which (c) doesn't.
  2. Default to refresh-token-tied expiration, with scope-upgrade re-prompt layered in. The MCP spec / RFC 6749 treat public clients (which include claude.ai's connector, PKCE-only with no client_secret) as inherently lower-trust than confidential clients, so the safer default is to make consent expire at the same horizon as the refresh token — re-confirming "yes, this app should keep talking to my MCP servers" on a known cadence. The current default refresh-token lifetime is 14 days; that's a reasonable consent horizon. A permanent mode can be exposed as opt-in (approvalExpiration: 'refresh-token' | 'permanent' | 'session') for deployments that prefer the GitHub/Google "approve once" feel.
  3. Dashboard "Authorized applications" page with revoke action, gated to the current user (admin variant is a follow-up).
  4. Config flag for opt-out: systemConfig.oauthServer.alwaysShowApprovalScreen: boolean (default false) so deployments that explicitly want every-time consent (security-sensitive multi-tenant, or compliance) can keep current behaviour.

A reasonable PR scope is the persistence + skip logic + config flag, with the dashboard revoke page as a separate follow-up.

Out of scope

Filing for visibility — realistically can't commit to a PR on this one, but happy to discuss design direction if that helps.


🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions