Files
mcias/internal/sso/store.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

94 lines
2.7 KiB
Go

// 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
}