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:
141
internal/server/handlers_sso.go
Normal file
141
internal/server/handlers_sso.go
Normal file
@@ -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"),
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user