Koder ID
- Area: Foundation
- Path:
services/foundation/id/engine(canonical; legacyplatform/symlinked per RFC-003) - Kind: Identity provider (OIDC / OAuth2 / SAML) — custom Go microservice
- Version: 0.9.3
- Status: In production at
id.koder.devsince 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-configuration—claims_supportedincludestenant(added 2026-05-18, engine#069) for RP-side tenant validation - Authorization:
GET /oauth/v2/authorize - Token:
POST /oauth/v2/token— id_token carriestenantclaim matchingAccessTokenClaims.TenantID - UserInfo:
GET /api/v1/me— returnssub,email,full_name,preferred_username - Admin gRPC:
RegisterClientRequestaccepts optionaldesired_client_id(field 7) for first-party clients with stable reverse-domain IDs - Migration endpoint:
POST /v1/admin/migrate/oauth-client(requiresKODER_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_authorizationmints a32-byte URL-safe
device_code+ 8-charXXXX-XXXXuser_codefrom the BCP-47-safe alphabet (ABCDEFGHJKLMNPQRSTUVWXYZ23456789— no 0O1IL). Default lifetime 600s; recommended poll interval 5s. - Storage lives in
oauth_device_authorizations(kdb1 table)/
DeviceAuthorizationRowproto (kdb-next). The kdb-next repo uses Scan + filter foruser_codelookups (bounded table; index added later if perf requires it). - Token endpoint
POST /oauth/v2/tokenaccepts bothgrant_type=urn:ietf:params:oauth:grant-type:device_codeand the short aliasdevice_code. Implements the §3.5 polling protocol inservice.ExchangeDeviceCode:authorization_pending/slow_down(back-to-back polls underinterval/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_supportedand exposesdevice_authorization_endpointper RFC 8628 §4. - Verification page
GET/POST /oauth/v2/deviceis a vanillaHTML form (no template-engine dependency) that pre-fills the usercode from `?usercode=
(matchesverificationuricomplete) and acceptsemail + password + decisionin 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 talksto
https://id.koder.dev/oauth/v2/device_authorizationdirectly (form-encoded, no Authorization header), polls/oauth/v2/tokenwith properslow_down/expired_token/access_denied/invalid_granthandling, and parses the returnedid_tokenJWT to populatecfg.UserID(sub) +cfg.Username(name → email fallback). Override viaKODER_ID_URLenv for self-hosted Koder ID instances. - Seed client
khub-cliis registered as a public clientwith
device_code + refresh_tokengrants viaengine/tools/seed-clients/main.go(the catalog now supports per-entrygrantTypesoverrides). - 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.
tokenClientCredentialsrejectspublic 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_credentialsin
client.GrantTypes; otherwise the endpoint returnsunauthorized_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 yieldsinvalid_scope. An omittedscopeparameter 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'onkoder_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 inprecedence order:
KHUB_CLIENT_CREDENTIALS(file withclient_id:client_secret),KHUB_CLIENT_ID + KHUB_CLIENT_SECRET(paired vars), and falls back to the cached human token fromkhub loginwhen none are set.KODER_ID_URLoverrides 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+StoreFromContextpropagate therequest-scoped tx-store via context.
WithTenantTxstashes the tx-store automatically so unmodified callsites pick it up.apikey_repo+audit_reporead every DB op viakdb.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.gowraps everyadmin HTTP request in
WithTenantTxwhen the env gate is on, using the request'sX-Tenant-ID. Requests without a tenant header (system-admin ops like list-all-tenants) skip the wrap.- Wired in
services/admin/cmd/main.gobetween mux and ListenAndServe.
Phase 2 slice 2 (#105, this batch) extends the same pattern to the auth service:
services/auth/internal/repository/migrations_rls.goregistersRLS for
auth_events+auth_flows+auth_lockouts(the 3 hot- path tables) at versions 12001201, 12101211, 1220/1221.event_repo,flow_repo,lockout_reporoute every DB op viakdb.StoreFromContext(ctx, r.client.Raw()).services/auth/internal/handler/rls_middleware.gomirrors theadmin helper;
services/auth/cmd/main.gowires 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_reporouted via
kdb.StoreFromContext; RLS forclients,authorization_codes,consents,signing_keysat versions 13001301, 13101311, 13201321, 13301331. - session:
session_reporouted; RLS forsessions,access_tokensat 14001401, 14101411.
- Both services ship a mirror
rls_middleware.goand wire it inmain.gobehindKODER_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.goextended 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.goregisters RLS foraudit_events,mfa_policies,org_settingswith theorg_id = current_setting('app.current_org_id')predicate (versions 1700-1721);rls_middleware.gowraps inWithOrgTxusingX-Org-IDheader; main.go wires both gated by env. - qr (user-scoped exemplar): 4 callsites routed; RLS for
qr_sessionswithuser_idpredicate (1800-1801);WithUserTxmiddleware extractsX-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.goregisters RLS forwebhooks,webhook_deliveries, andapi_keyswith theuser_id = current_setting('app.current_user_id')predicate (versions 1900-1921).rls_middleware.gowraps inWithUserTxusingX-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.goregisters RLS foraccess_policiesandaccess_evaluations(versions 2300-2311 per RFC-016 §5's per-sub-service 100-block). Policy predicate admits bothorg_id = current_setting('app.current_org_id', true)rows ANDorg_id = ''global rows so system-wide access policies remain visible to every org.rls_middleware.gowraps inWithOrgTxusingX-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_limitsintentionally excluded — composite key-only table withoutuser_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.htmltemplate shows linked identities with link/unlink controls. - Storage:
user_identitiestable (provider, provideruserid, userid snapshot). `socialprovidersper tenant.socialloginstates` for CSRF (TTL 10 min).
Proto
services/foundation/id/platform/proto/koder/id/admin/v1/admin.protoRegisterClientRequest.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_idcolumn onaudit_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-meshGET /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 aninternal/auditHTTP client wired byKODER_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_audit≠koder_id_identity), so identity cannot readaudit_eventsdirectly — 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).
LoginPageoverridesTenantLogo(validated http(s) URL, else falls back tothe default text logo),
MagicLinkEnabled, andSocialProvidersfrom a 60s per-tenant cache (LoginConfigCache); admin writes invalidate immediately.- Admin:
GET`PUT /v1admin/login-config(tenant from gatewayX-Tenant-ID`,mirroring
/v1/admin/clients; gateway routes it explicitly to oauth). - Scope:
theme_overridesis persisted/round-tripped but not yet rendered (nocolor 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):
- #197 —
project_idis the 4th nested RLS axis:WithProjectTx+app.current_project_idGUC, thep_project/p_project_memberpolicy templates inspecs/multi-tenancy/contract.kmd, gatewayX-Project-IDforwarding + the reusablepkg/rlsmwproject middleware, and a cross-project RealPG proof (T10 isolation, T12 fail-closed, membership dimension). - #198 — membership core:
org_members+workspace_group_grants(
workspace_membersalready existed) andMembershipService.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 …) forworkspaceproject 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).
Provider keys + consent — cross-component consumers (2026-05-31)
- Consent service (#188) is live (
POST /v1/consentissue,/v1/consent/validate+/revokeservice-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 viaPOST /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 dialskdbnextDial()), so providerkeys is consistent, not an outlier (no PG-backend design change). Slice 3 integration tests pass live against a realkdb-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:18090listener), so the keys service can't run in prod yet — tracked as #202 (blocks the whole keys service, including liveResolveProviderKey).
Related
services/foundation/id/platform/— production Go sourceapps/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 IDservice-account). Normative spec:
meta/docs/stack/specs/naming/svid-identity.kmd. - Endpoint:
POST /v1/svid/issue(service-account, scopesvid:issue; subject fromthe 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, passphrasefrom KMS/systemd-creds — kms#004); unsealed at boot, fail-closed if unavailable. One-time
keys svid-bootstrapgenerates + seals the CA. - Allow-list: static subject→identity pin (
KEYS_SVID_ALLOWLIST,@fileok),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 inmeta/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.HandleApprovalsPOSTpublishes to the hub additively, alongside the existingPushDispatcher. The app (id/app lib/authgate/pending_poller.dart) consumes the stream and relaxes its/v1/me/auth-gate/pendingpoll 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-inand 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/authnow fetches credentials from identity (GetWebAuthnCredentials/UpdateWebAuthnSignCountgRPC, already present on the identity side) and converts them towebauthn.Credential— fixing 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_expectedflow status; R4.3). - R5: the in-flight go-webauthn
SessionDatamoved off a process-local maponto 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 newauth_flowscolumn: kdb-nextEnsureTablerejects a divergent schema on an existing table (strict-drift wall), so evolvingauth_flowsin place would crash boot in prod — same dedicated-table pattern asauth_mfa_recency(#191), perkdbnext-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 build → web/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 GeoIPsso_sessionstable. Two backends behind oneSSOSessionStoreinterface: PG (SSOSessionRepository) and kdb-next (SSOSessionRepoKdbNext, tablesession_sso_sessions, PKbrowser_id\x00idfor prefix-scanning a browser's account set). Multiple rows share onebrowser_id(the multi-account set); each row hasidle_deadline(silent-SSO window) +expires_at(absolute cap). - Session-service SSO gRPC surface (slice 3a).
SessionServiceexposesCreateGetListByBrowserRevokeTouch over the
SessionServicegRPC (5 RPCs); config TTLs idle 30 m / absolute 12 h / cap 10 (RFC §3.4); create mintsbrowser_id, computes deadlines, and evicts the oldest live row at the cap. - OAuth
Authorize()integration (slice 3b). A segregatedSSOBackendinterface (so existing
SessionBackendfakes are untouched) lets the oauth handler reach the SSO RPCs.__Host-koder_ssocookie (HttpOnlySecure SameSiteLax/Path/, no Domain — origin-pinned).decideAuthorizeis a pure, exhaustively-tested implementation of the RFC §3.3prompttable (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=noneambiguous →login_required; multiple accounts → chooser (#215). On login success (LoginSubmitAAL 1 /MFASubmitAAL 2 / webauthn-only passkey AAL 2),establishSSOSessionupserts the row (samebrowser_idfor 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
StepUpmode 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.decideAuthorizereturnsssoActionStepUp; wired into bothAuthorize()and the chooser'sselect-account. (/v1/auth/step-up/checkexists 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()rendersaccount-select.htmlfor >1 live account orprompt=select_account(renderAccountChooserresolves each session's display profile viaIdentityClient.GetUser);POST /oauth/v2/authorize/select-accountsilent-continues the chosen account (or re-auths if its session lapsed) and…/remove-accountrevokes 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 ranSSOSessionRepoKdbNextagainst the live prod kdb-next cluster (177.136.231.74:18090, the store prod session uses) from dev-linux-id, isolated test tenantkoder-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).