Move SSO clients from config to database

- Add sso_clients table (migration 000010) with client_id, redirect_uri,
  tags (JSON), enabled flag, and audit timestamps
- Add SSOClient model struct and audit events
- Implement DB CRUD with 10 unit tests
- Add REST API: GET/POST/PATCH/DELETE /v1/sso/clients (policy-gated)
- Add gRPC SSOClientService with 5 RPCs (admin-only)
- Add mciasctl sso list/create/get/update/delete commands
- Add web UI admin page at /sso-clients with HTMX create/toggle/delete
- Migrate handleSSOAuthorize and handleSSOTokenExchange to use DB
- Remove SSOConfig, SSOClient struct, lookup methods from config
- Simplify: client_id = service_name for policy evaluation

Security:
- SSO client CRUD is admin-only (policy-gated REST, requireAdmin gRPC)
- redirect_uri must use https:// (validated at DB layer)
- Disabled clients are rejected at both authorize and token exchange
- All mutations write audit events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-31 23:47:53 -07:00
parent 4430ce38a4
commit df7773229c
24 changed files with 2284 additions and 217 deletions

View File

@@ -22,24 +22,6 @@ 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]
@@ -264,48 +246,9 @@ 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,153 +244,6 @@ 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 {

View File

@@ -22,7 +22,7 @@ var migrationsFS embed.FS
// LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file
// is added.
const LatestSchemaVersion = 9
const LatestSchemaVersion = 10
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL
// files. It opens a dedicated *sql.DB using the same DSN as the main

View File

@@ -0,0 +1,10 @@
CREATE TABLE sso_clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL UNIQUE,
redirect_uri TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0,1)),
created_by INTEGER REFERENCES accounts(id),
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
);

206
internal/db/sso_clients.go Normal file
View File

