Files
mcias/internal/sso/session.go
Kyle Isom e450ade988 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>
2026-03-30 15:21:48 -07:00

92 lines
2.3 KiB
Go

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
}