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