Skip to content

OAuth consent works only with TTY — daemonized server cannot complete DCR flow #188

@danieljustus

Description

@danieljustus

Summary

The OAuth authorization endpoint at internal/mcp/serverbootstrap/oauth.go:340 requires an interactive TTY prompt via server.RequestApproval before issuing an authorization code. This was the right fix for #21, but it makes the OAuth/DCR flow completely unusable when the MCP server runs as a daemon — which is the recommended setup on macOS (com.openpass.<agent>.server.plist LaunchAgent), and equivalent setups under systemd / Windows services.

The user-visible symptom is a browser landing on:

{"error":"access_denied","error_description":"Authorization denied by user"}

…even though the user never denied anything. The server has no controlling TTY (PPID=1, TTY=??, stdin=/dev/null), so openTTYDevice() fails immediately, RequestApproval returns Approved: false, and the handler emits HTTP 403 with the misleading access_denied.

Where it breaks

  • internal/mcp/serverbootstrap/oauth.go:338-351 — authorize handler calls RequestApproval
  • internal/mcp/server/approval.go:137-149RequestApproval short-circuits when openTTYDevice() returns an error, with internal message "approval required but no TTY available (running non-interactively)"
  • The internal error is swallowed — the browser only sees access_denied, which is wrong: this is server_error / interaction_required, not a user denial.

Why the current design is at a dead end

The TTY barrier guarantees "no token issuance without active human action". Any replacement must preserve that. Three options I considered and rejected:

Option Why it fails
CLI subcommand openpass oauth approve <state> Either it requires no auth (defeats #21) or it requires the admin bearer token (in which case OAuth was unnecessary — the caller already has full access). UX of copy-pasting state is also poor.
macOS notification with click-through LaunchAgent daemons cannot post UNUserNotificationCenter notifications without a registered .app bundle. Workarounds (osascript, terminal-notifier) have no reliable click callback. A click also doesn't prove human presence — Accessibility API can drive it. macOS-only.
Auto-approve when no TTY Direct regression of #21 — any local process mints tokens silently. Non-starter.

Proposed design: browser-based consent page with biometric / passphrase challenge

This is the standard OAuth UX (Google, GitHub, Auth0 all do this). Adapted for OpenPass's threat model:

  1. GET /oauth/authorize?... (after client_id + redirect_uri validation) responds with an HTML consent page instead of blocking on TTY. The page shows: client name, requested scopes, redirect target.
  2. The page contains a POST /oauth/authorize/confirm form with a CSRF token and an auth challenge that proves human presence:
    • macOS: Touch ID via LocalAuthentication.framework (CGO bridge). Daemon triggers the system biometric prompt; result is signed and posted back.
    • Windows: Windows Hello via WebAuthn platform authenticator (navigator.credentials.get).
    • Linux / fallback everywhere: vault passphrase entry, verified against the existing KDF — no new trust material introduced.
  3. On success, mint the auth code and 302 to redirect_uri exactly as today. On failure / timeout, emit a proper OAuth error: interaction_required for cancel, access_denied only if the user explicitly clicks Deny.

Preserves the #21 security property

  • Touch ID / Hello / passphrase all require active human action.
  • Passphrase challenge is the cleanest cross-platform option: it ties OAuth approval to the same secret that gates the vault root anyway → no new attack surface, no new credentials to manage.
  • Headless processes can't fake a biometric prompt or guess a passphrase.

Plays well with the existing static-token path

Users who don't want the consent flow can keep using the pre-provisioned static bearer token (the Authorization: Bearer … already injected into ~/.config/opencode/opencode.json by openpass agent install). The OAuth path becomes opt-in for agents that genuinely need DCR — typically remote agents or those with stricter scoping needs.

Smaller, separable fix worth doing immediately

Independent of the larger design above, the current handler emits the wrong OAuth error code. When RequestApproval.Error is non-nil with "no TTY available", return:

{"error": "interaction_required",
 "error_description": "server is running non-interactively; OAuth DCR requires an interactive consent surface (see issue #THIS)"}

…and include a Retry-After or a pointer to a docs page explaining how to enable the consent surface. This alone would have saved the debugging session that produced this issue.

Out of scope

  • Replacing the static bearer-token path. Static tokens remain the simple, fast option for trusted local agents.
  • Multi-user / non-loopback OAuth scenarios. Those need their own threat-model pass before any of the above ships.

Context

Discovered while debugging an OpenCode ↔ OpenPass integration where the openpass serve --port 8092 --agent opencode server was running as a LaunchAgent. OAuth DCR initiated by OpenCode hits the authorize endpoint, which can't reach a TTY, and the browser lands on access_denied with no recovery path.

Metadata

Metadata

Assignees

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions