Codumentor logo Codumentor

OAuth Plugin

The oauth plugin owns the OAuth 2.0 / OIDC mechanics for a Codumentor deployment: provider registration, the device-code and authorization-code flows, encrypted token storage, background refresh, the IdP callback route, and a TUI settings pane. Provider-specific plugins (GitHub, Google Workspace, …) declare an OAuthProviderConfig and consume access tokens through a per-request registry — they never run flows or touch storage themselves.

Overview

Three layers, intentionally separated so adding a provider requires no core changes:

Requirements

Configuration

plugins:
  - module: codumentor.plugins.oauth
    class: OAuthPlugin
    priority: 4
    args:
      enabled: true
      previous_jwt_secrets: []          # for key-rotation grace period
      state_ttl_seconds: 600

  - module: codumentor.plugins.github_oauth
    class: GitHubOAuthPlugin
    priority: 3                          # < OAuthPlugin so its hook is registered first
    args:
      client_id: ${GITHUB_OAUTH_CLIENT_ID}
      client_secret: ${GITHUB_OAUTH_CLIENT_SECRET}

  - module: codumentor.plugins.google_workspace
    class: GoogleWorkspacePlugin
    priority: 3
    args:
      client_id: ${GOOGLE_OAUTH_CLIENT_ID}
      client_secret: ${GOOGLE_OAUTH_CLIENT_SECRET}

priority: 4 lets the OAuth plugin's onContextReady hook run after user_secrets (priority 3) so the registry can be built using already-resolved secrets, but before any plugin that wants to call get_access_token from a hook of its own.

Requires the oauth:manage RBAC permission, granted to power_user and admin by default.

For the auth-code flow, set api.external_url (and optionally api.trust_forwarded) so the redirect URI is reachable from the user's browser. When unset, the plugin falls back to http://127.0.0.1:<port>/oauth/callback/<provider> (RFC 8252 loopback), which both GitHub and Google accept for installed-app clients.

Flows supported

FlowWhen to useProvider declaration
Device-code (RFC 8628)Default for local-first installs; works without any callback URLflow="device_code" or "either"
Authorization-code + PKCE (RFC 6749 + RFC 7636)Better UX when an external_url is configured (one click, no manual code entry)flow="auth_code" or "either"

flow="either" lets the OAuth plugin pick: auth-code when a callback URL is wired, device-code otherwise.

Storage

SQLite at {agent.storage_dir}/oauth/oauth_tokens.db with two tables:

The token blob is wrapped via core.secrets.crypto.FieldEncryptor — the same primitive user_secrets and model_profiles use.

Runtime wiring

onContextReady (priority 4)                 onResponsePersist (priority 99)
──────────────────────────────              ──────────────────────────────
  load user.summaries → OAuthRegistry         reset _oauth_registry ContextVar
  publish via ContextVar (one per turn)

Background refresh runs through the existing JobQueueRegistry/Scheduler infrastructure as job type oauth.refresh_token, scheduled at expires_at - 300s after every successful flow / refresh. The plugin's in-line refresh path in OAuthRegistry.get_access_token covers misses (the row is within 60s of expiry but no background job has fired yet) and uses a per-row asyncio.Lock so two concurrent consumers share one IdP exchange.

API

Two routers are mounted by the plugin host:

/ui/plugins/oauth/ (requires oauth:manage):

MethodPathPurpose
GET/providersList registered providers and their default scopes
GET/accountsList the caller's connected accounts (metadata only)
POST/flowsStart a flow; body {provider, scopes?}
GET/flows/{flow_id}Poll flow status
DELETE/accounts/{provider}/{account_id}Local revoke + best-effort upstream provider.revocation_url call

/oauth/callback/{provider} (no auth dependency):

The IdP redirects the user's browser here after the auth-code consent screen. Authentication of the returning user is via the OAuth state parameter — the row in oauth_flows keyed by state carries the user_id that started the flow, so the route can land tokens for the right user without ever seeing a session cookie.

All lifecycle events are emitted through auth.audit.try_log_event:

