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

@@ -22,6 +22,24 @@ type Config struct { //nolint:govet // fieldalignment: TOML section order is mor
Tokens TokensConfig `toml:"tokens"`
Argon2 Argon2Config `toml:"argon2"`
WebAuthn WebAuthnConfig `toml:"webauthn"`
SSO SSOConfig `toml:"sso"`
}
// SSOConfig holds registered SSO clients that may use the authorization code
// flow to authenticate users via MCIAS. Omitting the [sso] section or leaving
// clients empty disables SSO.
type SSOConfig struct {
Clients []SSOClient `toml:"clients"`
}
// SSOClient is a registered relying-party application that may redirect users
// to MCIAS for login. The redirect_uri is validated as an exact match (no
// wildcards) to prevent open-redirect attacks.
type SSOClient struct {
ClientID string `toml:"client_id"` // unique identifier (e.g. "mcr")
RedirectURI string `toml:"redirect_uri"` // exact callback URL, https required
ServiceName string `toml:"service_name"` // passed to policy engine on login
Tags []string `toml:"tags"` // passed to policy engine on login
}
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
@@ -246,9 +264,48 @@ func (c *Config) validate() error {
}
}
// SSO clients — if any are configured, each must have a unique client_id,
// a non-empty redirect_uri with the https:// scheme, and a non-empty
// service_name.
seen := make(map[string]bool, len(c.SSO.Clients))
for i, cl := range c.SSO.Clients {
prefix := fmt.Sprintf("sso.clients[%d]", i)
if cl.ClientID == "" {
errs = append(errs, fmt.Errorf("%s: client_id is required", prefix))
} else if seen[cl.ClientID] {
errs = append(errs, fmt.Errorf("%s: duplicate client_id %q", prefix, cl.ClientID))
} else {
seen[cl.ClientID] = true
}
if cl.RedirectURI == "" {
errs = append(errs, fmt.Errorf("%s: redirect_uri is required", prefix))
} else if !strings.HasPrefix(cl.RedirectURI, "https://") {
errs = append(errs, fmt.Errorf("%s: redirect_uri must use the https:// scheme (got %q)", prefix, cl.RedirectURI))
}
if cl.ServiceName == "" {
errs = append(errs, fmt.Errorf("%s: service_name is required", prefix))
}
}
return errors.Join(errs...)
}
// SSOClient looks up a registered SSO client by client_id.
// Returns nil if no client with that ID is registered.
func (c *Config) SSOClient(clientID string) *SSOClient {
for i := range c.SSO.Clients {
if c.SSO.Clients[i].ClientID == clientID {
return &c.SSO.Clients[i]
}
}
return nil
}
// SSOEnabled reports whether any SSO clients are registered.
func (c *Config) SSOEnabled() bool {
return len(c.SSO.Clients) > 0
}
// DefaultExpiry returns the configured default token expiry duration.
func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration }

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 {