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).
Quando esta spec se aplica
Triggers primários
- Implement sign-in in any new Koder component
- Audit conformance of an existing component to canonical auth
Todos os triggers
- Implement sign-in in any new Koder component or surface variant
- Embed a Koder ID OAuth2 client in any product
- Change post-auth routing, callback handler, or session cookie scope
- Audit conformance of an existing component to canonical auth
Corpo da especificação
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..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
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/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-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/callbackwith port range 49152-65535, single-use - extension (WebExtension MV3): browser-provided redirect
https://<extension-id>.chromiumapp.org/viachrome.identity.launchWebAuthFlow/browser.identity.getRedirectURL(). Public client → PKCE (S256), no client secret. Added for theextensionsurface (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
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 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=truestyle) - 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_sidfor Koder Flow,koder_sessionfor 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 errorErrTokenReuseDetected)- 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_tokenwhen it's near expiry (75% of TTL ≈ every 11 min) using the persisted refresh_token. The refresh_token persists across module close/reopen (perkoder_kitcontract: token stored influtter_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)
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 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 usingxdg-open/g_app_info_launch_default_for_uri. Subsequent re-auth is initiated by the panel badge inkolide-shell(an in-shell button, not an embedded form). - Flow. 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. - Storage. 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~/.config/kolide/*files in plaintext. - Refresh.
auth_serviceschedules a refreshexpires_in - 60sbefore expiry. Refresh failure withinvalid_grantclears 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.LoggedOutD-Bus signal so apps can drop cached identity, and openshttps://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).
- Top-bar badge in
- Test obligations. T1–T8 of
auth/oauth-flow-test-template.kmdapply with the loopback-URL adaptations; conformance row inregistries/koder-id-auth-coverage.mdMUST 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:
| Code | Cause | User-facing message |
|---|---|---|
state_mismatch | CSRF state token mismatch in callback | "Sign-in expired. Try again." → redirect to OAuth flow |
invalid_code | Authorization code rejected by Koder ID | "Sign-in failed. Try again." → OAuth flow |
token_exchange_failed | Token endpoint returned 4xx/5xx | "Koder ID unavailable. Try later." |
id_token_invalid | JWKS signature verification failed | "Sign-in failed. Try again." → log [E] for ops |
auto_provisioning_rejected | Local user creation refused (collision, policy) | LinkAccountMode (R7) |
session_create_failed | Cookie/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:
| Component | Issue | Tracking |
|---|---|---|
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 audited | — | per 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 tokoder_sessionfor SDK-based, keep_koder_sidfor 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 (
ErrTokenReuseDetectedclears all sessions for the affected user). Stricter TTLs for admin/billing routes opt in per- component via[auth.session]inkoder.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.mdrfcs/id-RFC-004-oauth2-oidc-service.mdrfcs/id-RFC-005-session-service.mdspecs/errors/user-facing-messages.kmd- 2026-05-12 incident: Koder Flow OAuth flow recovery (this session)
Referências
specs/koder-app/behaviors.kmdspecs/identity/login-resolution.kmdspecs/auth/oauth-flow-test-template.kmdrfcs/id-RFC-003-authentication-service.mdrfcs/id-RFC-004-oauth2-oidc-service.mdrfcs/id-RFC-005-session-service.mdregistries/koder-id-auth-coverage.md