External-provider OAuth client — connector-side contract

mandatory

Normative contract for every Koder component that acts as an OAuth 2.0 *client* (relying party) against a THIRD-PARTY provider — Google, Microsoft, GitHub — to ingest the user's external data (Gmail, Drive, Cloud, Outlook, repos). This is the INVERSE direction of `specs/auth/oauth-flow.kmd`: there, Koder ID is the identity provider and Koder components are the relying parties for *sign-in*; here, the external provider is a *data source* and Koder is the relying party for *resource access*. An external provider is NEVER a sign-in IdP for the Stack (R1). All third-party OAuth tokens live in one place — the Koder Keys `oauthvault` (`services/foundation/id/engine/services/keys`) — never re-implemented or persisted per component. Covers the canonical client flow (native loopback + web), least-privilege scoping, per-tenant/per-user isolation, refresh + failure lifecycle, consent + revocation, retention + erasure, provider app registration/branding, and service-account domain-wide delegation.

Version: 0.1.0 — Draft Status: Proposed (2026-06-03)

Scope. This spec governs the connector side of authentication: how a Koder component obtains and uses an access token *from a third party* (Google, Microsoft, GitHub) to read or write the user's data on that provider (Gmail, Drive, Google Cloud, Outlook, OneDrive, GitHub repos). The issuance side — token storage, refresh, encryption, the vault API — lives in the Koder Keys service (services/foundation/id/engine/services/keys, package oauthvault).

Not in scope. End-user sign-in. That is the opposite direction and is governed by specs/auth/oauth-flow.kmd (Koder ID is the sole IdP). Reading this spec when you mean "let users log in" is a category error — see R1.


R1 — Direction invariant: external provider is a data source, never an IdP

A third-party provider (Google, Microsoft, GitHub) MUST be used only as an OAuth resource provider — a source of the user's data — and MUST NOT be used as a sign-in identity provider for any Koder component.

  • Authentication of the end-user is always Koder ID

    (specs/auth/oauth-flow.kmd §R1). "Sign in with Google" as a Koder login button is forbidden.

  • The flows in this spec run after the user is already a Koder ID

    principal. Connecting a Google account is an authenticated user linking an external data source to their existing Koder identity, not establishing identity.

  • A token obtained under this spec carries provider scopes (Gmail, Drive,

    …). It MUST NOT be used to derive Koder session identity. The Koder UserID on the stored token is the owner of the link, resolved from the live Koder ID session, not from the provider's email/sub.

Rationale. Keeping identity (Koder ID) and data-access (external provider) on separate axes prevents account-takeover via a third-party email collision, keeps the single-IdP invariant intact, and lets a user connect multiple Google accounts to one Koder identity.


R2 — Single token vault (reuse-first, SSOT)

Every third-party OAuth token and provider service-account key in the Stack MUST be stored, refreshed and revoked through the Koder Keys oauthvault (services/foundation/id/engine/services/keys/internal/oauthvault).

A consuming component MUST NOT:

  • persist a refresh token (or long-lived provider credential) in its own

    database, config, secret store, or on disk;

  • implement its own refresh loop against a provider token endpoint;
  • read raw token files (meta/context/credentials/google-token-*.json)

    outside of local dev fixtures — production code reaches tokens via the Keys API/SDK only.

The vault is the SSOT. Components request a *fresh access token for (tenant, user, provider, account)* from Keys and use it; Keys owns expiry, rotation, encryption and failure state.

Rationale. policies/reuse-first.kmd (≥3 consumers ⇒ shared service): Gmail, Drive, Flow backups and Pulse/Orbit all need Google tokens. One vault means one place to revoke on erasure (R9), one refresh policy, one encryption boundary, one audit surface. The oauthvault package already models exactly this (Provider, OAuthToken{AccessToken, RefreshToken, ExpiresAt, Scopes, AccountEmail, UserID, TenantID, Status, FailureCount}, ServiceAccountKey, RefreshGoogleToken, AES-256-GCM at rest, tenancy by kdbnext keyspace prefix). This spec ratifies it as the only sanctioned home.

R2.E1 — Migration debt (existing per-component clients)

These pre-spec implementations hold their own Google OAuth surface and MUST be migrated to route through oauthvault (see Migration plan):

