Koder ID OAuth Flow — consumer-side contract

mandatory

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).

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

Version: 1.0.0 — Draft Status: Proposed (20260512)

*cope.*This spec governs the *onsumer 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 signin form, no proprietary credential store, no thirdparty SSO forwarder.

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

R1.E1 — Provider's own administration UIs (ratified 20260518)

Koder ID's own accountUI and adminUI (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 OAuthtoself.

  • 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 subservices of the engine that expose userfacing 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 *-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.

*orbidden variants:*KoderID, koderid, KODER_ID, koder_id, KoderId, kid, id.

*ationale.*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 20260512. 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:

  • webbackend: `user (Gitea convention) or /auth` (Koder default)
  • mobiledesktop native: deep-link scheme `product:oauthcallback`
  • CLITUI: local loopback `http:127.0.0.1:portoauth/callback`

    with port range 4915265535, singleuse


R4 — Post-auth routing invariant

After a successful OAuth callback:

*UST* 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.

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

*HOULD* 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 landingvsdashboard content delta is small (≤ 50% of the rendered DOM). For large deltas, use (a) or (c).

*orbidden* serving the same content at / regardless of auth state. This was the Koder Flow bug on 20260512 (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 302303 to `auth-prefixoauth2koder-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 usernameemailpassword 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 localonly account), the component MUST present a clear linkor- 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

*orbidden* prompting for password to "merge accounts" — there are no local passwords postRFC006.


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 (Googlelike persistence, rotationprotected):
    • access_token: *5 minutes*(short-lived, rotated frequently)
    • refresh_token: *80 days*(6 months) absolute lifetime;

      rotated on every refresh (RT reuse-detected → all sessions for user revoked per repo error ErrTokenReuseDetected)

    • Idle timeout: *0 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 refreshtoken persists across module close/reopen (per `koderkit contract: token stored in fluttersecurestorage` → Keychain (iOS), Keystore (Android), libsecret (Linux), Credential Manager (Windows), AESGCMwrapped localStorage (Web))

  • Server MUST validate session on every request to authenticated

    routes; expiredinvalid → redirect to `auth-prefixoauth2koder-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 R3R9 serverside.

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 LinuxmacOSWindows) — 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 Linux session shell (Kolide) is a nonFlutter, GTK4+layershell desktop environment. It performs OAuth on behalf of the *hole desktop session*(not per-app); apps running inside the session inherit the identity via the IPC contract in specs/ipc/protocol.kmd.

  • *nitiator.*The firstrun wizard (`kolideonboarding`,

    infra/linux/kolide #007) launches the system browser to the authorize URL using xdg-open / g_app_info_launch_default_for_uri. Subsequent reauth is initiated by the panel badge in `kolideshell` (an in-shell button, not an embedded form).

  • *low.*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.

  • *torage.*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 `~.configkolide

Source: ../home/koder/dev/koder/meta/docs/stack/specs/auth/oauth-flow.kmd