e0b002975f
The OpenAPI spec and Swagger UI were public. Put them behind the same authentication as the rest of the API, and add a compact route list so a client can ask "what can I call?" without opening dev tools. Access: - /api/openapi.json and /api/routes require a bearer token or API key. - /api/docs additionally accepts a session cookie set at login, so a signed-in operator can open the docs in a new tab; an anonymous browser is redirected to /login?redirect=... and returned afterwards. - The cookie is HttpOnly and path-scoped to /api/docs, so it is never sent to /api/v1/* and cannot authenticate an API call (no CSRF surface). Verified: cookie-only request to /api/v1/rules returns 401. Discovery: both the spec and GET /api/routes accept ?method=get,post and ?path=<substring> (comma-separated, case-insensitive). The route list returns method, path, summary, tag, public, and `allowed` — false when a read-scoped API key cannot invoke that route. /api/docs passes the same query through to the spec it loads. UI: a </> icon in the header (both layouts) and an Administration → API Docs menu entry, opened in a new tab via a new `external` nav-item flag. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
356 lines
12 KiB
Go
356 lines
12 KiB
Go
// Package api - OIDC / SSO authentication and configuration handlers.
|
|
//
|
|
// OIDC configuration is resolved with app_settings (set via the UI) taking
|
|
// precedence over environment variables, then defaults (see
|
|
// services.SettingsService.Resolve*). This lets an administrator configure SSO
|
|
// entirely from the UI while env vars can still seed a working setup.
|
|
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/audit"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/auth"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/logging"
|
|
"github.com/Grace-Solutions/OrchestrAD/internal/services"
|
|
"github.com/coreos/go-oidc/v3/oidc"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// OIDCHandler handles SSO login/callback and admin configuration.
|
|
type OIDCHandler struct {
|
|
authService *auth.Service
|
|
settings *services.SettingsService
|
|
auditService *audit.Service
|
|
logger *logging.Logger
|
|
}
|
|
|
|
// NewOIDCHandler creates an OIDCHandler.
|
|
func NewOIDCHandler(authService *auth.Service, settings *services.SettingsService, auditService *audit.Service, logger *logging.Logger) *OIDCHandler {
|
|
return &OIDCHandler{authService: authService, settings: settings, auditService: auditService, logger: logger}
|
|
}
|
|
|
|
type oidcConfig struct {
|
|
Enabled bool
|
|
Issuer string
|
|
ClientID string
|
|
ClientSecret string
|
|
RedirectURL string
|
|
Scopes string
|
|
UsernameClaim string
|
|
EmailClaim string
|
|
NameClaim string
|
|
DefaultRole string
|
|
}
|
|
|
|
func (h *OIDCHandler) resolve() oidcConfig {
|
|
r := h.settings
|
|
return oidcConfig{
|
|
Enabled: r.ResolveBool("oidc.enabled", "ORCHESTRAD_OIDC_ENABLED", false),
|
|
Issuer: r.ResolveString("oidc.issuer", "ORCHESTRAD_OIDC_ISSUER", ""),
|
|
ClientID: r.ResolveString("oidc.client_id", "ORCHESTRAD_OIDC_CLIENT_ID", ""),
|
|
ClientSecret: r.ResolveString("oidc.client_secret", "ORCHESTRAD_OIDC_CLIENT_SECRET", ""),
|
|
RedirectURL: r.ResolveString("oidc.redirect_url", "ORCHESTRAD_OIDC_REDIRECT_URL", ""),
|
|
Scopes: r.ResolveString("oidc.scopes", "ORCHESTRAD_OIDC_SCOPES", "openid profile email"),
|
|
UsernameClaim: r.ResolveString("oidc.username_claim", "ORCHESTRAD_OIDC_USERNAME_CLAIM", "preferred_username"),
|
|
EmailClaim: r.ResolveString("oidc.email_claim", "ORCHESTRAD_OIDC_EMAIL_CLAIM", "email"),
|
|
NameClaim: r.ResolveString("oidc.name_claim", "ORCHESTRAD_OIDC_NAME_CLAIM", "name"),
|
|
DefaultRole: r.ResolveString("oidc.default_role", "ORCHESTRAD_OIDC_DEFAULT_ROLE", ""),
|
|
}
|
|
}
|
|
|
|
// redirectURI returns the callback URL: the configured one if set, else derived
|
|
// from the request so it works without explicit configuration. It must be
|
|
// identical between the login and callback requests.
|
|
func (h *OIDCHandler) redirectURI(r *http.Request, cfg oidcConfig) string {
|
|
if cfg.RedirectURL != "" {
|
|
return cfg.RedirectURL
|
|
}
|
|
// Derive from the request. requestBaseURL honors X-Forwarded-Proto/Host only
|
|
// when the proxy middleware already applied them (trusted peer), so an
|
|
// untrusted client cannot forge the callback host.
|
|
return requestBaseURL(r) + "/api/v1/auth/oidc/callback"
|
|
}
|
|
|
|
func (h *OIDCHandler) build(ctx context.Context, cfg oidcConfig, redirectURL string) (*oidc.Provider, oauth2.Config, error) {
|
|
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
|
|
if err != nil {
|
|
return nil, oauth2.Config{}, err
|
|
}
|
|
oc := oauth2.Config{
|
|
ClientID: cfg.ClientID,
|
|
ClientSecret: cfg.ClientSecret,
|
|
Endpoint: provider.Endpoint(),
|
|
RedirectURL: redirectURL,
|
|
Scopes: strings.Fields(cfg.Scopes),
|
|
}
|
|
return provider, oc, nil
|
|
}
|
|
|
|
// Status handles GET /api/v1/auth/oidc/status (public) so the login page can
|
|
// decide whether to show the SSO button.
|
|
func (h *OIDCHandler) Status(w http.ResponseWriter, r *http.Request) {
|
|
cfg := h.resolve()
|
|
enabled := cfg.Enabled && cfg.Issuer != "" && cfg.ClientID != ""
|
|
WriteJSON(w, http.StatusOK, map[string]any{
|
|
"enabled": enabled,
|
|
"loginUrl": "/api/v1/auth/oidc/login",
|
|
})
|
|
}
|
|
|
|
// Login handles GET /api/v1/auth/oidc/login (public): starts the auth-code +
|
|
// PKCE flow and redirects to the provider.
|
|
func (h *OIDCHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|
cfg := h.resolve()
|
|
if !cfg.Enabled || cfg.Issuer == "" || cfg.ClientID == "" {
|
|
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "SSO is not enabled")
|
|
return
|
|
}
|
|
redirectURL := h.redirectURI(r, cfg)
|
|
_, oc, err := h.build(r.Context(), cfg, redirectURL)
|
|
if err != nil {
|
|
h.logger.Error("OIDC", "provider discovery failed: %v", err)
|
|
WriteError(w, http.StatusBadGateway, ErrCodeInternalError, "SSO provider is unreachable")
|
|
return
|
|
}
|
|
|
|
state := randToken()
|
|
nonce := randToken()
|
|
verifier := oauth2.GenerateVerifier()
|
|
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
|
h.setFlowCookie(w, "oidc_state", state, secure)
|
|
h.setFlowCookie(w, "oidc_nonce", nonce, secure)
|
|
h.setFlowCookie(w, "oidc_verifier", verifier, secure)
|
|
|
|
authURL := oc.AuthCodeURL(state, oidc.Nonce(nonce), oauth2.S256ChallengeOption(verifier))
|
|
http.Redirect(w, r, authURL, http.StatusFound)
|
|
}
|
|
|
|
// Callback handles GET /api/v1/auth/oidc/callback (public).
|
|
func (h *OIDCHandler) Callback(w http.ResponseWriter, r *http.Request) {
|
|
cfg := h.resolve()
|
|
if !cfg.Enabled {
|
|
WriteError(w, http.StatusNotFound, ErrCodeNotFound, "SSO is not enabled")
|
|
return
|
|
}
|
|
if e := r.URL.Query().Get("error"); e != "" {
|
|
h.failRedirect(w, r, e)
|
|
return
|
|
}
|
|
state, _ := r.Cookie("oidc_state")
|
|
nonce, _ := r.Cookie("oidc_nonce")
|
|
verifier, _ := r.Cookie("oidc_verifier")
|
|
if state == nil || nonce == nil || verifier == nil || r.URL.Query().Get("state") != state.Value {
|
|
h.failRedirect(w, r, "invalid_state")
|
|
return
|
|
}
|
|
h.clearFlowCookies(w, r.TLS != nil)
|
|
|
|
redirectURL := h.redirectURI(r, cfg)
|
|
provider, oc, err := h.build(r.Context(), cfg, redirectURL)
|
|
if err != nil {
|
|
h.failRedirect(w, r, "provider_error")
|
|
return
|
|
}
|
|
oauth2Token, err := oc.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifier.Value))
|
|
if err != nil {
|
|
h.logger.Warn("OIDC", "token exchange failed: %v", err)
|
|
h.failRedirect(w, r, "exchange_failed")
|
|
return
|
|
}
|
|
rawID, ok := oauth2Token.Extra("id_token").(string)
|
|
if !ok {
|
|
h.failRedirect(w, r, "no_id_token")
|
|
return
|
|
}
|
|
idToken, err := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}).Verify(r.Context(), rawID)
|
|
if err != nil {
|
|
h.logger.Warn("OIDC", "id_token verification failed: %v", err)
|
|
h.failRedirect(w, r, "invalid_id_token")
|
|
return
|
|
}
|
|
if idToken.Nonce != nonce.Value {
|
|
h.failRedirect(w, r, "invalid_nonce")
|
|
return
|
|
}
|
|
|
|
var claims map[string]any
|
|
_ = idToken.Claims(&claims)
|
|
identity := auth.OIDCIdentity{
|
|
ProviderID: cfg.Issuer,
|
|
Subject: idToken.Subject,
|
|
Username: claimString(claims, cfg.UsernameClaim, idToken.Subject),
|
|
Email: claimString(claims, cfg.EmailClaim, ""),
|
|
DisplayName: claimString(claims, cfg.NameClaim, ""),
|
|
DefaultRole: cfg.DefaultRole,
|
|
}
|
|
|
|
result, err := h.authService.LoginOIDC(identity)
|
|
if err != nil {
|
|
h.logger.Warn("OIDC", "provisioning failed for subject %s: %v", idToken.Subject, err)
|
|
emitAudit(h.auditService, r, audit.EventLogin, "User", "", "OIDCLogin", false, map[string]any{"subject": idToken.Subject}, err.Error())
|
|
h.failRedirect(w, r, "provisioning_failed")
|
|
return
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventLogin, "User", result.User.ID, "OIDCLogin", true, map[string]any{"username": result.User.Username}, "")
|
|
|
|
// Hand the session token to the SPA via the URL fragment (not the query, so
|
|
// it is not logged by intermediaries), then let the app store it.
|
|
frag := url.Values{}
|
|
frag.Set("oidc_token", result.SessionToken)
|
|
frag.Set("expires_at", result.ExpiresAt.Format(time.RFC3339))
|
|
SetDocsSessionCookie(w, r, result.SessionToken, result.ExpiresAt)
|
|
http.Redirect(w, r, "/login#"+frag.Encode(), http.StatusFound)
|
|
}
|
|
|
|
func (h *OIDCHandler) failRedirect(w http.ResponseWriter, r *http.Request, reason string) {
|
|
http.Redirect(w, r, "/login#oidc_error="+url.QueryEscape(reason), http.StatusFound)
|
|
}
|
|
|
|
// --- Admin configuration ---
|
|
|
|
type oidcConfigResponse struct {
|
|
Enabled bool `json:"enabled"`
|
|
Issuer string `json:"issuer"`
|
|
ClientID string `json:"clientId"`
|
|
ClientSecret string `json:"clientSecret"` // redacted
|
|
RedirectURL string `json:"redirectUrl"`
|
|
Scopes string `json:"scopes"`
|
|
UsernameClaim string `json:"usernameClaim"`
|
|
EmailClaim string `json:"emailClaim"`
|
|
NameClaim string `json:"nameClaim"`
|
|
DefaultRole string `json:"defaultRole"`
|
|
}
|
|
|
|
// GetConfig handles GET /api/v1/auth/oidc/config (admin).
|
|
func (h *OIDCHandler) GetConfig(w http.ResponseWriter, r *http.Request) {
|
|
c := h.resolve()
|
|
secret := ""
|
|
if c.ClientSecret != "" {
|
|
secret = "********"
|
|
}
|
|
WriteJSON(w, http.StatusOK, oidcConfigResponse{
|
|
Enabled: c.Enabled, Issuer: c.Issuer, ClientID: c.ClientID, ClientSecret: secret,
|
|
RedirectURL: c.RedirectURL, Scopes: c.Scopes, UsernameClaim: c.UsernameClaim,
|
|
EmailClaim: c.EmailClaim, NameClaim: c.NameClaim, DefaultRole: c.DefaultRole,
|
|
})
|
|
}
|
|
|
|
type oidcConfigRequest struct {
|
|
Enabled *bool `json:"enabled"`
|
|
Issuer *string `json:"issuer"`
|
|
ClientID *string `json:"clientId"`
|
|
ClientSecret *string `json:"clientSecret"`
|
|
RedirectURL *string `json:"redirectUrl"`
|
|
Scopes *string `json:"scopes"`
|
|
UsernameClaim *string `json:"usernameClaim"`
|
|
EmailClaim *string `json:"emailClaim"`
|
|
NameClaim *string `json:"nameClaim"`
|
|
DefaultRole *string `json:"defaultRole"`
|
|
}
|
|
|
|
// PutConfig handles PUT /api/v1/auth/oidc/config (admin). Only provided fields
|
|
// are changed. When enabling, the issuer is validated via OIDC discovery.
|
|
func (h *OIDCHandler) PutConfig(w http.ResponseWriter, r *http.Request) {
|
|
var req oidcConfigRequest
|
|
if err := DecodeJSON(r, &req); err != nil {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeBadRequest, "Invalid request body")
|
|
return
|
|
}
|
|
set := func(key, val string, sensitive bool) error {
|
|
return h.settings.Upsert(services.Setting{Key: key, Value: val, ValueType: "string", IsSensitive: sensitive})
|
|
}
|
|
if req.Issuer != nil {
|
|
_ = set("oidc.issuer", strings.TrimSpace(*req.Issuer), false)
|
|
}
|
|
if req.ClientID != nil {
|
|
_ = set("oidc.client_id", strings.TrimSpace(*req.ClientID), false)
|
|
}
|
|
if req.ClientSecret != nil && *req.ClientSecret != "" && *req.ClientSecret != "********" {
|
|
_ = set("oidc.client_secret", *req.ClientSecret, true)
|
|
}
|
|
if req.RedirectURL != nil {
|
|
_ = set("oidc.redirect_url", strings.TrimSpace(*req.RedirectURL), false)
|
|
}
|
|
if req.Scopes != nil {
|
|
_ = set("oidc.scopes", strings.TrimSpace(*req.Scopes), false)
|
|
}
|
|
if req.UsernameClaim != nil {
|
|
_ = set("oidc.username_claim", strings.TrimSpace(*req.UsernameClaim), false)
|
|
}
|
|
if req.EmailClaim != nil {
|
|
_ = set("oidc.email_claim", strings.TrimSpace(*req.EmailClaim), false)
|
|
}
|
|
if req.NameClaim != nil {
|
|
_ = set("oidc.name_claim", strings.TrimSpace(*req.NameClaim), false)
|
|
}
|
|
if req.DefaultRole != nil {
|
|
_ = set("oidc.default_role", strings.TrimSpace(*req.DefaultRole), false)
|
|
}
|
|
|
|
// Validate discovery before allowing enable.
|
|
cfg := h.resolve()
|
|
if req.Enabled != nil && *req.Enabled {
|
|
if cfg.Issuer == "" || cfg.ClientID == "" {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "issuer and clientId are required to enable SSO")
|
|
return
|
|
}
|
|
if _, err := oidc.NewProvider(r.Context(), cfg.Issuer); err != nil {
|
|
WriteError(w, http.StatusBadRequest, ErrCodeValidation, "OIDC discovery failed for the issuer: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
if req.Enabled != nil {
|
|
_ = h.settings.Upsert(services.Setting{Key: "oidc.enabled", Value: boolStr(*req.Enabled), ValueType: "bool"})
|
|
}
|
|
emitAudit(h.auditService, r, audit.EventConfigChange, "OIDC", "config", "Update", true, map[string]any{"enabled": req.Enabled != nil && *req.Enabled}, "")
|
|
|
|
h.GetConfig(w, r)
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
func (h *OIDCHandler) setFlowCookie(w http.ResponseWriter, name, value string, secure bool) {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: name, Value: value, Path: "/api/v1/auth/oidc",
|
|
HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode,
|
|
MaxAge: 600,
|
|
})
|
|
}
|
|
|
|
func (h *OIDCHandler) clearFlowCookies(w http.ResponseWriter, secure bool) {
|
|
for _, n := range []string{"oidc_state", "oidc_nonce", "oidc_verifier"} {
|
|
http.SetCookie(w, &http.Cookie{Name: n, Value: "", Path: "/api/v1/auth/oidc", HttpOnly: true, Secure: secure, MaxAge: -1})
|
|
}
|
|
}
|
|
|
|
func randToken() string {
|
|
b := make([]byte, 32)
|
|
_, _ = rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func claimString(claims map[string]any, key, fallback string) string {
|
|
if key != "" {
|
|
if v, ok := claims[key]; ok {
|
|
if s, ok := v.(string); ok && s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func boolStr(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|