Event typeWhen
oauth.account.addedFirst successful flow for a (user, provider, account) row
oauth.scope.addedRe-authorization that strictly broadens the granted scopes
oauth.token.refreshedSuccessful background or in-line refresh
oauth.account.revokedDELETE /accounts/... or TUI revoke

Hooks emitted

Provider plugins and other consumers can subscribe to:

Consumer pattern

from codumentor.plugins.oauth.registry import (
    get_oauth_registry, ScopesMissingError, OAuthAccountNotFoundError,
)

async def call_github_api():
    registry = get_oauth_registry()
    if registry is None:
        raise RuntimeError("OAuth registry not in context")
    try:
        token = await registry.get_access_token("github", scopes=["repo"])
    except ScopesMissingError as exc:
        # exc.missing_scopes lists what's absent. The TUI's "Add scopes"
        # button starts a fresh flow with default ∪ existing ∪ requested.
        raise
    except OAuthAccountNotFoundError:
        # Tell the user to open Settings → OAuth Accounts.
        raise
    # use ``token`` against the IdP's API.

The scopes= kwarg is the contract for "give me a token, but only if it carries these scopes". A consumer that does not pass it gets the access token unconditionally and is responsible for handling 4xx scope errors from the IdP itself.

TUI

Settings → OAuth Accounts pane:

Files

FilePurpose
plugins/oauth/plugin.pyPlugin class, hook subscriptions, refresh-job + audit wiring
plugins/oauth/providers.pyOAuthProviderConfig, ProviderRegistry
plugins/oauth/token_store.pyEncrypted token persistence + optimistic fence
plugins/oauth/state_store.pyShort-lived per-flow rows
plugins/oauth/flows.pyDevice-code controller + shared persist/userinfo helpers
plugins/oauth/auth_code.pyAuthorization-code + PKCE controller, redirect-URI builder
plugins/oauth/refresh.pyOAuthTokenRefresher + oauth.refresh_token job entry
plugins/oauth/registry.pyPer-request OAuthRegistry, ScopesMissingError
plugins/oauth/routes.pyFastAPI management router + callback router + upstream revoke helper
plugins/oauth/hooks.pyHook constant strings + payload shapes
plugins/oauth/clock.pyClock seam for time-warp tests
plugins/github_oauth/plugin.pyGitHub provider declaration (~80 LOC)
plugins/google_workspace/plugin.pyGoogle provider declaration
plugins/google_workspace/common.pyGoogleReadTool + GoogleWriteTool bases (token + HTTP + error surfacing; writes add the approval gate)
plugins/google_workspace/gmail/Gmail read (gmail_search, gmail_get_message, gmail_get_thread, gmail_list_drafts, gmail_list_labels) + write (gmail_send, gmail_draft, gmail_delete_draft, gmail_modify_message, gmail_trash_message, gmail_manage_label) tools — parse/compose/labels helpers, gmail.modify scope
plugins/google_workspace/calendar/Calendar read tools (calendar_list_calendars, calendar_list_events, calendar_get_event) + event trimming
plugins/google_workspace/drive/Drive discovery tool (drive_search) — finds Docs/Sheets by name or full text
plugins/google_workspace/docs/Docs read (docs_get_document) + edit (docs_replace_text, docs_insert_text) tools — compact Markdown text
plugins/google_workspace/sheets/Sheets read (sheets_get_spreadsheet, sheets_read_values) + edit (sheets_update_values, sheets_append_values, sheets_clear_values) tools
tui/widgets/settings/oauth.pyTUI settings pane

Testing

python -m pytest \
  tests/test_oauth_token_store.py tests/test_oauth_state_store.py \
  tests/test_oauth_provider_registry.py tests/test_oauth_registry.py \
  tests/test_oauth_device_flow.py tests/test_oauth_routes.py \
  tests/test_oauth_refresh.py tests/test_oauth_hooks.py \
  tests/test_oauth_auth_code.py tests/test_oauth_callback.py \
  tests/test_github_oauth_plugin.py tests/test_tui_oauth_pane.py \
  tests/test_google_workspace_plugin.py tests/test_google_workspace_tools.py \
  tests/test_oauth_scope_coalescing.py tests/test_oauth_revocation.py \
  -v

See also