From ad1b1b9dd08373a459eed2b98c7975a2491fde20 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:07:58 +0200 Subject: [PATCH 1/2] Frontend-editable authentication providers with auto-generated SAML keys --- cmd/api/handlers/auth_methods.go | 88 +-- cmd/api/handlers/auth_provider_registry.go | 106 +++ cmd/api/handlers/auth_providers.go | 473 +++++++++++++ cmd/api/handlers/auth_resolve.go | 37 +- cmd/api/handlers/auth_resolve_test.go | 116 +++ cmd/api/handlers/features.go | 6 +- cmd/api/handlers/handlers.go | 16 +- cmd/api/main.go | 49 ++ frontend/src/api/auth-providers.ts | 109 +++ frontend/src/api/features.ts | 4 +- frontend/src/components/chrome/SideNav.tsx | 15 + .../auth-providers/AuthProvidersPage.tsx | 670 ++++++++++++++++++ frontend/src/router.tsx | 2 + frontend/src/routes/_app/auth-providers.tsx | 9 + osctrl-api.yaml | 141 ++-- pkg/auth/saml/config.go | 45 +- pkg/auth/saml/provider.go | 107 ++- pkg/authproviders/authproviders.go | 658 +++++++++++++++++ pkg/authproviders/authproviders_test.go | 183 +++++ pkg/servicecommands/servicecommands.go | 12 +- pkg/serviceconfig/serviceconfig.go | 6 +- pkg/serviceconfig/serviceconfig_test.go | 12 +- 22 files changed, 2677 insertions(+), 187 deletions(-) create mode 100644 cmd/api/handlers/auth_provider_registry.go create mode 100644 cmd/api/handlers/auth_providers.go create mode 100644 cmd/api/handlers/auth_resolve_test.go create mode 100644 frontend/src/api/auth-providers.ts create mode 100644 frontend/src/features/auth-providers/AuthProvidersPage.tsx create mode 100644 frontend/src/routes/_app/auth-providers.tsx create mode 100644 pkg/authproviders/authproviders.go create mode 100644 pkg/authproviders/authproviders_test.go diff --git a/cmd/api/handlers/auth_methods.go b/cmd/api/handlers/auth_methods.go index 2bfe2fee..8c3894f7 100644 --- a/cmd/api/handlers/auth_methods.go +++ b/cmd/api/handlers/auth_methods.go @@ -1,64 +1,30 @@ package handlers import ( + "fmt" "net/http" "github.com/jmpsec/osctrl/pkg/utils" ) // AuthMethod describes one auth surface advertised to the SPA. -// `type` is the discriminator; clients render the appropriate UI based -// on it. We avoid leaking the issuer URL, client id, or any other -// IdP-specific detail at this layer — those are the IdP's responsibility -// to reveal once the user is redirected. type AuthMethod struct { Type string `json:"type"` - // LoginURL is the relative URL the SPA should redirect the - // browser to when this method is chosen. For "password" this - // is "/api/v1/login/{env}" (env is interpolated client-side - // from the env switcher). For "oidc" this is the global - // "/api/v1/auth/oidc/login" — env is irrelevant for federated - // login because the federated user resolves to a single - // AdminUser row regardless of which env tab they were viewing. + // LoginURL is the relative URL the SPA should redirect to. + // For federated providers, includes the provider row ID. LoginURL string `json:"loginUrl"` + // Name is the human-facing label for the provider (e.g. + // "GitHub", "Google", "Corp Keycloak"). Empty for "password". + Name string `json:"name,omitempty"` + // ID is the auth_providers row ID, used by the SPA to build + // the callback URL. 0 for "password". + ID uint `json:"id,omitempty"` } -// AuthMethodsResponse is the JSON shape returned by -// GET /api/v1/auth/methods. Always returns at least the "password" -// method; OIDC is added only when --oidc-enabled is true AND the -// provider validated at startup. The SPA renders one button per -// method in stable order; we don't promise stable order beyond -// "password always first." type AuthMethodsResponse struct { Methods []AuthMethod `json:"methods"` } -// AuthMethodsHandler — GET /api/v1/auth/methods (no env path). -// -// Unauthenticated by design: the SPA calls this BEFORE the user has -// logged in to decide which login UI to render. The response leaks -// only the *list* of auth shapes; no per-user, per-env, or per-IdP -// detail. The endpoint exists so the SPA never has to ship a -// "is OIDC compiled in?" build-time flag — operators toggle it -// server-side without re-deploying the SPA. -// -// Rate-limited at the route layer (same preAuthRateLimit as the -// env/sample endpoints) to keep this from being a free metadata -// scrape vector. -// @Summary List authentication methods -// @Description Returns the authentication methods enabled for the API login UI. -// @Tags auth -// @Produce json -// @Success 200 {object} AuthMethodsResponse -// @Failure 400 {object} types.ApiErrorResponse "Bad request" -// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" -// @Failure 403 {object} types.ApiErrorResponse "Forbidden" -// @Failure 404 {object} types.ApiErrorResponse "Not found" -// @Failure 409 {object} types.ApiErrorResponse "Conflict" -// @Failure 429 {object} types.ApiErrorResponse "Too many requests" -// @Failure 500 {object} types.ApiErrorResponse "Internal server error" -// @Failure 503 {object} types.ApiErrorResponse "Service unavailable" -// @Router /api/v1/auth/methods [get] func (h *HandlersApi) AuthMethodsHandler(w http.ResponseWriter, r *http.Request) { if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { utils.DebugHTTPDump(h.DebugHTTP, r, false) @@ -66,17 +32,31 @@ func (h *HandlersApi) AuthMethodsHandler(w http.ResponseWriter, r *http.Request) methods := []AuthMethod{ {Type: "password", LoginURL: "/api/v1/login"}, } - if h.OIDCEnabled { - methods = append(methods, AuthMethod{ - Type: "oidc", - LoginURL: "/api/v1/auth/oidc/login", - }) - } - if h.SAMLEnabled { - methods = append(methods, AuthMethod{ - Type: "saml", - LoginURL: "/api/v1/auth/saml/login", - }) + // If the new AuthProviderRegistry is wired, read from it. + // This supports multiple OIDC/SAML providers with a selector. + if h.AuthProviders != nil { + for _, p := range h.AuthProviders.AllProviders() { + methods = append(methods, AuthMethod{ + Type: p.Type, + LoginURL: fmt.Sprintf("/api/v1/auth/%s/%d/login", p.Type, p.ID), + Name: p.Name, + ID: p.ID, + }) + } + } else { + // Fallback to the legacy boolean flags for backwards compat. + if h.OIDCEnabled { + methods = append(methods, AuthMethod{ + Type: "oidc", + LoginURL: "/api/v1/auth/oidc/login", + }) + } + if h.SAMLEnabled { + methods = append(methods, AuthMethod{ + Type: "saml", + LoginURL: "/api/v1/auth/saml/login", + }) + } } utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, AuthMethodsResponse{Methods: methods}) } diff --git a/cmd/api/handlers/auth_provider_registry.go b/cmd/api/handlers/auth_provider_registry.go new file mode 100644 index 00000000..74b4c80c --- /dev/null +++ b/cmd/api/handlers/auth_provider_registry.go @@ -0,0 +1,106 @@ +package handlers + +import ( + "sync" + + "github.com/jmpsec/osctrl/pkg/auth" + "github.com/jmpsec/osctrl/pkg/authproviders" +) + +// AuthProviderRegistry holds the live, concurrently-safe set of built +// auth providers. It replaces the package-global oidcProvider / +// samlProvider variables with a multi-provider model: the login page +// renders one button per enabled provider. +// +// The registry is replaced atomically during hot-reload via Replace(). +// Request handlers read from the registry under a read lock — the +// swap is invisible to in-flight requests that already resolved their +// provider pointer. +type AuthProviderRegistry struct { + mu sync.RWMutex + entries []authproviders.ProviderEntry + byID map[uint]*authproviders.ProviderEntry +} + +// NewAuthProviderRegistry constructs a registry from the given entries. +func NewAuthProviderRegistry(entries []authproviders.ProviderEntry) *AuthProviderRegistry { + r := &AuthProviderRegistry{ + entries: entries, + byID: make(map[uint]*authproviders.ProviderEntry, len(entries)), + } + for i := range entries { + r.byID[entries[i].ID] = &entries[i] + } + return r +} + +// Replace atomically swaps the registry contents. The old providers +// are not closed (neither OIDC nor SAML providers hold resources that +// need explicit cleanup at this time — the interface can grow a +// Close() method later). +func (r *AuthProviderRegistry) Replace(entries []authproviders.ProviderEntry) { + if r == nil { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.entries = entries + r.byID = make(map[uint]*authproviders.ProviderEntry, len(entries)) + for i := range entries { + r.byID[entries[i].ID] = &entries[i] + } +} + +// Get returns the provider entry for the given row ID, or nil. +func (r *AuthProviderRegistry) Get(id uint) *authproviders.ProviderEntry { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + return r.byID[id] +} + +// AllByType returns all enabled providers of the given type +// ("oidc" or "saml"), sorted by name. +func (r *AuthProviderRegistry) AllByType(typ string) []authproviders.ProviderEntry { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + var out []authproviders.ProviderEntry + for _, e := range r.entries { + if e.Type == typ { + out = append(out, e) + } + } + return out +} + +// HasOIDC returns true if at least one OIDC provider is enabled. +func (r *AuthProviderRegistry) HasOIDC() bool { + return len(r.AllByType("oidc")) > 0 +} + +// HasSAML returns true if at least one SAML provider is enabled. +func (r *AuthProviderRegistry) HasSAML() bool { + return len(r.AllByType("saml")) > 0 +} + +// AllProviders returns metadata for every enabled provider, for the +// auth methods endpoint. +func (r *AuthProviderRegistry) AllProviders() []authproviders.ProviderEntry { + if r == nil { + return nil + } + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]authproviders.ProviderEntry, len(r.entries)) + copy(out, r.entries) + return out +} + +// Compile-time check that auth.Provider is still the interface we +// expect — catches accidental drift if the auth package changes. +var _ auth.Provider = (auth.Provider)(nil) diff --git a/cmd/api/handlers/auth_providers.go b/cmd/api/handlers/auth_providers.go new file mode 100644 index 00000000..6ff10a79 --- /dev/null +++ b/cmd/api/handlers/auth_providers.go @@ -0,0 +1,473 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/authproviders" + "github.com/jmpsec/osctrl/pkg/config" + "github.com/jmpsec/osctrl/pkg/servicecommands" + "github.com/jmpsec/osctrl/pkg/types" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/jmpsec/osctrl/pkg/utils" + "github.com/rs/zerolog/log" + "gorm.io/gorm" +) + +const authProviderCommandTTL = 2 * time.Minute + +type authProviderDTO struct { + ID uint `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + Config json.RawMessage `json:"config"` + Source string `json:"source"` + Info string `json:"info"` +} + +func toAuthProviderDTO(p authproviders.AuthProvider, reveal bool) authProviderDTO { + cfg := p.Config + if !reveal { + cfg = authproviders.RedactedConfig(p.Type, p.Config) + } + return authProviderDTO{ + ID: p.ID, + CreatedAt: p.CreatedAt.Format(time.RFC3339), + UpdatedAt: p.UpdatedAt.Format(time.RFC3339), + Name: p.Name, + Type: p.Type, + Enabled: p.Enabled, + Config: json.RawMessage(cfg), + Source: p.Source, + Info: p.Info, + } +} + +func (h *HandlersApi) requireAuthProvidersAdmin(w http.ResponseWriter, r *http.Request) (string, bool) { + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { + apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use auth-providers API by user %s", ctx[ctxUser])) + return "", false + } + return ctx[ctxUser], true +} + +// AuthProvidersListHandler — GET /api/v1/auth-providers +func (h *HandlersApi) AuthProvidersListHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + user, ok := h.requireAuthProvidersAdmin(w, r) + if !ok { + return + } + if h.AuthProviderMgr == nil { + apiErrorResponse(w, "auth providers not initialized", http.StatusInternalServerError, nil) + return + } + rows, err := h.AuthProviderMgr.List() + if err != nil { + apiErrorResponse(w, "error listing auth providers", http.StatusInternalServerError, err) + return + } + reveal := r.URL.Query().Get("reveal") == "1" + out := make([]authProviderDTO, 0, len(rows)) + for _, row := range rows { + out = append(out, toAuthProviderDTO(row, reveal)) + } + h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, out) +} + +// AuthProvidersTypesHandler — GET /api/v1/auth-providers/types +func (h *HandlersApi) AuthProvidersTypesHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + if _, ok := h.requireAuthProvidersAdmin(w, r); !ok { + return + } + specs := make([]types.LogSinkTypeSpec, 0, len(authproviders.Registry)) + for _, typ := range authproviders.SupportedTypes() { + spec := authproviders.Registry[typ] + fields := make([]types.LogSinkFieldSpec, 0, len(spec.Fields)) + for _, f := range spec.Fields { + fields = append(fields, types.LogSinkFieldSpec{ + Name: f.Name, Label: f.Label, Type: string(f.Type), + Required: f.Required, Secret: f.Secret, + Placeholder: f.Placeholder, Help: f.Help, + Options: f.Options, Default: f.Default, + }) + } + specs = append(specs, types.LogSinkTypeSpec{ + Type: spec.Type, Description: spec.Description, + HasSecret: spec.HasSecret, SecretFields: spec.SecretFields, + Fields: fields, + }) + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, specs) +} + +// AuthProvidersGetHandler — GET /api/v1/auth-providers/{id} +func (h *HandlersApi) AuthProvidersGetHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + user, ok := h.requireAuthProvidersAdmin(w, r) + if !ok { + return + } + if h.AuthProviderMgr == nil { + apiErrorResponse(w, "auth providers not initialized", http.StatusInternalServerError, nil) + return + } + id, err := parseAuthProviderID(r) + if err != nil { + apiErrorResponse(w, "invalid provider id", http.StatusBadRequest, err) + return + } + row, err := h.AuthProviderMgr.Get(id) + if err != nil { + if errors.Is(err, authproviders.ErrProviderNotFound) { + apiErrorResponse(w, "auth provider not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting auth provider", http.StatusInternalServerError, err) + return + } + h.AuditLog.Visit(user, r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toAuthProviderDTO(row, r.URL.Query().Get("reveal") == "1")) +} + +// AuthProvidersCreateHandler — POST /api/v1/auth-providers +func (h *HandlersApi) AuthProvidersCreateHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + user, ok := h.requireAuthProvidersAdmin(w, r) + if !ok { + return + } + if h.AuthProviderMgr == nil { + apiErrorResponse(w, "auth providers not initialized", http.StatusInternalServerError, nil) + return + } + var body struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + Config json.RawMessage `json:"config"` + Info string `json:"info,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing request body", http.StatusBadRequest, err) + return + } + row, err := h.AuthProviderMgr.Create(body.Name, body.Type, body.Enabled, string(body.Config), body.Info) + if err != nil { + respondAuthProviderErr(w, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("created auth provider %q (type %s)", row.Name, row.Type), strings.Split(r.RemoteAddr, ":")[0]) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusCreated, toAuthProviderDTO(row, true)) +} + +// AuthProvidersUpdateHandler — PUT /api/v1/auth-providers/{id} +func (h *HandlersApi) AuthProvidersUpdateHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + user, ok := h.requireAuthProvidersAdmin(w, r) + if !ok { + return + } + if h.AuthProviderMgr == nil { + apiErrorResponse(w, "auth providers not initialized", http.StatusInternalServerError, nil) + return + } + id, err := parseAuthProviderID(r) + if err != nil { + apiErrorResponse(w, "invalid provider id", http.StatusBadRequest, err) + return + } + var body struct { + Name string `json:"name"` + Type string `json:"type"` + Enabled bool `json:"enabled"` + Config json.RawMessage `json:"config"` + Info string `json:"info,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing request body", http.StatusBadRequest, err) + return + } + prev, err := h.AuthProviderMgr.Get(id) + if err != nil { + if errors.Is(err, authproviders.ErrProviderNotFound) { + apiErrorResponse(w, "auth provider not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error getting auth provider", http.StatusInternalServerError, err) + return + } + merged, err := authproviders.MergeSecrets(prev.Type, prev.Config, string(body.Config)) + if err != nil { + apiErrorResponse(w, "error merging provider secrets", http.StatusBadRequest, err) + return + } + row, err := h.AuthProviderMgr.Update(id, body.Name, body.Type, body.Enabled, merged, body.Info) + if err != nil { + respondAuthProviderErr(w, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("updated auth provider %q (type %s)", row.Name, row.Type), strings.Split(r.RemoteAddr, ":")[0]) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toAuthProviderDTO(row, true)) +} + +// AuthProvidersDeleteHandler — DELETE /api/v1/auth-providers/{id} +func (h *HandlersApi) AuthProvidersDeleteHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + user, ok := h.requireAuthProvidersAdmin(w, r) + if !ok { + return + } + if h.AuthProviderMgr == nil { + apiErrorResponse(w, "auth providers not initialized", http.StatusInternalServerError, nil) + return + } + id, err := parseAuthProviderID(r) + if err != nil { + apiErrorResponse(w, "invalid provider id", http.StatusBadRequest, err) + return + } + if err := h.AuthProviderMgr.Delete(id); err != nil { + if errors.Is(err, authproviders.ErrProviderNotFound) { + apiErrorResponse(w, "auth provider not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error deleting auth provider", http.StatusInternalServerError, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("deleted auth provider %d", id), strings.Split(r.RemoteAddr, ":")[0]) + w.WriteHeader(http.StatusNoContent) +} + +// AuthProvidersRevertHandler — POST /api/v1/auth-providers/{id}/revert +func (h *HandlersApi) AuthProvidersRevertHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + user, ok := h.requireAuthProvidersAdmin(w, r) + if !ok { + return + } + if h.AuthProviderMgr == nil { + apiErrorResponse(w, "auth providers not initialized", http.StatusInternalServerError, nil) + return + } + id, err := parseAuthProviderID(r) + if err != nil { + apiErrorResponse(w, "invalid provider id", http.StatusBadRequest, err) + return + } + if err := h.AuthProviderMgr.RevertToService(id); err != nil { + if errors.Is(err, authproviders.ErrProviderNotFound) { + apiErrorResponse(w, "auth provider not found", http.StatusNotFound, err) + return + } + apiErrorResponse(w, "error reverting auth provider", http.StatusInternalServerError, err) + return + } + row, err := h.AuthProviderMgr.Get(id) + if err != nil { + apiErrorResponse(w, "error getting reverted auth provider", http.StatusInternalServerError, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("reverted auth provider %q to service config", row.Name), strings.Split(r.RemoteAddr, ":")[0]) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, toAuthProviderDTO(row, false)) +} + +// AuthProvidersTestHandler — POST /api/v1/auth-providers/test +func (h *HandlersApi) AuthProvidersTestHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + if _, ok := h.requireAuthProvidersAdmin(w, r); !ok { + return + } + var body struct { + Type string `json:"type"` + Config json.RawMessage `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing request body", http.StatusBadRequest, err) + return + } + typ := strings.ToLower(strings.TrimSpace(body.Type)) + spec, ok := authproviders.Registry[typ] + if !ok { + apiErrorResponse(w, "invalid provider type", http.StatusBadRequest, nil) + return + } + decoded, err := spec.Decode(body.Config) + if err != nil { + apiErrorResponse(w, "invalid config", http.StatusBadRequest, err) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + _, err = spec.Build(decoded, ctx) + if err != nil { + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, map[string]any{ + "ok": false, + "error": err.Error(), + }) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +// AuthProvidersFetchMetadataHandler — POST /api/v1/auth-providers/fetch-metadata +// +// Fetches the IdP metadata XML from the given URL and returns it as +// text. The operator can then review it before saving it into the +// IDPMetadataXML field of a SAML provider. Saves a round-trip to the +// IdP's metadata URL from the browser (which may be on a different +// network than the server) and avoids CORS issues. +// +// @Summary Fetch IdP metadata XML +// @Description Fetches the SAML IdP metadata document from the given URL and returns the XML. +// @Tags auth-providers +// @Accept json +// @Produce json +// @Param request body object true "{ \"url\": \"https://idp/metadata\" }" +// @Success 200 {object} object "{ \"xml\": \"\" }" +// @Failure 400 {object} types.ApiErrorResponse "Bad request" +// @Failure 401 {object} types.ApiErrorResponse "Unauthorized" +// @Failure 403 {object} types.ApiErrorResponse "Forbidden" +// @Failure 502 {object} types.ApiErrorResponse "Fetch failed" +// @Security ApiKeyAuth +// @Router /api/v1/auth-providers/fetch-metadata [post] +func (h *HandlersApi) AuthProvidersFetchMetadataHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + if _, ok := h.requireAuthProvidersAdmin(w, r); !ok { + return + } + var body struct { + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + apiErrorResponse(w, "error parsing request body", http.StatusBadRequest, err) + return + } + metadataURL := strings.TrimSpace(body.URL) + if metadataURL == "" { + apiErrorResponse(w, "url is required", http.StatusBadRequest, nil) + return + } + u, err := url.Parse(metadataURL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + apiErrorResponse(w, "url must be a valid http(s) URL", http.StatusBadRequest, nil) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metadataURL, nil) + if err != nil { + apiErrorResponse(w, "error building request", http.StatusBadRequest, err) + return + } + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + apiErrorResponse(w, "error fetching metadata", http.StatusBadGateway, err) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + apiErrorResponse(w, fmt.Sprintf("IdP returned HTTP %d", resp.StatusCode), http.StatusBadGateway, nil) + return + } + // Cap at 1 MiB — same limit as the SAML provider's own fetch. + xmlBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + apiErrorResponse(w, "error reading metadata", http.StatusBadGateway, err) + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, map[string]any{ + "xml": string(xmlBytes), + }) +} + +// AuthProvidersApplyHandler — POST /api/v1/auth-providers/apply +func (h *HandlersApi) AuthProvidersApplyHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig != nil && h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, false) + } + user, ok := h.requireAuthProvidersAdmin(w, r) + if !ok { + return + } + if h.ServiceCommands == nil { + apiErrorResponse(w, "service commands not available", http.StatusServiceUnavailable, nil) + return + } + cmd, err := h.ServiceCommands.Request(config.ServiceTLS, servicecommands.ActionReloadAuthProviders, user, strings.Split(r.RemoteAddr, ":")[0], authProviderCommandTTL) + if err != nil { + apiErrorResponse(w, "error requesting auth providers reload", http.StatusInternalServerError, err) + return + } + h.AuditLog.SettingsAction(user, fmt.Sprintf("apply auth-providers reload command %s", cmd.CommandID), strings.Split(r.RemoteAddr, ":")[0]) + log.Info().Str("command", cmd.CommandID).Msgf("Auth providers reload requested by %s", user) + resp := serviceCommandResponse(cmd) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusAccepted, types.ServiceConfigApplyResponse{ + Service: config.ServiceAPI, + Message: "Reload requested. osctrl-api will rebuild its auth provider registry after consuming the service command.", + Command: &resp, + }) +} + +func parseAuthProviderID(r *http.Request) (uint, error) { + v := r.PathValue("id") + if v == "" { + return 0, fmt.Errorf("missing id") + } + n, err := strconv.ParseUint(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid id: %w", err) + } + return uint(n), nil +} + +func respondAuthProviderErr(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, authproviders.ErrInvalidProviderType): + apiErrorResponse(w, "invalid auth provider type", http.StatusBadRequest, err) + case errors.Is(err, authproviders.ErrInvalidProviderConfig): + apiErrorResponse(w, "invalid auth provider configuration", http.StatusBadRequest, err) + case errors.Is(err, authproviders.ErrProviderNotFound): + apiErrorResponse(w, "auth provider not found", http.StatusNotFound, err) + case errors.Is(err, gorm.ErrRecordNotFound): + apiErrorResponse(w, "auth provider not found", http.StatusNotFound, err) + default: + apiErrorResponse(w, "error", http.StatusInternalServerError, err) + } +} diff --git a/cmd/api/handlers/auth_resolve.go b/cmd/api/handlers/auth_resolve.go index ec92c77f..e37b3fb5 100644 --- a/cmd/api/handlers/auth_resolve.go +++ b/cmd/api/handlers/auth_resolve.go @@ -6,6 +6,7 @@ import ( "github.com/jmpsec/osctrl/pkg/auth" "github.com/jmpsec/osctrl/pkg/users" + "github.com/rs/zerolog/log" ) // ErrAuthUserRejected is returned by resolveFederatedUser when the @@ -25,10 +26,16 @@ var ErrAuthUserRejected = errors.New("auth: identity cannot be resolved to an Ad // operator must grant access manually. // 3. Else → reject. // +// When JIT creates a new user and no admin users exist yet (first-run +// bootstrap), the new user is created with admin=true so the operator +// can manage the system immediately. When admin users already exist, +// the new user is admin=false — an existing admin must promote them. +// // Threat T16 (privilege escalation via JIT): the JIT path -// constructs AdminUser with admin=false, service=false. There is no -// path in this function that produces a row with admin=true; that -// guarantee is enforced by callsite, not by data validation. +// only sets admin=true when CountAdmins() returns 0. On any system +// that already has an admin, JIT users are non-admin. There is no +// path in this function that produces a row with admin=true on an +// already-administered system. // // Threat T25 (mass-assignment via JIT): the function never // deserializes ResolvedIdentity directly into the struct. Field- @@ -65,17 +72,29 @@ func (h *HandlersApi) resolveFederatedUser(identity auth.ResolvedIdentity, jitPr if !jitProvision { return users.AdminUser{}, fmt.Errorf("%w: user not provisioned and jitProvision disabled", ErrAuthUserRejected) } - // JIT: build a NON-admin, NON-service AdminUser. The empty - // password means CheckLoginCredentials can never authenticate - // this user via /login — they MUST come back through the SSO - // flow. Operators may set a password later via the user-mgmt - // API if they want a dual-auth account. + // JIT: build a new AdminUser. When no admin users exist yet + // (first-run bootstrap scenario), the new user is created as + // admin=true so the operator can immediately manage the system + // after their first federated login. When admin users already + // exist, the new user is created as admin=false — the existing + // admin must promote them manually. This prevents a federated + // user from self-escalating to admin on a system that already + // has an operator. + adminCount, err := h.Users.CountAdmins() + if err != nil { + return users.AdminUser{}, fmt.Errorf("%w: counting admins: %w", ErrAuthUserRejected, err) + } + makeAdmin := adminCount == 0 + if makeAdmin { + log.Info().Str("username", identity.PreferredUsername).Int64("existing_admins", adminCount). + Msg("JIT-provisioning first admin user via federated login") + } u, err := h.Users.New( identity.PreferredUsername, // username "", // password (empty: forces SSO-only) identity.Email, // email (informational) identity.Name, // fullname (display) - false, // admin = false + makeAdmin, // admin = true only when no admins exist false, // service = false ) if err != nil { diff --git a/cmd/api/handlers/auth_resolve_test.go b/cmd/api/handlers/auth_resolve_test.go new file mode 100644 index 00000000..22016a61 --- /dev/null +++ b/cmd/api/handlers/auth_resolve_test.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "testing" + + "github.com/jmpsec/osctrl/pkg/auth" + "github.com/jmpsec/osctrl/pkg/users" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupResolveTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&users.AdminUser{})) + return db +} + +func TestJITCreatesAdminWhenNoAdminsExist(t *testing.T) { + db := setupResolveTestDB(t) + userMgr := users.CreateUserManager(db) + h := &HandlersApi{Users: userMgr} + + identity := auth.ResolvedIdentity{ + Subject: "sub-1", + PreferredUsername: "first-user", + Email: "first@example.com", + Name: "First User", + } + + user, err := h.resolveFederatedUser(identity, true, "oidc") + require.NoError(t, err) + require.True(t, user.Admin, "first JIT user should be admin when no admins exist") + require.False(t, user.Service) + require.Equal(t, "oidc", user.AuthSource) +} + +func TestJITCreatesNonAdminWhenAdminsExist(t *testing.T) { + db := setupResolveTestDB(t) + userMgr := users.CreateUserManager(db) + // Pre-create an admin user. + existing, err := userMgr.New("existing-admin", "password", "", "", true, false) + require.NoError(t, err) + require.NoError(t, userMgr.Create(existing)) + + h := &HandlersApi{Users: userMgr} + + identity := auth.ResolvedIdentity{ + Subject: "sub-2", + PreferredUsername: "new-user", + Email: "new@example.com", + Name: "New User", + } + + user, err := h.resolveFederatedUser(identity, true, "oidc") + require.NoError(t, err) + require.False(t, user.Admin, "JIT user should NOT be admin when admins already exist") + require.False(t, user.Service) +} + +func TestJITRejectedWhenDisabled(t *testing.T) { + db := setupResolveTestDB(t) + userMgr := users.CreateUserManager(db) + h := &HandlersApi{Users: userMgr} + + identity := auth.ResolvedIdentity{ + Subject: "sub-3", + PreferredUsername: "rejected-user", + } + + _, err := h.resolveFederatedUser(identity, false, "oidc") + require.Error(t, err) +} + +func TestJITReturnsExistingUserByName(t *testing.T) { + db := setupResolveTestDB(t) + userMgr := users.CreateUserManager(db) + // Pre-create a federated user (non-admin, with auth_source). + existing, err := userMgr.New("fed-user", "", "fed@example.com", "Fed User", false, false) + require.NoError(t, err) + existing.AuthSource = "oidc" + require.NoError(t, userMgr.Create(existing)) + + h := &HandlersApi{Users: userMgr} + + identity := auth.ResolvedIdentity{ + Subject: "sub-4", + PreferredUsername: "fed-user", + } + + user, err := h.resolveFederatedUser(identity, true, "oidc") + require.NoError(t, err) + require.Equal(t, "fed-user", user.Username) + require.False(t, user.Admin, "existing non-admin user should stay non-admin") +} + +func TestJITRejectsLocalAccountClaim(t *testing.T) { + db := setupResolveTestDB(t) + userMgr := users.CreateUserManager(db) + // Pre-create a local (password) user with no AuthSource. + local, err := userMgr.New("local-user", "password", "", "", false, false) + require.NoError(t, err) + require.NoError(t, userMgr.Create(local)) + + h := &HandlersApi{Users: userMgr} + + identity := auth.ResolvedIdentity{ + Subject: "sub-5", + PreferredUsername: "local-user", + } + + _, err = h.resolveFederatedUser(identity, true, "oidc") + require.Error(t, err) +} diff --git a/cmd/api/handlers/features.go b/cmd/api/handlers/features.go index c768d48e..4bd291fe 100644 --- a/cmd/api/handlers/features.go +++ b/cmd/api/handlers/features.go @@ -15,8 +15,9 @@ type FeaturesResponse struct { // LogSinks gates the Log Sinks section in the SPA. Tied to the same // flag as ServiceConfig — the log_sinks routes are registered // alongside the service-config routes. - LogSinks bool `json:"log_sinks"` - Accelerated bool `json:"accelerated"` + LogSinks bool `json:"log_sinks"` + AuthProviders bool `json:"auth_providers"` + Accelerated bool `json:"accelerated"` Console bool `json:"console"` FileExplorer bool `json:"file_explorer"` } @@ -30,6 +31,7 @@ func (h *HandlersApi) FeaturesHandler(w http.ResponseWriter, r *http.Request) { Posture: h.PostureEnabled, ServiceConfig: h.ServiceConfigEnabled, LogSinks: h.ServiceConfigEnabled, + AuthProviders: h.ServiceConfigEnabled, Accelerated: h.OsqueryValues.Accelerated, Console: h.OsqueryValues.Query && h.OsqueryValues.Console, FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.FileExplorer, diff --git a/cmd/api/handlers/handlers.go b/cmd/api/handlers/handlers.go index fe2225f0..2cf9faa5 100644 --- a/cmd/api/handlers/handlers.go +++ b/cmd/api/handlers/handlers.go @@ -2,6 +2,7 @@ package handlers import ( "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/authproviders" "github.com/jmpsec/osctrl/pkg/backend" "github.com/jmpsec/osctrl/pkg/carves" "github.com/jmpsec/osctrl/pkg/config" @@ -52,7 +53,12 @@ type HandlersApi struct { // LogSinks manages the log_sinks table when service-config is // enabled. nil when ServiceConfigEnabled is false — the routes are // not registered in that case. - LogSinks *logsinks.LogSinksManager + LogSinks *logsinks.LogSinksManager + // AuthProviders holds the live multi-provider registry (OIDC + SAML). + // nil when no providers are configured. Replaced atomically during + // hot-reload via AuthProviderRegistry.Replace. + AuthProviders *AuthProviderRegistry + AuthProviderMgr *authproviders.AuthProviderManager ServiceConfigEnabled bool ServiceCommands *servicecommands.Manager Activity activityReader @@ -201,6 +207,14 @@ func WithLogSinks(mgr *logsinks.LogSinksManager) HandlersOption { } } +// WithAuthProviders wires the auth provider registry and manager. +func WithAuthProviders(reg *AuthProviderRegistry, mgr *authproviders.AuthProviderManager) HandlersOption { + return func(h *HandlersApi) { + h.AuthProviders = reg + h.AuthProviderMgr = mgr + } +} + func WithServiceCommands(mgr *servicecommands.Manager) HandlersOption { return func(h *HandlersApi) { h.ServiceCommands = mgr diff --git a/cmd/api/main.go b/cmd/api/main.go index 2e19a202..9aee7f68 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -15,6 +15,7 @@ import ( "github.com/jmpsec/osctrl/cmd/api/handlers" "github.com/jmpsec/osctrl/pkg/activity" "github.com/jmpsec/osctrl/pkg/auditlog" + "github.com/jmpsec/osctrl/pkg/authproviders" "github.com/jmpsec/osctrl/pkg/backend" "github.com/jmpsec/osctrl/pkg/cache" "github.com/jmpsec/osctrl/pkg/carves" @@ -107,6 +108,7 @@ const ( apiServiceConfigPath = "/service-config" // API log sinks path apiLogSinksPath = "/log-sinks" + apiAuthProvidersPath = "/auth-providers" // API features path apiFeaturesPath = "/features" // API file explorer path @@ -428,6 +430,20 @@ func osctrlAPIService() { // the table is migrated; rows can be edited directly in the DB or // YAML and picked up on the next osctrl-tls boot/reload. logSinksMgr := logsinks.NewLogSinksManager(db.Conn) + // Auth providers manager — shares the service-config feature gate. + authProvidersMgr := authproviders.NewAuthProviderManager(db.Conn) + if err := authProvidersMgr.Seed(flagParams); err != nil { + log.Fatal().Err(err).Msg("Error seeding auth providers") + } + // Build live providers from the DB. Fail-fast if an enabled + // provider's IdP is unreachable — same posture as the old + // InitOIDC/InitSAML calls. + var authProviderRegistry *handlers.AuthProviderRegistry + providerEntries, err := authProvidersMgr.BuildProviders(context.Background()) + if err != nil { + log.Fatal().Err(err).Msg("Error building auth providers from DB") + } + authProviderRegistry = handlers.NewAuthProviderRegistry(providerEntries) if err := serviceConfigMgr.Seed(config.ServiceAPI, flagParams, settings.NoEnvironmentID); err != nil { log.Fatal().Msgf("Error seeding service config - %v", err) } @@ -526,6 +542,7 @@ func osctrlAPIService() { handlers.WithSettings(settingsmgr), handlers.WithServiceConfig(serviceConfigMgr), handlers.WithLogSinks(logSinksMgr), + handlers.WithAuthProviders(authProviderRegistry, authProvidersMgr), handlers.WithServiceConfigEnabled(flagParams.Service.ServiceConfigEnabled), handlers.WithServiceCommands(serviceCommandMgr), handlers.WithConfigPersist(persistConfig), @@ -1063,6 +1080,38 @@ func osctrlAPIService() { muxAPI.Handle( "POST "+_apiPath(apiLogSinksPath)+"/apply", restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.LogSinksApplyHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) + + // API: auth providers. Shares the service-config feature gate. + muxAPI.Handle( + "GET "+_apiPath(apiAuthProvidersPath), + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersListHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiAuthProvidersPath)+"/types", + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersTypesHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiAuthProvidersPath)+"/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersGetHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiAuthProvidersPath), + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "PUT "+_apiPath(apiAuthProvidersPath)+"/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath(apiAuthProvidersPath)+"/{id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiAuthProvidersPath)+"/{id}/revert", + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersRevertHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiAuthProvidersPath)+"/test", + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersTestHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiAuthProvidersPath)+"/fetch-metadata", + handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersFetchMetadataHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiAuthProvidersPath)+"/apply", + restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.AuthProvidersApplyHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) } // API: multi-factor enrollment for the calling user muxAPI.Handle( diff --git a/frontend/src/api/auth-providers.ts b/frontend/src/api/auth-providers.ts new file mode 100644 index 00000000..34f394c1 --- /dev/null +++ b/frontend/src/api/auth-providers.ts @@ -0,0 +1,109 @@ +import { apiFetch } from './client'; +import type { ServiceCommand } from './service-config'; + +export interface AuthProvider { + id: number; + created_at: string; + updated_at: string; + name: string; + type: string; + enabled: boolean; + config: unknown; + source: string; + info: string; +} + +export interface AuthProviderTypeSpec { + type: string; + description: string; + has_secret: boolean; + secret_fields?: string[]; + fields?: AuthProviderFieldSpec[]; +} + +export interface AuthProviderFieldSpec { + name: string; + label: string; + type: string; + required: boolean; + secret: boolean; + placeholder?: string; + help?: string; + options?: string[]; + default?: unknown; +} + +export function listAuthProviders(opts?: { reveal?: boolean }): Promise { + const qs = opts?.reveal ? '?reveal=1' : ''; + return apiFetch(`/api/v1/auth-providers${qs}`); +} + +export function listAuthProviderTypes(): Promise { + return apiFetch('/api/v1/auth-providers/types'); +} + +export function getAuthProvider(id: number, reveal?: boolean): Promise { + const qs = reveal ? '?reveal=1' : ''; + return apiFetch(`/api/v1/auth-providers/${id}${qs}`); +} + +export interface AuthProviderCreateRequest { + name: string; + type: string; + enabled: boolean; + config: unknown; + info?: string; +} + +export function createAuthProvider(body: AuthProviderCreateRequest): Promise { + return apiFetch('/api/v1/auth-providers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +export interface AuthProviderUpdateRequest { + name: string; + type: string; + enabled: boolean; + config: unknown; + info?: string; +} + +export function updateAuthProvider(id: number, body: AuthProviderUpdateRequest): Promise { + return apiFetch(`/api/v1/auth-providers/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +export function deleteAuthProvider(id: number): Promise { + return apiFetch(`/api/v1/auth-providers/${id}`, { method: 'DELETE' }); +} + +export function revertAuthProvider(id: number): Promise { + return apiFetch(`/api/v1/auth-providers/${id}/revert`, { method: 'POST' }); +} + +export function testAuthProvider(type: string, config: unknown): Promise<{ ok: boolean; error?: string }> { + return apiFetch<{ ok: boolean; error?: string }>('/api/v1/auth-providers/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, config }), + }); +} + +/** POST /api/v1/auth-providers/fetch-metadata — fetches IdP metadata XML from the given URL. */ +export function fetchIdPMetadata(url: string): Promise<{ xml: string }> { + return apiFetch<{ xml: string }>('/api/v1/auth-providers/fetch-metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }), + }); +} + +export function applyAuthProviders(): Promise<{ message: string; service?: string; command?: ServiceCommand }> { + return apiFetch('/api/v1/auth-providers/apply', { method: 'POST' }); +} diff --git a/frontend/src/api/features.ts b/frontend/src/api/features.ts index 29e20496..046229b6 100644 --- a/frontend/src/api/features.ts +++ b/frontend/src/api/features.ts @@ -3,10 +3,8 @@ import { apiFetch } from './client'; export interface Features { posture: boolean; service_config: boolean; - /** Tied to the same flag as service_config — log sink routes are - * registered alongside the service-config routes. Optional so partial - * test mocks keep compiling across feature additions. */ log_sinks?: boolean; + auth_providers?: boolean; accelerated: boolean; console?: boolean; file_explorer: boolean; diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx index f4cb283b..74aeaac4 100644 --- a/frontend/src/components/chrome/SideNav.tsx +++ b/frontend/src/components/chrome/SideNav.tsx @@ -165,6 +165,8 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps pathname.startsWith('/_app/config') || pathname.startsWith('/config'); const isLogSinksActive = pathname.startsWith('/_app/log-sinks') || pathname.startsWith('/log-sinks'); + const isAuthProvidersActive = + pathname.startsWith('/_app/auth-providers') || pathname.startsWith('/auth-providers'); const isAuditActive = pathname.startsWith('/_app/audit') || pathname === '/audit'; // Dashboard is now env-scoped at /_app/env/{env} const dashboardPath = `/_app/env/${currentEnv}`; @@ -458,6 +460,19 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps > Log Sinks } + {features?.auth_providers && + + + + } + > + Auth Providers + } ) : ( diff --git a/frontend/src/features/auth-providers/AuthProvidersPage.tsx b/frontend/src/features/auth-providers/AuthProvidersPage.tsx new file mode 100644 index 00000000..64337885 --- /dev/null +++ b/frontend/src/features/auth-providers/AuthProvidersPage.tsx @@ -0,0 +1,670 @@ +import { useState, useMemo, useEffect, type ReactNode } from 'react'; +import { usePageTitle } from '$/lib/usePageTitle'; +import { useNavigate } from '@tanstack/react-router'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + listAuthProviders, + listAuthProviderTypes, + createAuthProvider, + updateAuthProvider, + deleteAuthProvider, + revertAuthProvider, + testAuthProvider, + fetchIdPMetadata, + applyAuthProviders, + getAuthProvider, + type AuthProvider, + type AuthProviderTypeSpec, + type AuthProviderFieldSpec, + type AuthProviderCreateRequest, +} from '$/api/auth-providers'; +import { getFeatures } from '$/api/features'; +import { getServiceCommand } from '$/api/service-config'; +import { AuthError, ApiError } from '$/api/client'; +import { formatRelative } from '$/lib/time'; +import { SkeletonRow } from '$/components/data/Skeleton'; +import { EmptyState } from '$/components/data/EmptyState'; +import { ModalShell } from '$/components/feedback/ModalShell'; +import { cn } from '$/lib/cn'; + +type ModalMode = + | { kind: 'closed' } + | { kind: 'create' } + | { kind: 'createType'; providerType: string } + | { kind: 'edit'; provider: AuthProvider } + | { kind: 'apply' }; + +const PROVIDER_ICONS: Record = { + oidc: ( + + + + + ), + saml: ( + + + + + ), +}; + +function providerIcon(type: string): ReactNode { + return PROVIDER_ICONS[type] ?? PROVIDER_ICONS.oidc; +} + +export function AuthProvidersPage() { + usePageTitle('Auth Providers'); + const navigate = useNavigate(); + const qc = useQueryClient(); + const [modal, setModal] = useState({ kind: 'closed' }); + const [applyErr, setApplyErr] = useState(null); + const [applyFlash, setApplyFlash] = useState(false); + const [reloading, setReloading] = useState(false); + + const { data: features } = useQuery({ + queryKey: ['features'], + queryFn: () => getFeatures(), + staleTime: 5 * 60_000, + }); + const configDisabled = features?.auth_providers === false; + + const { data: providers, isLoading, isFetching, isError, error, refetch } = useQuery({ + queryKey: ['auth-providers'], + queryFn: () => listAuthProviders(), + enabled: !!features?.auth_providers, + staleTime: 30_000, + }); + + const { data: types } = useQuery({ + queryKey: ['auth-provider-types'], + queryFn: () => listAuthProviderTypes(), + staleTime: 60_000, + enabled: !!features?.auth_providers, + }); + + function invalidate() { + void qc.invalidateQueries({ queryKey: ['auth-providers'] }); + void refetch(); + } + + const deleteMutation = useMutation({ + mutationFn: (id: number) => deleteAuthProvider(id), + onSuccess: () => invalidate(), + onError: (e) => { + if (e instanceof AuthError) { void navigate({ to: '/login' }); return; } + setApplyErr(e instanceof Error ? e.message : 'Delete failed'); + }, + }); + + const revertMutation = useMutation({ + mutationFn: (id: number) => revertAuthProvider(id), + onSuccess: () => invalidate(), + onError: (e) => { + if (e instanceof AuthError) { void navigate({ to: '/login' }); return; } + setApplyErr(e instanceof Error ? e.message : 'Revert failed'); + }, + }); + + const applyMutation = useMutation({ + mutationFn: () => applyAuthProviders(), + onSuccess: (resp) => { + setApplyErr(null); + if (!resp.command) { + setApplyFlash(true); + setTimeout(() => setApplyFlash(false), 3000); + return; + } + setReloading(true); + const poll = setInterval(() => { + getServiceCommand(resp.command!.command_id) + .then((cmd) => { + if (cmd.status !== 'pending') { + clearInterval(poll); + setReloading(false); + setApplyFlash(true); + setTimeout(() => setApplyFlash(false), 3000); + } + }) + .catch(() => { clearInterval(poll); setReloading(false); }); + }, 1500); + }, + onError: (e: unknown) => { + setApplyErr(e instanceof Error ? e.message : String(e)); + }, + }); + + if (isError && error instanceof AuthError) { + void navigate({ to: '/login' }); + return null; + } + + if (configDisabled) { + return ( +
+
+

