Move SSO clients from config to database
- Add sso_clients table (migration 000010) with client_id, redirect_uri, tags (JSON), enabled flag, and audit timestamps - Add SSOClient model struct and audit events - Implement DB CRUD with 10 unit tests - Add REST API: GET/POST/PATCH/DELETE /v1/sso/clients (policy-gated) - Add gRPC SSOClientService with 5 RPCs (admin-only) - Add mciasctl sso list/create/get/update/delete commands - Add web UI admin page at /sso-clients with HTMX create/toggle/delete - Migrate handleSSOAuthorize and handleSSOTokenExchange to use DB - Remove SSOConfig, SSOClient struct, lookup methods from config - Simplify: client_id = service_name for policy evaluation Security: - SSO client CRUD is admin-only (policy-gated REST, requireAdmin gRPC) - redirect_uri must use https:// (validated at DB layer) - Disabled clients are rejected at both authorize and token exchange - All mutations write audit events Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,24 +22,6 @@ type Config struct { //nolint:govet // fieldalignment: TOML section order is mor
|
||||
Tokens TokensConfig `toml:"tokens"`
|
||||
Argon2 Argon2Config `toml:"argon2"`
|
||||
WebAuthn WebAuthnConfig `toml:"webauthn"`
|
||||
SSO SSOConfig `toml:"sso"`
|
||||
}
|
||||
|
||||
// SSOConfig holds registered SSO clients that may use the authorization code
|
||||
// flow to authenticate users via MCIAS. Omitting the [sso] section or leaving
|
||||
// clients empty disables SSO.
|
||||
type SSOConfig struct {
|
||||
Clients []SSOClient `toml:"clients"`
|
||||
}
|
||||
|
||||
// SSOClient is a registered relying-party application that may redirect users
|
||||
// to MCIAS for login. The redirect_uri is validated as an exact match (no
|
||||
// wildcards) to prevent open-redirect attacks.
|
||||
type SSOClient struct {
|
||||
ClientID string `toml:"client_id"` // unique identifier (e.g. "mcr")
|
||||
RedirectURI string `toml:"redirect_uri"` // exact callback URL, https required
|
||||
ServiceName string `toml:"service_name"` // passed to policy engine on login
|
||||
Tags []string `toml:"tags"` // passed to policy engine on login
|
||||
}
|
||||
|
||||
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
|
||||
@@ -264,48 +246,9 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// SSO clients — if any are configured, each must have a unique client_id,
|
||||
// a non-empty redirect_uri with the https:// scheme, and a non-empty
|
||||
// service_name.
|
||||
seen := make(map[string]bool, len(c.SSO.Clients))
|
||||
for i, cl := range c.SSO.Clients {
|
||||
prefix := fmt.Sprintf("sso.clients[%d]", i)
|
||||
if cl.ClientID == "" {
|
||||
errs = append(errs, fmt.Errorf("%s: client_id is required", prefix))
|
||||
} else if seen[cl.ClientID] {
|
||||
errs = append(errs, fmt.Errorf("%s: duplicate client_id %q", prefix, cl.ClientID))
|
||||
} else {
|
||||
seen[cl.ClientID] = true
|
||||
}
|
||||
if cl.RedirectURI == "" {
|
||||
errs = append(errs, fmt.Errorf("%s: redirect_uri is required", prefix))
|
||||
} else if !strings.HasPrefix(cl.RedirectURI, "https://") {
|
||||
errs = append(errs, fmt.Errorf("%s: redirect_uri must use the https:// scheme (got %q)", prefix, cl.RedirectURI))
|
||||
}
|
||||
if cl.ServiceName == "" {
|
||||
errs = append(errs, fmt.Errorf("%s: service_name is required", prefix))
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// SSOClient looks up a registered SSO client by client_id.
|
||||
// Returns nil if no client with that ID is registered.
|
||||
func (c *Config) SSOClient(clientID string) *SSOClient {
|
||||
for i := range c.SSO.Clients {
|
||||
if c.SSO.Clients[i].ClientID == clientID {
|
||||
return &c.SSO.Clients[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSOEnabled reports whether any SSO clients are registered.
|
||||
func (c *Config) SSOEnabled() bool {
|
||||
return len(c.SSO.Clients) > 0
|
||||
}
|
||||
|
||||
// DefaultExpiry returns the configured default token expiry duration.
|
||||
func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user