Koder ID OAuth Flow — consumer-side contract
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..010series underservices/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/engine — account-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
toself. - 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 49152
65535, 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
IsSignedrendering 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=truestyle) - 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_sidforKoder Flow,
koder_sessionfor SDK-based) - Token TTL defaults (Google
like 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_tokenwhen 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 influttersecurestorage` → 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)
R9 — Deep-link preservation
When an anonymous user hits a deep-link (e.g., /Koder/koder/issues/1234), the component MUST:
- Capture the original URL as
redirect_to - Redirect to OAuth flow with
redirect_toparam - 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 first
run wizard (`kolideonboarding`,infra/linux/kolide #007) launches the system browser to the authorize URL usingxdg-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_serviceopens a transient HTTP listener on127.0.0.1:<ephemeral>; the authorize URL'sredirect_uripoints there. The listener accepts exactly one inbound request, capturescode+state, then closes. The redirect URI MUST NOT depend on a reserved port — bind to port 0 and read it back viagetsockname. - *torage.*Tokens (access + refresh + id_token) are stored in
libsecret under schema
dev.koder.kolide.tokenwith attributes{component: "kolide", user_id: "<sub>"}. NEVER write tokens to `~.configkolide