Skip to content

Durable personal downstream MCP authorization

Status

Implemented and verified. Personal selection and authorization default to automatic availability once their security prerequisites are configured; administrators can explicitly opt out. No frontend, personal runtime execution or published Helm chart changes are included.

Current personal tool permission contract

Server publication and current connector-level group permission, together with owner enablement and valid owner OAuth, authorize all tools available to that owner. Explicit discovery saves an encrypted, complete private inventory; no administrator tool approval, activation or tool grant gates personal discovery or execution. Shared/native permissions remain unchanged. Reconnect and refresh invalidate the private snapshot through authorization revision changes, requiring fresh explicit discovery. Earlier delivery/status descriptions below are historical where they describe unavailable runtime features.

Scope

Third main-Umbod iteration: durable owner-scoped authorization attempts and worker-independent callbacks. Preserve Desktop removal, shared authentication and unrelated changes. Runtime execution, discovery, refresh scheduling and frontend remain disabled. Expose authorization automatically when the explicit identity namespace, validated authentication policy, persistent SQLite and a nonblank encryption secret are configured. Both operator feature modes default to auto and accept explicit true/false overrides. Missing prerequisites keep automatic availability off; explicit invalid enablement and malformed supplied identity configuration still fail startup. Direct embedded application configuration retains resolved boolean fields. Automatic deployment availability does not change administrator approval, per-user enablement or OAuth consent. Supported worker-independent topology uses a shared persistent database file, identical encryption configuration and SQLite-compatible locking/storage semantics; separate pod-local databases are unsupported. SQLite remains a development/evaluation backend, not a new production database architecture.

Provider support

Support RFC 9728 protected-resource metadata, RFC 8414 issuer metadata, authorization code with S256 PKCE, RFC 9207 issuer-bearing responses or persisted issuer-bound callback routes, and dynamic public-client registration with authentication method none. Require exact issuer/resource/client/redirect binding. Include RFC 8707 resource with the exact validated protected-resource identifier in authorization and token requests. Reject endpoint query parameters that could override protocol or scope parameters. Use the configured public API origin plus /api/oauth/downstream-mcp/callback as the trusted base; never derive callback URLs from request headers. Explicit true issuer-response support uses this generic route with mandatory exact response issuer. Omitted or false support uses the base plus /issuer/ and lowercase SHA-256 of the exact validated discovered issuer UTF-8 bytes, finalized before registration. Only its matching persisted route may omit the actual response issuer; supplied empty or mismatching issuers reject before claim consumption and token exchange. Null or mistyped metadata flags reject initiation. Legacy prepared records stay strict generic without migration. Preconfigured clients must externally register the derived URI; administrator policy and existing clients are not rewritten. Unsupported providers fail explicitly without shared credentials, historical client metadata, preconfigured/confidential-client fallback or inferred scopes.

Approved requested scopes are empty in this iteration. Omit scopes from requests; reject nonempty scopes in registration, callback and token responses. This is not a zero-privilege guarantee: providers may assign implicit defaults. The subsequent provider-policy iteration supersedes this initial subset with explicit requested scopes and pinned preconfigured public/confidential clients; it preserves all durable ownership and one-shot guarantees.

All provider requests require TLS verification, connection-time public-address validation and pinning, no redirects/proxies, bounded timeouts and response bodies, and no automatic ambiguous POST retries. Do not connect to MCP or enumerate capabilities during authorization.

Contracts

Domain models and ports live in personal_connections/oauth_models.py and oauth_ports.py. Reserve a preparing attempt before outbound discovery/registration, then publish prepared context as pending before returning its authorization URL. Require an existing enabled personal connection and current connector-level grant/publication. One active attempt per owner/connector; concurrent starts conflict.

Persist owner key, expected connection revision, definition fingerprint, verified group snapshot, expiration, PKCE verifier, opaque unpredictable state and validated provider/client context encrypted. Only attempt identifier/status/expiry and the initiating owner's authorization URL may leave these boundaries. Terminal attempts erase sensitive preparation/context. Bound retained history per owner/connector; expire interrupted preparation/exchange without replaying authorization codes.

State has an opaque attempt identifier plus an unpredictable secret. A valid callback atomically claims pending as exchanging. Invalid state, duplicates, expiration and wrong owner queries cannot consume or disclose another attempt. No network request occurs inside a database transaction. Successful completion installs authorization and marks success atomically, checking the claim, expiry, current enabled connection revision, definition fingerprint, personal mode, current publication and current explicit grants against the initiating verified group snapshot. This does not claim live identity-provider group introspection after the initiating JWT was verified.

