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:
2026-03-30 15:21:48 -07:00
parent 5b5e1a7ed6
commit e450ade988
15 changed files with 809 additions and 13 deletions

91
internal/sso/session.go Normal file
View 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
View 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
View 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")
}
}