Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 34 additions & 54 deletions cmd/api/handlers/auth_methods.go
Original file line number Diff line number Diff line change
@@ -1,82 +1,62 @@
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)
}
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})
}
106 changes: 106 additions & 0 deletions cmd/api/handlers/auth_provider_registry.go
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading