Files
Alphaeus Mote e0b002975f feat(api-docs): require auth for docs and add filterable route discovery
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>
2026-09-03 10:44:54 -04:00

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"
}