Add SSO authorization code flow (Phase 1)
MCIAS now acts as an SSO provider for downstream services. Services redirect users to /sso/authorize, MCIAS handles login (password, TOTP, or passkey), then redirects back with an authorization code that the service exchanges for a JWT via POST /v1/sso/token. - Add SSO client registry to config (client_id, redirect_uri, service_name, tags) with validation - Add internal/sso package: authorization code and session stores using sync.Map with TTL, single-use LoadAndDelete, cleanup goroutines - Add GET /sso/authorize endpoint (validates client, creates session, redirects to /login?sso=<nonce>) - Add POST /v1/sso/token endpoint (exchanges code for JWT with policy evaluation using client's service_name/tags from config) - Thread SSO nonce through password→TOTP and WebAuthn login flows - Update login.html, totp_step.html, and webauthn.js for SSO nonce passthrough Security: - Authorization codes are 256-bit random, single-use, 60-second TTL - redirect_uri validated as exact match against registered config - Policy context comes from MCIAS config, not the calling service - SSO sessions are server-side only; nonce is the sole client-visible value - WebAuthn SSO returns redirect URL as JSON (not HTTP redirect) for JS compat Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,24 @@ 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]
|
||||
@@ -246,9 +264,48 @@ 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