// internal/server/config/oidc.go — OIDC subsection of the server // config. Disabled when oidc.issuer is empty or absent. package config import ( "errors" "fmt" "os" ) // OIDCConfig is the OIDC sub-block. The struct doubles as YAML schema; // loadOIDC applies env overlays on top and fills defaults. type OIDCConfig struct { Issuer string `yaml:"issuer"` ClientID string `yaml:"client_id"` ClientSecret string `yaml:"client_secret"` DisplayName string `yaml:"display_name"` Scopes []string `yaml:"scopes"` RoleClaim string `yaml:"role_claim"` RoleMapping map[string]string `yaml:"role_mapping"` RedirectURL string `yaml:"redirect_url"` } // loadOIDC merges YAML + env, applies defaults, validates. Returns // nil + nil when OIDC is disabled (issuer empty after merge); a // non-nil OIDCConfig means the caller should wire OIDC. // // Env vars (override YAML when set): // // RM_OIDC_ISSUER, RM_OIDC_CLIENT_ID, RM_OIDC_CLIENT_SECRET, // RM_OIDC_CLIENT_SECRET_FILE, RM_OIDC_DISPLAY_NAME, // RM_OIDC_REDIRECT_URL. // // envs is passed in (rather than read with os.LookupEnv) so unit // tests can supply a fake env map. func loadOIDC(envs map[string]string, yaml OIDCConfig) (*OIDCConfig, error) { c := yaml if v, ok := envs["RM_OIDC_ISSUER"]; ok { c.Issuer = v } if v, ok := envs["RM_OIDC_CLIENT_ID"]; ok { c.ClientID = v } if v, ok := envs["RM_OIDC_CLIENT_SECRET"]; ok { c.ClientSecret = v } if v, ok := envs["RM_OIDC_CLIENT_SECRET_FILE"]; ok && v != "" { body, err := os.ReadFile(v) if err != nil { return nil, fmt.Errorf("config: oidc client_secret_file: %w", err) } c.ClientSecret = string(body) } if v, ok := envs["RM_OIDC_DISPLAY_NAME"]; ok { c.DisplayName = v } if v, ok := envs["RM_OIDC_REDIRECT_URL"]; ok { c.RedirectURL = v } if c.Issuer == "" { return nil, nil } if c.ClientID == "" { return nil, errors.New("config: oidc.client_id required when issuer is set") } if c.ClientSecret == "" { return nil, errors.New("config: oidc.client_secret required when issuer is set") } if len(c.RoleMapping) == 0 { return nil, errors.New("config: oidc.role_mapping must have at least one entry") } if c.DisplayName == "" { c.DisplayName = "SSO" } if c.RoleClaim == "" { c.RoleClaim = "groups" } if len(c.Scopes) == 0 { c.Scopes = []string{"openid", "profile", "email", "groups"} } return &c, nil } // envSnapshot reads the OIDC env vars into a map. Lets the production // loadOIDC call site stay env-driven while tests pass an explicit // map. func envSnapshot() map[string]string { keys := []string{ "RM_OIDC_ISSUER", "RM_OIDC_CLIENT_ID", "RM_OIDC_CLIENT_SECRET", "RM_OIDC_CLIENT_SECRET_FILE", "RM_OIDC_DISPLAY_NAME", "RM_OIDC_REDIRECT_URL", } out := make(map[string]string, len(keys)) for _, k := range keys { if v, ok := os.LookupEnv(k); ok { out[k] = v } } return out }