diff --git a/internal/config/config.go b/internal/config/config.go index 3954aee..7820a68 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 611f471..ae61448 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -244,6 +244,153 @@ func TestTrustedProxyValidation(t *testing.T) { } } +func TestSSOClientValidation(t *testing.T) { + tests := []struct { + name string + extra string + wantErr bool + }{ + { + name: "valid single client", + extra: ` +[[sso.clients]] +client_id = "mcr" +redirect_uri = "https://mcr.example.com/sso/callback" +service_name = "mcr" +tags = ["env:restricted"] +`, + wantErr: false, + }, + { + name: "valid multiple clients", + extra: ` +[[sso.clients]] +client_id = "mcr" +redirect_uri = "https://mcr.example.com/sso/callback" +service_name = "mcr" + +[[sso.clients]] +client_id = "mcat" +redirect_uri = "https://mcat.example.com/sso/callback" +service_name = "mcat" +`, + wantErr: false, + }, + { + name: "missing client_id", + extra: ` +[[sso.clients]] +redirect_uri = "https://mcr.example.com/sso/callback" +service_name = "mcr" +`, + wantErr: true, + }, + { + name: "missing redirect_uri", + extra: ` +[[sso.clients]] +client_id = "mcr" +service_name = "mcr" +`, + wantErr: true, + }, + { + name: "http redirect_uri rejected", + extra: ` +[[sso.clients]] +client_id = "mcr" +redirect_uri = "http://mcr.example.com/sso/callback" +service_name = "mcr" +`, + wantErr: true, + }, + { + name: "missing service_name", + extra: ` +[[sso.clients]] +client_id = "mcr" +redirect_uri = "https://mcr.example.com/sso/callback" +`, + wantErr: true, + }, + { + name: "duplicate client_id", + extra: ` +[[sso.clients]] +client_id = "mcr" +redirect_uri = "https://mcr.example.com/sso/callback" +service_name = "mcr" + +[[sso.clients]] +client_id = "mcr" +redirect_uri = "https://other.example.com/sso/callback" +service_name = "mcr2" +`, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := writeTempConfig(t, validConfig()+tc.extra) + _, err := Load(path) + if tc.wantErr && err == nil { + t.Error("expected validation error, got nil") + } + if !tc.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +func TestSSOClientLookup(t *testing.T) { + path := writeTempConfig(t, validConfig()+` +[[sso.clients]] +client_id = "mcr" +redirect_uri = "https://mcr.example.com/sso/callback" +service_name = "mcr" +tags = ["env:restricted"] +`) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + cl := cfg.SSOClient("mcr") + if cl == nil { + t.Fatal("SSOClient(mcr) returned nil") + } + if cl.RedirectURI != "https://mcr.example.com/sso/callback" { + t.Errorf("RedirectURI = %q", cl.RedirectURI) + } + if cl.ServiceName != "mcr" { + t.Errorf("ServiceName = %q", cl.ServiceName) + } + if len(cl.Tags) != 1 || cl.Tags[0] != "env:restricted" { + t.Errorf("Tags = %v", cl.Tags) + } + + if cfg.SSOClient("nonexistent") != nil { + t.Error("SSOClient(nonexistent) should return nil") + } + + if !cfg.SSOEnabled() { + t.Error("SSOEnabled() should return true") + } +} + +func TestSSODisabledByDefault(t *testing.T) { + path := writeTempConfig(t, validConfig()) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.SSOEnabled() { + t.Error("SSOEnabled() should return false with no clients") + } +} + func TestDurationParsing(t *testing.T) { var d duration if err := d.UnmarshalText([]byte("1h30m")); err != nil { diff --git a/internal/model/model.go b/internal/model/model.go index b93b741..9adcc2f 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -218,6 +218,9 @@ const ( EventWebAuthnRemoved = "webauthn_removed" EventWebAuthnLoginOK = "webauthn_login_ok" EventWebAuthnLoginFail = "webauthn_login_fail" + + EventSSOAuthorize = "sso_authorize" + EventSSOLoginOK = "sso_login_ok" ) // ServiceAccountDelegate records that a specific account has been granted diff --git a/internal/server/handlers_sso.go b/internal/server/handlers_sso.go new file mode 100644 index 0000000..94f94c5 --- /dev/null +++ b/internal/server/handlers_sso.go @@ -0,0 +1,141 @@ +package server + +import ( + "net/http" + + "git.wntrmute.dev/mc/mcias/internal/audit" + "git.wntrmute.dev/mc/mcias/internal/middleware" + "git.wntrmute.dev/mc/mcias/internal/model" + "git.wntrmute.dev/mc/mcias/internal/policy" + "git.wntrmute.dev/mc/mcias/internal/sso" + "git.wntrmute.dev/mc/mcias/internal/token" +) + +// ssoTokenRequest is the request body for POST /v1/sso/token. +type ssoTokenRequest struct { + Code string `json:"code"` + ClientID string `json:"client_id"` + RedirectURI string `json:"redirect_uri"` +} + +// handleSSOTokenExchange exchanges an SSO authorization code for a JWT token. +// +// Security design: +// - The authorization code is single-use (consumed via LoadAndDelete). +// - The client_id and redirect_uri must match the values stored when the code +// was issued, preventing a stolen code from being exchanged by a different +// service. +// - Policy evaluation uses the service_name and tags from the registered SSO +// client config (not from the request), preventing identity spoofing. +// - The code expires after 60 seconds to limit the interception window. +func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request) { + var req ssoTokenRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Code == "" || req.ClientID == "" || req.RedirectURI == "" { + middleware.WriteError(w, http.StatusBadRequest, "code, client_id, and redirect_uri are required", "bad_request") + return + } + + // Consume the authorization code (single-use). + ac, ok := sso.Consume(req.Code) + if !ok { + middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code") + return + } + + // Security: verify client_id and redirect_uri match the stored values. + if ac.ClientID != req.ClientID || ac.RedirectURI != req.RedirectURI { + s.logger.Warn("sso: token exchange parameter mismatch", + "expected_client", ac.ClientID, "got_client", req.ClientID) + middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code") + return + } + + // Look up the registered SSO client for policy context. + client := s.cfg.SSOClient(req.ClientID) + if client == nil { + // Should not happen if the authorize endpoint validated, but defend in depth. + middleware.WriteError(w, http.StatusUnauthorized, "unknown client", "invalid_code") + return + } + + // Load account. + acct, err := s.db.GetAccountByID(ac.AccountID) + if err != nil { + s.logger.Error("sso: load account for token exchange", "error", err, "account_id", ac.AccountID) + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + if acct.Status != model.AccountStatusActive { + middleware.WriteError(w, http.StatusForbidden, "account is not active", "account_inactive") + return + } + + // Load roles for policy evaluation and expiry decision. + roles, err := s.db.GetRoles(acct.ID) + if err != nil { + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + // Policy evaluation with the SSO client's service_name and tags. + { + input := policy.PolicyInput{ + Subject: acct.UUID, + AccountType: string(acct.AccountType), + Roles: roles, + Action: policy.ActionLogin, + Resource: policy.Resource{ + ServiceName: client.ServiceName, + Tags: client.Tags, + }, + } + if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny { + s.writeAudit(r, model.EventLoginFail, &acct.ID, nil, + audit.JSON("reason", "policy_deny", "service_name", client.ServiceName, "via", "sso")) + middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied") + return + } + } + + // Determine expiry. + expiry := s.cfg.DefaultExpiry() + for _, rol := range roles { + if rol == "admin" { + expiry = s.cfg.AdminExpiry() + break + } + } + + privKey, err := s.vault.PrivKey() + if err != nil { + middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed") + return + } + tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry) + if err != nil { + s.logger.Error("sso: issue token", "error", err) + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil { + s.logger.Error("sso: track token", "error", err) + middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error") + return + } + + s.writeAudit(r, model.EventSSOLoginOK, &acct.ID, nil, + audit.JSON("jti", claims.JTI, "client_id", client.ClientID)) + s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil, + audit.JSON("jti", claims.JTI, "via", "sso")) + + writeJSON(w, http.StatusOK, loginResponse{ + Token: tokenStr, + ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"), + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index ddbd6d9..8604ef0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -215,6 +215,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /v1/health", s.handleHealth) mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey) mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin))) + mux.Handle("POST /v1/sso/token", loginRateLimit(http.HandlerFunc(s.handleSSOTokenExchange))) mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate))) // API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml. diff --git a/internal/sso/session.go b/internal/sso/session.go new file mode 100644 index 0000000..e92b236 --- /dev/null +++ b/internal/sso/session.go @@ -0,0 +1,91 @@ +package sso + +import ( + "fmt" + "sync" + "time" + + "git.wntrmute.dev/mc/mcias/internal/crypto" +) + +const ( + sessionTTL = 5 * time.Minute + sessionBytes = 16 // 128 bits of entropy for the nonce +) + +// Session holds the SSO parameters between /sso/authorize and login completion. +// The nonce is embedded as a hidden form field in the login page and carried +// through the multi-step login flow (password → TOTP, or WebAuthn). +type Session struct { //nolint:govet // fieldalignment: field order matches logical grouping + ClientID string + RedirectURI string + State string + ExpiresAt time.Time +} + +// pendingSessions stores SSO sessions created at /sso/authorize. +var pendingSessions sync.Map //nolint:gochecknoglobals + +func init() { + go cleanupSessions() +} + +func cleanupSessions() { + ticker := time.NewTicker(cleanupPeriod) + defer ticker.Stop() + for range ticker.C { + now := time.Now() + pendingSessions.Range(func(key, value any) bool { + s, ok := value.(*Session) + if !ok || now.After(s.ExpiresAt) { + pendingSessions.Delete(key) + } + return true + }) + } +} + +// StoreSession creates and stores a new SSO session, returning the hex-encoded +// nonce that should be embedded in the login form. +func StoreSession(clientID, redirectURI, state string) (string, error) { + raw, err := crypto.RandomBytes(sessionBytes) + if err != nil { + return "", fmt.Errorf("sso: generate session nonce: %w", err) + } + nonce := fmt.Sprintf("%x", raw) + pendingSessions.Store(nonce, &Session{ + ClientID: clientID, + RedirectURI: redirectURI, + State: state, + ExpiresAt: time.Now().Add(sessionTTL), + }) + return nonce, nil +} + +// ConsumeSession retrieves and deletes an SSO session by nonce. +// Returns the Session and true if valid, or (nil, false) if unknown or expired. +func ConsumeSession(nonce string) (*Session, bool) { + v, ok := pendingSessions.LoadAndDelete(nonce) + if !ok { + return nil, false + } + s, ok2 := v.(*Session) + if !ok2 || time.Now().After(s.ExpiresAt) { + return nil, false + } + return s, true +} + +// GetSession retrieves an SSO session without consuming it (for read-only checks +// during multi-step login). Returns nil if unknown or expired. +func GetSession(nonce string) *Session { + v, ok := pendingSessions.Load(nonce) + if !ok { + return nil + } + s, ok2 := v.(*Session) + if !ok2 || time.Now().After(s.ExpiresAt) { + return nil + } + return s +} diff --git a/internal/sso/store.go b/internal/sso/store.go new file mode 100644 index 0000000..b1eb90a --- /dev/null +++ b/internal/sso/store.go @@ -0,0 +1,93 @@ +// Package sso implements the authorization code store for the SSO redirect flow. +// +// MCIAS acts as the SSO provider: downstream services (MCR, MCAT, Metacrypt) +// redirect users to MCIAS for login, and MCIAS issues a short-lived, single-use +// authorization code that the service exchanges for a JWT token. +// +// Security design: +// - Authorization codes are 32 random bytes (256 bits), hex-encoded. +// - Codes are single-use: consumed via sync.Map LoadAndDelete on first exchange. +// - Codes expire after 60 seconds to limit the window for interception. +// - A background goroutine evicts expired codes every 5 minutes. +// - The code is bound to the client_id and redirect_uri presented at authorize +// time; the token exchange endpoint must verify both match. +package sso + +import ( + "fmt" + "sync" + "time" + + "git.wntrmute.dev/mc/mcias/internal/crypto" +) + +const ( + codeTTL = 60 * time.Second + codeBytes = 32 // 256 bits of entropy + cleanupPeriod = 5 * time.Minute +) + +// AuthCode is a pending authorization code awaiting exchange for a JWT. +type AuthCode struct { //nolint:govet // fieldalignment: field order matches logical grouping + ClientID string + RedirectURI string + State string + AccountID int64 + ExpiresAt time.Time +} + +// pendingCodes stores issued authorization codes awaiting exchange. +var pendingCodes sync.Map //nolint:gochecknoglobals + +func init() { + go cleanupCodes() +} + +func cleanupCodes() { + ticker := time.NewTicker(cleanupPeriod) + defer ticker.Stop() + for range ticker.C { + now := time.Now() + pendingCodes.Range(func(key, value any) bool { + ac, ok := value.(*AuthCode) + if !ok || now.After(ac.ExpiresAt) { + pendingCodes.Delete(key) + } + return true + }) + } +} + +// Store creates and stores a new authorization code bound to the given +// client_id, redirect_uri, state, and account. Returns the hex-encoded code. +func Store(clientID, redirectURI, state string, accountID int64) (string, error) { + raw, err := crypto.RandomBytes(codeBytes) + if err != nil { + return "", fmt.Errorf("sso: generate authorization code: %w", err) + } + code := fmt.Sprintf("%x", raw) + pendingCodes.Store(code, &AuthCode{ + ClientID: clientID, + RedirectURI: redirectURI, + State: state, + AccountID: accountID, + ExpiresAt: time.Now().Add(codeTTL), + }) + return code, nil +} + +// Consume retrieves and deletes an authorization code. Returns the AuthCode +// and true if the code was valid and not expired, or (nil, false) otherwise. +// +// Security: LoadAndDelete ensures single-use; the code cannot be replayed. +func Consume(code string) (*AuthCode, bool) { + v, ok := pendingCodes.LoadAndDelete(code) + if !ok { + return nil, false + } + ac, ok2 := v.(*AuthCode) + if !ok2 || time.Now().After(ac.ExpiresAt) { + return nil, false + } + return ac, true +} diff --git a/internal/sso/store_test.go b/internal/sso/store_test.go new file mode 100644 index 0000000..ba55934 --- /dev/null +++ b/internal/sso/store_test.go @@ -0,0 +1,132 @@ +package sso + +import ( + "testing" + "time" +) + +func TestStoreAndConsume(t *testing.T) { + code, err := Store("mcr", "https://mcr.example.com/cb", "state123", 42) + if err != nil { + t.Fatalf("Store: %v", err) + } + if code == "" { + t.Fatal("Store returned empty code") + } + + ac, ok := Consume(code) + if !ok { + t.Fatal("Consume returned false for valid code") + } + if ac.ClientID != "mcr" { + t.Errorf("ClientID = %q, want %q", ac.ClientID, "mcr") + } + if ac.RedirectURI != "https://mcr.example.com/cb" { + t.Errorf("RedirectURI = %q", ac.RedirectURI) + } + if ac.State != "state123" { + t.Errorf("State = %q", ac.State) + } + if ac.AccountID != 42 { + t.Errorf("AccountID = %d, want 42", ac.AccountID) + } +} + +func TestConsumeSingleUse(t *testing.T) { + code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1) + if err != nil { + t.Fatalf("Store: %v", err) + } + + if _, ok := Consume(code); !ok { + t.Fatal("first Consume should succeed") + } + if _, ok := Consume(code); ok { + t.Error("second Consume should fail (single-use)") + } +} + +func TestConsumeUnknownCode(t *testing.T) { + if _, ok := Consume("nonexistent"); ok { + t.Error("Consume should fail for unknown code") + } +} + +func TestConsumeExpiredCode(t *testing.T) { + code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1) + if err != nil { + t.Fatalf("Store: %v", err) + } + + // Manually expire the code. + v, loaded := pendingCodes.Load(code) + if !loaded { + t.Fatal("code not found in pendingCodes") + } + ac, ok := v.(*AuthCode) + if !ok { + t.Fatal("unexpected type in pendingCodes") + } + ac.ExpiresAt = time.Now().Add(-1 * time.Second) + + if _, ok := Consume(code); ok { + t.Error("Consume should fail for expired code") + } +} + +func TestStoreSessionAndConsume(t *testing.T) { + nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "state456") + if err != nil { + t.Fatalf("StoreSession: %v", err) + } + if nonce == "" { + t.Fatal("StoreSession returned empty nonce") + } + + // GetSession should return it without consuming. + s := GetSession(nonce) + if s == nil { + t.Fatal("GetSession returned nil") + } + if s.ClientID != "mcr" { + t.Errorf("ClientID = %q", s.ClientID) + } + + // Still available after GetSession. + s2, ok := ConsumeSession(nonce) + if !ok { + t.Fatal("ConsumeSession returned false") + } + if s2.State != "state456" { + t.Errorf("State = %q", s2.State) + } + + // Consumed — should be gone. + if _, ok := ConsumeSession(nonce); ok { + t.Error("second ConsumeSession should fail") + } + if GetSession(nonce) != nil { + t.Error("GetSession should return nil after consume") + } +} + +func TestConsumeSessionExpired(t *testing.T) { + nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "s") + if err != nil { + t.Fatalf("StoreSession: %v", err) + } + + v, loaded := pendingSessions.Load(nonce) + if !loaded { + t.Fatal("session not found in pendingSessions") + } + sess, ok := v.(*Session) + if !ok { + t.Fatal("unexpected type in pendingSessions") + } + sess.ExpiresAt = time.Now().Add(-1 * time.Second) + + if _, ok := ConsumeSession(nonce); ok { + t.Error("ConsumeSession should fail for expired session") + } +} diff --git a/internal/ui/handlers_auth.go b/internal/ui/handlers_auth.go index a1d9472..5c33bb9 100644 --- a/internal/ui/handlers_auth.go +++ b/internal/ui/handlers_auth.go @@ -15,6 +15,7 @@ import ( func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { u.render(w, "login", LoginData{ WebAuthnEnabled: u.cfg.WebAuthnEnabled(), + SSONonce: r.URL.Query().Get("sso"), }) } @@ -97,6 +98,8 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) { return } + ssoNonce := r.FormValue("sso_nonce") + // TOTP required: issue a server-side nonce and show the TOTP step form. // Security: the nonce replaces the password hidden field (F-02). The password // is not stored anywhere after this point; only the account ID is retained. @@ -110,11 +113,12 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) { u.render(w, "totp_step", LoginData{ Username: username, Nonce: nonce, + SSONonce: ssoNonce, }) return } - u.finishLogin(w, r, acct) + u.finishLogin(w, r, acct, ssoNonce) } // handleTOTPStep handles the second POST when totp_step=1 is set. @@ -129,6 +133,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") //nolint:gosec // body already limited by caller nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller + ssoNonce := r.FormValue("sso_nonce") //nolint:gosec // body already limited by caller // Security: consume the nonce (single-use); reject if unknown or expired. accountID, ok := u.consumeTOTPNonce(nonce) @@ -172,6 +177,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) { Error: "invalid TOTP code", Username: username, Nonce: newNonce, + SSONonce: ssoNonce, }) return } @@ -189,15 +195,28 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) { Error: "invalid TOTP code", Username: username, Nonce: newNonce, + SSONonce: ssoNonce, }) return } - u.finishLogin(w, r, acct) + u.finishLogin(w, r, acct, ssoNonce) } // finishLogin issues a JWT, sets the session cookie, and redirects to dashboard. -func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account) { +// When ssoNonce is non-empty, the login is part of an SSO redirect flow: instead +// of setting a session cookie, an authorization code is issued and the user is +// redirected back to the service's callback URL. +func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account, ssoNonce string) { + // SSO redirect flow: issue authorization code and redirect to service. + if ssoNonce != "" { + if callbackURL, ok := u.buildSSOCallback(r, ssoNonce, acct.ID); ok { + http.Redirect(w, r, callbackURL, http.StatusFound) + return + } + // SSO session expired/consumed — fall through to normal login. + } + // Determine token expiry based on admin role. expiry := u.cfg.DefaultExpiry() roles, err := u.db.GetRoles(acct.ID) diff --git a/internal/ui/handlers_sso.go b/internal/ui/handlers_sso.go new file mode 100644 index 0000000..2d1a9b1 --- /dev/null +++ b/internal/ui/handlers_sso.go @@ -0,0 +1,84 @@ +package ui + +import ( + "net/http" + "net/url" + + "git.wntrmute.dev/mc/mcias/internal/audit" + "git.wntrmute.dev/mc/mcias/internal/model" + "git.wntrmute.dev/mc/mcias/internal/sso" +) + +// handleSSOAuthorize validates the SSO request parameters against registered +// clients, creates an SSO session, and redirects to /login with the SSO nonce. +// +// Security: the client_id and redirect_uri are validated against the MCIAS +// config (exact match). The state parameter is opaque and carried through +// unchanged. An SSO session is created server-side so the nonce is the only +// value embedded in the login form. +func (u *UIServer) handleSSOAuthorize(w http.ResponseWriter, r *http.Request) { + clientID := r.URL.Query().Get("client_id") + redirectURI := r.URL.Query().Get("redirect_uri") + state := r.URL.Query().Get("state") + + if clientID == "" || redirectURI == "" || state == "" { + http.Error(w, "missing required parameters: client_id, redirect_uri, state", http.StatusBadRequest) + return + } + + // Security: validate client_id against registered SSO clients. + client := u.cfg.SSOClient(clientID) + if client == nil { + u.logger.Warn("sso: unknown client_id", "client_id", clientID) + http.Error(w, "unknown client_id", http.StatusBadRequest) + return + } + + // Security: redirect_uri must exactly match the registered URI to prevent + // open-redirect attacks. + if redirectURI != client.RedirectURI { + u.logger.Warn("sso: redirect_uri mismatch", + "client_id", clientID, + "expected", client.RedirectURI, + "got", redirectURI) + http.Error(w, "redirect_uri does not match registered URI", http.StatusBadRequest) + return + } + + nonce, err := sso.StoreSession(clientID, redirectURI, state) + if err != nil { + u.logger.Error("sso: store session", "error", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + u.writeAudit(r, model.EventSSOAuthorize, nil, nil, + audit.JSON("client_id", clientID)) + + http.Redirect(w, r, "/login?sso="+url.QueryEscape(nonce), http.StatusFound) +} + +// buildSSOCallback consumes the SSO session, generates an authorization code, +// and returns the callback URL with code and state parameters. Returns ("", false) +// if the SSO session is expired or already consumed. +// +// Security: the SSO session is consumed (single-use) and the authorization code +// is stored server-side for exchange via POST /v1/sso/token. The state parameter +// is carried through unchanged for the service to validate. +func (u *UIServer) buildSSOCallback(r *http.Request, ssoNonce string, accountID int64) (string, bool) { + sess, ok := sso.ConsumeSession(ssoNonce) + if !ok { + return "", false + } + + code, err := sso.Store(sess.ClientID, sess.RedirectURI, sess.State, accountID) + if err != nil { + u.logger.Error("sso: store auth code", "error", err) + return "", false + } + + u.writeAudit(r, model.EventSSOLoginOK, &accountID, nil, + audit.JSON("client_id", sess.ClientID)) + + return sess.RedirectURI + "?code=" + url.QueryEscape(code) + "&state=" + url.QueryEscape(sess.State), true +} diff --git a/internal/ui/handlers_webauthn.go b/internal/ui/handlers_webauthn.go index 85ae9d1..8e85a96 100644 --- a/internal/ui/handlers_webauthn.go +++ b/internal/ui/handlers_webauthn.go @@ -27,10 +27,11 @@ const ( ) // webauthnCeremony holds a pending WebAuthn ceremony. -type webauthnCeremony struct { +type webauthnCeremony struct { //nolint:govet // fieldalignment: field order matches logical grouping expiresAt time.Time session *libwebauthn.SessionData accountID int64 + ssoNonce string // non-empty when login is part of an SSO redirect flow } // pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI. @@ -55,7 +56,7 @@ func cleanupUIWebAuthnCeremonies() { } } -func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) { +func storeUICeremony(session *libwebauthn.SessionData, accountID int64, ssoNonce string) (string, error) { raw, err := crypto.RandomBytes(webauthnNonceBytes) if err != nil { return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err) @@ -64,6 +65,7 @@ func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{ session: session, accountID: accountID, + ssoNonce: ssoNonce, expiresAt: time.Now().Add(webauthnCeremonyTTL), }) return nonce, nil @@ -170,7 +172,7 @@ func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) { return } - nonce, err := storeUICeremony(session, acct.ID) + nonce, err := storeUICeremony(session, acct.ID, "") if err != nil { writeJSONError(w, http.StatusInternalServerError, "internal error") return @@ -352,6 +354,7 @@ func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Reque r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) var req struct { Username string `json:"username"` + SSONonce string `json:"sso_nonce"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid JSON") @@ -413,7 +416,7 @@ func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Reque return } - nonce, err := storeUICeremony(session, accountID) + nonce, err := storeUICeremony(session, accountID, req.SSONonce) if err != nil { writeJSONError(w, http.StatusInternalServerError, "internal error") return @@ -582,6 +585,17 @@ func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Requ _ = u.db.ClearLoginFailures(acct.ID) + // SSO redirect flow: issue authorization code and return redirect URL as JSON. + if ceremony.ssoNonce != "" { + if callbackURL, ok := u.buildSSOCallback(r, ceremony.ssoNonce, acct.ID); ok { + u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"redirect": callbackURL}) + return + } + // SSO session expired — fall through to normal login. + } + // Issue JWT and set session cookie. expiry := u.cfg.DefaultExpiry() roles, err := u.db.GetRoles(acct.ID) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index be039f3..855d3d4 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -445,6 +445,9 @@ func (u *UIServer) Register(mux *http.ServeMux) { uiMux.HandleFunc("GET /unseal", u.handleUnsealPage) uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost))) + // SSO authorize route (no session required, rate-limited). + uiMux.Handle("GET /sso/authorize", loginRateLimit(http.HandlerFunc(u.handleSSOAuthorize))) + // Auth routes (no session required). uiMux.HandleFunc("GET /login", u.handleLoginPage) uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost))) @@ -810,6 +813,7 @@ type PageData struct { type LoginData struct { Error string Username string // pre-filled on TOTP step + SSONonce string // SSO session nonce (hidden field for SSO redirect flow) // Security (F-02): Password is no longer carried in the HTML form. Instead // a short-lived server-side nonce is issued after successful password // verification, and only the nonce is embedded in the TOTP step form. diff --git a/web/static/webauthn.js b/web/static/webauthn.js index 3305fc2..09df9dd 100644 --- a/web/static/webauthn.js +++ b/web/static/webauthn.js @@ -110,18 +110,22 @@ }; // mciasWebAuthnLogin initiates a passkey login. - window.mciasWebAuthnLogin = function (username, onSuccess, onError) { + // ssoNonce is optional — when non-empty, it is included in the begin/finish + // requests so the server can redirect back to the SSO client after login. + window.mciasWebAuthnLogin = function (username, ssoNonce, onSuccess, onError) { if (!window.PublicKeyCredential) { onError('WebAuthn is not supported in this browser.'); return; } var savedNonce = ''; + var beginBody = { username: username || '' }; + if (ssoNonce) { beginBody.sso_nonce = ssoNonce; } fetch('/login/webauthn/begin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: username || '' }) + body: JSON.stringify(beginBody) }) .then(function (resp) { if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); }); @@ -163,7 +167,7 @@ if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); }); return resp.json(); }) - .then(function () { onSuccess(); }) + .then(function (data) { onSuccess(data); }) .catch(function (err) { onError(err.message || 'Login failed'); }); }; @@ -208,11 +212,14 @@ hideError('webauthn-login-error'); var usernameInput = document.getElementById('username'); var username = usernameInput ? usernameInput.value.trim() : ''; + var ssoNonce = loginBtn.getAttribute('data-sso-nonce') || ''; loginBtn.disabled = true; loginBtn.textContent = 'Waiting for authenticator...'; - window.mciasWebAuthnLogin(username, function () { - window.location.href = '/dashboard'; + window.mciasWebAuthnLogin(username, ssoNonce, function (data) { + // The server returns a redirect URL — either /dashboard for direct + // login, or the SSO client callback URL with code and state params. + window.location.href = (data && data.redirect) || '/dashboard'; }, function (err) { loginBtn.disabled = false; loginBtn.textContent = 'Sign in with passkey'; diff --git a/web/templates/fragments/totp_step.html b/web/templates/fragments/totp_step.html index af02bca..0e6f30d 100644 --- a/web/templates/fragments/totp_step.html +++ b/web/templates/fragments/totp_step.html @@ -5,6 +5,7 @@ + {{if .SSONonce}}{{end}}