docs/github-auth-plan.md

main at 58e6347eeb72 · 6 KB

GitHub authentication and token lifecycle

Date: 2026-08-20

Status: Implemented and locally verified for Gate 6

Decision: ADR 0004

Current contract

GitHub serves two distinct roles that use separate grants:

  1. Sign-in establishes the local OpenAgents account identity from GitHub's immutable numeric user ID. It requests only user:email.
  2. A future repository authorization flow will request the separate rights that server-side GitHub repository tools need.

The sign-in callback stores the email-scoped token as versioned AES-256-GCM ciphertext in the local user row. It does not authorize repository imports, organization membership checks, or other repository tools. Those operations continue to require the exact repo,read:org scope set and fail closed until a separate authorization flow supplies it.

GitHub exposes public profile identity without a scope. OpenAgents requests user:email so sign-in can access the account's email address without asking for repository access. A future GitHub App integration should use fine-grained, repository-selected permissions for repository tools. See GitHub's OAuth scope reference and authorization guidance.

Implemented flow

  1. POST /auth/github creates a high-entropy state value, a PKCE S256 challenge, and a short-lived PostgreSQL OAuth-attempt row.
  2. The encrypted browser session carries only the attempt reference and PKCE verifier while GitHub handles authorization.
  3. GET /auth/github/callback consumes the attempt exactly once, exchanges the code server-side, refuses any grant other than user:email, and reads the GitHub /user projection server-side.
  4. OpenAgents.Accounts upserts the local account by numeric GitHub ID and refreshes the mutable login, name, and avatar projection.
  5. OpenAgents.Accounts.TokenVault encrypts the access token in a version-2 envelope carrying the non-secret active key ID before the ciphertext is stored. The row also records scopes and connection/rotation timestamps.
  6. The authenticated session contains only the local user ID. Repository tools reject the sign-in token because it does not contain their required scopes.
  7. DELETE /logout clears the browser session but intentionally does not revoke the retained GitHub grant.
  8. DELETE /github/connection authenticates the browser and uses the OAuth application's Basic-authenticated token-deletion endpoint. Local ciphertext is cleared only after GitHub returns 204; a provider or configuration failure preserves the local record so the revocation can be retried. GitHub documents the endpoint in its OAuth authorization REST API.

The token must never enter LiveView assigns, HTML, JSON responses, logs, telemetry, receipts, exception messages, build output, or exported account data.

Current configuration

Runtime configuration requires:

  • GITHUB_CLIENT_ID
  • GITHUB_CLIENT_SECRET
  • GITHUB_REDIRECT_URI
  • GITHUB_TOKEN_ENCRYPTION_KEY, a Base64-encoded 32-byte key
  • GITHUB_TOKEN_ENCRYPTION_KEY_ID, a bounded non-secret identifier
  • GITHUB_TOKEN_DECRYPTION_KEYS_JSON, an optional JSON object of at most 16 environment-prefixed prior key IDs to Base64-encoded 32-byte keys during rotation; it must not repeat the active key ID

Production-mode validation requires an HTTPS callback with the configured environment host. Tests use deterministic local configuration and fake Req responses; they do not require a live GitHub credential.

Existing rows retain their recorded scope metadata because that is the grant they actually received. Do not rewrite metadata to claim a provider-side scope reduction that did not occur.

Rotation and data rights

Rotate without losing access to existing ciphertext:

  1. Generate a new 32-byte key and a new environment-specific key ID.
  2. Make the new key active and put the prior ID/key in GITHUB_TOKEN_DECRYPTION_KEYS_JSON.
  3. Prove release readiness, migrate, and run bin/rotate-github-tokens.
  4. Verify users.github_token_key_id contains no prior ID, retain the rotation receipt/count, and remove the prior key in the following deploy.

This procedure rotates only the GitHub vault. The machine pairing vault seals under its own MACHINE_TOKEN_ENCRYPTION_KEY and its records survive this rotation unread and unmoved (INVARIANTS.md, VAULT-001; #192 records the release where that was not true). Keep step 2's prior key in GITHUB_TOKEN_DECRYPTION_KEYS_JSON until one pairing lifetime after the rotation deploy: the pairing vault's decrypt fallback reads that keyring for any pairing sealed while config/runtime.exs still bridged its key to the GitHub key.

The rewrap is one database transaction and reports only a count. Any unsealable row rolls the transaction back and emits no credential material.

Account export includes connection status, scopes, connection time, and rotation time plus credential_exported: false. Product-data deletion removes conversation, voice, and memory data but deliberately retains the GitHub grant with the minimal account row; the deletion UI and export say so. Disconnect GitHub tools is the explicit grant-deletion operation.

OAuth callback parameter logging is disabled at the router, sensitive parameter names are globally filtered, and the staging log export must pass the scanner documented in Secrets and log handling.

Executable evidence

  • test/openagents/github_oauth_test.exs
  • test/openagents/github_oauth/runtime_config_test.exs
  • test/openagents/accounts_test.exs
  • test/openagents/accounts/token_vault_test.exs
  • test/openagents/github_test.exs
  • test/openagents/tools/github_repo_tools_test.exs
  • test/openagents_web/auth_controller_test.exs
  • test/openagents_web/auth_gate_test.exs
  • test/openagents/log_safety_test.exs
  • test/openagents_web/route_authority_test.exs

The complete route-authority and secret-handling acceptance criteria remain in the hardening plan.