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:
oauthplugin (this catalog entry) — the only place that runs flows, manages refresh, and persists tokens.- Provider plugins — declare an
OAuthProviderConfigfrom theironRegisterOAuthProvidershandler. Examples shipped:github_oauth,google_workspace. - Consumers — any plugin or tool inside an agent turn calls
await registry.get_access_token("github")after locating the registry viaget_oauth_registry(). The token is decrypted on demand, refreshed in-line if it is within 60s of expiry, and never appears in the agent transcript.
Requirements
cryptographypackage — used for Fernet-based encryption (already a Codumentor dependency).httpxpackage — used for IdP HTTP calls (already a Codumentor dependency).config.auth.jwt_secretset — the Fernet key is derived from it via SHA-256 + URL-safe base64. Rotation is supported viaprevious_jwt_secrets.
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
| Flow | When to use | Provider declaration |
|---|---|---|
| Device-code (RFC 8628) | Default for local-first installs; works without any callback URL | flow="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:
oauth_tokens— per-(user_id, provider, account_id)encrypted token blob (access_token,refresh_token?,id_token?) plus unencrypted metadata (expires_at,scopes,granted_at,last_refreshed_at,version). Theversionis the optimistic-fence value that makes concurrent refreshes safe across processes.oauth_flows— short-lived per-flow rows (flow_id → user_id, provider, requested_scopes, pkce_verifier?, redirect_uri?, status). 10-minute TTL by default;consume()is atomic so a replayed callback cannot mint a second token for the same grant.
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):
| Method | Path | Purpose |
|---|---|---|
| GET | /providers | List registered providers and their default scopes |
| GET | /accounts | List the caller's connected accounts (metadata only) |
| POST | /flows | Start 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 type | When |
|---|---|
oauth.account.added | First successful flow for a (user, provider, account) row |
oauth.scope.added | Re-authorization that strictly broadens the granted scopes |
oauth.token.refreshed | Successful background or in-line refresh |
oauth.account.revoked | DELETE /accounts/... or TUI revoke |
Hooks emitted
Provider plugins and other consumers can subscribe to:
onRegisterOAuthProviders— fired during the OAuth plugin'sregister(). Payload{registry: ProviderRegistry}. The expected response is to callpayload["registry"].register(OAuthProviderConfig(...)).onOAuthAccountAdded,onOAuthAccountRemoved,onOAuthTokenRefreshed— payload constants live incodumentor.plugins.oauth.hooks.
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:
- Connect a provider — the pane drives the flow controllers directly (no HTTP round-trip), opens
verification_uri(device-code) orlogin_url(auth-code) viawebbrowser.open, and polls until completion. - Add scopes — re-runs the flow with
default_scopes ∪ existing_scopesso a consumer that hitScopesMissingErrorcan broaden the grant without losing the existing connection. - Revoke — local delete plus best-effort upstream revocation via
provider.revocation_url.
Files
| File | Purpose |
|---|---|
plugins/oauth/plugin.py | Plugin class, hook subscriptions, refresh-job + audit wiring |
plugins/oauth/providers.py | OAuthProviderConfig, ProviderRegistry |
plugins/oauth/token_store.py | Encrypted token persistence + optimistic fence |
plugins/oauth/state_store.py | Short-lived per-flow rows |
plugins/oauth/flows.py | Device-code controller + shared persist/userinfo helpers |
plugins/oauth/auth_code.py | Authorization-code + PKCE controller, redirect-URI builder |
plugins/oauth/refresh.py | OAuthTokenRefresher + oauth.refresh_token job entry |
plugins/oauth/registry.py | Per-request OAuthRegistry, ScopesMissingError |
plugins/oauth/routes.py | FastAPI management router + callback router + upstream revoke helper |
plugins/oauth/hooks.py | Hook constant strings + payload shapes |
plugins/oauth/clock.py | Clock seam for time-warp tests |
plugins/github_oauth/plugin.py | GitHub provider declaration (~80 LOC) |
plugins/google_workspace/plugin.py | Google provider declaration |
plugins/google_workspace/common.py | GoogleReadTool + 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.py | TUI 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
- User Secrets — sibling encryption-at-rest store; OAuth reuses its
FieldEncryptorprimitive - OAuth implementation plan — phased rollout and test strategy
- User secrets — design & roadmap — §4 covers the original OAuth design rationale