External-provider OAuth client — connector-side contract
auth specs/auth/oauth-client-external-providers.kmd
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.
When this spec applies
Primary triggers
- Implement an OAuth client against an external provider to read user data
- Add a 'Connect your external account' flow to any component
All triggers
- Implement an OAuth client against Google/Microsoft/GitHub to read user data
- Add a 'Connect your Google account' / external-account flow to any component
- Request Gmail / Drive / Cloud / Outlook / GitHub scopes from a Koder component
- Store or refresh a third-party OAuth refresh token, or a provider service-account key
- Register or rename an OAuth client in a provider's developer console (Google Cloud, Azure, GitHub Apps)
- Audit a component that talks to an external provider's API on behalf of a user
Specification body
Spec — External-provider OAuth client (connector-side contract)
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, packageoauthvault).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
UserIDon the stored token is the owner of the link, resolved from the live Koder ID session, not from the provider'semail/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 underoauth-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>/callback — bind 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:8080works 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); prefergmail.readonly/gmail.send/gmail.modifyover full-mailboxhttps://mail.google.com/. - Broad/restricted/sensitive scopes require explicit justification.
Full-mailbox Gmail (read/write/send/delete permanently), full-Drive
download, and
cloud-platform(the exact set seen on the 2026-06-03 consent screen) are restricted scopes under Google's policy: they gate production verification on a CASA security assessment and owner ratification. A component MUST NOT request them unless the feature genuinely requires that breadth, and MUST record the justification in itskoder.tomlunder[providers.<provider>] scopes = […]with a one-line rationale per scope. - Scope set is per (component, provider), declared, and reviewed. The vault records the granted scopes on each token; a component requesting a scope not in its declared set is a conformance failure.
Scope → consumer reference map (the consent screen the user sees maps 1:1 to a consuming component):
| Provider scope family | Consuming component |
|---|---|
gmail.* / full mailbox + settings | Koder Kmail (+ Raven multi-tenant bridge) |
drive / drive.file / drive.readonly | Koder Drive (+ Flow backups, file-level) |
cloud-platform + userinfo.email | Koder Keys service-account / Cloud management (R11) |
calendar.* | (future) Koder Cal |
GitHub repo / read:org | Koder Flow import, dev tooling |
R5 — Per-tenant, per-user, per-account isolation
A stored token is keyed by (TenantID, UserID, Provider, AccountEmail).
- Multi-tenant by default (
policies/multi-tenant-by-default.kmd): a tenant MUST NOT read another tenant's tokens. Theoauthvaultenforces this via the kdbnext keyspace prefix; a cross-tenant read returns not-found, never another tenant's ciphertext. - A single Koder user MAY connect multiple accounts of the same
provider (e.g. two Gmail addresses); each is a distinct row keyed by
AccountEmail. - Token ownership is the Koder
UserIDfrom the live session (R1), not the provider identity.
R6 — Encryption at rest & server-readability
- Tokens and service-account keys are encrypted at rest with the
service-level SecretBox (AES-256-GCM), per the
oauthvaultpackage contract. - These secrets are server-readable by design — unlike end-user
sign-in tokens, which live in client-device secure storage
(
oauth-flow.kmd §R8: Keychain/Keystore/libsecret). The connector needs the refresh token server-side because background sync (Kmail polling, Drive sync, Flow backups) runs without the user present. - Client ID/secret of the provider app are environment secrets
(
policies/environments.kmd), never committed; loaded per-environment.
R7 — Refresh & failure lifecycle
- The vault's refresher (
oauthvault/refresher.go) refreshes an access token before expiry (the token exposesExpiresWithin(d)); a consumer that fetches a token always receives a non-expired one. - After
MaxRefreshFailures(= 3) consecutive refresh failures the token transitions toStatus=refresh_failed. The owning component MUST surface a re-connect affordance to the user (the link is broken; the user must re-authorise) and MUST NOT silently retry forever. - Refresh-token rotation: when the provider returns a new
refresh_token, the vault replaces the stored one. Reuse of a retired refresh token (provider returnsinvalid_grant) marks the tokenrefresh_failed. - Testing-mode caveat (Google). An OAuth app in testing publishing
status issues refresh tokens that expire after ~7 days → periodic
invalid_grantstorms that look like bugs but are policy. The root-cause fix is publishing the OAuth app to production in the provider console (then refresh tokens stop expiring). Components MUST treat a 7-dayinvalid_grantcadence as the "app still in testing" signal, not as a code defect. (See the Stack's standing reminder to publishkoder-botto production.)
R8 — Consent, transparency & revocation
- A component that connects an external account MUST show the user, in a "connected accounts" surface: the provider, the account email, the granted scopes (human-readable), and the connection date.
- The user MUST be able to disconnect an account from that surface.
Disconnect MUST (a) call the provider's token-revocation endpoint
(
https://oauth2.googleapis.com/revokefor Google) and (b) set the vault tokenStatus=revoked/ delete it. - Consent presentation follows
specs/identity/consent.kmd. Scope changes (R3.2 incremental auth) re-prompt the user — never silently widen access.
R9 — Retention & right-to-erasure (extends identity-data-retention)
Third-party tokens are PII-adjacent (they name the user's external
account and grant access to their mail/files). They extend
policies/identity-data-retention.kmd, which today covers
auth_flows/auth_events/sso_sessions/lockouts but not the
oauthvault rows.
- R9.1 — Erasure cascade.
DELETE /v1/me(retention R5) MUST, for the erased user: call the provider revoke endpoint for every connected account, then delete theoauthvaulttoken + service-account rows. A revoked external grant is not "anonymised and kept" — it is revoked upstream and deleted locally. - R9.2 — Idle link expiry. A token in
refresh_failedfor 90 days with no re-connect is purged (the link is dead; keeping ciphertext for a broken grant is pure risk). - R9.3 — Data export.
GET /v1/me/data-export(retention R7) MUST list the user's connected accounts (provider + account email + scopes + dates) but MUST NOT include the token material itself. - R9.4 — Backup restore. Restoring a backup MUST NOT revive a token the user revoked (retention R6 semantics): a revoked/deleted link stays gone after restore.
R10 — Provider app registration & branding
- The OAuth client registered in the provider console (Google Cloud
Console project, Azure app registration, GitHub App) MUST carry a
display name that matches the consuming product or an umbrella Koder
name — e.g. "Koder Kmail", "Koder Drive", or "Koder" — so the consent
screen tells the user the truth about who is asking.
- Anti-pattern (observed 2026-06-03): the consent screen named the client "Dek" while requesting full Gmail + full Drive + Cloud — scopes that belong to Kmail/Drive, not to the Koder Dek audio recorder. A placeholder/dev client name on a broad-scope production grant is forbidden: the display name MUST reflect the actual data consumer. Reusing one dev client across unrelated products also conflates their scope audit trails.
- Production verification. A client serving end users MUST be published/verified with the provider (not left in testing — R7), with the privacy policy + ToS URLs the provider requires for the requested scopes.
- Android signing-certificate SHA-1 (native Google Sign-In). A
native Android client (Google
google_sign_in,GoogleSignInOptions, …) authenticates by matching the installed APK's signing certificate against the SHA-1 fingerprints registered on the provider's Android OAuth client. Register the SHA-1 of the keystore the release pipeline actually signs with, not a per-product "upload" key that may go unused. The Koder Hub release pipeline signs every Hub-distributed Android app with the sharedkoder-store-release.jkskeystore (cert subjectCN=Koder Store— a legacy name frozen at the certificate level; the Store→Hub rename does not re-key it, since re-keying would break app updates), SHA-1CD:7B:78:9F:49:FB:A1:03:2F:A3:D3:AB:3B:2B:AD:EA:9C:C7:B7:0A. A mismatch makes Google Sign-In instant-cancel withDEVELOPER_ERROR(code 10) right after the account picker — surfacing as a misleading "check your connection". The new "Google Auth Platform" console UI allows one SHA-1 per Android client: replace, or add a second client (Google disambiguates by signature). When a product migrates to its own per-product keystore (infra/ci#011), re-sync the registered SHA-1 to the new fingerprint. Diagnose withapksigner verify --print-certs base.apkagainst thecertificate_hashin the app'sgoogle-services.json. (Observed: Dek id#218, 2026-06-15 — the only Koder app with native Google Sign-In as of that date.) - Self-hosted-first note. Google/Microsoft/GitHub are external
dependencies. Per
policies/self-hosted-first.kmd, every external provider integration is ashadowentry inregistries/self-hosted-pairs.md— connectors exist to ingest from and migrate off these providers (e.g. Gmail → Koder Kmail native mailbox), not to deepen lock-in. New broad-scope grants SHOULD be paired with a migration/ingest goal, not standing mirror access.
R11 — Service accounts & domain-wide delegation (org-scale)
For organisation-wide access (a Workspace admin granting the Stack access to every user's mailbox/drive — e.g. Koder Kompass org sync), use a provider service account with domain-wide delegation (DWD), not a per-user interactive grant.
- The service-account JSON key is stored as an
oauthvault.ServiceAccountKey(encrypted at rest, per tenant), never on disk in plaintext. - Access tokens are minted by signing a JWT and exchanging it
(
ServiceAccountJWT/ExchangeServiceAccountJWT), impersonating the target user via thesubject(with_subject) parameter, scoped to the least-privilege DWD scope set authorised in the Workspace admin console. - Key rotation is handled by the Keys
sarotationsubsystem (services/keys/internal/sarotation), not by hand. - DWD subject-impersonation is powerful (every user's data); it MUST be tenant-isolated (R5) and audit-logged (R12).
R12 — Observability
Per policies/observability-first.kmd, every connector operation emits
structured signals correlated by trace_id:
- Logs.
flow=ext_oauth, provider=<p>, step=<authorize|callback|exchange|refresh|revoke>, account=<hash>, result=<ok|err>, error_code=<...>. The account email is hashed in telemetry — raw provider email is PII and never goes to logs/metrics. - Metrics (RED).
ext_oauth_token_refresh_total{provider,result},ext_oauth_tokens_active{provider,status},ext_oauth_refresh_failures_total{provider}. Cardinality budget: label byprovider+result+statusonly — never by user/account. - Alerting. A spike in
result=err,error_code=invalid_grantis the testing-mode / revoked-app signal (R7); wire it to the standing publish-to-production action, not a pager.
R13 — Error states
| Code | Cause | User-facing outcome |
|---|---|---|
state_mismatch | CSRF state mismatch in callback | "Connection expired. Try again." → restart link |
consent_denied | User declined at provider | Return to connected-accounts view; no token stored |
invalid_scope | Provider rejected a requested scope | "That permission isn't available." → log [E], review scope set |
token_exchange_failed | Provider token endpoint 4xx/5xx | " |
invalid_grant (refresh) | Refresh token expired/revoked/rotated-reuse | mark refresh_failed; surface Reconnect (R7) |
revoke_failed | Provider revoke endpoint error on disconnect | still mark local revoked; retry revoke async; log [E] |
User-facing strings follow specs/errors/user-facing-messages.kmd.
R14 — Test obligations
A connector implementing this spec MUST cover:
- T1 authorize-URL construction (correct endpoint, PKCE S256,
access_type=offline, declared least-privilege scopes). - T2 callback: state validation (reject mismatch), code→token exchange, granted-scope recording.
- T3 token persisted in
oauthvaultkeyed by (tenant, user, provider, account); cross-tenant read returns not-found (R5). - T4 refresh before expiry; 3-failure →
refresh_failed; rotation replaces stored RT (R7). - T5 disconnect calls provider revoke + marks/deletes vault row (R8).
- T6 erasure cascade revokes + deletes external tokens (R9.1).
- T7 incremental auth merges the new scope without dropping existing grants (R3.2).
- T8 (DWD only) JWT-bearer exchange with
subjectimpersonation, tenant-isolated (R11).
Structural conformance lives with the vault in
services/foundation/id/engine/services/keys; a per-connector behavioural
suite follows the pattern of specs/auth/oauth-flow-test-template.kmd
(a sibling oauth-client-external-providers-test-template.kmd is the
tracked follow-up to this draft).
Migration plan
- Land this spec (draft → proposed). Register the conformance gap.
- Route consumers through the vault (R2.E1): migrate Kmail
(
gmail/oauth.go), Drive (gdrive/oauth.go), Flow backups (backups/oauth/drive.go), Pulse/Orbit to request tokens from Koder Keys instead of holding their own refresh loop. Order: highest-data- sensitivity first (Kmail full-mailbox → Drive → Flow file-level). - Publish the OAuth app to production in the Google Cloud Console
(project
koder-bot) to end the 7-day refresh-token expiry (R7), and rename the console client off "Dek" to the real consumer/umbrella name (R10). - Tighten scopes to least-privilege per component (R4); justify any
remaining restricted scope in
koder.toml. - Extend retention enforcement (
internal/retention) to cascade external-token revoke+delete on erasure (R9.1) and purge dead links (R9.2).
Decisões abertas
- D1 — Where does the user-facing "connected accounts" surface live: a shared Koder ID account-UI panel (one place for all components) vs. per-component settings? Inclination: a shared panel in the Koder ID account-UI (single revoke surface), with components deep-linking to it.
- D2 — Should the vault expose a capability-scoped short-lived access token to consumers (so a compromised consumer can't exfiltrate the refresh token), rather than the consumer ever touching the RT? Inclination: yes — consumers get access tokens only; RTs never leave Keys. Ratify with the vault API design.
- D3 — Per-provider abstraction in an SDK helper
(
engines/sdk/goextauth?) so Kmail/Drive/Flow share one client, vs. Keys-API-only. Inclination: a thin SDK helper over the Keys API.
Referências
specs/auth/oauth-flow.kmd— the inverse (Koder ID as IdP, sign-in)specs/identity/consent.kmd— consent presentationspecs/multi-tenancy/contract.kmd+policies/multi-tenant-by-default.kmd— R5policies/identity-data-retention.kmd— R9 extends itpolicies/reuse-first.kmd— R2 (single vault)policies/self-hosted-first.kmd+registries/self-hosted-pairs.md— R10 shadow entriespolicies/observability-first.kmd— R12- Implementation:
services/foundation/id/engine/services/keys/internal/oauthvault/(token.go,google.go,refresher.go,store.go) - 2026-06-03 consent-screen analysis (Dek client / Gmail+Drive+Cloud scopes)
References
specs/auth/oauth-flow.kmdspecs/identity/consent.kmdspecs/multi-tenancy/contract.kmdpolicies/multi-tenant-by-default.kmdpolicies/identity-data-retention.kmdpolicies/reuse-first.kmdpolicies/self-hosted-first.kmdpolicies/observability-first.kmdregistries/self-hosted-pairs.md