Skip to content

Koder ID OAuth Flow — consumer-side contract

auth specs/auth/oauth-flow.kmd

Normative contract for every Koder component that authenticates end-users. Defines the OAuth2/OIDC flow against Koder ID (the sole identity provider, per koder-app/behaviors.kmd §1), the routing invariants (anonymous → Koder ID → dashboard, never anonymous form inside the component), the session lifecycle, and per-surface obligations. Applies to all surfaces (backend, mobile, desktop, tv, web, cli, tui) in every product, service, engine, and tool that has a user-facing UI. Consumed by SDKs (koder_kit Dart, koder_web_kit JS, engines/sdk/go) and by direct integrations (Koder Flow / Gitea fork, third-party OIDC clients).

When this spec applies

Primary triggers

All triggers

Specification body

Spec — Koder ID OAuth Flow (consumer-side contract)

Version: 1.0.0 — Draft Status: Proposed (2026-05-12)

Scope. This spec governs the consumer side of authentication: how a Koder component embeds Koder ID and routes authenticated users. The provider side (token issuance, session storage, JWKS, revocation) is covered by the id-RFC-001..010 series under services/foundation/id.


R1 — Sole identity provider

Every Koder component with user-facing auth MUST use Koder ID (https://id.koder.dev) as its sole OAuth2/OIDC provider. No local sign-in form, no proprietary credential store, no third-party SSO forwarder.

Rationale. koder-app/behaviors.kmd §1.1 already mandates this at the "what" level. This spec governs the "how" — including the post-auth routing failure mode we observed in Koder Flow (2026-05-12) where authenticated users landed on the marketing landing page instead of their dashboard.

R1.E1 — Provider's own administration UIs (ratified 2026-05-18)

Koder ID's own account-UI and admin-UI (the dashboards served directly by services/foundation/id/engineaccount-ui/, admin-ui/) MAY use a cookie-session established by the engine itself, without performing an OAuth2 round-trip against the same issuer. Rationale:

  • Industry norm — Auth0, Okta, Keycloak, Entra ID all serve their own admin consoles via direct session, not OAuth-to-self.
  • A provider OAuthing to itself is conceptually circular: the authorize endpoint, the callback handler, and the session backing store all live in the same process.
  • The extra hop adds zero defense (the trust boundary is the same process either way) and meaningful UX cost (extra redirect, extra cookie state, extra failure mode).

Scope: this exception applies only to UIs that ship inside the identity provider itself. Every other Koder component — including sub-services of the engine that expose user-facing UIs (e.g. a future Koder ID "device approval" page consumed cross-origin) — remains subject to R1 + R6 + the full T-suite.

The exception does NOT relax R3 (canonical OAuth flow) for any external consumer. RPs that integrate with Koder ID still see the standard OAuth2 + PKCE surface.

Conformance recording: rows in registries/koder-id-auth-coverage.md for services/foundation/id mark the affected surfaces as SKIP (R1.E1) rather than TODO.

This decision closes D-decision of backlog ticket services/foundation/id/engine#103.


R2 — Provider slug canonical

The OAuth2/OIDC provider source registered in any Koder component MUST use the slug koder-id (lowercase, kebab) in every identifier: source name, callback URL path segment, config key.

Forbidden variants: KoderID, koderid, KODER_ID, koder_id, KoderId, kid, id.

Rationale. Mismatched slugs between Flow's source name (KoderID) and the redirect URI registered in Koder ID (koder-id) caused a invalid_redirect_uri failure on 2026-05-12. Canonical kebab matches existing Koder ID client registrations and RFC 8414 conventions.


R3 — Canonical OAuth flow

The OAuth2 Authorization Code + PKCE flow against Koder ID:

1. User on /any/path of Koder component (anonymous)
2. Component issues redirect to /<auth-prefix>/oauth2/koder-id
   (preserving original target as redirect_to query param)
3. /<auth-prefix>/oauth2/koder-id constructs authorize URL:
     https://id.koder.dev/oauth/v2/authorize
       ?client_id=<client_id>
       &redirect_uri=https://<component-host>/<auth-prefix>/oauth2/koder-id/callback
       &response_type=code
       &scope=openid profile email
       &state=<random>
       &code_challenge=<sha256-base64url>
       &code_challenge_method=S256
4. User authenticates at Koder ID (UI of id.koder.dev, never
   embedded inside the component)
5. Koder ID redirects to <component>/<auth-prefix>/oauth2/koder-id/callback?code=...&state=...
6. Callback handler:
   a. validates state, exchanges code for tokens (client_secret_basic auth)
   b. validates id_token signature against JWKS
   c. resolves or auto-provisions local user (per koder_user_id claim)
   d. establishes session (cookie or token, per R8)
   e. redirects to redirect_to value if present and same-origin,
      else to /dashboard (web) / app home (native) — NEVER to the
      anonymous landing page

<auth-prefix> per surface:

  • web/backend: /user (Gitea convention) or /auth (Koder default)
  • mobile/desktop native: deep-link scheme <product>://oauth/callback
  • CLI/TUI: local loopback http://127.0.0.1:<port>/oauth/callback with port range 49152-65535, single-use
  • extension (WebExtension MV3): browser-provided redirect https://<extension-id>.chromiumapp.org/ via chrome.identity.launchWebAuthFlow / browser.identity.getRedirectURL(). Public client → PKCE (S256), no client secret. Added for the extension surface (rfcs/kruze-RFC-001); the consumer-side helper is @koder/sdk/auth (engines/sdk/js).

R4 — Post-auth routing invariant

After a successful OAuth callback:

MUST: redirect authenticated user to redirect_to (if present and same-origin) OR to the component's authenticated home (dashboard, inbox, file list, repository list, etc.). The authenticated home is the URL the user would reach by clicking the component's brand mark when already signed-in.

MUST NOT: redirect to the anonymous landing/marketing page. The landing is exclusively for unauthenticated visitors.

SHOULD: present a brief transition state (loading, "Welcome back, ...") for ≥150 ms to confirm to the user that auth succeeded, before rendering the dashboard. This avoids the "did login work?" ambiguity that arose when post-callback rendering looked identical to the anonymous landing.


R5 — Landing vs dashboard routing

Components that have BOTH a public landing page AND an authenticated dashboard MUST route / based on session state:

  • Anonymous request → landing page (marketing/intro)
  • Authenticated request → dashboard (or 302/303 to canonical dashboard URL)

Implementation MAY be:

  • (a) Server-side check on IsSigned rendering different templates at /, OR
  • (b) Single template that conditionally branches via {{if .IsSigned}}...{{else}}...{{end}}, OR
  • (c) Anonymous landing lives at a separate path (e.g. /about, /welcome) with / always serving dashboard

Variant (a) and (c) are equally acceptable. Variant (b) is allowed only if the landing-vs-dashboard content delta is small (≤ 50% of the rendered DOM). For large deltas, use (a) or (c).

Forbidden: serving the same content at / regardless of auth state. This was the Koder Flow bug on 2026-05-12 (Jet vhost had both root = /var/www/flow.koder.dev-site AND proxy = http://flow:3000; the static root overrode the proxy for /, breaking R4 + R5 simultaneously).


R6 — Sign-in surface

The sign-in UI itself MUST be rendered by Koder ID. Components MUST NOT host their own sign-in form (with username + password fields) under any circumstance, even as fallback.

The /user/login (or equivalent) route in each component MUST either:

  • (a) Issue HTTP 302/303 to /<auth-prefix>/oauth2/koder-id, OR
  • (b) Render a minimal redirect page (meta-refresh + JS fallback + noscript link) that bounces to the OAuth flow.

Variant (a) is preferred for new implementations. Variant (b) is acceptable when the component framework can't return 302 from the sign-in route (e.g., Gitea routes login as a renderable template).

The redirect page MUST NOT expose username/email/password inputs, even disabled or commented-out. It MAY show a "Redirecting to Koder ID..." status with the Koder ID brand mark, never the component's own brand mark prominently.


R7 — LinkAccountMode

When Koder ID returns a user identity that the component's auto- provisioning rejects (e.g., username collision with an existing local-only account), the component MUST present a clear link-or- create choice — not a username/password form.

Acceptable outcomes:

  • Auto-create new account from OAuth identity (preferred when no collision exists; ENABLE_AUTO_REGISTRATION=true style)
  • Manual link via "Create new account" button → completes signup using OAuth profile claims
  • Reject with clear error message and contact instructions

Forbidden: prompting for password to "merge accounts" — there are no local passwords post-RFC-006.


R8 — Session lifecycle

Session establishment after successful OAuth:

  • Session cookie scope: Path=/; Secure; HttpOnly; SameSite=Lax
  • Cookie name: per component framework (e.g. _koder_sid for Koder Flow, koder_session for SDK-based)
  • Token TTL defaults (Google-like persistence, rotation-protected):
    • access_token: 15 minutes (short-lived, rotated frequently)
    • refresh_token: 180 days (6 months) absolute lifetime; rotated on every refresh (RT reuse-detected → all sessions for user revoked per repo error ErrTokenReuseDetected)
    • Idle timeout: 90 days without any refresh — long enough that an occasional user (monthly, quarterly) doesn't get thrown out
  • Refresh: client SHOULD refresh access_token when it's near expiry (75% of TTL ≈ every 11 min) using the persisted refresh_token. The refresh_token persists across module close/reopen (per koder_kit contract: token stored in flutter_secure_storage → Keychain (iOS), Keystore (Android), libsecret (Linux), Credential Manager (Windows), AES-GCM-wrapped localStorage (Web))
  • Server MUST validate session on every request to authenticated routes; expired/invalid → redirect to /<auth-prefix>/oauth2/koder-id (re-auth), preserving original target

Per-product override: components with stricter requirements (admin consoles, billing/payment flows, identity-mutating actions) MAY pin shorter TTLs by overriding service.session.Config at startup. The override SHOULD be declared in the component's koder.toml under [auth.session] (refresh_token_ttl_days = N, idle_timeout_days = N) for auditability. Defaults are NOT overridable downward via env vars without an explicit codepath, to keep the default consistent across the Stack.

Sign-out:

  • Component MUST clear local session cookie
  • Component MUST redirect to Koder ID logout endpoint (https://id.koder.dev/logout) so the central session is also invalidated
  • After Koder ID logout, redirect back to component's anonymous landing (/ or equivalent)

When an anonymous user hits a deep-link (e.g., /Koder/koder/issues/1234), the component MUST:

  1. Capture the original URL as redirect_to
  2. Redirect to OAuth flow with redirect_to param
  3. After callback, redirect to the captured URL (validating same- origin to prevent open-redirect)

redirect_to validation:

  • MUST be same-origin (same scheme + host + port)
  • MUST be a path-only URL (no scheme/authority injection)
  • Invalid → fall back to authenticated home (R4)

R10 — Per-surface obligations

S1 — Backend (Go services)

Use engines/sdk/go/auth (when shipped) or direct OAuth2 lib (golang.org/x/oauth2). Implement R3-R9 server-side.

S2 — Mobile (Flutter Android/iOS) — koder_kit

KoderAuthGate widget wraps any screen requiring auth (per koder-app/behaviors.kmd §1 and existing KoderSignInButton). Uses deep-link callback <product>://oauth/callback. Secure storage: Keychain (iOS), Keystore (Android).

S3 — Desktop (Flutter Linux/macOS/Windows) — koder_kit

Same as S2 but uses local loopback (R3) or system browser flow. Secure storage: libsecret (Linux), Keychain (macOS), Credential Manager (Windows).

S4 — TV (React TizenOS/WebOS)

Device authorization grant (RFC 8628) — display code on TV, user enters at id.koder.dev/device from another device.

S5 — Web (Flutter Web / templ+HTMX) — koder_web_kit

KoderAuthGate JS component. Same-origin cookie session. Calls to authenticated APIs via fetch with credentials:'include'.

S6 — CLI (Go cobra)

Local loopback flow (R3). Tokens cached at ~/.config/koder/auth.json (0600 perms). Single sign-on with desktop apps via KoderIPC if available (per koder-app/behaviors.kmd §1.3).

S7 — TUI (Bubble Tea)

Same as S6.

S8 — Desktop shell (native, non-Flutter) — Kolide

The Koder Kodix session shell (Kolide) is a non-Flutter, GTK4+layer-shell desktop environment. It performs OAuth on behalf of the whole desktop session (not per-app); apps running inside the session inherit the identity via the IPC contract in specs/ipc/protocol.kmd.

  • Initiator. The first-run wizard (kolide-onboarding, infra/linux/kolide #007) launches the system browser to the authorize URL using xdg-open / g_app_info_launch_default_for_uri. Subsequent re-auth is initiated by the panel badge in kolide-shell (an in-shell button, not an embedded form).
  • Flow. R3 with PKCE (S256) over the local loopback redirect pattern: the shell's auth_service opens a transient HTTP listener on 127.0.0.1:<ephemeral>; the authorize URL's redirect_uri points there. The listener accepts exactly one inbound request, captures code + state, then closes. The redirect URI MUST NOT depend on a reserved port — bind to port 0 and read it back via getsockname.
  • Storage. Tokens (access + refresh + id_token) are stored in libsecret under schema dev.koder.kolide.token with attributes {component: "kolide", user_id: "<sub>"}. NEVER write tokens to ~/.config/kolide/* files in plaintext.
  • Refresh. auth_service schedules a refresh expires_in - 60s before expiry. Refresh failure with invalid_grant clears the libsecret entry and exposes the shell as anonymous; the panel badge flips to "Sign in".
  • Logout. Clears libsecret, emits the org.koder.Kolide.Auth.LoggedOut D-Bus signal so apps can drop cached identity, and opens https://id.koder.dev/logout?post_logout_redirect_uri=… to drop the central session per R5.
  • UI surfaces.
    • Top-bar badge in kolide-shell (panel.c) shows avatar + display name; anonymous shows a "Sign in" button.
    • Quick Settings logout entry (quick_settings.c) calls the auth service over D-Bus (org.koder.Kolide.Auth.Logout).
  • Test obligations. T1–T8 of auth/oauth-flow-test-template.kmd apply with the loopback-URL adaptations; conformance row in registries/koder-id-auth-coverage.md MUST be filled.

Tickets: infra/linux/kolide #007 (entry point), #008 (auth service + panel badge + logout).


R11 — Error states

Error states the component MUST handle gracefully:

CodeCauseUser-facing message
state_mismatchCSRF state token mismatch in callback"Sign-in expired. Try again." → redirect to OAuth flow
invalid_codeAuthorization code rejected by Koder ID"Sign-in failed. Try again." → OAuth flow
token_exchange_failedToken endpoint returned 4xx/5xx"Koder ID unavailable. Try later."
id_token_invalidJWKS signature verification failed"Sign-in failed. Try again." → log [E] for ops
auto_provisioning_rejectedLocal user creation refused (collision, policy)LinkAccountMode (R7)
session_create_failedCookie/session backend error"Service temporarily unavailable." → log [E]

All errors MUST be logged structured (koder-app/behaviors.kmd §2) with flow=oauth, step=<step>, error_code=<code>.

User-facing strings follow specs/errors/user-facing-messages.kmd.


R12 — Test coverage

Every component implementing this spec MUST run the test template specs/auth/oauth-flow-test-template.kmd (T1-T8 + I1-I3 + N1-N4) green before any release. Coverage tracked in registries/koder-id-auth-coverage.md.


Migration plan

Components with non-conformant auth as of 2026-05-12:

ComponentIssueTracking
services/foundation/flow (Koder Flow)Source slug KoderID (R2); landing covers / regardless of session (R4+R5); local form rendered before fix (R6)this commit + follow-up
Others to be auditedper registry

Migration order: highest-traffic web apps first (Flow, Hub, Talk), then service consoles, then native apps.


Decisões abertas

  • D1: Cookie name standardization across components. Current: Flow uses _koder_sid, others vary. Inclination: standardize to koder_session for SDK-based, keep _koder_sid for Flow (Gitea compat). Owner ratification needed.
  • D2: PKCE optional for confidential clients? RFC 6749 says no, but in practice some confidential clients skip it. Inclination: ALWAYS PKCE, even for confidential.
  • D3 (resolved 2026-05-23): Session TTL — RefreshToken 180d (6mo) absolute + 90d idle timeout, rotation-protected. Google- like persistence chosen as default; the convenience-vs-revocation trade-off is settled by RT-rotation-on-every-use + RT-reuse detection (ErrTokenReuseDetected clears all sessions for the affected user). Stricter TTLs for admin/billing routes opt in per- component via [auth.session] in koder.toml.

Referências

  • specs/koder-app/behaviors.kmd §1 (the "what")
  • specs/identity/login-resolution.kmd (input identifier resolution)
  • rfcs/id-RFC-003-authentication-service.md
  • rfcs/id-RFC-004-oauth2-oidc-service.md
  • rfcs/id-RFC-005-session-service.md
  • specs/errors/user-facing-messages.kmd
  • 2026-05-12 incident: Koder Flow OAuth flow recovery (this session)

References