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-149 — RequestApproval 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:
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.
- 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.
- 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.
Summary
The OAuth authorization endpoint at
internal/mcp/serverbootstrap/oauth.go:340requires an interactive TTY prompt viaserver.RequestApprovalbefore 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.plistLaunchAgent), 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), soopenTTYDevice()fails immediately,RequestApprovalreturnsApproved: false, and the handler emits HTTP 403 with the misleadingaccess_denied.Where it breaks
internal/mcp/serverbootstrap/oauth.go:338-351— authorize handler callsRequestApprovalinternal/mcp/server/approval.go:137-149—RequestApprovalshort-circuits whenopenTTYDevice()returns an error, with internal message"approval required but no TTY available (running non-interactively)"access_denied, which is wrong: this isserver_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:
openpass oauth approve <state>stateis also poor.UNUserNotificationCenternotifications without a registered.appbundle. Workarounds (osascript,terminal-notifier) have no reliable click callback. A click also doesn't prove human presence — Accessibility API can drive it. macOS-only.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:
GET /oauth/authorize?...(afterclient_id+redirect_urivalidation) responds with an HTML consent page instead of blocking on TTY. The page shows: client name, requested scopes, redirect target.POST /oauth/authorize/confirmform with a CSRF token and an auth challenge that proves human presence:LocalAuthentication.framework(CGO bridge). Daemon triggers the system biometric prompt; result is signed and posted back.navigator.credentials.get).redirect_uriexactly as today. On failure / timeout, emit a proper OAuth error:interaction_requiredfor cancel,access_deniedonly if the user explicitly clicks Deny.Preserves the #21 security property
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.jsonbyopenpass 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.Erroris 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-Afteror 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
Context
Discovered while debugging an OpenCode ↔ OpenPass integration where the
openpass serve --port 8092 --agent opencodeserver was running as a LaunchAgent. OAuth DCR initiated by OpenCode hits the authorize endpoint, which can't reach a TTY, and the browser lands onaccess_deniedwith no recovery path.