@@ -0,0 +1,206 @@
package db
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"git.wntrmute.dev/mc/mcias/internal/model"
)
const ssoClientCols = `id, client_id, redirect_uri, tags_json, enabled, created_by, created_at, updated_at`
// CreateSSOClient inserts a new SSO client. The client_id must be unique
// and the redirect_uri must start with "https://".
func (db *DB) CreateSSOClient(clientID, redirectURI string, tags []string, createdBy *int64) (*model.SSOClient, error) {
if clientID == "" {
return nil, fmt.Errorf("db: client_id is required")
}
if !strings.HasPrefix(redirectURI, "https://") {
return nil, fmt.Errorf("db: redirect_uri must start with https://")
}
if tags == nil {
tags = []string{}
}
tagsJSON, err := json.Marshal(tags)
if err != nil {
return nil, fmt.Errorf("db: marshal tags: %w", err)
}
n := now()
result, err := db.sql.Exec(`
INSERT INTO sso_clients (client_id, redirect_uri, tags_json, enabled, created_by, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?)
`, clientID, redirectURI, string(tagsJSON), createdBy, n, n)
if err != nil {
return nil, fmt.Errorf("db: create SSO client: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("db: create SSO client last insert id: %w", err)
}
createdAt, err := parseTime(n)
if err != nil {
return nil, err
}
return &model.SSOClient{
ID: id,
ClientID: clientID,
RedirectURI: redirectURI,
Tags: tags,
Enabled: true,
CreatedBy: createdBy,
CreatedAt: createdAt,
UpdatedAt: createdAt,
}, nil
}
// GetSSOClient retrieves an SSO client by client_id.
// Returns ErrNotFound if no such client exists.
func (db *DB) GetSSOClient(clientID string) (*model.SSOClient, error) {
return scanSSOClient(db.sql.QueryRow(`
SELECT `+ssoClientCols+`
FROM sso_clients WHERE client_id = ?
`, clientID))
}
// ListSSOClients returns all SSO clients ordered by client_id.
func (db *DB) ListSSOClients() ([]*model.SSOClient, error) {
rows, err := db.sql.Query(`
SELECT ` + ssoClientCols + `
FROM sso_clients ORDER BY client_id ASC
`)
if err != nil {
return nil, fmt.Errorf("db: list SSO clients: %w", err)
}
defer func() { _ = rows.Close() }()
var clients []*model.SSOClient
for rows.Next() {
c, err := scanSSOClientRow(rows)
if err != nil {
return nil, err
}
clients = append(clients, c)
}
return clients, rows.Err()
}
// UpdateSSOClient updates the mutable fields of an SSO client.
// Only non-nil fields are changed.
func (db *DB) UpdateSSOClient(clientID string, redirectURI *string, tags *[]string, enabled *bool) error {
n := now()
setClauses := "updated_at = ?"
args := []interface{}{n}
if redirectURI != nil {
if !strings.HasPrefix(*redirectURI, "https://") {
return fmt.Errorf("db: redirect_uri must start with https://")
}
setClauses += ", redirect_uri = ?"
args = append(args, *redirectURI)
}
if tags != nil {
tagsJSON, err := json.Marshal(*tags)
if err != nil {
return fmt.Errorf("db: marshal tags: %w", err)
}
setClauses += ", tags_json = ?"
args = append(args, string(tagsJSON))
}
if enabled != nil {
enabledInt := 0
if *enabled {
enabledInt = 1
}
setClauses += ", enabled = ?"
args = append(args, enabledInt)
}
args = append(args, clientID)
res, err := db.sql.Exec(`UPDATE sso_clients SET `+setClauses+` WHERE client_id = ?`, args...)
if err != nil {
return fmt.Errorf("db: update SSO client %s: %w", clientID, err)
}
n2, _ := res.RowsAffected()
if n2 == 0 {
return ErrNotFound
}
return nil
}
// DeleteSSOClient removes an SSO client by client_id.
func (db *DB) DeleteSSOClient(clientID string) error {
res, err := db.sql.Exec(`DELETE FROM sso_clients WHERE client_id = ?`, clientID)
if err != nil {
return fmt.Errorf("db: delete SSO client %s: %w", clientID, err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// scanSSOClient scans a single SSO client from a *sql.Row.
func scanSSOClient(row *sql.Row) (*model.SSOClient, error) {
var c model.SSOClient
var enabledInt int
var tagsJSON, createdAtStr, updatedAtStr string
var createdBy *int64
err := row.Scan(&c.ID, &c.ClientID, &c.RedirectURI, &tagsJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("db: scan SSO client: %w", err)
}
return finishSSOClientScan(&c, enabledInt, createdBy, tagsJSON, createdAtStr, updatedAtStr)
}
// scanSSOClientRow scans a single SSO client from *sql.Rows.
func scanSSOClientRow(rows *sql.Rows) (*model.SSOClient, error) {
var c model.SSOClient
var enabledInt int
var tagsJSON, createdAtStr, updatedAtStr string
var createdBy *int64
err := rows.Scan(&c.ID, &c.ClientID, &c.RedirectURI, &tagsJSON,
&enabledInt, &createdBy, &createdAtStr, &updatedAtStr)
if err != nil {
return nil, fmt.Errorf("db: scan SSO client row: %w", err)
}
return finishSSOClientScan(&c, enabledInt, createdBy, tagsJSON, createdAtStr, updatedAtStr)
}
func finishSSOClientScan(c *model.SSOClient, enabledInt int, createdBy *int64, tagsJSON, createdAtStr, updatedAtStr string) (*model.SSOClient, error) {
c.Enabled = enabledInt == 1
c.CreatedBy = createdBy
var err error
if c.CreatedAt, err = parseTime(createdAtStr); err != nil {
return nil, err
}
if c.UpdatedAt, err = parseTime(updatedAtStr); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
return nil, fmt.Errorf("db: unmarshal SSO client tags: %w", err)
}
if c.Tags == nil {
c.Tags = []string{}
}
return c, nil
}

View File

@@ -0,0 +1,192 @@
package db
import (
"errors"
"testing"
)
func TestCreateAndGetSSOClient(t *testing.T) {
db := openTestDB(t)
c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/sso/callback", []string{"env:prod"}, nil)
if err != nil {
t.Fatalf("CreateSSOClient: %v", err)
}
if c.ID == 0 {
t.Error("expected non-zero ID")
}
if c.ClientID != "mcr" {
t.Errorf("client_id = %q, want %q", c.ClientID, "mcr")
}
if !c.Enabled {
t.Error("new client should be enabled by default")
}
if len(c.Tags) != 1 || c.Tags[0] != "env:prod" {
t.Errorf("tags = %v, want [env:prod]", c.Tags)
}
got, err := db.GetSSOClient("mcr")
if err != nil {
t.Fatalf("GetSSOClient: %v", err)
}
if got.RedirectURI != "https://mcr.example.com/sso/callback" {
t.Errorf("redirect_uri = %q", got.RedirectURI)
}
if len(got.Tags) != 1 || got.Tags[0] != "env:prod" {
t.Errorf("tags = %v after round-trip", got.Tags)
}
}
func TestCreateSSOClient_DuplicateClientID(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
if err != nil {
t.Fatalf("first create: %v", err)
}
_, err = db.CreateSSOClient("mcr", "https://other.example.com/cb", nil, nil)
if err == nil {
t.Error("expected error for duplicate client_id")
}
}
func TestCreateSSOClient_Validation(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("", "https://example.com/cb", nil, nil)
if err == nil {
t.Error("expected error for empty client_id")
}
_, err = db.CreateSSOClient("mcr", "http://example.com/cb", nil, nil)
if err == nil {
t.Error("expected error for non-https redirect_uri")
}
}
func TestGetSSOClient_NotFound(t *testing.T) {
db := openTestDB(t)
_, err := db.GetSSOClient("nonexistent")
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestListSSOClients(t *testing.T) {
db := openTestDB(t)
clients, err := db.ListSSOClients()
if err != nil {
t.Fatalf("ListSSOClients (empty): %v", err)
}
if len(clients) != 0 {
t.Errorf("expected 0 clients, got %d", len(clients))
}
_, _ = db.CreateSSOClient("mcat", "https://mcat.example.com/cb", nil, nil)
_, _ = db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
clients, err = db.ListSSOClients()
if err != nil {
t.Fatalf("ListSSOClients: %v", err)
}
if len(clients) != 2 {
t.Fatalf("expected 2 clients, got %d", len(clients))
}
// Ordered by client_id ASC.
if clients[0].ClientID != "mcat" {
t.Errorf("first client = %q, want %q", clients[0].ClientID, "mcat")
}
}
func TestUpdateSSOClient(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", []string{"a"}, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
newURI := "https://mcr.example.com/sso/callback"
newTags := []string{"b", "c"}
disabled := false
if err := db.UpdateSSOClient("mcr", &newURI, &newTags, &disabled); err != nil {
t.Fatalf("UpdateSSOClient: %v", err)
}
got, err := db.GetSSOClient("mcr")
if err != nil {
t.Fatalf("get after update: %v", err)
}
if got.RedirectURI != newURI {
t.Errorf("redirect_uri = %q, want %q", got.RedirectURI, newURI)
}
if len(got.Tags) != 2 || got.Tags[0] != "b" {
t.Errorf("tags = %v, want [b c]", got.Tags)
}
if got.Enabled {
t.Error("expected enabled=false after update")
}
}
func TestUpdateSSOClient_NotFound(t *testing.T) {
db := openTestDB(t)
uri := "https://x.example.com/cb"
err := db.UpdateSSOClient("nonexistent", &uri, nil, nil)
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestDeleteSSOClient(t *testing.T) {
db := openTestDB(t)
_, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if err := db.DeleteSSOClient("mcr"); err != nil {
t.Fatalf("DeleteSSOClient: %v", err)
}
_, err = db.GetSSOClient("mcr")
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
}
func TestDeleteSSOClient_NotFound(t *testing.T) {
db := openTestDB(t)
err := db.DeleteSSOClient("nonexistent")
if !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound, got %v", err)
}
}
func TestCreateSSOClient_NilTags(t *testing.T) {
db := openTestDB(t)
c, err := db.CreateSSOClient("mcr", "https://mcr.example.com/cb", nil, nil)
if err != nil {
t.Fatalf("create: %v", err)
}
if c.Tags == nil {
t.Error("Tags should be empty slice, not nil")
}
if len(c.Tags) != 0 {
t.Errorf("expected 0 tags, got %d", len(c.Tags))
}
got, err := db.GetSSOClient("mcr")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Tags == nil || len(got.Tags) != 0 {
t.Errorf("Tags round-trip: got %v", got.Tags)
}
}

View File

@@ -118,6 +118,7 @@ func (s *Server) buildServer(extra ...grpc.ServerOption) *grpc.Server {
mciasv1.RegisterAccountServiceServer(srv, &accountServiceServer{s: s})
mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s})
mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s})
mciasv1.RegisterSSOClientServiceServer(srv, &ssoClientServiceServer{s: s})
return srv
}

