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 }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
91
internal/sso/session.go
Normal file
91
internal/sso/session.go
Normal file
@@ -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
|
||||
}
|
||||
93
internal/sso/store.go
Normal file
93
internal/sso/store.go
Normal file
@@ -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
|
||||
}
|
||||
132
internal/sso/store_test.go
Normal file
132
internal/sso/store_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
84
internal/ui/handlers_sso.go
Normal file
84
internal/ui/handlers_sso.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user