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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user