Cancellation preserves previous authorization and prevents in-flight preparation/exchange from publishing or installing results. Disconnect/disable revision changes invalidate pending completion. Aggregate connector deletion cleans attempts transactionally and prevents resurrection. Unknown exchange outcomes fail closed and require a new attempt; do not replay codes after worker failure.

REST

  • POST /api/me/connectors/mcp/{id}/oauth/attempts: strict empty body, return 201 only after durable preparation.
  • GET /api/me/connectors/mcp/{id}/oauth/attempts/{attempt_id}: owner-scoped safe status.
  • DELETE /api/me/connectors/mcp/{id}/oauth/attempts/{attempt_id}: owner-scoped cancellation, including cleanup after eligibility withdrawal.
  • GET /api/oauth/downstream-mcp/callback and /api/oauth/downstream-mcp/callback/issuer/{issuer_key}: unauthenticated but state- and matched-route-bound, canonical raw paths and fixed secret-free no-store responses. Reject duplicate/unknown callback parameters, invalid issuer and malformed or unapproved scope. The initial iteration rejected all nonempty scopes; the provider-policy extension validates them against the captured administrator policy. Never redirect to a supplied return URL.

Existing authenticated user and cookie-CSRF boundaries apply to initiation/cancellation. Callback processing must redact query secrets before access logging; responses must not echo codes, state, provider errors or endpoint details. Error codes distinguish unsupported provider, temporary provider failure, conflict and unavailable attempts without exposing raw exceptions. Catalogue runtime availability remains false even after authorization. Operators must expose this callback without gateway authentication and configure query-free callback logging/redaction at ingress, reverse proxies, tracing and error reporters as well: application middleware cannot redact requests logged before reaching Umbod. No-referrer responses prevent onward browser referrer leakage.

Acceptance

  • A worker starts, adapters are recreated, and another worker finishes with only durable state.
  • Same display identity on different principals cannot read/cancel/finish each other's attempts.
  • Duplicate callbacks exchange at most once, including independently created concurrent adapters.
  • Cancel/expire/disconnect/disable/delete/unpublish/revoke grants during preparation or exchange prevents authorization installation.
  • Existing authorization survives failed/cancelled reauthorization.
  • DNS rebinding/private and mapped addresses, redirects, mismatched metadata issuer/resource, invalid client registration, unsupported PKCE/issuer responses, and extra returned scopes fail closed.
  • Stored data is encrypted and decrypted ownership is validated; projections/logs omit secrets.
  • Shared flows and personal runtime guards remain unchanged.

Reconnect and refresh delivery contract

This follow-up starts after the verified tools-only runtime delivery. The earlier scope and verification describe the original authorization iteration, not the current runtime feature set. Delivery order is reconnect semantics, basic user UI, backend refresh/reconnect lifecycle, then recovery UX. No scheduler or background refresh is included.

Reconnect

Reconnect is an explicit owner action that starts another attempt through the existing strict-empty OAuth initiation endpoint. It does not disconnect first, enable the connection, approve capabilities or change permissions. An existing active attempt conflicts; the owner may inspect or cancel a known attempt. No automatic replacement, initiation or retry occurs.

Previous authorization remains installed during a pending, failed, expired or cancelled replacement. It remains usable only while it independently satisfies current authorization and runtime gates; retaining an expired or rejected credential never restores usability. Successful completion atomically replaces authorization. Private observations bind the authorization revision, so changed authorization requires fresh, explicit user discovery before tools become available. Discovery neither grants server permission nor publishes user-derived tool metadata globally.

Disablement retains authorization but invalidates pending completion. Disconnect disables the connection, removes authorization and prevents stale work from restoring it. Publication withdrawal, permission revocation and policy changes continue to win over pending recovery. These operations remain owner-scoped and revision checked.

Basic UI boundary

The first UI uses existing enablement, authorization-presence, database runtime-availability and OAuth-attempt contracts only. It offers enable/disable, sign in or sign in again, inspect/cancel the current known attempt, explicit discovery and disconnect. Authorization continues in a separate user-opened tab; the original page can inspect the durable attempt without callback redirects or cross-window messages. Attempt data stays in page memory, not browser persistence. Reloading cannot recover an unknown attempt identifier; an active-attempt conflict must not be silently cancelled or restarted.

Saved credentials are labelled “Authorization saved,” not “Connected.” Runtime availability means current local eligibility, not a live provider-health check. An unavailable result does not establish expiry, missing discovery or provider rejection: the current coarse reason cannot distinguish them. The first UI must not invent these states or advertise automatic refresh. Reads, mounting, navigation and status checks never trigger enablement, authorization, discovery or tool execution. Error messages do not echo provider URLs, callback parameters, credentials or raw response bodies.