View File

@@ -0,0 +1,187 @@
// ssoclientservice implements mciasv1.SSOClientServiceServer.
// All handlers are admin-only and delegate to the same db package used by
// the REST SSO client handlers in internal/server/handlers_sso_clients.go.
package grpcserver
import (
"context"
"errors"
"fmt"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
mciasv1 "git.wntrmute.dev/mc/mcias/gen/mcias/v1"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/model"
)
type ssoClientServiceServer struct {
mciasv1.UnimplementedSSOClientServiceServer
s *Server
}
func ssoClientToProto(c *model.SSOClient) *mciasv1.SSOClient {
return &mciasv1.SSOClient{
ClientId: c.ClientID,
RedirectUri: c.RedirectURI,
Tags: c.Tags,
Enabled: c.Enabled,
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func (ss *ssoClientServiceServer) ListSSOClients(ctx context.Context, _ *mciasv1.ListSSOClientsRequest) (*mciasv1.ListSSOClientsResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
clients, err := ss.s.db.ListSSOClients()
if err != nil {
ss.s.logger.Error("list SSO clients", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
resp := &mciasv1.ListSSOClientsResponse{
Clients: make([]*mciasv1.SSOClient, 0, len(clients)),
}
for _, c := range clients {
resp.Clients = append(resp.Clients, ssoClientToProto(c))
}
return resp, nil
}
func (ss *ssoClientServiceServer) CreateSSOClient(ctx context.Context, req *mciasv1.CreateSSOClientRequest) (*mciasv1.CreateSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
if req.RedirectUri == "" {
return nil, status.Error(codes.InvalidArgument, "redirect_uri is required")
}
claims := claimsFromContext(ctx)
var createdBy *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
createdBy = &actor.ID
}
}
c, err := ss.s.db.CreateSSOClient(req.ClientId, req.RedirectUri, req.Tags, createdBy)
if err != nil {
ss.s.logger.Error("create SSO client", "error", err)
return nil, status.Error(codes.InvalidArgument, err.Error())
}
ss.s.db.WriteAuditEvent(model.EventSSOClientCreated, createdBy, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, c.ClientID))
return &mciasv1.CreateSSOClientResponse{Client: ssoClientToProto(c)}, nil
}
func (ss *ssoClientServiceServer) GetSSOClient(ctx context.Context, req *mciasv1.GetSSOClientRequest) (*mciasv1.GetSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
c, err := ss.s.db.GetSSOClient(req.ClientId)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("get SSO client", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
return &mciasv1.GetSSOClientResponse{Client: ssoClientToProto(c)}, nil
}
func (ss *ssoClientServiceServer) UpdateSSOClient(ctx context.Context, req *mciasv1.UpdateSSOClientRequest) (*mciasv1.UpdateSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
var redirectURI *string
if req.RedirectUri != nil {
v := req.GetRedirectUri()
redirectURI = &v
}
var tags *[]string
if req.UpdateTags {
t := req.Tags
tags = &t
}
var enabled *bool
if req.Enabled != nil {
v := req.GetEnabled()
enabled = &v
}
err := ss.s.db.UpdateSSOClient(req.ClientId, redirectURI, tags, enabled)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("update SSO client", "error", err)
return nil, status.Error(codes.InvalidArgument, err.Error())
}
claims := claimsFromContext(ctx)
var actorID *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
ss.s.db.WriteAuditEvent(model.EventSSOClientUpdated, actorID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, req.ClientId))
updated, err := ss.s.db.GetSSOClient(req.ClientId)
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
return &mciasv1.UpdateSSOClientResponse{Client: ssoClientToProto(updated)}, nil
}
func (ss *ssoClientServiceServer) DeleteSSOClient(ctx context.Context, req *mciasv1.DeleteSSOClientRequest) (*mciasv1.DeleteSSOClientResponse, error) {
if err := ss.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.ClientId == "" {
return nil, status.Error(codes.InvalidArgument, "client_id is required")
}
err := ss.s.db.DeleteSSOClient(req.ClientId)
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "SSO client not found")
}
if err != nil {
ss.s.logger.Error("delete SSO client", "error", err)
return nil, status.Error(codes.Internal, "internal error")
}
claims := claimsFromContext(ctx)
var actorID *int64
if claims != nil {
if actor, err := ss.s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
ss.s.db.WriteAuditEvent(model.EventSSOClientDeleted, actorID, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"client_id":%q}`, req.ClientId))
return &mciasv1.DeleteSSOClientResponse{}, nil
}

View File

@@ -221,8 +221,26 @@ const (
EventSSOAuthorize = "sso_authorize"
EventSSOLoginOK = "sso_login_ok"
EventSSOClientCreated = "sso_client_created"
EventSSOClientUpdated = "sso_client_updated"
EventSSOClientDeleted = "sso_client_deleted"
)
// SSOClient represents a registered relying-party application that may use
// the MCIAS SSO authorization code flow. The ClientID serves as both the
// unique identifier and the service_name for policy evaluation.
type SSOClient struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CreatedBy *int64 `json:"-"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
ID int64 `json:"-"`
Enabled bool `json:"enabled"`
}
// ServiceAccountDelegate records that a specific account has been granted
// permission to issue tokens for a given system account. Only admins can
// add or remove delegates; delegates can issue/rotate tokens for that specific