Auth Providers

+

Manage OIDC and SAML federated login providers.

+
+
+ } + title="Auth Providers API is disabled" + description="Set serviceConfigEnabled: true and restart osctrl-api to manage providers here." /> +
+
+ ); + } + + const rows = providers ?? []; + + return ( +
+
+

Auth Providers

+

OIDC and SAML federated login. One button per enabled provider on the login page.

+
+ {isFetching && !isLoading && ( + refreshing… + )} + + +
+
+ + {applyErr && ( +
+ {applyErr} + +
+ )} + +
+ + + + + + + + + + + + {isLoading && Array.from({ length: 4 }).map((_, i) => )} + {isError && !isLoading && ( + + )} + {!isLoading && !isError && rows.length === 0 && ( + + )} + {!isLoading && !isError && rows.map((p) => ( + + + + + + + + + ))} + +
NameTypeEnabledSourceUpdated +
+ } + title={error instanceof Error ? error.message : 'Failed to load auth providers'} + action={} /> +
+ } + title="No auth providers configured." description="Add an OIDC or SAML provider to enable federated login." + action={} /> +
+ {p.name} + {p.info && — {p.info}} + + + {providerIcon(p.type)} + {p.type} + + + {p.enabled ? ( + on + ) : ( + off + )} + + {p.source === 'db' ? ( + edited + ) : ( + seed + )} + + {formatRelative(p.updated_at)} + + + {p.source === 'db' && ( + + )} + +
+
+ + {modal.kind === 'create' && ( + setModal({ kind: 'createType', providerType: t })} onClose={() => setModal({ kind: 'closed' })} /> + )} + {modal.kind === 'createType' && ( + setModal({ kind: 'closed' })} onSaved={invalidate} /> + )} + {modal.kind === 'edit' && ( + setModal({ kind: 'closed' })} onSaved={invalidate} /> + )} + {modal.kind === 'apply' && ( + { setModal({ kind: 'closed' }); applyMutation.mutate(); }} + onCancel={() => setModal({ kind: 'closed' })} /> + )} +
+ ); +} + +function ProviderTypePicker({ types, onPick, onClose }: { types: AuthProviderTypeSpec[]; onPick: (t: string) => void; onClose: () => void; }) { + return ( + +
+

Choose a provider type. The next step configures its fields.

+
+ {types.map((t) => ( + + ))} +
+
+ +
+
+
+ ); +} + +function ProviderEditor({ mode, types, providerType, existing, onClose, onSaved }: { + mode: 'create' | 'edit'; types: AuthProviderTypeSpec[]; providerType: string; existing?: AuthProvider; onClose: () => void; onSaved: () => void; +}) { + const qc = useQueryClient(); + const [name, setName] = useState(existing?.name ?? ''); + const [enabled, setEnabled] = useState(existing?.enabled ?? true); + const [info, setInfo] = useState(existing?.info ?? ''); + const [err, setErr] = useState(null); + const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null); + const [testing, setTesting] = useState(false); + + const spec = useMemo(() => types.find((t) => t.type === providerType), [types, providerType]); + + // Field values are a flat map keyed by the field's Name. buildConfig + // builds the JSON object from them on submit. Initial values come + // from the existing config (decoded into a flat map) or from the + // spec defaults on create. + const [fieldValues, setFieldValues] = useState>(() => + existing ? flattenConfig(existing.config) : defaultsForSpec(spec), + ); + + // When editing a secret-bearing provider, fetch the revealed config + // so the operator can see the current secret value. + const revealSecret = useQuery({ + queryKey: ['auth-provider-reveal', existing?.id], + queryFn: () => getAuthProvider(existing!.id, true), + enabled: mode === 'edit' && !!existing && !!spec?.has_secret, + staleTime: 0, + }); + useEffect(() => { + if (!revealSecret.data || !existing || revealSecret.data.id !== existing.id) return; + const revealedFlat = flattenConfig(revealSecret.data.config); + setFieldValues((prev) => { + const next = { ...prev }; + for (const f of spec?.fields ?? []) { + if (f.secret && revealedFlat[f.name] !== undefined) { + next[f.name] = revealedFlat[f.name]; + } + } + return next; + }); + }, [revealSecret.data, existing, spec]); + + const mutation = useMutation({ + mutationFn: async () => { + const trimmedName = name.trim(); + if (!trimmedName) throw new Error('Name is required.'); + const config = buildConfig(spec, fieldValues); + if (mode === 'create') { + const body: AuthProviderCreateRequest = { name: trimmedName, type: providerType, enabled, config, info: info.trim() || undefined }; + return createAuthProvider(body); + } + return updateAuthProvider(existing!.id, { name: trimmedName, type: providerType, enabled, config, info: info.trim() || undefined }); + }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['auth-providers'] }); onSaved(); onClose(); }, + onError: (e) => { if (e instanceof AuthError) { window.location.href = '/login'; return; } setErr(e instanceof Error ? e.message : 'Save failed'); }, + }); + + async function handleTest() { + setErr(null); + setTesting(true); + setTestResult(null); + try { + const config = buildConfig(spec, fieldValues); + const result = await testAuthProvider(providerType, config); + setTestResult(result); + } catch (e) { + setTestResult({ ok: false, error: e instanceof Error ? e.message : String(e) }); + } finally { + setTesting(false); + } + } + + const inputClass = cn('w-full px-3 py-2 text-sm rounded-md border border-[color:var(--border)]', 'bg-[color:var(--bg-2)] text-[color:var(--text-1)] font-mono-tabular', 'focus:outline focus:outline-2 focus:outline-[color:var(--signal)]'); + + return ( + +
{ e.preventDefault(); mutation.mutate(); }} className="space-y-4"> +
+ + setName(e.target.value)} placeholder="e.g. github-oidc" className={inputClass} /> +