Later backend lifecycle

Implement safe lifecycle projections separately from authorization presence and tool eligibility. Persist a downstream 401 as reconnect-required only for the exact affected current authorization revision; a delayed failure must not poison a newer replacement. Do not interpret every provider/network failure as invalid credentials.

Refresh is on demand before an explicitly requested operation, never an automatic reaction to a tool failure or 401. Coordinate one durable owner-scoped refresh claim across workers, perform network I/O outside the transaction, and install only against unchanged ownership, revisions and eligibility. Do not retry an ambiguous token exchange or tool POST. An uncertain refresh outcome requires explicit reauthorization. A changed authorization invalidates private observations; fresh explicit discovery remains necessary, and the original tool operation is not resumed automatically. Refresh never expands scopes, enablement, permissions or approved contracts. Safe lifecycle reason codes and the expiry safety window are decisions for that backend slice, not assumptions in the basic UI.

Authorization-health implementation slice

Implement health and rejection tracking before refresh. A new owner-scoped, no-store GET /api/me/connectors/mcp/{id}/authorization reports authorization facts without altering the existing selection response. Its explicit states are no authorization, locally valid authorization, expired authorization, unsupported saved authorization and reconnect required after provider rejection. These states do not claim live provider health or establish tool eligibility. The initial slice adds no refresh endpoint, scheduler or automatic recovery.

Only an actual 401 observed after mandatory raw-response safety checks, from a non-cleanup request to the bound personal MCP resource, establishes provider rejection. If the raw-byte secrecy guard rejects a response before its HTTP status can be established, the operation remains generically unavailable and does not establish authorization rejection. Preserve this guard rather than introducing another HTTP parser to classify rejected bytes. Generic transport errors, 403 responses, tool error content and cleanup failures do not. Record rejection against the exact captured admitted connection revision; a delayed failure cannot affect a newer revision. Persist the owner-bound rejection separately from credentials, and keep an already-recorded rejection effective across disable/re-enable while those credentials are unchanged. Recording health does not rotate connection revisions or remove authorization.

Successful interactive OAuth completion clears prior rejection atomically and establishes a fresh connection revision even if the returned authorization bytes are identical. This completion-specific rule does not change the existing generic identical-authorization replacement contract. Starting, failing or cancelling an attempt never clears rejection. Private observations therefore require explicit renewal after successful reconnect, and old in-flight 401 reports cannot poison the replacement. Disconnect and aggregate deletion clean up the corresponding recovery metadata.

Explicit refresh implementation slice

The initial refresh delivery is a user-requested, strict-empty POST /api/me/connectors/mcp/{id}/authorization/refresh, not scheduled or pre-tool refresh. A successful no-store response is {"status":"refreshed","discovery_required":true}. Existing selection responses remain unchanged. Initially support bound, known-lifetime v2 grants with a stored refresh token and preconfigured public/confidential clients only. Dynamic registrations currently establish only authorization_code; do not infer refresh capability or register another client. Unsupported grants, absent refresh tokens and known provider rejection fail before provider contact.

Reserve one encrypted owner-scoped claim capturing the connection revision, complete credential fingerprint, configuration revision, definition fingerprint and verified groups. Use a 30-second claim and a bounded 20-second provider operation. Commit the exchanging transition before exactly one token POST; hold no database transaction during network work. Preparing failures may release a fenced claim without changing authorization. Expired or interrupted exchanging claims never permit the old refresh token to be retried: project reconnect-required uncertainty across worker restarts and enablement toggles.

Refresh and interactive OAuth initiation must exclude each other's active claims. Terminal refresh uncertainty permits explicit reauthorization, but only successful OAuth completion clears recovery records. Disconnect, deletion and successful reconnect clear refresh claims atomically; late workers must match the still-current claim before recording either success or failure. If refresh might have consumed a token but completion loses its revision race, retain uncertainty for the unchanged old credential, including after enablement changes. Never poison a new authorization after its old claim has been cleared.

Send only the stored token endpoint, exact resource and currently approved client authentication. Do not request scopes; returned scopes may only narrow the previous granted scopes. A definitively successful response may retain an omitted refresh token or rotate a returned token, but must establish a known usable access-token expiry. Install atomically only after rechecking claim, ownership, revisions, eligibility and absence of a concurrent provider rejection. Always establish a fresh connection revision and invalidate observations, even for identical returned authorization. New runtime admission is blocked during active or uncertain refresh; already-admitted bounded work may finish.

