Koder ID

  • Area: Foundation
  • Path: services/foundation/id/engine (canonical; legacy platform/ symlinked per RFC-003)
  • Kind: Identity provider (OIDC / OAuth2 / SAML) — custom Go microservice
  • Version: 0.9.3
  • Status: In production at id.koder.dev since 2026-04-09

Role in the stack

Koder ID is the centralized identity layer for every Koder product. Every product that needs authentication redirects to https://id.koder.dev/oauth/v2/authorize, gets an OIDC token back, and uses it to authorize the user. Products never implement their own login flow — they consume tokens minted by ID.

The implementation is a from-scratch Go IAM (not Zitadel). It targets hyperscale tenant density (kdb-next substrate) and is the production identity provider. The codebase lives in services/foundation/id/platform/.

Primary couplings

Consumer Relationship
foundation/flow Uses Koder ID as OIDC provider for Koder Flow git forge login
foundation/kompass Organizations and tenants scoped via Koder ID
apps/pass / foundation/pass Companion app for passkeys and identity management
products/horizontal/chat/engine dev.koder.chat OIDC client — exchanges Koder ID tokens for Chat sessions
products/vertical/lex/app dev.koder.lex OIDC client — PKCE flow for legal app auth
Every Koder SaaS product OIDC client — redirects to ID for login

Auth Flow — Handle Lookup

CreateFlow resolves the login identifier in two steps: first by email (GetUserByEmail), then by handle/username (GetUserByHandle) if the email lookup fails. Both strategies produce the same flow.UserID; on both failing the flow is created with an empty UserID and fails generically at the password step (timing-safe — existence is never revealed to the caller).

The by_handle secondary index on identity_users (IndexID 2) was added alongside the existing by_email index (IndexID 1). The gRPC method GetUserByHandle was added to IdentityService.

