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

View File

@@ -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 {