View File

@@ -51,6 +51,8 @@ const (
ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
ActionRemoveWebAuthn Action = "webauthn:remove" // admin
ActionManageSSOClients Action = "sso_clients:manage" // admin
)
// ResourceType identifies what kind of object a request targets.
@@ -62,8 +64,9 @@ const (
ResourcePGCreds ResourceType = "pgcreds"
ResourceAuditLog ResourceType = "audit_log"
ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy"
ResourceWebAuthn ResourceType = "webauthn"
ResourcePolicy ResourceType = "policy"
ResourceWebAuthn ResourceType = "webauthn"
ResourceSSOClient ResourceType = "sso_client"
)
// Effect is the outcome of policy evaluation.

View File

@@ -54,13 +54,17 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request)
return
}
// Look up the registered SSO client for policy context.
client := s.cfg.SSOClient(req.ClientID)
if client == nil {
// Look up the registered SSO client from the database for policy context.
client, clientErr := s.db.GetSSOClient(req.ClientID)
if clientErr != nil {
// Should not happen if the authorize endpoint validated, but defend in depth.
middleware.WriteError(w, http.StatusUnauthorized, "unknown client", "invalid_code")
return
}
if !client.Enabled {
middleware.WriteError(w, http.StatusForbidden, "SSO client is disabled", "client_disabled")
return
}
// Load account.
acct, err := s.db.GetAccountByID(ac.AccountID)
@@ -82,7 +86,7 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request)
return
}
// Policy evaluation with the SSO client's service_name and tags.
// Policy evaluation: client_id serves as both identifier and service_name.
{
input := policy.PolicyInput{
Subject: acct.UUID,
@@ -90,13 +94,13 @@ func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request)
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{
ServiceName: client.ServiceName,
ServiceName: client.ClientID,
Tags: client.Tags,
},
}
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
audit.JSON("reason", "policy_deny", "service_name", client.ServiceName, "via", "sso"))
audit.JSON("reason", "policy_deny", "service_name", client.ClientID, "via", "sso"))
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}