Component File Provider surface Status
Koder Kmail products/horizontal/kmail/engine/internal/gmail/ Gmail (gmail.readonly/modify/send/labels) migrated 2026-06-03 (ID-205)
Koder Drive products/horizontal/drive/engine/internal/gdrive/ Drive (auth/drive, R4-justified) migrated 2026-06-03 (ID-205)
Koder Flow (backups) products/dev/flow/engine/services/backups/ Drive + OneDrive (drive.file) pending FLOW-217 — own x/oauth2 refresh loop. Carve-out question RESOLVED 2026-06-08: migrate (Option A), no R2.E2 exception — a separate second token-store + refresh loop is the exact fragmentation R2 forbids; it raises cost, not correctness (Regra 13). Imports engines/sdk/go/extauth (SDKGO-004a). Impl slice gated only on owner-go for the prod-git-host deploy.
Koder Raven products/horizontal/kmail/raven/internal/gmailbridge/ Gmail via service-account DWD (R11) pending — own ticket

Until migrated they remain conformant to R3–R11 in place; new consumers MUST use the vault from day one.

Not an R2 consumer: products/horizontal/pulse/orbit/internal/oauth/ is "Sign in with Google/GitHub" (social login, openid+email+profile, issues a session) — an external provider used as a sign-in IdP. That is an R1 concern (Koder ID is the sole sign-in IdP), not a data-token vault migration. It does not belong in this table; route it through Koder ID under oauth-flow.kmd.


R3 — Canonical client flow (Authorization Code + PKCE)

The connector uses OAuth 2.0 Authorization Code + PKCE (S256) against the provider's authorize endpoint.

1. Authenticated Koder user invokes "Connect <provider> account" in a
   component (the user already has a Koder ID session — R1).
2. Component asks Keys to begin a link for (tenant, user, provider,
   requested-scopes). Keys returns the authorize URL + opaque state.
3. Browser/system-browser navigates to the provider authorize endpoint:
     https://accounts.google.com/o/oauth2/v2/auth      (Google)
     https://login.microsoftonline.com/.../authorize   (Microsoft)
     https://github.com/login/oauth/authorize          (GitHub)
   with:
     ?client_id=<registered client>
     &redirect_uri=<R3 per surface>
     &response_type=code
     &scope=<least-privilege set — R4>
     &state=<random, vault-bound>
     &code_challenge=<sha256-base64url>&code_challenge_method=S256
     &access_type=offline&prompt=consent     (Google: required for a refresh_token)
4. User consents at the provider (provider UI — never embedded).
5. Provider redirects to redirect_uri with code + state.
6. Callback handler:
   a. validates state against the vault-issued value (CSRF);
   b. exchanges code for { access_token, refresh_token, expires_in,
      scope } at the provider token endpoint (oauth2.googleapis.com/token,
      etc.) using client_secret for confidential clients, PKCE-only for
      public/native clients;
   c. records the granted scope set (may be narrower than requested);
   d. stores the token in oauthvault keyed by (TenantID, UserID,
      Provider, AccountEmail), Status=active (R5, R6);
   e. returns the user to the component's "connected accounts" view.

R3.1 — redirect_uri per surface

Surface redirect_uri
web / backend (Go service) https://<component-host>/<prefix>/connect/<provider>/callback (server-side)
desktop / CLI / TUI (native) local loopback http://127.0.0.1:<ephemeral>/callbackbind port 0, read back via getsockname, single inbound request, then close
mobile (Flutter) deep-link <product>://connect/<provider>/callback
extension (MV3) https://<extension-id>.chromiumapp.org/ via the browser identity API

The loopback page renders a terminal "Authentication flow has completed — you may close this window" body (matching the observed Google native-app flow). Native/public clients are PKCE-only (no embedded client secret — secrets in a distributed binary are not secrets).

The historical Google client observed binding a fixed localhost:8080 works but is discouraged: a fixed port collides across concurrent sessions and is not reservable. New native clients MUST bind port 0.

R3.2 — Incremental authorization

When a component needs an additional scope on an already-connected account, it MUST run an incremental authorization (request only the new scope, include_granted_scopes=true on Google) rather than re-requesting the full set. The consent screen then shows only the delta ("… quer mais acesso"), and the vault merges the new scope into the existing token's Scopes.


R4 — Least-privilege scopes (justify-and-register)

A component MUST request the narrowest scope that satisfies the feature, and MUST register its scope set so it is auditable.

  • Default to incremental/file-level scopes. Prefer

    https://www.googleapis.com/auth/drive.file (app-created files only) over …/auth/drive (all files); prefer gmail.readonly / gmail.send / gmail.modify over full-mailbox https://mail.google.com/.

  • Broadrestrictedsensitive scopes require explicit justification.

    Full-mailbox Gmail (readwritesend