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:
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user