Sub-Task #254
openEpic #3: Implement a secure user authentication system
Feature #160: User Login
User Story #176: As a user, I want to log in via OAuth/OIDC, so that I can use existing providers
Backend: OIDC provider configuration
100%
Description
Support multi-provider OIDC configuration, config-file based, not DB-backed and not CRD-based (Kubestro is a standalone Postgres-backed server, no operator/controller infra exists, so a CRD would mean building watch/reconcile machinery just to read config at boot).
Pattern (used by Grafana, Loki, Gitea): unified structured config file (YAML), client_secret given as an explicit ${ENV_VAR_NAME} placeholder resolved against the process env at load time — matches the repo's existing config style (Config::from_env in apps/server/cli/src/config.rs, and apps/server/api/src/app/context/*.rs, both read std::env::var directly, no config-merging crate anywhere today).
Kubernetes/GitOps split:
- ConfigMap holds the full provider structure (issuer, client_id, scopes, icon) except
client_secret— fully git-committed, plaintext, PR-reviewable. - Secret holds each provider's
client_secret, injected as an env var per provider (works as-is with SOPS, Sealed Secrets, External Secrets Operator, Vault, etc — no format changes needed on the secret side). - One mechanism:
client_secretvalues are${ENV_VAR_NAME}placeholders, resolved against the process env at load time — admin picks the var name explicitly per provider (not derived from the provider slug, which would break for hyphenated keys since env var names can't portably contain hyphens).
Format spec:
# config.yaml (ConfigMap, git-committed, no secrets)
oidc_providers:
google:
name: Google
issuer_url: https://accounts.google.com # discovery doc = {issuer_url}/.well-known/openid-configuration
client_id: xxxxx.apps.googleusercontent.com
client_secret: "${OIDC_GOOGLE_CLIENT_SECRET}"
scopes: [openid, email, profile]
icon: google # slug into github.com/homarr-labs/dashboard-icons
keycloak:
name: Company SSO
issuer_url: https://sso.example.com/realms/kubestro
client_id: kubestro-dashboard
client_secret: "${OIDC_KEYCLOAK_CLIENT_SECRET}"
scopes: [openid, email]
icon_url: https://sso.example.com/icon.svg # override for providers not in the known icon set
Env vars (from Secret, per provider — names are admin-chosen, matching whatever each client_secret placeholder above references):
Deployment mounts the ConfigMap as /etc/kubestro/config.yaml (readOnly volume) and injects each provider's secret as an env var via secretKeyRef, using whichever var name that provider's ${...} placeholder names. A config/secret change plus a rollout (as GitOps tools like Flux/ArgoCD already do) picks up the new config on next boot — no hot-reload, no CRD, no extra RBAC.
- Provider key (
google,keycloak, ...) is the stable slug: used in redirect URL (/auth/oidc/{id}/callback) and in the discovery-endpoint response. Map key replaces the earlierid:list-item field. issuer_url: OIDC issuer identifier, not the discovery endpoint itself — backend fetches{issuer_url}/.well-known/openid-configurationat startup to resolve authorize/token/jwks endpoints (standard OIDC discovery — this is the "OpenID Provider Metadata" document, distinct from OAuth Dynamic Client Registration metadata, which this design deliberately does not use since providers are statically pre-registered by an admin).icon: slug resolved against homarr-labs/dashboard-icons (https://github.com/homarr-labs/dashboard-icons), served via their CDN.icon_url: explicit override for custom/self-hosted providers not in that set.
Non-k8s/simple deployments: same placeholder mechanism works standalone (set whatever env var each ${...} reference names), config file path still required via OIDC_CONFIG_FILE. If OIDC_CONFIG_FILE is unset, OIDC is disabled entirely (empty provider registry, no boot failure).
Startup discovery is fail-fast: if any configured provider's .well-known/openid-configuration fetch fails at boot, the whole server aborts (matches existing DATABASE_URL/APP_SECRET "bad config = don't start" behavior) — no partial-availability state to reason about.
Layering: Domain gets a ProviderKey/IssuerUrl/ClientId/ClientSecret/OidcProviderConfig value-object set plus a port pair — OidcProviderRegistry (lookup discovered client + config by ProviderKey) and a minimal marker-shaped OidcClient trait (just proves a discovered client is reachable through the port; #256 extends it with the actual authorize/token-exchange methods once that ticket designs the flow). Infra implements both via openidconnect-rs (discovery, CoreClient) — no openidconnect-rs/serde_yaml dependency leaks into Domain or Application. Config loading itself has no Application-layer use case (it's boot-time wiring, same category as Core::build(), not a request-time Command/Query).