Interfaces

  • OIDC Discovery: https://id.koder.dev/.well-known/openid-configurationclaims_supported includes tenant (added 2026-05-18, engine#069) for RP-side tenant validation
  • Authorization: GET /oauth/v2/authorize
  • Token: POST /oauth/v2/token — id_token carries tenant claim matching AccessTokenClaims.TenantID
  • UserInfo: GET /api/v1/me — returns sub, email, full_name, preferred_username
  • Admin gRPC: RegisterClientRequest accepts optional desired_client_id (field 7) for first-party clients with stable reverse-domain IDs
  • Migration endpoint: POST /v1/admin/migrate/oauth-client (requires KODER_ID_MIGRATION_MODE=true) — registers clients with specific IDs
  • Admin REST + CLI surface fully documented in engine/docs/rfcs/RFC-007-admin-service-and-cli.md (revised 2026-05-18 to reflect shipped reality from engine#073)

Self-conformance to oauth-flow.kmd (R1.E1)

Koder ID's own administration UIs (engine/account-ui/, engine/admin-ui/) ride a cookie-session direct from the engine — they do NOT perform OAuth round-trips against the same issuer. This is the R1.E1 exception ratified 2026-05-18 in specs/auth/oauth-flow.kmd and tracked in registries/koder-id-auth-coverage.md as SKIP (R1.E1). External RPs (Flow, Kall, Dek, …) remain subject to the full R1+R6 + T-suite.

Verge parity across UI surfaces (#176#177#178)

Per specs/themes/verge.kmd, every Koder UI must be on Verge (v0 = Adwaita 1:1, accent #3584E4). Koder ID's surfaces:

Surface Path Verge status
Flutter app (android+linux) app/ ✅ — inherited from koder_kit (KoderUIStyle.verge default since 2026-05-14)
Auth UI (SSR) — loginconsentsignupmfaresetaccount-selectlogouterrorinvite engine/pkg/authui/ ✅ #176 — Verge v0 inline (auth.css); deployed live 2026-05-27 (#179): id.koder.dev/ui/auth/css/auth.css serves #3584E4
Admin SPA engine/admin-ui/engine/web/admin/ ✅ #177 Verge; LIVE 2026-05-27 (#180) at id.koder.dev/admin/ (same-origin via gateway, base /admin/)
Account SPA engine/account-ui/engine/web/account/ ✅ #178 Verge; LIVE 2026-05-27 (#180) at id.koder.dev/account/ (same-origin via gateway, base /account/)

Pre-#176 the web surfaces shipped a bespoke palette (#4361ee), off-Verge. The Auth UI is the most-visible surface (every product's OAuth redirect lands here). #176 chose inline token values over linking koder_web_kit to keep the login path dependency-free (zero-FOUC, no 19-preset bundle); drift from Adwaita→Verge is guarded by tests/regression/176_authui_verge_tokens_test.go.

Interacts with per-tenant white-label theme_overrides (#125): those are persisted but not yet rendered (§"Per-tenant LoginPage white-label"); when wired, overrides layer on top of the Verge base.

Framed / recessed surface pattern (Auth UI, dark)

In dark mode the auth surface uses a recessed composition — the reverse of the usual elevated card. pkg/authui/static/css/auth.css [data-theme="dark"] sets --bg: #1F1F22 (the Verge --card-high tonal level → lighter "mat"/frame, applied to the page body) and --bg-card: #0F0F0F (the Verge --surface level → darker base recess, applied to .auth-card). The card is darker than the page; depth reads from the lighter frame plus the hairline --border.

This is not a Verge model change: the 6-level elevation ramp (specs/themes/elevation.kmd) and the single depth axis (specs/themes/depth.kmd) stay intact — a recessed model would invert them and can't recurse. It is a composition from existing dark levels (no --well/--sunken token; canonical Verge v1 §R4.3 values, just framed). Measured: page/card separation ≈1.17×, accent-on-card 5.09 (AA). Light mode unchanged. Origin: tools/design-gen#173 (the design conclusion) + id#190 (the auth-UI redesign that lands it across the SSR screens).

GeoIP enrichment (tickets 075083084)

pkg/geoip ships a Resolver interface with two implementations: NoOpResolver (private/unknown short-circuit) and MaxMindResolver (reads GeoLite2-City.mmdb via github.com/oschwald/maxminddb-golang). Both auth and sso services consume pkg/geoip — moved out of services/auth/internal/geoip into the shared package in #084 to anticipate the ASN follow-ups (#077/#085) and other future consumers.

Wiring: both services/auth/cmd/main.go and services/sso/cmd/main.go read env KODER_ID_GEOIP_DB (default empty → NoOpResolver) and pass the resolver to AuthService.SetGeoResolver / SSOService.SetGeoResolver. Schema columns shipped on auth_events (migrations 17-23) and sso_sessions (migrations 8-14): country code + name + city + lat + lon + source + lookupat. The idtoken discovery + tenant claim are unaffected — these fields live on persisted events/sessions only.

ASN / network-type enrichment (tickets 077/085)

pkg/asn mirrors the pkg/geoip design: Resolver interface + NoOpResolver + MaxMindResolver (GeoLite2-ASN / GeoIP2-ISP). The resolver layers a curated datacenter ASN table (pkg/asn/datacenter.go — AWSGCPAzureDOHetznerOVHLinodeVultr OracleAlibabaIBMTencentCloudflareFastlyScalewayContaboLeaseweb) and a curated VPN provider ASN table (NordMullvadExpressProton Surfshark/IPXO) on top of the raw lookup. The TorExitTracker pulls https://check.torproject.org/torbulkexitlist once a day and pushes the set into the resolver via SetTorExits (override before lookup).

Wiring: services/auth/cmd/main.go reads env KODER_ID_ASN_DB to load the .mmdb. KODER_ID_TOR_LIST=1 opt-in starts the refresh goroutine. Schema columns shipped on auth_events (migrations 24-27): asn, asnorg, networktype, asnlookupat. network_type is a closed taxonomy: unknownprivateresidentialmobiledatacentervpntor.

Risk scoring (per policies/multi-tenant-by-default.kmd + future #086) will compose ParsedASN.RiskWeight() (residential0, vpn20, datacenter50, tor80) with geo-anomaly and device-mismatch signals.

Personal Access Tokens (#104)

PAT issuance lands as pat_tokens (migrations 38-40) + PATRepository + PATService (IssueListRevokeValidate) + `v1mepats HTTP endpoints + koder-id-cli pat {create,list,revoke}. Tokens are formatted as kpat_<43 base64 chars> (32 bytes entropy), stored as SHA-256 hex with a unique index. Scope validation at issuance gates against pkg/scopes.WellKnownPATScopes (read:usage` Wave 1, more as consumers land). Plaintext is returned exactly once (copy-now UX in CLI/UI). The Flutter Settings UI lives in follow-up #106.

Admin erasure-replay endpoint (#098)

POST /admin/v1/erasure-replay triggers ErasureService.ReplayErasures in a goroutine and returns 202 + job_id. Companion to the env-gated boot replay (KODER_ID_ERASURE_REPLAY_ON_BOOT=1 from #094); same loopback-only posture as the rest of admin REST (LXC firewall is the enforcement layer until admin auth ratifies). Optional body {"reason": "post-restore 2026-05-14"} for audit.

Per-service observability (#102 closed + RFC-015)

Every sub-service exposes /health + /metrics via the shared pkg/observability helper (Wave 1 ratified 2026-05-16, Wave 2 covered 15 sub-services). RFC-015-observability.md (this batch) codifies the wiring pattern, response shape, and rollout map; the helper gains CheckKDBCheckRedisCheckPinger/CheckFunc dependency-check constructors so per-service main.go can wire realistic health checks in one line. Auth service wired as exemplar (observability.CheckKDB("kdb", kdbClient)). RED-style histograms + gRPC interceptor tracked in engine#107; admin metrics aggregator update in engine#108.

Wave 3b (2026-05-20, #113 + #114 closed): auth + identity cmd/main.go now wrap the parent HTTP mux with observability.WrapHandler("", handler) so every route emits koder_id_http_request_duration_seconds{handler=<URL.Path>,method,status,quantile} samples. Their grpc.NewServer is constructed with grpc.UnaryInterceptor(observability.UnaryServerInterceptor()), which records koder_id_grpc_server_handled_seconds{grpc_service,grpc_method,grpc_code,quantile} into a sibling registry exposed by the same /metrics endpoint. Reservoir is capped at 1024 samples per (handler,method,status) and per (grpcservice,grpcmethod,grpc_code) key — bounded cardinality. The interceptor's self-conformance test (grpc_interceptor_test.go) drives an in-process gRPC server through the standard health probe to assert sample emission for both OK and PermissionDenied codes.

OAuth Device Authorization Grant (RFC 8628, #116)

Headless CLI / AI / CI authentication landed 2026-05-20 (#116 phases 1-2, 4-5, 7-partial — phases 3 + 6 open):

  • Start endpoint POST /oauth/v2/device_authorization mints a

    32-byte URL-safe device_code + 8-char XXXX-XXXX user_code from the BCP-47-safe alphabet (ABCDEFGHJKLMNPQRSTUVWXYZ23456789 — no 0O1IL). Default lifetime 600s; recommended poll interval 5s.

  • Storage lives in oauth_device_authorizations (kdb1 table)

    / DeviceAuthorizationRow proto (kdb-next). The kdb-next repo uses Scan + filter for user_code lookups (bounded table; index added later if perf requires it).

  • Token endpoint POST /oauth/v2/token accepts both

    grant_type=urn:ietf:params:oauth:grant-type:device_code and the short alias device_code. Implements the §3.5 polling protocol in service.ExchangeDeviceCode: authorization_pending / slow_down (back-to-back polls under interval/2) / access_denied / expired_token / invalid_grant (client_id mismatch). On success: mints access + id_token (15min, no refresh yet) and deletes the row for single-use enforcement.

  • OIDC discovery advertises the new grant in

    grant_types_supported and exposes device_authorization_endpoint per RFC 8628 §4.

  • Verification page GET/POST /oauth/v2/device is a vanilla

    HTML form (no template-engine dependency) that pre-fills the usercode from `?usercode= (matches verificationuricomplete) and accepts email + password + decision in a single submit. Allow → service.AuthorizeDevice; Deny → service.DenyDevice`; surfaces friendly errors for unknown / expired / MFA-locked branches. Multi-step "if already logged in" flow deferred until the oauth service gains session-cookie detection.

  • Hub CLI (products/dev/hub/cli/main.go::cmdLogin) now talks

    to https://id.koder.dev/oauth/v2/device_authorization directly (form-encoded, no Authorization header), polls /oauth/v2/token with proper slow_down / expired_token / access_denied / invalid_grant handling, and parses the returned id_token JWT to populate cfg.UserID (sub) + cfg.Username (name → email fallback). Override via KODER_ID_URL env for self-hosted Koder ID instances.

  • Seed client khub-cli is registered as a public client

    with device_code + refresh_token grants via engine/tools/seed-clients/main.go (the catalog now supports per-entry grantTypes overrides).

  • T-suite + handler tests:
    • services/oauth/internal/service/device_grant_test.go

      T1-T8 (start fields, alphabet, pending, slow_down, success+delete, denied, expired, single-use) + client_id mismatch + unauthorized_client. 10/10 PASS.

    • services/oauth/internal/handler/device_grant_verification_test.go

      — 7 tests on the verification page (GET render, GET pre-fill, POST missingunknownexpired user_code, no-auth-client, method-not-allowed). 7/7 PASS.

OAuth client_credentials grant — service accounts (#115)

Machine-to-machine auth path for CI / /k-ship / AI agents that need to publish without a human in the loop. The client_credentials grant is OAuth 2.0 §4.4; this section documents the Koder ID-specific contracts that #115 hardened.

  • Confidential clients only. tokenClientCredentials rejects

    public clients up front — a public client without a registered secret cannot prove possession of the credential, so granting it a JWT would defeat the grant's purpose.

  • Constant-time secret compare via subtle.ConstantTimeCompare.
  • Grant-list enforcement. Client must list client_credentials

    in client.GrantTypes; otherwise the endpoint returns unauthorized_client (400). Catches the case where a client was provisioned for one grant and is being abused with another.

  • Scope subset check. Requested scopes must all be present in

    client.Scopes; an unregistered scope yields invalid_scope. An omitted scope parameter grants the full registered set.

  • Subject convention. JWTs carry sub=service-account:<client_id>

    so downstream middleware (notably the Hub's requireDev) can recognize service-account tokens without a schema change. The user-table-row materialization (kind='service' on koder_users) remains an open slice; the prefix convention is enough for the immediate publishing path.

  • No refresh token. Clients re-mint when the access token

    expires (1h TTL).

  • Hub CLI (products/dev/hub/cli) recognizes three env vars in

    precedence order: KHUB_CLIENT_CREDENTIALS (file with client_id:client_secret), KHUB_CLIENT_ID + KHUB_CLIENT_SECRET (paired vars), and falls back to the cached human token from khub login when none are set. KODER_ID_URL overrides the issuer for self-hosted instances.

  • Test suite: services/oauth/internal/handler/client_credentials_test.go

    — T1 success roundtrip + JWT shape, T2 wrong secret, public-client reject, unauthorizedclient guard, invalidscope, omitted-scope defaults, unknown client, form-body credentials. 8/8 PASS.

  • Operator runbook:

    meta/context/runbooks/hub-publishing-service-account.kmd.

Open follow-ups: service-account materialization on koder_users (kind='service' column + auto-Apply to developer table) and automated 90-day rotation cron.

Multi-tenant isolation (RLS, tickets 100 + 105)

Phase 1 (#100, 2026-05-18) shipped Postgres-only RLS DDL helpers in pkg/kdb (AddPostgresOnly, BackendKind, WithTenantTx) and CREATE POLICY migrations for the admin service's tenant-scoped tables (api_keys, audit_log). All behind KODER_ID_RLS_ENABLE=true.

Phase 2 slice 1 (#105, this batch) wires the admin service to actually serve requests under RLS:

  • pkg/kdb.ContextWithStore + StoreFromContext propagate the

    request-scoped tx-store via context. WithTenantTx stashes the tx-store automatically so unmodified callsites pick it up.

  • apikey_repo + audit_repo read every DB op via

    kdb.StoreFromContext(ctx, r.client.Raw()) — tx-aware by default, fallback to the long-lived store when no ctx-store is set.

  • services/admin/internal/handler/rls_middleware.go wraps every

    admin HTTP request in WithTenantTx when the env gate is on, using the request's X-Tenant-ID. Requests without a tenant header (system-admin ops like list-all-tenants) skip the wrap.

  • Wired in services/admin/cmd/main.go between mux and ListenAndServe.

Phase 2 slice 2 (#105, this batch) extends the same pattern to the auth service:

  • services/auth/internal/repository/migrations_rls.go registers

    RLS for auth_events + auth_flows + auth_lockouts (the 3 hot- path tables) at versions 12001201, 12101211, 1220/1221.

  • event_repo, flow_repo, lockout_repo route every DB op via

    kdb.StoreFromContext(ctx, r.client.Raw()).

  • services/auth/internal/handler/rls_middleware.go mirrors the

    admin helper; services/auth/cmd/main.go wires both the RLS migrations (behind the env gate) and the middleware.

Phase 2 slice 3 (#105, this batch) covers oauth + session:

  • oauth: client_repo, authcode_repo, consent_repo, key_repo

    routed via kdb.StoreFromContext; RLS for clients, authorization_codes, consents, signing_keys at versions 13001301, 13101311, 13201321, 13301331.

  • session: session_repo routed; RLS for sessions, access_tokens

    at 14001401, 14101411.

  • Both services ship a mirror rls_middleware.go and wire it in

    main.go behind KODER_ID_RLS_ENABLE.

Phase 2 slice 4 (#105, this batch) — narrower than originally scoped. Auditing the remaining sub-services revealed that audit, behavior, device, access, qr, sync, telephony, webhooks, and sso use org_id / user_id / no isolation column — NOT tenant_id. The RLS template from #100 / #105 slice 1-3 doesn't fit them as-is. Captured in engine#109: each needs categorization (retroactive tenant-scope, org-scope with a new WithOrgTx helper, user-scope with WHERE-clause-only, or genuinely system-wide) before RLS adoption proceeds.

The slice 4 work that did ship: saml (legitimately tenant-scoped, 2 tables) — sprepo + idpkeyrepo routed via kdb.StoreFromContext; RLS migrations 1500-1511 for saml_sps + saml_idp_keys; rls_middleware.go mirror; main.go wire.

Phase 2 slice 5 (#105, this batch) — finishes the truly tenant-scoped sub-services:

  • auth completion: remaining 6 repos (magiclink, socialprovider,

    socialstate, workspacesso, ldap, identity) routed via StoreFromContext; migrations_rls.go extended to versions 1230-1289 covering magiclinks / socialproviders / socialloginstates / workspacessoconfigs / ldap_servers / user_identities.

  • identity (biggest single sub-service — 15 tenant-scoped tables

    out of 32 total): 17 repos refactored, migrations_rls.go (new) registers RLS at versions 1600-1696 for users, credentials, mfadevices, verificationtokens, groups, group_members, scimtokens, subtenantrole_bindings, invites, roles, roleassignments, webhooks, webhookdeliveries, pat_tokens, erasurerequests. `rlsmiddleware.go` mirror + main.go wire.

RLS adoption count after slice 5: admin + auth + oauth + session + saml + identity = 6/6 of the confirmed tenant-scoped sub-services (100%) — every sub-service that genuinely uses tenant_id is now ctx-aware and gated behind KODER_ID_RLS_ENABLE.

Phase 2 infra extension (batch 11, 2026-05-18) — pkg/kdb now ships WithOrgTx + WithUserTx helpers (mirrors of WithTenantTx, setting app.current_org_id / app.current_user_id GUCs instead). #109 carries the schema audit + classification of the remaining 8 sub-services: audit + access = org-scoped; behavior + qr + sync + telephony + webhooks + sso = user-scoped (with a handful of system- wide tables — rate_limits, sso_clients, sso_revocations — that don't need RLS at all). Mixed-scope sub-services (device) split per-table or use composite predicates.

Phase 2 slice 6 (#105, batch 12) — first adoption of the new scopes:

  • audit (org-scoped exemplar): 9 repository callsites routed via

    kdb.StoreFromContext(ctx, r.db); migrations_rls.go registers RLS for audit_events, mfa_policies, org_settings with the org_id = current_setting('app.current_org_id') predicate (versions 1700-1721); rls_middleware.go wraps in WithOrgTx using X-Org-ID header; main.go wires both gated by env.

  • qr (user-scoped exemplar): 4 callsites routed; RLS for

    qr_sessions with user_id predicate (1800-1801); WithUserTx middleware extracts X-User-ID (already injected by the gateway for authenticated requests).

Remaining work: behavior, sync, telephony, webhooks (user-scoped, mechanical now), access (org-scoped), device (mixed — split per table). Cross-tenant + cross-org + cross-user T1 integration tests + default-on cutover remain as gating items. Cross-tenant T1 integration test against a real Postgres + default-on cutover after staging burn-in remain as gating items before flipping the env-gate default.

Phase 2 slice 7 (#105, batch 13 / 2026-05-20) — wires the webhooks sub-service (user-scoped, mirrors the qr exemplar):

  • 18 repository callsites routed via kdb.StoreFromContext(ctx, r.db).
  • migrations_rls.go registers RLS for webhooks,

    webhook_deliveries, and api_keys with the user_id = current_setting('app.current_user_id') predicate (versions 1900-1921).

  • rls_middleware.go wraps in WithUserTx using X-User-ID.
  • main.go gates both behind KODER_ID_RLS_ENABLE.

RFC-016 ratified in the same commit (docs/rfcs/RFC-016-row-level-security.md). Codifies the two-layer model (Postgres RLS + middleware), the three-scope taxonomy (tenant / org / user), the three-file per-sub-service layout, the migration-version namespace table (100-block per sub-service), the koder-spec-audit multi-tenancy --strict audit-hook contract, and the four-criterion default-on cutover plan.

RLS adoption count after slice 7: admin + auth + oauth + session + saml + identity + sso = tenant-scoped (7); audit = org-scoped (1); qr + webhooks = user-scoped (2). 10 sub-services wired. Remaining mechanical work: behavior, sync, telephony (user-scoped), access (org-scoped), device (mixed) — each follows an existing exemplar.

Phase 2 slice 8 (#105, batch 14 / 2026-05-20) — wires the access sub-service (org-scoped, mirrors the audit exemplar):

  • 8 repository callsites routed via kdb.StoreFromContext(ctx, r.db).
  • migrations_rls.go registers RLS for access_policies and

    access_evaluations (versions 2300-2311 per RFC-016 §5's per-sub-service 100-block). Policy predicate admits both org_id = current_setting('app.current_org_id', true) rows AND org_id = '' global rows so system-wide access policies remain visible to every org.

  • rls_middleware.go wraps in WithOrgTx using X-Org-ID.
  • main.go gates both behind KODER_ID_RLS_ENABLE.

RLS adoption count after slice 8: 7 tenant + 2 org + 2 user = 11 sub-services wired. Remaining mechanical work: behavior, sync, telephony (user-scoped), device (mixed — split per table).

Phase 2 slices 9-11 (#105, batch 15 / 2026-05-20) — bundles three user-scoped sub-services in one lote (mechanical, all mirror the qr/webhooks exemplar):

  • behavior (slice 9, versions 2000-2099): RLS on

    behavior_events, user_risk_profiles, anomaly_records. 9 repository callsites routed.

  • sync (slice 10, versions 2100-2199): RLS on sync_versions,

    sync_entries, sync_devices, sync_snapshots. 16 callsites routed.

  • telephony (slice 11, versions 2200-2299): RLS on

    otp_requests, verified_phones. rate_limits intentionally excluded — composite key-only table without user_id, global by design (wrapping it in WithUserTx would silently drop all rows). 13 callsites routed.

RLS adoption count after slice 11: 7 tenant + 2 org + 5 user = 14 sub-services wired. Only device (mixed — split per table) remains in the mechanical-wiring queue.

Federated Identity (Social Login)

Social login is implemented inside services/auth/ (shares the auth service, reuses IdentityClient/SessionClient).

  • Providers built-in: Google, GitHub, Microsoft, GitLab; any OIDC-generic provider can be configured per tenant.
  • Flow: GET /v1/auth/social/login/{provider}/start → redirect to provider → GET /v1/auth/social/login/{provider}/callback → session tokens.
  • Account linking: on first social login, links by existing email or creates a new account. Provider profile picture imported to user avatar.
  • Unlink: DELETE /v1/auth/identities/{user_id}/{provider} — denied if it's the last auth method and no password credential exists.
  • UI: Login and signup pages show social buttons when providers are configured. account.html template shows linked identities with link/unlink controls.
  • Storage: user_identities table (provider, provideruserid, userid snapshot). `socialproviders per tenant. socialloginstates` for CSRF (TTL 10 min).

Proto

  • services/foundation/id/platform/proto/koder/id/admin/v1/admin.proto
  • RegisterClientRequest.desired_client_id (string, field 7) — optional stable ID for first-party clients

Registered OIDC Clients

Client ID Product Notes
dev.koder.chat Koder Chat Registered 2026-04-22 via MigrateClient
dev.koder.lex Koder Lex Registered 2026-04-22 via MigrateClient

Workspace-scoped audit trail (#171)

GET /v1/workspaces/{id}/audit — a workspace owner/manager (staff or board role; not super_admin) reads their own workspace's audit trail. Filters: actor_id, action, ip, from/to (RFC3339), limit; keyset pagination via page_token. RBAC distinguishes non-member → 404 (don't leak existence, per multi-tenant-by-default.kmd) from member-non-manager → 403.

Backing (Option B — dedicated column, owner-chosen over org_id overload):

  • The audit sub-service gained a workspace_id column on audit_events

    (migration 8) + composite index (workspace_id, created_at DESC, id DESC) (migration 9), EventFilter.WorkspaceID/IPAddress/PageToken, keyset cursor (EncodeCursor/DecodeCursor), and a service-mesh GET /internal/audit/query (never gateway-exposed; the caller authorizes).

  • The identity sub-service now emits workspace audit events (it

    emitted none before): on workspace create, member addupdaterevoke, settings update, subtenant create — workspace_id + org_id = workspace.id (a workspace is an organization). Emission + query go through an internal/audit HTTP client wired by KODER_ID_AUDIT_URL (best-effort: a failed audit write never breaks the user request).

  • DB topology note: id sub-services use per-service kdb namespaces

    (koder_id_auditkoder_id_identity), so identity cannot read audit_events directly — it queries the audit service over the mesh.

Deploy gate: the audit_events migration runs on kdb-next in prod, so it joins #173's kdb-next validation gate; the endpoint goes live (unblocking id/app#038) when #173 deploys. PG17-validated on s.khost1.dev-linux-id.

Per-tenant LoginPage white-label (#125)

Optional per-tenant customization of the OAuth login page, inert by default: a tenant with no tenant_login_config row (every tenant today) renders the built-in Koder defaults — no behavior change until it opts in. Lives on the oauth service (where LoginPage renders).

  • Migration 6 adds tenant_login_config (oauth namespace): logo_url,

    magiclinkenabled, socialproviders (JSON), themeoverrides (JSON).

  • LoginPage overrides TenantLogo (validated http(s) URL, else falls back to

    the default text logo), MagicLinkEnabled, and SocialProviders from a 60s per-tenant cache (LoginConfigCache); admin writes invalidate immediately.

  • Admin: GET`PUT /v1admin/login-config (tenant from gateway X-Tenant-ID`,

    mirroring /v1/admin/clients; gateway routes it explicitly to oauth).

  • Scope: theme_overrides is persisted/round-tripped but not yet rendered (no

    color slots in the template — Wave 2); social buttons render per config but a working social sign-in needs a social-login backend (not built yet).

Deploy of migration 6 joins the kdb-next gate (#173). PG17-validated.

SAML SP-metadata signing (#124)

Optional enveloped XML-DSig over the IdP metadata, off by default (KODER_ID_SAML_SIGN_METADATA, default false). Dormant capability: most SPs accept unsigned metadata, so it ships inert and is flipped on only when an RP requires a signed IdP descriptor. When enabled, BuildMetadata wraps the EntityDescriptor (which gains an ID) in a <ds:Signature> (rsa-sha256, exclusive c14n) signed with the tenant's IdP key — the same key whose cert sits in the KeyDescriptor, so an RP verifies with the public cert it already trusts (SAML X.509, not JWKS). Reuses pkg/crypto.NewXMLSigner/SignEnveloped (the SAML response-signing path; goxmldsig) — no new dependency. IdP key rotation covers metadata signing automatically.

CLI: koder-id-cli saml metadata-fingerprint [--tenant SLUG] prints the SHA-256 of the served metadata for RP onboarding. No migration → no kdb-next deploy gate.

RFC-017 tenancy hierarchy — Org → Workspace → Project (migrate phase, 2026-05-31)

RFC-017 collapsed four overlapping container tables into a ratified three-level hierarchy: Org = organizations, Workspace = subtenants (re-parented to org_id), Project = projects (re-parented to workspace_id). The migrate-phase tickets #197–#200 shipped on the engine (services/foundation/id/engine):

  • #197project_id is the 4th nested RLS axis: WithProjectTx +

    app.current_project_id GUC, the p_project / p_project_member policy templates in specs/multi-tenancy/contract.kmd, gateway X-Project-ID forwarding + the reusable pkg/rlsmw project middleware, and a cross-project RealPG proof (T10 isolation, T12 fail-closed, membership dimension).

  • #198 — membership core: org_members + workspace_group_grants

    (workspace_members already existed) and MembershipService.ResolveEffectiveRole (highest-wins union of org-role inheritance, direct workspace ACL, and group grants; org-admin → workspace-admin).

  • #199 — limits cascade min(org, workspace) (NULL = unlimited),

    default-workspace resolution, and the usage-rollup shape over the materialized path. Enforcement consumers routed to gateway#058 (workspace-scoped keys) and billing#021 (3-level rollup).

  • #200 — admin API + CLI (koder-id-cli workspace|project …) for

    workspaceproject CRUD + membergroup grants, authz via the #198 resolver (org/workspace-admin → mutate; member → 403; non-member → 404), and workspace archive (revokes the workspace's id-owned keys). Parity table vs Claude Workspaces Admin API in engine/docs/tenancy-admin-api-parity.md.

RFC-019 — Org ↔ internet-domain model (2026-06-04, resolves #206)

The open account↔org↔domain cardinality question (#206) is ratified as Option B: an Org owns one primary (default) domain + N verified alias domains, and the account is the org (no super-account layer above Org — rejects reintroducing a 4th level over RFC-017's three). A verified domain is globally unique (belongs to exactly one org). The org's primary domain is its login-resolution default_domain (bare local-part expands against it); alias domains are full-email-only (new login-resolution R9). Already the de-facto code shape — the existing workspace_domains table (DNS-TXT verified, "an org owns one or more verified domains") is canonized as org_domains with is_primary. Propagated to specs/multi-tenancy/contract.kmd + specs/identity/login-resolution.kmd; fan-out impl tickets #208 (org_domains table — done, EXPAND phase: migration 61/62 + model.OrgDomain + OrgRepository domain methods, RealPG validated for global-unique + atomic primary flip), #209 (default_domain ≔ primary — pending, re-sequenced behind the MIGRATE backfill so it doesn't regress bare-username login), #210 (domain admin API + DNS-TXT verify — done: service.NormalizeDomain IDNA + service.VerifyDomainTXT injectable-resolver verifier; 5 OrganizationService gRPC RPCs (AddVerifySetPrimaryRemoveListDomains) regenerated via buf + handler error mapping; koder-id-cli domain … CLI; full identity suite green). Decision record: engine/docs/rfcs/RFC-019-org-domain-model.kmd.

Crescer provisioning (#207, partial — 2026-06-04)

Tenant Crescer (crescer.net) + accounts owner@/admin@crescer.net provisioned live on prod id @10.0.1.35. Identity redeployed 2026-06-04 (owner-authorized): the #208 org_domains schema (migrations 61/62) + the #210 domain gRPC API are now LIVE — canary-booted against the live kdb-next store first, atomic mv swap + .bak rollback, fleet 10/10 healthy, public OIDC 200. (Note: PORT=4001gRPC, `HTTP_PORT4011=REST by design — not a stale binary.) Remaining for **#211**: **#212 done** — org_members now has gRPC RPCs (OrganizationService.{Add,Remove,List}OrgMember + koder-id-cli org member, wrapping the #198 repo/resolver). The last blocker is **#196** (organizations unreconciled vs the live tenant` — which org_id to bind owner@/admin@ to); a clean Crescer binding + WorkspaceProject + owneradmin RBAC awaits that reconciliation (CONTRACT phase is owner-gated, not a /k-go batch).

All five RFC-017 RealPG suites pass together on dev-linux-id. The #196 EXPAND phase is deployed; the MIGRATE backfill and CONTRACT phases (NOT NULL, drop legacy parent columns, retire the RFC-012 workspaces governance role) remain — destructive prod migrations gated on a runbook (always-on.kmd R3.1), not yet executed. Follow-up #201 tracks gateway-side X-Project-ID membership pre-validation (a clean 403 before forward, additive to the authoritative p_project_member DB boundary).

  • Consent service (#188) is live (POST /v1/consent issue, /v1/consent/validate + /revoke service-account-scoped, DELETE /v1/consent/{jti} self-revoke). It is the issuervalidator for voice-clone consent; `servicesaisynth (synth#019) validates clone tokens against it (HTTPValidator, fail-closed) and revokes on delete. Remaining for full e2e: the Talk consent **client UX** that mints a token via POST /v1consent` (products/Talk surface).
  • Provider keys (#194) — the keys service's gRPC ProviderKeysService.ResolveProviderKey (server-to-server only; HTTP surface is user-CRUD) is now consumed by the AI gateway (AIGW#056) to resolve a caller's stored BYO key by identity (user→workspace→org, cross-tenant=404), closing #194's gateway-side acceptance. The user-facing "Connected providers" vault tab (slice 5) shipped in the id app (services/foundation/id/app, Flutter; listaddremove over the live HTTP surface; 5 widget tests green on Flutter 3.44) — visual curation + the live e2e (Stage D AI-gateway deploy) remain.
  • Substrate (#194 slice 3 — DONE): the entire keys service runs on kdb-next (every store — vaultsitemsfoldersversionsenrollmentsoauthvaultproviderkeys — is pkg/kdbnext-backed, daemon dials kdbnextDial()), so providerkeys is consistent, not an outlier (no PG-backend design change). Slice 3 integration tests pass live against a real kdb-gateway (roundtrip + user→workspace→org precedence + isolation + expiry); tenancyRLS is keyspace-level. Operational gap (not #194-specific): no kdb-next gateway is provisioned in the id env (prod id LXC has no :7900:18090 listener), so the keys service can't run in prod yet — tracked as #202 (blocks the whole keys service, including live ResolveProviderKey).
  • services/foundation/id/platform/ — production Go source
  • apps/pass / foundation/pass — identity companion app

Internal-mTLS SVID issuance (#204, stack-RFC-009 Layer 2)

Koder ID is the Stack's single issuer of internal service identities — short-lived x509 SVIDs for east-west service-to-service mTLS, reusing the existing identity root instead of a new CA component (reuse-first). Issued by the keys service.

  • Identity scheme: spiffe://koder/<area>/<sector>/<instance> (1:1 with a Koder ID

    service-account). Normative spec: meta/docs/stack/specs/naming/svid-identity.kmd.

  • Endpoint: POST /v1/svid/issue (service-account, scope svid:issue; subject from

    the gateway-injected X-User-ID). Body {csr, ttl_seconds} — the caller presents a PKCS#10 CSR; the issuer signs a leaf over the CSR public key (never sees the private key) and never lets a caller name its own identity (own-identity-only authz — pkg/crypto/svid_issuer.go).

  • CA custody: the internal CA private key is KMS-sealed (crypto.SecretBox, passphrase

    from KMS/systemd-creds — kms#004); unsealed at boot, fail-closed if unavailable. One-time keys svid-bootstrap generates + seals the CA.

  • Allow-list: static subject→identity pin (KEYS_SVID_ALLOWLIST, @file ok),

    fail-closed; a kdb-next dynamic registry is a follow-up.

  • Revocation: short TTL only (≤ 24h, consumer-pull rotation; no CRL/OCSP in v1).
  • Code: pkg/crypto/svid*.go (mintingauthzCSRcustody) + `serviceskeysinternalserversvid_{server,http,allowlist}.go + serviceskeyscmdsvid.go`. 40 tests + live smoke (Go x509 chain-verify of an HTTP-issued leaf).
  • Status: issuer side complete + tested (dev-green; CA passphrase host-sealable via

    KEYS_SVID_CA_PASSPHRASE_FILE = systemd-creds, kms#004). Prod cutover + the client wiring (FLEET-039 item 1, kdb#761 Phase 8.1) are coordinated via the integration contract + staged A–F runbook in meta/context/runbooks/svid-issuance-prod-enable.md (B deploys the binary SVID-off = zero-risk; E flips on once consumers supply their SA client_ids).

Auth-gate notification transports (gate-RFC-001 §4, app#016, 2026-06-02)

When Koder Jet POSTs an auth-gate approval (/v1/auth-gate/approvals, handled in-gateway by pkg/authgate), the admin's Koder ID app must learn about it. gate-RFC-001 §4 delegated the transport choice to id; resolved per Regra 13 + self-hosted-first.kmd:

  • Foreground = self-hosted SSE (default, no external credential). The app

    holds an open GET /v1/me/auth-gate/stream (Server-Sent-Events) while foregrounded; StreamHub (pkg/authgate/stream.go) fans each stored approval out instantly (event: approval), with a catch-up replay of outstanding approvals on connect and comment heartbeats. HandleApprovalsPOST publishes to the hub additively, alongside the existing PushDispatcher. The app (id/app lib/authgate/pending_poller.dart) consumes the stream and relaxes its /v1/me/auth-gate/pending poll from ~10s to 60s while connected — killing the documented poll battery-drain. SSE is skipped on Flutter Web (package:http buffers); web stays on the 10s poll.

  • Background = OS push behind PushDispatcher (FCMAPNSUnifiedPush), opt-in

    and credential-gated (Firebase project / APNS .p8), for when the app is killed. Foreground SSE does not need it.

Auth on the stream mirrors HandleMyPendingGET (X-User-ID + ?admin_id= V0 fallback). Verified headless on dev-linux-id (Go, real-TCP SSE E2E) + dev-linux-dek (Flutter decoder + analyze).

SSE foreground transport — prod status (2026-06-02)

Gateway endpoint GET /v1/me/auth-gate/stream deployed to prod (id LXC 10.0.1.35, deploy.sh SERVICES=gateway) and verified working directly against the gateway (:18443: 200 text/event-stream, 0.6ms TTFB, streams). A staged-verify caught + fixed a Flusher gap in the gateway Logging middleware (statusWriter.Flush() pass-through + regression test). End-to-end through Jet is gated on infra/net/jet#203: Jet's response-writer wrappers must implement http.Flusher pass-through (and skip buffering text/event-stream) for httputil.ReverseProxy to stream SSE. Until then the id app falls back to its 10s poll (no regression).

Passkey-first / WebAuthn-only login — engine slice (2026-06-04, #212)

Passkeys shipped as MFA (2nd factor after password) but getWebAuthnCredentials in services/auth was a return nil,nil stub — so passkey-as-MFA was in fact broken, and there was no passwordless-primary path. #212 closed the engine side (spec specs/auth/passkeys.kmd):

  • services/auth now fetches credentials from identity (GetWebAuthnCredentials/

    UpdateWebAuthnSignCount gRPC, already present on the identity side) and converts them to webauthn.Credentialfixing passkey-as-MFA and enabling passkey-first.

  • New POST /v1/auth/webauthn-only/{start,verify} (passwordless-primary,

    passkeys.kmd R4): resolves the identifier, mints a real assertion challenge for a user with ≥1 passkey, and is enumeration-resistant (R4.2 — always creates a flow + returns a synthetic challenge for unknown/no-passkey users). Separate from the password→MFA state machine (new webauthn_expected flow status; R4.3).

  • R5: the in-flight go-webauthn SessionData moved off a process-local map

    onto a dedicated shared store auth_webauthn_sessions (keyed by flow_id) so the ceremony survives across the 10-instance deployment and restarts. It is a NEW table, NOT a new auth_flows column: kdb-next EnsureTable rejects a divergent schema on an existing table (strict-drift wall), so evolving auth_flows in place would crash boot in prod — same dedicated-table pattern as auth_mfa_recency (#191), per kdbnext-schema-evolution.kmd.

Verified green on s.khost1.dev-linux-id (build + tests, incl. enumeration + R5 round-trip) and deployed to prod auth (2026-06-04, staged canary).

Web passkey-first on the OAuth login page (2026-06-05, #213)

The engine endpoints are now wired into the web sign-in: oauth exposes POST /oauth/v2/authorize/webauthn-only/{start,verify} (proxying auth's live HTTP webauthn-only over internal localhost, then completing the authorize flow: verify → trusted user_id → CreateAuthorizationCode → redirect). pkg/authui login.html gained a "Entrar com passkey" button (JS-gated on WebAuthn support) + the auth.js ceremony + pt-BR/en-US l10n. The Flutter id app benefits via its OAuth-in-browser login delegation. Deployed gateway+oauth; verified on the public edge (/oauth/v2/authorize/webauthn-only/start → 200 + challenge). Explicit-button flow (R4.1); app-native passkey (Flutter has no WebAuthn — stub) is gated on the koder_kit passkey plugin (app#048); usernameless conditional-UI (R4.4) is a follow-up needing discoverable-credential login.

The happy-path get→verify→ValidateLogin→sign-count→session leg is proven by a self-contained software-ES256-authenticator Go test (webauthn_e2e_test.go, regression #892): a real signed assertion → FinishWebAuthnOnly → flow completed + session + sign-count update; a tampered signature is rejected. #212 and #213 are DONE (engine + web delivered, deployed, e2e-proven). Remaining passkey work is app-native (app#048) + usernameless conditional-UI, both separate tickets.

Passkey ENROLLMENT — registration ceremony (2026-06-05, #214 Part A)

The missing half: passkey login (#212/#213) was live but enrollment had no endpoint (Begin/FinishRegistration existed in pkg/crypto with zero callers; /v1/me/passkeys was list+delete only) → users couldn't register a passkey, so the feature was unusable. #214 Part A adds the ceremony to the identity service: POST /v1/me/passkeys/register/{begin,finish} (bearer via currentUser), storing the credential as a verified webauthn MFA device the #212 login path reads (CreateWebAuthnCredential → existing CreateWebAuthn repo; no new table → no kdb-next strict-drift risk). The in-flight registration SessionData rides an AEAD-sealed stateless reg_token (crypto.SecretBox) the browser echoes begin→finish — multi-instance-safe with no session store (a fresh begin/finish pair, unlike login's gRPC-flowID-keyed MFA path). Deployed to prod identity (staged canary; "passkey registration enabled"; register/begin → 401 auth-gated on the public edge). Part B (web enrollment UI) + a real prod WEBAUTHN_REG_SECRET remain before a user can self-enroll.

Passkey enrollment UI — account SPA (2026-06-05, #214 Part B, DONE)

The account SPA (account-ui/, Preact+Vite → web/account/, served at id.koder.dev/account/) Passkeys page now runs the real registration ceremony (navigator.credentials.create against #214 Part A's begin/finish) — the "Add passkey" button was a dead placeholder redirect. Passkey is now end-to-end usable: enroll on the web account page → sign in with it on web (#213) or the app (via its OAuth-in-browser delegation). Build/deploy: node installed on dev-linux-id, vite buildweb/account (committed); prod gateway serves it from ACCOUNT_UI_DIR=/usr/share/koder-id-v2/web/account (static push, no restart); prod WEBAUTHN_REG_SECRET set via an identity systemd drop-in. Remaining passkey work is separate + lower-priority: app#048 (native in-app) + usernameless conditional-UI (R4.4).

IdP browser SSO foundation (RFC-018, #216 — flag-gated, 2026-06-06)

Until now Koder ID had no IdP-side browser session: Authorize() rendered the login form unconditionally, so every /oauth/v2/authorize re-authenticated from scratch. RFC-018 (owner-ratified) adds a browser↔IdP SSO session — the foundation under silent SSO and the multi-account chooser (#215) — with a passkey-first posture. Built in three slices, all verified on dev-linux-id, all gated by KODER_ID_SSO_ENABLED (default OFF) so the prod posture is unchanged until the owner flips it (RFC §5).

  • Storage (sso_sessions, RFC §3.1). Server-side + revocable (logout-

    everywhere, remove-account, AAL changes all need server authority). Lives in the session service (koder_id_session), distinct from the RP refresh-token sessions (RFC-005) and from the auth-service GeoIP sso_sessions table. Two backends behind one SSOSessionStore interface: PG (SSOSessionRepository) and kdb-next (SSOSessionRepoKdbNext, table session_sso_sessions, PK browser_id\x00id for prefix-scanning a browser's account set). Multiple rows share one browser_id (the multi-account set); each row has idle_deadline (silent-SSO window) + expires_at (absolute cap).

  • Session-service SSO gRPC surface (slice 3a). SessionService exposes

    CreateGetListByBrowserRevokeTouch over the SessionService gRPC (5 RPCs); config TTLs idle 30 m / absolute 12 h / cap 10 (RFC §3.4); create mints browser_id, computes deadlines, and evicts the oldest live row at the cap.

  • OAuth Authorize() integration (slice 3b). A segregated SSOBackend

    interface (so existing SessionBackend fakes are untouched) lets the oauth handler reach the SSO RPCs. __Host-koder_sso cookie (HttpOnlySecure SameSiteLax/Path/, no Domain — origin-pinned). decideAuthorize is a pure, exhaustively-tested implementation of the RFC §3.3 prompt table (login|consent|select_account|none|absent) + the §3.4 step-up gate: silent- continue only for a single session within its idle window AND whose client was previously consented (GetConsent); prompt=none ambiguous → login_required; multiple accounts → chooser (#215). On login success (LoginSubmit AAL 1 / MFASubmit AAL 2 / webauthn-only passkey AAL 2), establishSSOSession upserts the row (same browser_id for a 2nd account in the browser) and sets the cookie. All best-effort — a failure never blocks login.

  • Step-up re-tap (#217) — DONE 2026-06-06. Idle-lapsed / new-client cases for

    a single known account now render a "confirm it's you" step-up screen — login.html in StepUp mode with the account email pre-filled + locked, heading "Confirm it's you / Tap your passkey to continue as email" — reusing the #213 webauthn-only ceremony for a single-tap re-auth (password as fallback), instead of a cold login form. decideAuthorize returns ssoActionStepUp; wired into both Authorize() and the chooser's select-account. (/v1/auth/step-up/check exists but isn't consumed — a recency-skip optimization left for later.)

  • Multi-account chooser (#215) — DONE 2026-06-06. The Google-style picker on

    top of the foundation: Authorize() renders account-select.html for >1 live account or prompt=select_account (renderAccountChooser resolves each session's display profile via IdentityClient.GetUser); POST /oauth/v2/authorize/select-account silent-continues the chosen account (or re-auths if its session lapsed) and …/remove-account revokes one account + repoints/clears the cookie. Template gained a per-row × remove affordance. Flag-gated like the rest; oauth + authui verified on dev-linux-id.

  • kdb-next SSO-store e2e — PROVEN 2026-06-06. The slice-2-deferred live

    round-trip is closed non-destructively ahead of any flip: a committed kdbnext_e2e-tagged test ran SSOSessionRepoKdbNext against the live prod kdb-next cluster (177.136.231.74:18090, the store prod session uses) from dev-linux-id, isolated test tenant koder-id-ssotest — EnsureTable + all ops green. The kdb-next store is no longer an unknown for the enable window.

  • Deferred (owner-gated): prod enablement = KODER_ID_SSO_ENABLED=true +

    gateway+oauth swap (RFC §5) — purely the owner's go/no-go now (security-posture decision), not a technical unknown. CSRF tokens for select/remove (RFC §3.7) are a hardening follow-up (today protected by the SameSite=Lax SSO cookie).