View File

@@ -0,0 +1,175 @@
package server
import (
"errors"
"fmt"
"net/http"
"time"
"git.wntrmute.dev/mc/mcias/internal/db"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
)
type ssoClientResponse struct {
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
func ssoClientToResponse(c *model.SSOClient) ssoClientResponse {
return ssoClientResponse{
ClientID: c.ClientID,
RedirectURI: c.RedirectURI,
Tags: c.Tags,
Enabled: c.Enabled,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),
}
}
func (s *Server) handleListSSOClients(w http.ResponseWriter, r *http.Request) {
clients, err := s.db.ListSSOClients()
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
resp := make([]ssoClientResponse, 0, len(clients))
for _, c := range clients {
resp = append(resp, ssoClientToResponse(c))
}
writeJSON(w, http.StatusOK, resp)
}
type createSSOClientRequest struct {
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
Tags []string `json:"tags"`
}
func (s *Server) handleCreateSSOClient(w http.ResponseWriter, r *http.Request) {
var req createSSOClientRequest
if !decodeJSON(w, r, &req) {
return
}
if req.ClientID == "" {
middleware.WriteError(w, http.StatusBadRequest, "client_id is required", "bad_request")
return
}
if req.RedirectURI == "" {
middleware.WriteError(w, http.StatusBadRequest, "redirect_uri is required", "bad_request")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var createdBy *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
createdBy = &actor.ID
}
}
c, err := s.db.CreateSSOClient(req.ClientID, req.RedirectURI, req.Tags, createdBy)
if err != nil {
s.logger.Error("create SSO client", "error", err)
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
s.writeAudit(r, model.EventSSOClientCreated, createdBy, nil,
fmt.Sprintf(`{"client_id":%q}`, c.ClientID))
writeJSON(w, http.StatusCreated, ssoClientToResponse(c))
}
func (s *Server) handleGetSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
c, err := s.db.GetSSOClient(clientID)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, ssoClientToResponse(c))
}
type updateSSOClientRequest struct {
RedirectURI *string `json:"redirect_uri,omitempty"`
Tags *[]string `json:"tags,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
func (s *Server) handleUpdateSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
var req updateSSOClientRequest
if !decodeJSON(w, r, &req) {
return
}
err := s.db.UpdateSSOClient(clientID, req.RedirectURI, req.Tags, req.Enabled)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
s.logger.Error("update SSO client", "error", err)
middleware.WriteError(w, http.StatusBadRequest, err.Error(), "bad_request")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventSSOClientUpdated, actorID, nil,
fmt.Sprintf(`{"client_id":%q}`, clientID))
c, err := s.db.GetSSOClient(clientID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
writeJSON(w, http.StatusOK, ssoClientToResponse(c))
}
func (s *Server) handleDeleteSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
err := s.db.DeleteSSOClient(clientID)
if errors.Is(err, db.ErrNotFound) {
middleware.WriteError(w, http.StatusNotFound, "SSO client not found", "not_found")
return
}
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
claims := middleware.ClaimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if actor, err := s.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &actor.ID
}
}
s.writeAudit(r, model.EventSSOClientDeleted, actorID, nil,
fmt.Sprintf(`{"client_id":%q}`, clientID))
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -373,6 +373,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /v1/policy/rules/{id}",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule)))
// SSO client management (admin-only).
mux.Handle("GET /v1/sso/clients",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleListSSOClients)))
mux.Handle("POST /v1/sso/clients",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleCreateSSOClient)))
mux.Handle("GET /v1/sso/clients/{clientId}",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleGetSSOClient)))
mux.Handle("PATCH /v1/sso/clients/{clientId}",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleUpdateSSOClient)))
mux.Handle("DELETE /v1/sso/clients/{clientId}",
requirePolicy(policy.ActionManageSSOClients, policy.ResourceSSOClient, nil)(http.HandlerFunc(s.handleDeleteSSOClient)))
// UI routes (HTMX-based management frontend).
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
if err != nil {

View File

@@ -26,14 +26,20 @@ func (u *UIServer) handleSSOAuthorize(w http.ResponseWriter, r *http.Request) {
return
}
// Security: validate client_id against registered SSO clients.
client := u.cfg.SSOClient(clientID)
if client == nil {
// Security: validate client_id against registered SSO clients in the database.
client, err := u.db.GetSSOClient(clientID)
if err != nil {
u.logger.Warn("sso: unknown client_id", "client_id", clientID)
http.Error(w, "unknown client_id", http.StatusBadRequest)
return
}
if !client.Enabled {
u.logger.Warn("sso: disabled client", "client_id", clientID)
http.Error(w, "SSO client is disabled", http.StatusForbidden)
return
}
// Security: redirect_uri must exactly match the registered URI to prevent
// open-redirect attacks.
if redirectURI != client.RedirectURI {

View File

@@ -0,0 +1,129 @@
package ui
import (
"fmt"
"net/http"
"strings"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/model"
)
func (u *UIServer) handleSSOClientsPage(w http.ResponseWriter, r *http.Request) {
csrfToken, err := u.setCSRFCookies(w)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
clients, err := u.db.ListSSOClients()
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to load SSO clients")
return
}
u.render(w, "sso_clients", SSOClientsData{
PageData: PageData{CSRFToken: csrfToken, ActorName: u.actorName(r), IsAdmin: isAdmin(r)},
Clients: clients,
})
}
func (u *UIServer) handleCreateSSOClientUI(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form submission")
return
}
clientID := strings.TrimSpace(r.FormValue("client_id"))
redirectURI := strings.TrimSpace(r.FormValue("redirect_uri"))
tagsStr := strings.TrimSpace(r.FormValue("tags"))
if clientID == "" || redirectURI == "" {
u.renderError(w, r, http.StatusBadRequest, "client_id and redirect_uri are required")
return
}
var tags []string
if tagsStr != "" {
for _, t := range strings.Split(tagsStr, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if acct, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &acct.ID
}
}
c, err := u.db.CreateSSOClient(clientID, redirectURI, tags, actorID)
if err != nil {
u.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
u.writeAudit(r, model.EventSSOClientCreated, actorID, nil,
audit.JSON("client_id", c.ClientID))
u.render(w, "sso_client_row", c)
}
func (u *UIServer) handleToggleSSOClient(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
if err := r.ParseForm(); err != nil {
u.renderError(w, r, http.StatusBadRequest, "invalid form")
return
}
enabled := r.FormValue("enabled") == "true"
if err := u.db.UpdateSSOClient(clientID, nil, nil, &enabled); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to update SSO client")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if acct, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &acct.ID
}
}
u.writeAudit(r, model.EventSSOClientUpdated, actorID, nil,
fmt.Sprintf(`{"client_id":%q,"enabled":%v}`, clientID, enabled))
c, err := u.db.GetSSOClient(clientID)
if err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to reload SSO client")
return
}
u.render(w, "sso_client_row", c)
}
func (u *UIServer) handleDeleteSSOClientUI(w http.ResponseWriter, r *http.Request) {
clientID := r.PathValue("clientId")
if err := u.db.DeleteSSOClient(clientID); err != nil {
u.renderError(w, r, http.StatusInternalServerError, "failed to delete SSO client")
return
}
claims := claimsFromContext(r.Context())
var actorID *int64
if claims != nil {
if acct, err := u.db.GetAccountByUUID(claims.Subject); err == nil {
actorID = &acct.ID
}
}
u.writeAudit(r, model.EventSSOClientDeleted, actorID, nil,
audit.JSON("client_id", clientID))
// Return empty response so HTMX removes the row.
w.WriteHeader(http.StatusOK)
}

View File

@@ -501,6 +501,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword))
uiMux.Handle("GET /sso-clients", adminGet(u.handleSSOClientsPage))
uiMux.Handle("POST /sso-clients", admin(u.handleCreateSSOClientUI))
uiMux.Handle("PATCH /sso-clients/{clientId}/toggle", admin(u.handleToggleSSOClient))
uiMux.Handle("DELETE /sso-clients/{clientId}", admin(u.handleDeleteSSOClientUI))
// Service accounts page — accessible to any authenticated user; shows only
// the service accounts for which the current user is a token-issue delegate.
@@ -923,6 +927,12 @@ type PolicyRuleView struct {
IsPending bool // true if not_before is in the future
}
// SSOClientsData is the view model for the SSO clients admin page.
type SSOClientsData struct {
PageData
Clients []*model.SSOClient
}
// PoliciesData is the view model for the policies list page.
type PoliciesData struct {
PageData