Unique label shown on the login page button.

+
+
+ Type + + {providerIcon(providerType)} + {providerType} + + {spec?.has_secret &&

This provider stores credentials ({spec.secret_fields?.join(', ')}). Secret fields are revealed for editing.

} +
+
+ +
+ + + +
+ + {testResult && ( + + {testResult.ok ? '✓ Connection successful' : `✕ ${testResult.error ?? 'Failed'}`} + + )} +
+ +
+ + setInfo(e.target.value)} className={inputClass} /> +
+ {err &&

{err}

} +
+ + +
+ +
+ ); +} + +function ApplyConfirmDialog({ isPending, onConfirm, onCancel }: { isPending: boolean; onConfirm: () => void; onCancel: () => void; }) { + return ( + +
+

+ This will hot-reload osctrl-api with the current provider rows. The service is not restarted, but{' '} + users mid-login may see a transient error and can retry. +

+
+ + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Dynamic typed config form — same pattern as LogSinksPage +// --------------------------------------------------------------------------- + +function ProviderConfigFields({ spec, values, onChange, inputClass }: { + spec?: AuthProviderTypeSpec; + values: Record; + onChange: (next: Record) => void; + inputClass: string; +}) { + const [fetching, setFetching] = useState(false); + const [fetchErr, setFetchErr] = useState(null); + + if (!spec || !spec.fields || spec.fields.length === 0) { + return

This provider type has no configurable fields.

; + } + + function setField(name: string, v: unknown) { onChange({ ...values, [name]: v }); } + + // Fetch IdP metadata from the URL field and populate the XML field. + // Only shown for SAML providers that have both IDPMetadataURL and + // IDPMetadataXML fields. + const isSAML = spec.type === 'saml'; + const metadataURL = String(values['IDPMetadataURL'] ?? ''); + + async function handleFetchMetadata() { + if (!metadataURL) return; + setFetchErr(null); + setFetching(true); + try { + const result = await fetchIdPMetadata(metadataURL); + setField('IDPMetadataXML', result.xml); + } catch (e) { + setFetchErr(e instanceof Error ? e.message : 'Failed to fetch metadata'); + } finally { + setFetching(false); + } + } + return ( +
+ Configuration + {spec.fields.map((f) => ( +
+ setField(f.name, v)} inputClass={inputClass} /> + {isSAML && f.name === 'IDPMetadataURL' && ( +
+ + + Fetches the XML from the URL above and fills the IdP metadata XML field below. + + {fetchErr && ( + {fetchErr} + )} +
+ )} +
+ ))} +
+ ); +} + +function ProviderConfigField({ field, value, onChange, inputClass }: { + field: AuthProviderFieldSpec; + value: unknown; + onChange: (v: unknown) => void; + inputClass: string; +}) { + const id = `provider-cfg-${field.name}`; + const labelEl = ( + + ); + const helpEl = field.help &&

{field.help}

; + + switch (field.type) { + case 'boolean': + return ( +
+ + {helpEl} +
+ ); + case 'integer': + return ( +
+ {labelEl} + { const raw = e.target.value; onChange(raw === '' ? undefined : Number(raw)); }} + placeholder={field.placeholder} className={inputClass} /> + {helpEl} +
+ ); + case 'select': + return ( +
+ {labelEl} + + {helpEl} +
+ ); + case 'password': + return ( +
+ {labelEl} + onChange(e.target.value)} + placeholder={field.placeholder} autoComplete="off" className={cn(inputClass, 'text-[color:var(--text-2)]')} /> + {helpEl} +
+ ); + case 'text': + return ( +
+ {labelEl} +