Health adds an in-progress state and explicit refresh_uncertain/refresh_denied reconnect reasons without network work on GET. Locally authorized and expired states expose refresh_supported, derived only from a bound known-lifetime grant, stored refresh-token presence and preconfigured client mode. This is local protocol capability, not proof of provider acceptance or permission to refresh while disabled; the UI must gate enablement separately. Definitive token-endpoint invalid_grant or invalid_client is denied; other non-success after exchange begins is conservatively uncertain. Neither health nor refresh reveals tokens, fingerprints, claim identifiers, client secrets or raw provider failures. Response codes distinguish fixed unsupported/conflict/changed/reconnect-required 409 outcomes, strict-body 422 rejection and sanitized 503 operational failures. No token POST or original tool operation is replayed, and no discovery is implicit.

Acceptance

  • Given usable previous authorization, when its owner starts, cancels or fails reauthorization, then it remains installed and usable only under the existing gates.
  • Given an active owner attempt, when another start is requested, then it conflicts without replacing credentials or cancelling the first attempt.
  • Given successful replacement, when an old observation or retained tool is used, then execution remains unavailable until fresh explicit discovery establishes current eligibility.
  • Given disablement, disconnect, policy invalidation or withdrawn access during recovery, when old work completes, then it cannot restore access.
  • Given saved authorization and unavailable runtime, when the basic UI renders, then it reports those facts without diagnosing expiry, triggering recovery or issuing discovery requests.
  • Given a delayed 401 or refresh completion for an old revision, when a newer grant exists, then the newer grant remains unchanged.
  • Given concurrent workers or an ambiguous refresh outcome, when recovery is attempted, then no duplicate refresh or tool replay occurs and secrets remain absent from public responses and logs.

Verification

Recovery UI delivery completed after the backend gates. All 528 frontend tests passed; Svelte checks reported no errors and one existing administration accessibility warning. Independent backend and UI reviews found no blocking issues. The rebuilt Compose UI at /settings/connections was checked in a browser at desktop and mobile sizes. Controlled browser responses verified explicit renewal, no implicit discovery, uncertainty requiring a manual status check, and owner-state clearing on 401 without automatic navigation. The default local stack's unavailable state was also inspected. Browser recovery checks do not establish real-provider account interoperability. Automatic refresh and dynamic-client refresh remain excluded.

Explicit refresh implementation verification: 3,440 backend tests passed with three skips; the focused refresh/store/service/REST selection passed 78 tests. Ruff and git diff --check passed. Shared Semgrep completed with 66 findings across the workspace and no refresh-core/store/REST findings. The disposable Kind run passed its Helm installation checks and all 33 running-service tests. A test-only second REST worker observed the shared encrypted refresh claim, rejected concurrent refresh and OAuth initiation, and verified exactly one provider exchange. Refresh issued no implicit discovery or tool call; fresh explicit discovery was required afterward. API, API-B, MCP and MCP-B stdout passed refresh-token and existing credential sentinel checks. These are controlled fixture checks, not live external-provider certification.

Verified: 2,733 API/MCP tests passed, two skips (existing empty Desktop-path parametrization and one SQLite-only contract case under the in-memory parametrization); Ruff and git diff --check passed. Disposable Kind installation/chart tests and all 26 live runtime tests passed, including private-provider denial, public callback rejection and collected API stdout sentinel checks for callback secrets and the disposable encryption root. Initial authorization verification correctly rejected a Kind fixture without an encryption root; the runner creates a disposable secret shared by the test services. The default-policy follow-up runs Kind without either feature-enabling flag, supplying only the stable test identity namespace and storage/security prerequisites to both services. Configuration tests verify automatic availability, explicit opt-outs and invalid explicit enablement. Compose configuration checks verify consistent mode, namespace and optional SQLite-path forwarding for API and MCP.

Shared Semgrep completed with 52 findings: the prior 47 plus five reviewed nullable parameters required by the httpcore network-backend interface. These SDK-boundary annotations are documented in the adapter index; no rules were suppressed and no additional error-severity findings remain. Review identified expiry cleanup rollback, expiry crossing validation and temporary metadata-outage classification issues; fixes include immediate persisted-secret assertions and controlled-clock tests. Independent focused authorization review verified all fixes with 404 tests passed and one SQLite-specific skip. Default-policy review found no concrete issues and passed 69 focused tests; Semgrep remains unchanged at 52 findings.

Positive authorization flows are verified through provider/store ports and recreated application/adapters, not a real external OAuth account. Running-service checks deliberately contact no real account and reject a loopback provider before connection. External ingress/tracing redaction and each real provider's advertised supported contract remain deployment verification obligations. No Desktop or frontend verification applies to this iteration.