7 Commits

Author SHA1 Message Date
33e0f9b8bd Fix SSO clients page template wrapper
Page templates must define a named block that invokes {{template "base"}}
(e.g. {{define "sso_clients"}}{{template "base" .}}{{end}}). Without this,
ExecuteTemplate cannot find the named template and returns an error.

Also add logging to the SSO clients handler for easier diagnosis.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:59:06 -07:00
44a1b9ad3a Register SSO client templates
The sso_clients page and sso_client_row fragment were missing from the
template registration lists, causing a 500 on GET /sso-clients.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:49:45 -07:00
df7773229c 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>
2026-03-31 23:47:53 -07:00
4430ce38a4 Allow htmx swap styles in CSP
Add 'unsafe-hashes' with the htmx swap indicator style hash to the
style-src CSP directive. Without this, htmx swap transitions are
blocked by CSP, which can prevent HX-Redirect from being processed
on the SSO login flow.

Security:
- Uses 'unsafe-hashes' (not 'unsafe-inline') so only the specific
  htmx style hash is permitted, not arbitrary inline styles

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:43:53 -07:00
4ed2cecec5 Fix SSO redirect failing with htmx login form
The login form uses hx-post, so htmx sends the POST via fetch. A 302
redirect to the cross-origin service callback URL fails silently because
fetch follows the redirect but gets blocked by CORS. Use HX-Redirect
header instead, which tells htmx to perform a full page navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:32:44 -07:00
9385c3846d Fix protobuf runtime panic on startup
Regenerated protobuf stubs and bumped google.golang.org/protobuf from
v1.36.10 to v1.36.11 to match protoc-gen-go v1.36.11. The version
mismatch caused a panic in filedesc.unmarshalSeed during init().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:55:15 -07:00
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
55 changed files with 3059 additions and 114 deletions

View File

@@ -88,6 +88,7 @@ func main() {
root.AddCommand(pgcredsCmd()) root.AddCommand(pgcredsCmd())
root.AddCommand(policyCmd()) root.AddCommand(policyCmd())
root.AddCommand(tagCmd()) root.AddCommand(tagCmd())
root.AddCommand(ssoCmd())
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
os.Exit(1) os.Exit(1)
@@ -956,6 +957,160 @@ func tagSetCmd() *cobra.Command {
return cmd return cmd
} }
// ---- SSO client commands ----
func ssoCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "sso",
Short: "SSO client management commands",
}
cmd.AddCommand(ssoListCmd())
cmd.AddCommand(ssoCreateCmd())
cmd.AddCommand(ssoGetCmd())
cmd.AddCommand(ssoUpdateCmd())
cmd.AddCommand(ssoDeleteCmd())
return cmd
}
func ssoListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List all SSO clients",
Run: func(cmd *cobra.Command, args []string) {
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/sso/clients", nil, &result)
printJSON(result)
},
}
}
func ssoCreateCmd() *cobra.Command {
var clientID, redirectURI, tagsFlag string
cmd := &cobra.Command{
Use: "create",
Short: "Register a new SSO client",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso create: --client-id is required")
}
if redirectURI == "" {
fatalf("sso create: --redirect-uri is required")
}
c := newController()
body := map[string]interface{}{
"client_id": clientID,
"redirect_uri": redirectURI,
}
if tagsFlag != "" {
tags := []string{}
for _, t := range strings.Split(tagsFlag, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
body["tags"] = tags
}
var result json.RawMessage
c.doRequest("POST", "/v1/sso/clients", body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier / service name (required)")
cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "callback URL (https:// required)")
cmd.Flags().StringVar(&tagsFlag, "tags", "", "comma-separated list of tags")
return cmd
}
func ssoGetCmd() *cobra.Command {
var clientID string
cmd := &cobra.Command{
Use: "get",
Short: "Get an SSO client by client-id",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso get: --client-id is required")
}
c := newController()
var result json.RawMessage
c.doRequest("GET", "/v1/sso/clients/"+clientID, nil, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)")
return cmd
}
func ssoUpdateCmd() *cobra.Command {
var clientID, redirectURI, tagsFlag, enabledFlag string
cmd := &cobra.Command{
Use: "update",
Short: "Update an SSO client",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso update: --client-id is required")
}
c := newController()
body := map[string]interface{}{}
if cmd.Flags().Changed("redirect-uri") {
body["redirect_uri"] = redirectURI
}
if cmd.Flags().Changed("tags") {
tags := []string{}
if tagsFlag != "" {
for _, t := range strings.Split(tagsFlag, ",") {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
}
body["tags"] = tags
}
if cmd.Flags().Changed("enabled") {
body["enabled"] = enabledFlag == "true"
}
var result json.RawMessage
c.doRequest("PATCH", "/v1/sso/clients/"+clientID, body, &result)
printJSON(result)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)")
cmd.Flags().StringVar(&redirectURI, "redirect-uri", "", "new callback URL")
cmd.Flags().StringVar(&tagsFlag, "tags", "", "comma-separated list of tags (empty clears)")
cmd.Flags().StringVar(&enabledFlag, "enabled", "", "true or false")
return cmd
}
func ssoDeleteCmd() *cobra.Command {
var clientID string
cmd := &cobra.Command{
Use: "delete",
Short: "Delete an SSO client",
Run: func(cmd *cobra.Command, args []string) {
if clientID == "" {
fatalf("sso delete: --client-id is required")
}
c := newController()
c.doRequest("DELETE", "/v1/sso/clients/"+clientID, nil, nil)
fmt.Printf("SSO client %q deleted\n", clientID)
},
}
cmd.Flags().StringVar(&clientID, "client-id", "", "SSO client identifier (required)")
return cmd
}
// ---- HTTP helpers ---- // ---- HTTP helpers ----
// doRequest performs an authenticated JSON HTTP request. If result is non-nil, // doRequest performs an authenticated JSON HTTP request. If result is non-nil,

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1
@@ -1080,7 +1080,7 @@ const file_mcias_v1_account_proto_rawDesc = "" +
"\n" + "\n" +
"GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" + "GetPGCreds\x12\x1b.mcias.v1.GetPGCredsRequest\x1a\x1c.mcias.v1.GetPGCredsResponse\x12G\n" +
"\n" + "\n" +
"SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3" "SetPGCreds\x12\x1b.mcias.v1.SetPGCredsRequest\x1a\x1c.mcias.v1.SetPGCredsResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_account_proto_rawDescOnce sync.Once file_mcias_v1_account_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/account.proto // source: mcias/v1/account.proto
package mciasv1 package mciasv1

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/admin.proto // source: mcias/v1/admin.proto
package mciasv1 package mciasv1
@@ -238,7 +238,7 @@ const file_mcias_v1_admin_proto_rawDesc = "" +
"\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" + "\x01x\x18\x05 \x01(\tR\x01x2\x9a\x01\n" +
"\fAdminService\x12;\n" + "\fAdminService\x12;\n" +
"\x06Health\x12\x17.mcias.v1.HealthRequest\x1a\x18.mcias.v1.HealthResponse\x12M\n" + "\x06Health\x12\x17.mcias.v1.HealthRequest\x1a\x18.mcias.v1.HealthResponse\x12M\n" +
"\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\fGetPublicKey\x12\x1d.mcias.v1.GetPublicKeyRequest\x1a\x1e.mcias.v1.GetPublicKeyResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_admin_proto_rawDescOnce sync.Once file_mcias_v1_admin_proto_rawDescOnce sync.Once

View File

@@ -4,7 +4,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/admin.proto // source: mcias/v1/admin.proto
package mciasv1 package mciasv1

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/auth.proto // source: mcias/v1/auth.proto
package mciasv1 package mciasv1
@@ -919,7 +919,7 @@ const file_mcias_v1_auth_proto_rawDesc = "" +
"\n" + "\n" +
"RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" + "RemoveTOTP\x12\x1b.mcias.v1.RemoveTOTPRequest\x1a\x1c.mcias.v1.RemoveTOTPResponse\x12n\n" +
"\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" + "\x17ListWebAuthnCredentials\x12(.mcias.v1.ListWebAuthnCredentialsRequest\x1a).mcias.v1.ListWebAuthnCredentialsResponse\x12q\n" +
"\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\x18RemoveWebAuthnCredential\x12).mcias.v1.RemoveWebAuthnCredentialRequest\x1a*.mcias.v1.RemoveWebAuthnCredentialResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_auth_proto_rawDescOnce sync.Once file_mcias_v1_auth_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/auth.proto // source: mcias/v1/auth.proto
package mciasv1 package mciasv1

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/common.proto // source: mcias/v1/common.proto
package mciasv1 package mciasv1
@@ -349,7 +349,7 @@ const file_mcias_v1_common_proto_rawDesc = "" +
"\x04port\x18\x05 \x01(\x05R\x04port\"5\n" + "\x04port\x18\x05 \x01(\x05R\x04port\"5\n" +
"\x05Error\x12\x18\n" + "\x05Error\x12\x18\n" +
"\amessage\x18\x01 \x01(\tR\amessage\x12\x12\n" + "\amessage\x18\x01 \x01(\tR\amessage\x12\x12\n" +
"\x04code\x18\x02 \x01(\tR\x04codeB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\x04code\x18\x02 \x01(\tR\x04codeB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_common_proto_rawDescOnce sync.Once file_mcias_v1_common_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/policy.proto // source: mcias/v1/policy.proto
package mciasv1 package mciasv1
@@ -703,7 +703,7 @@ const file_mcias_v1_policy_proto_rawDesc = "" +
"\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" + "\x10CreatePolicyRule\x12!.mcias.v1.CreatePolicyRuleRequest\x1a\".mcias.v1.CreatePolicyRuleResponse\x12P\n" +
"\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\n" + "\rGetPolicyRule\x12\x1e.mcias.v1.GetPolicyRuleRequest\x1a\x1f.mcias.v1.GetPolicyRuleResponse\x12Y\n" +
"\x10UpdatePolicyRule\x12!.mcias.v1.UpdatePolicyRuleRequest\x1a\".mcias.v1.UpdatePolicyRuleResponse\x12Y\n" + "\x10UpdatePolicyRule\x12!.mcias.v1.UpdatePolicyRuleRequest\x1a\".mcias.v1.UpdatePolicyRuleResponse\x12Y\n" +
"\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\x10DeletePolicyRule\x12!.mcias.v1.DeletePolicyRuleRequest\x1a\".mcias.v1.DeletePolicyRuleResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_policy_proto_rawDescOnce sync.Once file_mcias_v1_policy_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/policy.proto // source: mcias/v1/policy.proto
package mciasv1 package mciasv1

View File

@@ -0,0 +1,703 @@
// SSOClientService: CRUD management of SSO client registrations.
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.32.1
// source: mcias/v1/sso_client.proto
package mciasv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// SSOClient is the wire representation of an SSO client registration.
type SSOClient struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
RedirectUri string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"`
Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"`
Enabled bool `protobuf:"varint,4,opt,name=enabled,proto3" json:"enabled,omitempty"`
CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339
UpdatedAt string `protobuf:"bytes,6,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // RFC3339
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SSOClient) Reset() {
*x = SSOClient{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SSOClient) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SSOClient) ProtoMessage() {}
func (x *SSOClient) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use SSOClient.ProtoReflect.Descriptor instead.
func (*SSOClient) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{0}
}
func (x *SSOClient) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *SSOClient) GetRedirectUri() string {
if x != nil {
return x.RedirectUri
}
return ""
}
func (x *SSOClient) GetTags() []string {
if x != nil {
return x.Tags
}
return nil
}
func (x *SSOClient) GetEnabled() bool {
if x != nil {
return x.Enabled
}
return false
}
func (x *SSOClient) GetCreatedAt() string {
if x != nil {
return x.CreatedAt
}
return ""
}
func (x *SSOClient) GetUpdatedAt() string {
if x != nil {
return x.UpdatedAt
}
return ""
}
type ListSSOClientsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListSSOClientsRequest) Reset() {
*x = ListSSOClientsRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListSSOClientsRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListSSOClientsRequest) ProtoMessage() {}
func (x *ListSSOClientsRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListSSOClientsRequest.ProtoReflect.Descriptor instead.
func (*ListSSOClientsRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{1}
}
type ListSSOClientsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Clients []*SSOClient `protobuf:"bytes,1,rep,name=clients,proto3" json:"clients,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ListSSOClientsResponse) Reset() {
*x = ListSSOClientsResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ListSSOClientsResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ListSSOClientsResponse) ProtoMessage() {}
func (x *ListSSOClientsResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ListSSOClientsResponse.ProtoReflect.Descriptor instead.
func (*ListSSOClientsResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{2}
}
func (x *ListSSOClientsResponse) GetClients() []*SSOClient {
if x != nil {
return x.Clients
}
return nil
}
type CreateSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
RedirectUri string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"`
Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSSOClientRequest) Reset() {
*x = CreateSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateSSOClientRequest) ProtoMessage() {}
func (x *CreateSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateSSOClientRequest.ProtoReflect.Descriptor instead.
func (*CreateSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{3}
}
func (x *CreateSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *CreateSSOClientRequest) GetRedirectUri() string {
if x != nil {
return x.RedirectUri
}
return ""
}
func (x *CreateSSOClientRequest) GetTags() []string {
if x != nil {
return x.Tags
}
return nil
}
type CreateSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateSSOClientResponse) Reset() {
*x = CreateSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateSSOClientResponse) ProtoMessage() {}
func (x *CreateSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CreateSSOClientResponse.ProtoReflect.Descriptor instead.
func (*CreateSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{4}
}
func (x *CreateSSOClientResponse) GetClient() *SSOClient {
if x != nil {
return x.Client
}
return nil
}
type GetSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSSOClientRequest) Reset() {
*x = GetSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSSOClientRequest) ProtoMessage() {}
func (x *GetSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetSSOClientRequest.ProtoReflect.Descriptor instead.
func (*GetSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{5}
}
func (x *GetSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
type GetSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *GetSSOClientResponse) Reset() {
*x = GetSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *GetSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetSSOClientResponse) ProtoMessage() {}
func (x *GetSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetSSOClientResponse.ProtoReflect.Descriptor instead.
func (*GetSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{6}
}
func (x *GetSSOClientResponse) GetClient() *SSOClient {
if x != nil {
return x.Client
}
return nil
}
type UpdateSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
RedirectUri *string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3,oneof" json:"redirect_uri,omitempty"`
Tags []string `protobuf:"bytes,3,rep,name=tags,proto3" json:"tags,omitempty"`
Enabled *bool `protobuf:"varint,4,opt,name=enabled,proto3,oneof" json:"enabled,omitempty"`
UpdateTags bool `protobuf:"varint,5,opt,name=update_tags,json=updateTags,proto3" json:"update_tags,omitempty"` // when true, tags field is applied (allows clearing)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateSSOClientRequest) Reset() {
*x = UpdateSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateSSOClientRequest) ProtoMessage() {}
func (x *UpdateSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateSSOClientRequest.ProtoReflect.Descriptor instead.
func (*UpdateSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{7}
}
func (x *UpdateSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
func (x *UpdateSSOClientRequest) GetRedirectUri() string {
if x != nil && x.RedirectUri != nil {
return *x.RedirectUri
}
return ""
}
func (x *UpdateSSOClientRequest) GetTags() []string {
if x != nil {
return x.Tags
}
return nil
}
func (x *UpdateSSOClientRequest) GetEnabled() bool {
if x != nil && x.Enabled != nil {
return *x.Enabled
}
return false
}
func (x *UpdateSSOClientRequest) GetUpdateTags() bool {
if x != nil {
return x.UpdateTags
}
return false
}
type UpdateSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Client *SSOClient `protobuf:"bytes,1,opt,name=client,proto3" json:"client,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *UpdateSSOClientResponse) Reset() {
*x = UpdateSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *UpdateSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*UpdateSSOClientResponse) ProtoMessage() {}
func (x *UpdateSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use UpdateSSOClientResponse.ProtoReflect.Descriptor instead.
func (*UpdateSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{8}
}
func (x *UpdateSSOClientResponse) GetClient() *SSOClient {
if x != nil {
return x.Client
}
return nil
}
type DeleteSSOClientRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ClientId string `protobuf:"bytes,1,opt,name=client_id,json=clientId,proto3" json:"client_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteSSOClientRequest) Reset() {
*x = DeleteSSOClientRequest{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteSSOClientRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteSSOClientRequest) ProtoMessage() {}
func (x *DeleteSSOClientRequest) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteSSOClientRequest.ProtoReflect.Descriptor instead.
func (*DeleteSSOClientRequest) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{9}
}
func (x *DeleteSSOClientRequest) GetClientId() string {
if x != nil {
return x.ClientId
}
return ""
}
type DeleteSSOClientResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *DeleteSSOClientResponse) Reset() {
*x = DeleteSSOClientResponse{}
mi := &file_mcias_v1_sso_client_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *DeleteSSOClientResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*DeleteSSOClientResponse) ProtoMessage() {}
func (x *DeleteSSOClientResponse) ProtoReflect() protoreflect.Message {
mi := &file_mcias_v1_sso_client_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use DeleteSSOClientResponse.ProtoReflect.Descriptor instead.
func (*DeleteSSOClientResponse) Descriptor() ([]byte, []int) {
return file_mcias_v1_sso_client_proto_rawDescGZIP(), []int{10}
}
var File_mcias_v1_sso_client_proto protoreflect.FileDescriptor
const file_mcias_v1_sso_client_proto_rawDesc = "" +
"\n" +
"\x19mcias/v1/sso_client.proto\x12\bmcias.v1\"\xb7\x01\n" +
"\tSSOClient\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\x12!\n" +
"\fredirect_uri\x18\x02 \x01(\tR\vredirectUri\x12\x12\n" +
"\x04tags\x18\x03 \x03(\tR\x04tags\x12\x18\n" +
"\aenabled\x18\x04 \x01(\bR\aenabled\x12\x1d\n" +
"\n" +
"created_at\x18\x05 \x01(\tR\tcreatedAt\x12\x1d\n" +
"\n" +
"updated_at\x18\x06 \x01(\tR\tupdatedAt\"\x17\n" +
"\x15ListSSOClientsRequest\"G\n" +
"\x16ListSSOClientsResponse\x12-\n" +
"\aclients\x18\x01 \x03(\v2\x13.mcias.v1.SSOClientR\aclients\"l\n" +
"\x16CreateSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\x12!\n" +
"\fredirect_uri\x18\x02 \x01(\tR\vredirectUri\x12\x12\n" +
"\x04tags\x18\x03 \x03(\tR\x04tags\"F\n" +
"\x17CreateSSOClientResponse\x12+\n" +
"\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"2\n" +
"\x13GetSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\"C\n" +
"\x14GetSSOClientResponse\x12+\n" +
"\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"\xce\x01\n" +
"\x16UpdateSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\x12&\n" +
"\fredirect_uri\x18\x02 \x01(\tH\x00R\vredirectUri\x88\x01\x01\x12\x12\n" +
"\x04tags\x18\x03 \x03(\tR\x04tags\x12\x1d\n" +
"\aenabled\x18\x04 \x01(\bH\x01R\aenabled\x88\x01\x01\x12\x1f\n" +
"\vupdate_tags\x18\x05 \x01(\bR\n" +
"updateTagsB\x0f\n" +
"\r_redirect_uriB\n" +
"\n" +
"\b_enabled\"F\n" +
"\x17UpdateSSOClientResponse\x12+\n" +
"\x06client\x18\x01 \x01(\v2\x13.mcias.v1.SSOClientR\x06client\"5\n" +
"\x16DeleteSSOClientRequest\x12\x1b\n" +
"\tclient_id\x18\x01 \x01(\tR\bclientId\"\x19\n" +
"\x17DeleteSSOClientResponse2\xbe\x03\n" +
"\x10SSOClientService\x12S\n" +
"\x0eListSSOClients\x12\x1f.mcias.v1.ListSSOClientsRequest\x1a .mcias.v1.ListSSOClientsResponse\x12V\n" +
"\x0fCreateSSOClient\x12 .mcias.v1.CreateSSOClientRequest\x1a!.mcias.v1.CreateSSOClientResponse\x12M\n" +
"\fGetSSOClient\x12\x1d.mcias.v1.GetSSOClientRequest\x1a\x1e.mcias.v1.GetSSOClientResponse\x12V\n" +
"\x0fUpdateSSOClient\x12 .mcias.v1.UpdateSSOClientRequest\x1a!.mcias.v1.UpdateSSOClientResponse\x12V\n" +
"\x0fDeleteSSOClient\x12 .mcias.v1.DeleteSSOClientRequest\x1a!.mcias.v1.DeleteSSOClientResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var (
file_mcias_v1_sso_client_proto_rawDescOnce sync.Once
file_mcias_v1_sso_client_proto_rawDescData []byte
)
func file_mcias_v1_sso_client_proto_rawDescGZIP() []byte {
file_mcias_v1_sso_client_proto_rawDescOnce.Do(func() {
file_mcias_v1_sso_client_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mcias_v1_sso_client_proto_rawDesc), len(file_mcias_v1_sso_client_proto_rawDesc)))
})
return file_mcias_v1_sso_client_proto_rawDescData
}
var file_mcias_v1_sso_client_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_mcias_v1_sso_client_proto_goTypes = []any{
(*SSOClient)(nil), // 0: mcias.v1.SSOClient
(*ListSSOClientsRequest)(nil), // 1: mcias.v1.ListSSOClientsRequest
(*ListSSOClientsResponse)(nil), // 2: mcias.v1.ListSSOClientsResponse
(*CreateSSOClientRequest)(nil), // 3: mcias.v1.CreateSSOClientRequest
(*CreateSSOClientResponse)(nil), // 4: mcias.v1.CreateSSOClientResponse
(*GetSSOClientRequest)(nil), // 5: mcias.v1.GetSSOClientRequest
(*GetSSOClientResponse)(nil), // 6: mcias.v1.GetSSOClientResponse
(*UpdateSSOClientRequest)(nil), // 7: mcias.v1.UpdateSSOClientRequest
(*UpdateSSOClientResponse)(nil), // 8: mcias.v1.UpdateSSOClientResponse
(*DeleteSSOClientRequest)(nil), // 9: mcias.v1.DeleteSSOClientRequest
(*DeleteSSOClientResponse)(nil), // 10: mcias.v1.DeleteSSOClientResponse
}
var file_mcias_v1_sso_client_proto_depIdxs = []int32{
0, // 0: mcias.v1.ListSSOClientsResponse.clients:type_name -> mcias.v1.SSOClient
0, // 1: mcias.v1.CreateSSOClientResponse.client:type_name -> mcias.v1.SSOClient
0, // 2: mcias.v1.GetSSOClientResponse.client:type_name -> mcias.v1.SSOClient
0, // 3: mcias.v1.UpdateSSOClientResponse.client:type_name -> mcias.v1.SSOClient
1, // 4: mcias.v1.SSOClientService.ListSSOClients:input_type -> mcias.v1.ListSSOClientsRequest
3, // 5: mcias.v1.SSOClientService.CreateSSOClient:input_type -> mcias.v1.CreateSSOClientRequest
5, // 6: mcias.v1.SSOClientService.GetSSOClient:input_type -> mcias.v1.GetSSOClientRequest
7, // 7: mcias.v1.SSOClientService.UpdateSSOClient:input_type -> mcias.v1.UpdateSSOClientRequest
9, // 8: mcias.v1.SSOClientService.DeleteSSOClient:input_type -> mcias.v1.DeleteSSOClientRequest
2, // 9: mcias.v1.SSOClientService.ListSSOClients:output_type -> mcias.v1.ListSSOClientsResponse
4, // 10: mcias.v1.SSOClientService.CreateSSOClient:output_type -> mcias.v1.CreateSSOClientResponse
6, // 11: mcias.v1.SSOClientService.GetSSOClient:output_type -> mcias.v1.GetSSOClientResponse
8, // 12: mcias.v1.SSOClientService.UpdateSSOClient:output_type -> mcias.v1.UpdateSSOClientResponse
10, // 13: mcias.v1.SSOClientService.DeleteSSOClient:output_type -> mcias.v1.DeleteSSOClientResponse
9, // [9:14] is the sub-list for method output_type
4, // [4:9] is the sub-list for method input_type
4, // [4:4] is the sub-list for extension type_name
4, // [4:4] is the sub-list for extension extendee
0, // [0:4] is the sub-list for field type_name
}
func init() { file_mcias_v1_sso_client_proto_init() }
func file_mcias_v1_sso_client_proto_init() {
if File_mcias_v1_sso_client_proto != nil {
return
}
file_mcias_v1_sso_client_proto_msgTypes[7].OneofWrappers = []any{}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_mcias_v1_sso_client_proto_rawDesc), len(file_mcias_v1_sso_client_proto_rawDesc)),
NumEnums: 0,
NumMessages: 11,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_mcias_v1_sso_client_proto_goTypes,
DependencyIndexes: file_mcias_v1_sso_client_proto_depIdxs,
MessageInfos: file_mcias_v1_sso_client_proto_msgTypes,
}.Build()
File_mcias_v1_sso_client_proto = out.File
file_mcias_v1_sso_client_proto_goTypes = nil
file_mcias_v1_sso_client_proto_depIdxs = nil
}

View File

@@ -0,0 +1,289 @@
// SSOClientService: CRUD management of SSO client registrations.
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.32.1
// source: mcias/v1/sso_client.proto
package mciasv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
SSOClientService_ListSSOClients_FullMethodName = "/mcias.v1.SSOClientService/ListSSOClients"
SSOClientService_CreateSSOClient_FullMethodName = "/mcias.v1.SSOClientService/CreateSSOClient"
SSOClientService_GetSSOClient_FullMethodName = "/mcias.v1.SSOClientService/GetSSOClient"
SSOClientService_UpdateSSOClient_FullMethodName = "/mcias.v1.SSOClientService/UpdateSSOClient"
SSOClientService_DeleteSSOClient_FullMethodName = "/mcias.v1.SSOClientService/DeleteSSOClient"
)
// SSOClientServiceClient is the client API for SSOClientService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// SSOClientService manages SSO client registrations (admin only).
type SSOClientServiceClient interface {
// ListSSOClients returns all registered SSO clients.
ListSSOClients(ctx context.Context, in *ListSSOClientsRequest, opts ...grpc.CallOption) (*ListSSOClientsResponse, error)
// CreateSSOClient registers a new SSO client.
CreateSSOClient(ctx context.Context, in *CreateSSOClientRequest, opts ...grpc.CallOption) (*CreateSSOClientResponse, error)
// GetSSOClient returns a single SSO client by client_id.
GetSSOClient(ctx context.Context, in *GetSSOClientRequest, opts ...grpc.CallOption) (*GetSSOClientResponse, error)
// UpdateSSOClient applies a partial update to an SSO client.
UpdateSSOClient(ctx context.Context, in *UpdateSSOClientRequest, opts ...grpc.CallOption) (*UpdateSSOClientResponse, error)
// DeleteSSOClient removes an SSO client registration.
DeleteSSOClient(ctx context.Context, in *DeleteSSOClientRequest, opts ...grpc.CallOption) (*DeleteSSOClientResponse, error)
}
type sSOClientServiceClient struct {
cc grpc.ClientConnInterface
}
func NewSSOClientServiceClient(cc grpc.ClientConnInterface) SSOClientServiceClient {
return &sSOClientServiceClient{cc}
}
func (c *sSOClientServiceClient) ListSSOClients(ctx context.Context, in *ListSSOClientsRequest, opts ...grpc.CallOption) (*ListSSOClientsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListSSOClientsResponse)
err := c.cc.Invoke(ctx, SSOClientService_ListSSOClients_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) CreateSSOClient(ctx context.Context, in *CreateSSOClientRequest, opts ...grpc.CallOption) (*CreateSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CreateSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_CreateSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) GetSSOClient(ctx context.Context, in *GetSSOClientRequest, opts ...grpc.CallOption) (*GetSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_GetSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) UpdateSSOClient(ctx context.Context, in *UpdateSSOClientRequest, opts ...grpc.CallOption) (*UpdateSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_UpdateSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *sSOClientServiceClient) DeleteSSOClient(ctx context.Context, in *DeleteSSOClientRequest, opts ...grpc.CallOption) (*DeleteSSOClientResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeleteSSOClientResponse)
err := c.cc.Invoke(ctx, SSOClientService_DeleteSSOClient_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// SSOClientServiceServer is the server API for SSOClientService service.
// All implementations must embed UnimplementedSSOClientServiceServer
// for forward compatibility.
//
// SSOClientService manages SSO client registrations (admin only).
type SSOClientServiceServer interface {
// ListSSOClients returns all registered SSO clients.
ListSSOClients(context.Context, *ListSSOClientsRequest) (*ListSSOClientsResponse, error)
// CreateSSOClient registers a new SSO client.
CreateSSOClient(context.Context, *CreateSSOClientRequest) (*CreateSSOClientResponse, error)
// GetSSOClient returns a single SSO client by client_id.
GetSSOClient(context.Context, *GetSSOClientRequest) (*GetSSOClientResponse, error)
// UpdateSSOClient applies a partial update to an SSO client.
UpdateSSOClient(context.Context, *UpdateSSOClientRequest) (*UpdateSSOClientResponse, error)
// DeleteSSOClient removes an SSO client registration.
DeleteSSOClient(context.Context, *DeleteSSOClientRequest) (*DeleteSSOClientResponse, error)
mustEmbedUnimplementedSSOClientServiceServer()
}
// UnimplementedSSOClientServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedSSOClientServiceServer struct{}
func (UnimplementedSSOClientServiceServer) ListSSOClients(context.Context, *ListSSOClientsRequest) (*ListSSOClientsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListSSOClients not implemented")
}
func (UnimplementedSSOClientServiceServer) CreateSSOClient(context.Context, *CreateSSOClientRequest) (*CreateSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CreateSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) GetSSOClient(context.Context, *GetSSOClientRequest) (*GetSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) UpdateSSOClient(context.Context, *UpdateSSOClientRequest) (*UpdateSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) DeleteSSOClient(context.Context, *DeleteSSOClientRequest) (*DeleteSSOClientResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DeleteSSOClient not implemented")
}
func (UnimplementedSSOClientServiceServer) mustEmbedUnimplementedSSOClientServiceServer() {}
func (UnimplementedSSOClientServiceServer) testEmbeddedByValue() {}
// UnsafeSSOClientServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to SSOClientServiceServer will
// result in compilation errors.
type UnsafeSSOClientServiceServer interface {
mustEmbedUnimplementedSSOClientServiceServer()
}
func RegisterSSOClientServiceServer(s grpc.ServiceRegistrar, srv SSOClientServiceServer) {
// If the following call panics, it indicates UnimplementedSSOClientServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&SSOClientService_ServiceDesc, srv)
}
func _SSOClientService_ListSSOClients_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListSSOClientsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).ListSSOClients(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_ListSSOClients_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).ListSSOClients(ctx, req.(*ListSSOClientsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_CreateSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CreateSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).CreateSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_CreateSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).CreateSSOClient(ctx, req.(*CreateSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_GetSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).GetSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_GetSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).GetSSOClient(ctx, req.(*GetSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_UpdateSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).UpdateSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_UpdateSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).UpdateSSOClient(ctx, req.(*UpdateSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
func _SSOClientService_DeleteSSOClient_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeleteSSOClientRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(SSOClientServiceServer).DeleteSSOClient(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: SSOClientService_DeleteSSOClient_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(SSOClientServiceServer).DeleteSSOClient(ctx, req.(*DeleteSSOClientRequest))
}
return interceptor(ctx, in, info, handler)
}
// SSOClientService_ServiceDesc is the grpc.ServiceDesc for SSOClientService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var SSOClientService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "mcias.v1.SSOClientService",
HandlerType: (*SSOClientServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "ListSSOClients",
Handler: _SSOClientService_ListSSOClients_Handler,
},
{
MethodName: "CreateSSOClient",
Handler: _SSOClientService_CreateSSOClient_Handler,
},
{
MethodName: "GetSSOClient",
Handler: _SSOClientService_GetSSOClient_Handler,
},
{
MethodName: "UpdateSSOClient",
Handler: _SSOClientService_UpdateSSOClient_Handler,
},
{
MethodName: "DeleteSSOClient",
Handler: _SSOClientService_DeleteSSOClient_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "mcias/v1/sso_client.proto",
}

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v3.20.3 // protoc v6.32.1
// source: mcias/v1/token.proto // source: mcias/v1/token.proto
package mciasv1 package mciasv1
@@ -346,7 +346,7 @@ const file_mcias_v1_token_proto_rawDesc = "" +
"\fTokenService\x12P\n" + "\fTokenService\x12P\n" +
"\rValidateToken\x12\x1e.mcias.v1.ValidateTokenRequest\x1a\x1f.mcias.v1.ValidateTokenResponse\x12\\\n" + "\rValidateToken\x12\x1e.mcias.v1.ValidateTokenRequest\x1a\x1f.mcias.v1.ValidateTokenResponse\x12\\\n" +
"\x11IssueServiceToken\x12\".mcias.v1.IssueServiceTokenRequest\x1a#.mcias.v1.IssueServiceTokenResponse\x12J\n" + "\x11IssueServiceToken\x12\".mcias.v1.IssueServiceTokenRequest\x1a#.mcias.v1.IssueServiceTokenResponse\x12J\n" +
"\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB2Z0git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3" "\vRevokeToken\x12\x1c.mcias.v1.RevokeTokenRequest\x1a\x1d.mcias.v1.RevokeTokenResponseB0Z.git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1b\x06proto3"
var ( var (
file_mcias_v1_token_proto_rawDescOnce sync.Once file_mcias_v1_token_proto_rawDescOnce sync.Once

View File

@@ -3,7 +3,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.1
// - protoc v3.20.3 // - protoc v6.32.1
// source: mcias/v1/token.proto // source: mcias/v1/token.proto
package mciasv1 package mciasv1

2
go.mod
View File

@@ -13,7 +13,7 @@ require (
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
google.golang.org/grpc v1.79.3 google.golang.org/grpc v1.79.3
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.47.0 modernc.org/sqlite v1.47.0
) )

4
go.sum
View File

@@ -100,8 +100,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -22,7 +22,7 @@ var migrationsFS embed.FS
// LatestSchemaVersion is the highest migration version defined in the // LatestSchemaVersion is the highest migration version defined in the
// migrations/ directory. Update this constant whenever a new migration file // migrations/ directory. Update this constant whenever a new migration file
// is added. // is added.
const LatestSchemaVersion = 9 const LatestSchemaVersion = 10
// newMigrate constructs a migrate.Migrate instance backed by the embedded SQL // 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 // 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.RegisterAccountServiceServer(srv, &accountServiceServer{s: s})
mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s}) mciasv1.RegisterCredentialServiceServer(srv, &credentialServiceServer{s: s})
mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s}) mciasv1.RegisterPolicyServiceServer(srv, &policyServiceServer{s: s})
mciasv1.RegisterSSOClientServiceServer(srv, &ssoClientServiceServer{s: s})
return srv 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

@@ -218,8 +218,29 @@ const (
EventWebAuthnRemoved = "webauthn_removed" EventWebAuthnRemoved = "webauthn_removed"
EventWebAuthnLoginOK = "webauthn_login_ok" EventWebAuthnLoginOK = "webauthn_login_ok"
EventWebAuthnLoginFail = "webauthn_login_fail" EventWebAuthnLoginFail = "webauthn_login_fail"
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 // ServiceAccountDelegate records that a specific account has been granted
// permission to issue tokens for a given system account. Only admins can // permission to issue tokens for a given system account. Only admins can
// add or remove delegates; delegates can issue/rotate tokens for that specific // 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 ActionEnrollWebAuthn Action = "webauthn:enroll" // self-service
ActionRemoveWebAuthn Action = "webauthn:remove" // admin ActionRemoveWebAuthn Action = "webauthn:remove" // admin
ActionManageSSOClients Action = "sso_clients:manage" // admin
) )
// ResourceType identifies what kind of object a request targets. // ResourceType identifies what kind of object a request targets.
@@ -64,6 +66,7 @@ const (
ResourceTOTP ResourceType = "totp" ResourceTOTP ResourceType = "totp"
ResourcePolicy ResourceType = "policy" ResourcePolicy ResourceType = "policy"
ResourceWebAuthn ResourceType = "webauthn" ResourceWebAuthn ResourceType = "webauthn"
ResourceSSOClient ResourceType = "sso_client"
) )
// Effect is the outcome of policy evaluation. // Effect is the outcome of policy evaluation.

View File

@@ -0,0 +1,145 @@
package server
import (
"net/http"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/middleware"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/policy"
"git.wntrmute.dev/mc/mcias/internal/sso"
"git.wntrmute.dev/mc/mcias/internal/token"
)
// ssoTokenRequest is the request body for POST /v1/sso/token.
type ssoTokenRequest struct {
Code string `json:"code"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
}
// handleSSOTokenExchange exchanges an SSO authorization code for a JWT token.
//
// Security design:
// - The authorization code is single-use (consumed via LoadAndDelete).
// - The client_id and redirect_uri must match the values stored when the code
// was issued, preventing a stolen code from being exchanged by a different
// service.
// - Policy evaluation uses the service_name and tags from the registered SSO
// client config (not from the request), preventing identity spoofing.
// - The code expires after 60 seconds to limit the interception window.
func (s *Server) handleSSOTokenExchange(w http.ResponseWriter, r *http.Request) {
var req ssoTokenRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Code == "" || req.ClientID == "" || req.RedirectURI == "" {
middleware.WriteError(w, http.StatusBadRequest, "code, client_id, and redirect_uri are required", "bad_request")
return
}
// Consume the authorization code (single-use).
ac, ok := sso.Consume(req.Code)
if !ok {
middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code")
return
}
// Security: verify client_id and redirect_uri match the stored values.
if ac.ClientID != req.ClientID || ac.RedirectURI != req.RedirectURI {
s.logger.Warn("sso: token exchange parameter mismatch",
"expected_client", ac.ClientID, "got_client", req.ClientID)
middleware.WriteError(w, http.StatusUnauthorized, "invalid or expired authorization code", "invalid_code")
return
}
// 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)
if err != nil {
s.logger.Error("sso: load account for token exchange", "error", err, "account_id", ac.AccountID)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if acct.Status != model.AccountStatusActive {
middleware.WriteError(w, http.StatusForbidden, "account is not active", "account_inactive")
return
}
// Load roles for policy evaluation and expiry decision.
roles, err := s.db.GetRoles(acct.ID)
if err != nil {
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
// Policy evaluation: client_id serves as both identifier and service_name.
{
input := policy.PolicyInput{
Subject: acct.UUID,
AccountType: string(acct.AccountType),
Roles: roles,
Action: policy.ActionLogin,
Resource: policy.Resource{
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.ClientID, "via", "sso"))
middleware.WriteError(w, http.StatusForbidden, "access denied by policy", "policy_denied")
return
}
}
// Determine expiry.
expiry := s.cfg.DefaultExpiry()
for _, rol := range roles {
if rol == "admin" {
expiry = s.cfg.AdminExpiry()
break
}
}
privKey, err := s.vault.PrivKey()
if err != nil {
middleware.WriteError(w, http.StatusServiceUnavailable, "vault sealed", "vault_sealed")
return
}
tokenStr, claims, err := token.IssueToken(privKey, s.cfg.Tokens.Issuer, acct.UUID, roles, expiry)
if err != nil {
s.logger.Error("sso: issue token", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
if err := s.db.TrackToken(claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
s.logger.Error("sso: track token", "error", err)
middleware.WriteError(w, http.StatusInternalServerError, "internal error", "internal_error")
return
}
s.writeAudit(r, model.EventSSOLoginOK, &acct.ID, nil,
audit.JSON("jti", claims.JTI, "client_id", client.ClientID))
s.writeAudit(r, model.EventTokenIssued, &acct.ID, nil,
audit.JSON("jti", claims.JTI, "via", "sso"))
writeJSON(w, http.StatusOK, loginResponse{
Token: tokenStr,
ExpiresAt: claims.ExpiresAt.Format("2006-01-02T15:04:05Z"),
})
}

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

@@ -215,6 +215,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /v1/health", s.handleHealth) mux.HandleFunc("GET /v1/health", s.handleHealth)
mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey) mux.HandleFunc("GET /v1/keys/public", s.handlePublicKey)
mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin))) mux.Handle("POST /v1/auth/login", loginRateLimit(http.HandlerFunc(s.handleLogin)))
mux.Handle("POST /v1/sso/token", loginRateLimit(http.HandlerFunc(s.handleSSOTokenExchange)))
mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate))) mux.Handle("POST /v1/token/validate", loginRateLimit(http.HandlerFunc(s.handleTokenValidate)))
// API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml. // API documentation: Swagger UI at /docs and raw spec at /docs/openapi.yaml.
@@ -372,6 +373,18 @@ func (s *Server) Handler() http.Handler {
mux.Handle("DELETE /v1/policy/rules/{id}", mux.Handle("DELETE /v1/policy/rules/{id}",
requirePolicy(policy.ActionManageRules, policy.ResourcePolicy, nil)(http.HandlerFunc(s.handleDeletePolicyRule))) 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). // UI routes (HTMX-based management frontend).
uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger) uiSrv, err := ui.New(s.db, s.cfg, s.vault, s.logger)
if err != nil { if err != nil {

91
internal/sso/session.go Normal file
View File

@@ -0,0 +1,91 @@
package sso
import (
"fmt"
"sync"
"time"
"git.wntrmute.dev/mc/mcias/internal/crypto"
)
const (
sessionTTL = 5 * time.Minute
sessionBytes = 16 // 128 bits of entropy for the nonce
)
// Session holds the SSO parameters between /sso/authorize and login completion.
// The nonce is embedded as a hidden form field in the login page and carried
// through the multi-step login flow (password → TOTP, or WebAuthn).
type Session struct { //nolint:govet // fieldalignment: field order matches logical grouping
ClientID string
RedirectURI string
State string
ExpiresAt time.Time
}
// pendingSessions stores SSO sessions created at /sso/authorize.
var pendingSessions sync.Map //nolint:gochecknoglobals
func init() {
go cleanupSessions()
}
func cleanupSessions() {
ticker := time.NewTicker(cleanupPeriod)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
pendingSessions.Range(func(key, value any) bool {
s, ok := value.(*Session)
if !ok || now.After(s.ExpiresAt) {
pendingSessions.Delete(key)
}
return true
})
}
}
// StoreSession creates and stores a new SSO session, returning the hex-encoded
// nonce that should be embedded in the login form.
func StoreSession(clientID, redirectURI, state string) (string, error) {
raw, err := crypto.RandomBytes(sessionBytes)
if err != nil {
return "", fmt.Errorf("sso: generate session nonce: %w", err)
}
nonce := fmt.Sprintf("%x", raw)
pendingSessions.Store(nonce, &Session{
ClientID: clientID,
RedirectURI: redirectURI,
State: state,
ExpiresAt: time.Now().Add(sessionTTL),
})
return nonce, nil
}
// ConsumeSession retrieves and deletes an SSO session by nonce.
// Returns the Session and true if valid, or (nil, false) if unknown or expired.
func ConsumeSession(nonce string) (*Session, bool) {
v, ok := pendingSessions.LoadAndDelete(nonce)
if !ok {
return nil, false
}
s, ok2 := v.(*Session)
if !ok2 || time.Now().After(s.ExpiresAt) {
return nil, false
}
return s, true
}
// GetSession retrieves an SSO session without consuming it (for read-only checks
// during multi-step login). Returns nil if unknown or expired.
func GetSession(nonce string) *Session {
v, ok := pendingSessions.Load(nonce)
if !ok {
return nil
}
s, ok2 := v.(*Session)
if !ok2 || time.Now().After(s.ExpiresAt) {
return nil
}
return s
}

93
internal/sso/store.go Normal file
View File

@@ -0,0 +1,93 @@
// 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
}

132
internal/sso/store_test.go Normal file
View File

@@ -0,0 +1,132 @@
package sso
import (
"testing"
"time"
)
func TestStoreAndConsume(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "state123", 42)
if err != nil {
t.Fatalf("Store: %v", err)
}
if code == "" {
t.Fatal("Store returned empty code")
}
ac, ok := Consume(code)
if !ok {
t.Fatal("Consume returned false for valid code")
}
if ac.ClientID != "mcr" {
t.Errorf("ClientID = %q, want %q", ac.ClientID, "mcr")
}
if ac.RedirectURI != "https://mcr.example.com/cb" {
t.Errorf("RedirectURI = %q", ac.RedirectURI)
}
if ac.State != "state123" {
t.Errorf("State = %q", ac.State)
}
if ac.AccountID != 42 {
t.Errorf("AccountID = %d, want 42", ac.AccountID)
}
}
func TestConsumeSingleUse(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1)
if err != nil {
t.Fatalf("Store: %v", err)
}
if _, ok := Consume(code); !ok {
t.Fatal("first Consume should succeed")
}
if _, ok := Consume(code); ok {
t.Error("second Consume should fail (single-use)")
}
}
func TestConsumeUnknownCode(t *testing.T) {
if _, ok := Consume("nonexistent"); ok {
t.Error("Consume should fail for unknown code")
}
}
func TestConsumeExpiredCode(t *testing.T) {
code, err := Store("mcr", "https://mcr.example.com/cb", "s", 1)
if err != nil {
t.Fatalf("Store: %v", err)
}
// Manually expire the code.
v, loaded := pendingCodes.Load(code)
if !loaded {
t.Fatal("code not found in pendingCodes")
}
ac, ok := v.(*AuthCode)
if !ok {
t.Fatal("unexpected type in pendingCodes")
}
ac.ExpiresAt = time.Now().Add(-1 * time.Second)
if _, ok := Consume(code); ok {
t.Error("Consume should fail for expired code")
}
}
func TestStoreSessionAndConsume(t *testing.T) {
nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "state456")
if err != nil {
t.Fatalf("StoreSession: %v", err)
}
if nonce == "" {
t.Fatal("StoreSession returned empty nonce")
}
// GetSession should return it without consuming.
s := GetSession(nonce)
if s == nil {
t.Fatal("GetSession returned nil")
}
if s.ClientID != "mcr" {
t.Errorf("ClientID = %q", s.ClientID)
}
// Still available after GetSession.
s2, ok := ConsumeSession(nonce)
if !ok {
t.Fatal("ConsumeSession returned false")
}
if s2.State != "state456" {
t.Errorf("State = %q", s2.State)
}
// Consumed — should be gone.
if _, ok := ConsumeSession(nonce); ok {
t.Error("second ConsumeSession should fail")
}
if GetSession(nonce) != nil {
t.Error("GetSession should return nil after consume")
}
}
func TestConsumeSessionExpired(t *testing.T) {
nonce, err := StoreSession("mcr", "https://mcr.example.com/cb", "s")
if err != nil {
t.Fatalf("StoreSession: %v", err)
}
v, loaded := pendingSessions.Load(nonce)
if !loaded {
t.Fatal("session not found in pendingSessions")
}
sess, ok := v.(*Session)
if !ok {
t.Fatal("unexpected type in pendingSessions")
}
sess.ExpiresAt = time.Now().Add(-1 * time.Second)
if _, ok := ConsumeSession(nonce); ok {
t.Error("ConsumeSession should fail for expired session")
}
}

View File

@@ -15,6 +15,7 @@ import (
func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { func (u *UIServer) handleLoginPage(w http.ResponseWriter, r *http.Request) {
u.render(w, "login", LoginData{ u.render(w, "login", LoginData{
WebAuthnEnabled: u.cfg.WebAuthnEnabled(), WebAuthnEnabled: u.cfg.WebAuthnEnabled(),
SSONonce: r.URL.Query().Get("sso"),
}) })
} }
@@ -97,6 +98,8 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
return return
} }
ssoNonce := r.FormValue("sso_nonce")
// TOTP required: issue a server-side nonce and show the TOTP step form. // TOTP required: issue a server-side nonce and show the TOTP step form.
// Security: the nonce replaces the password hidden field (F-02). The password // Security: the nonce replaces the password hidden field (F-02). The password
// is not stored anywhere after this point; only the account ID is retained. // is not stored anywhere after this point; only the account ID is retained.
@@ -110,11 +113,12 @@ func (u *UIServer) handleLoginPost(w http.ResponseWriter, r *http.Request) {
u.render(w, "totp_step", LoginData{ u.render(w, "totp_step", LoginData{
Username: username, Username: username,
Nonce: nonce, Nonce: nonce,
SSONonce: ssoNonce,
}) })
return return
} }
u.finishLogin(w, r, acct) u.finishLogin(w, r, acct, ssoNonce)
} }
// handleTOTPStep handles the second POST when totp_step=1 is set. // handleTOTPStep handles the second POST when totp_step=1 is set.
@@ -129,6 +133,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username") //nolint:gosec // body already limited by caller username := r.FormValue("username") //nolint:gosec // body already limited by caller
nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller nonce := r.FormValue("totp_nonce") //nolint:gosec // body already limited by caller
totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller totpCode := r.FormValue("totp_code") //nolint:gosec // body already limited by caller
ssoNonce := r.FormValue("sso_nonce") //nolint:gosec // body already limited by caller
// Security: consume the nonce (single-use); reject if unknown or expired. // Security: consume the nonce (single-use); reject if unknown or expired.
accountID, ok := u.consumeTOTPNonce(nonce) accountID, ok := u.consumeTOTPNonce(nonce)
@@ -172,6 +177,7 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
Error: "invalid TOTP code", Error: "invalid TOTP code",
Username: username, Username: username,
Nonce: newNonce, Nonce: newNonce,
SSONonce: ssoNonce,
}) })
return return
} }
@@ -189,15 +195,36 @@ func (u *UIServer) handleTOTPStep(w http.ResponseWriter, r *http.Request) {
Error: "invalid TOTP code", Error: "invalid TOTP code",
Username: username, Username: username,
Nonce: newNonce, Nonce: newNonce,
SSONonce: ssoNonce,
}) })
return return
} }
u.finishLogin(w, r, acct) u.finishLogin(w, r, acct, ssoNonce)
} }
// finishLogin issues a JWT, sets the session cookie, and redirects to dashboard. // finishLogin issues a JWT, sets the session cookie, and redirects to dashboard.
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account) { // When ssoNonce is non-empty, the login is part of an SSO redirect flow: instead
// of setting a session cookie, an authorization code is issued and the user is
// redirected back to the service's callback URL.
func (u *UIServer) finishLogin(w http.ResponseWriter, r *http.Request, acct *model.Account, ssoNonce string) {
// SSO redirect flow: issue authorization code and redirect to service.
if ssoNonce != "" {
if callbackURL, ok := u.buildSSOCallback(r, ssoNonce, acct.ID); ok {
// Security: htmx follows 302 redirects via fetch, which fails
// cross-origin (no CORS on the service callback). Use HX-Redirect
// so htmx performs a full page navigation instead.
if isHTMX(r) {
w.Header().Set("HX-Redirect", callbackURL)
w.WriteHeader(http.StatusOK)
return
}
http.Redirect(w, r, callbackURL, http.StatusFound)
return
}
// SSO session expired/consumed — fall through to normal login.
}
// Determine token expiry based on admin role. // Determine token expiry based on admin role.
expiry := u.cfg.DefaultExpiry() expiry := u.cfg.DefaultExpiry()
roles, err := u.db.GetRoles(acct.ID) roles, err := u.db.GetRoles(acct.ID)

View File

@@ -0,0 +1,90 @@
package ui
import (
"net/http"
"net/url"
"git.wntrmute.dev/mc/mcias/internal/audit"
"git.wntrmute.dev/mc/mcias/internal/model"
"git.wntrmute.dev/mc/mcias/internal/sso"
)
// handleSSOAuthorize validates the SSO request parameters against registered
// clients, creates an SSO session, and redirects to /login with the SSO nonce.
//
// Security: the client_id and redirect_uri are validated against the MCIAS
// config (exact match). The state parameter is opaque and carried through
// unchanged. An SSO session is created server-side so the nonce is the only
// value embedded in the login form.
func (u *UIServer) handleSSOAuthorize(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
redirectURI := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
if clientID == "" || redirectURI == "" || state == "" {
http.Error(w, "missing required parameters: client_id, redirect_uri, state", http.StatusBadRequest)
return
}
// 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 {
u.logger.Warn("sso: redirect_uri mismatch",
"client_id", clientID,
"expected", client.RedirectURI,
"got", redirectURI)
http.Error(w, "redirect_uri does not match registered URI", http.StatusBadRequest)
return
}
nonce, err := sso.StoreSession(clientID, redirectURI, state)
if err != nil {
u.logger.Error("sso: store session", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
u.writeAudit(r, model.EventSSOAuthorize, nil, nil,
audit.JSON("client_id", clientID))
http.Redirect(w, r, "/login?sso="+url.QueryEscape(nonce), http.StatusFound)
}
// buildSSOCallback consumes the SSO session, generates an authorization code,
// and returns the callback URL with code and state parameters. Returns ("", false)
// if the SSO session is expired or already consumed.
//
// Security: the SSO session is consumed (single-use) and the authorization code
// is stored server-side for exchange via POST /v1/sso/token. The state parameter
// is carried through unchanged for the service to validate.
func (u *UIServer) buildSSOCallback(r *http.Request, ssoNonce string, accountID int64) (string, bool) {
sess, ok := sso.ConsumeSession(ssoNonce)
if !ok {
return "", false
}
code, err := sso.Store(sess.ClientID, sess.RedirectURI, sess.State, accountID)
if err != nil {
u.logger.Error("sso: store auth code", "error", err)
return "", false
}
u.writeAudit(r, model.EventSSOLoginOK, &accountID, nil,
audit.JSON("client_id", sess.ClientID))
return sess.RedirectURI + "?code=" + url.QueryEscape(code) + "&state=" + url.QueryEscape(sess.State), true
}

View File

@@ -0,0 +1,131 @@
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 {
u.logger.Error("sso-clients: set CSRF cookies", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
clients, err := u.db.ListSSOClients()
if err != nil {
u.logger.Error("sso-clients: list clients", "error", err)
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

@@ -27,10 +27,11 @@ const (
) )
// webauthnCeremony holds a pending WebAuthn ceremony. // webauthnCeremony holds a pending WebAuthn ceremony.
type webauthnCeremony struct { type webauthnCeremony struct { //nolint:govet // fieldalignment: field order matches logical grouping
expiresAt time.Time expiresAt time.Time
session *libwebauthn.SessionData session *libwebauthn.SessionData
accountID int64 accountID int64
ssoNonce string // non-empty when login is part of an SSO redirect flow
} }
// pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI. // pendingWebAuthnCeremonies stores in-flight WebAuthn ceremonies for the UI.
@@ -55,7 +56,7 @@ func cleanupUIWebAuthnCeremonies() {
} }
} }
func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string, error) { func storeUICeremony(session *libwebauthn.SessionData, accountID int64, ssoNonce string) (string, error) {
raw, err := crypto.RandomBytes(webauthnNonceBytes) raw, err := crypto.RandomBytes(webauthnNonceBytes)
if err != nil { if err != nil {
return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err) return "", fmt.Errorf("webauthn: generate ceremony nonce: %w", err)
@@ -64,6 +65,7 @@ func storeUICeremony(session *libwebauthn.SessionData, accountID int64) (string,
pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{ pendingUIWebAuthnCeremonies.Store(nonce, &webauthnCeremony{
session: session, session: session,
accountID: accountID, accountID: accountID,
ssoNonce: ssoNonce,
expiresAt: time.Now().Add(webauthnCeremonyTTL), expiresAt: time.Now().Add(webauthnCeremonyTTL),
}) })
return nonce, nil return nonce, nil
@@ -170,7 +172,7 @@ func (u *UIServer) handleWebAuthnBegin(w http.ResponseWriter, r *http.Request) {
return return
} }
nonce, err := storeUICeremony(session, acct.ID) nonce, err := storeUICeremony(session, acct.ID, "")
if err != nil { if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error") writeJSONError(w, http.StatusInternalServerError, "internal error")
return return
@@ -352,6 +354,7 @@ func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Reque
r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes) r.Body = http.MaxBytesReader(w, r.Body, maxFormBytes)
var req struct { var req struct {
Username string `json:"username"` Username string `json:"username"`
SSONonce string `json:"sso_nonce"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON") writeJSONError(w, http.StatusBadRequest, "invalid JSON")
@@ -413,7 +416,7 @@ func (u *UIServer) handleWebAuthnLoginBegin(w http.ResponseWriter, r *http.Reque
return return
} }
nonce, err := storeUICeremony(session, accountID) nonce, err := storeUICeremony(session, accountID, req.SSONonce)
if err != nil { if err != nil {
writeJSONError(w, http.StatusInternalServerError, "internal error") writeJSONError(w, http.StatusInternalServerError, "internal error")
return return
@@ -582,6 +585,17 @@ func (u *UIServer) handleWebAuthnLoginFinish(w http.ResponseWriter, r *http.Requ
_ = u.db.ClearLoginFailures(acct.ID) _ = u.db.ClearLoginFailures(acct.ID)
// SSO redirect flow: issue authorization code and return redirect URL as JSON.
if ceremony.ssoNonce != "" {
if callbackURL, ok := u.buildSSOCallback(r, ceremony.ssoNonce, acct.ID); ok {
u.writeAudit(r, model.EventWebAuthnLoginOK, &acct.ID, nil, "")
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"redirect": callbackURL})
return
}
// SSO session expired — fall through to normal login.
}
// Issue JWT and set session cookie. // Issue JWT and set session cookie.
expiry := u.cfg.DefaultExpiry() expiry := u.cfg.DefaultExpiry()
roles, err := u.db.GetRoles(acct.ID) roles, err := u.db.GetRoles(acct.ID)

View File

@@ -267,6 +267,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
"templates/fragments/webauthn_enroll.html", "templates/fragments/webauthn_enroll.html",
"templates/fragments/totp_section.html", "templates/fragments/totp_section.html",
"templates/fragments/totp_enroll_qr.html", "templates/fragments/totp_enroll_qr.html",
"templates/fragments/sso_client_row.html",
} }
base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...) base, err := template.New("").Funcs(funcMap).ParseFS(web.TemplateFS, sharedFiles...)
if err != nil { if err != nil {
@@ -287,6 +288,7 @@ func New(database *db.DB, cfg *config.Config, v *vault.Vault, logger *slog.Logge
"profile": "templates/profile.html", "profile": "templates/profile.html",
"unseal": "templates/unseal.html", "unseal": "templates/unseal.html",
"service_accounts": "templates/service_accounts.html", "service_accounts": "templates/service_accounts.html",
"sso_clients": "templates/sso_clients.html",
} }
tmpls := make(map[string]*template.Template, len(pageFiles)) tmpls := make(map[string]*template.Template, len(pageFiles))
for name, file := range pageFiles { for name, file := range pageFiles {
@@ -445,6 +447,9 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.HandleFunc("GET /unseal", u.handleUnsealPage) uiMux.HandleFunc("GET /unseal", u.handleUnsealPage)
uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost))) uiMux.Handle("POST /unseal", unsealRateLimit(http.HandlerFunc(u.handleUnsealPost)))
// SSO authorize route (no session required, rate-limited).
uiMux.Handle("GET /sso/authorize", loginRateLimit(http.HandlerFunc(u.handleSSOAuthorize)))
// Auth routes (no session required). // Auth routes (no session required).
uiMux.HandleFunc("GET /login", u.handleLoginPage) uiMux.HandleFunc("GET /login", u.handleLoginPage)
uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost))) uiMux.Handle("POST /login", loginRateLimit(http.HandlerFunc(u.handleLoginPost)))
@@ -498,6 +503,10 @@ func (u *UIServer) Register(mux *http.ServeMux) {
uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule)) uiMux.Handle("DELETE /policies/{id}", admin(u.handleDeletePolicyRule))
uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags)) uiMux.Handle("PUT /accounts/{id}/tags", admin(u.handleSetAccountTags))
uiMux.Handle("PUT /accounts/{id}/password", admin(u.handleAdminResetPassword)) 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 // Service accounts page — accessible to any authenticated user; shows only
// the service accounts for which the current user is a token-issue delegate. // the service accounts for which the current user is a token-issue delegate.
@@ -746,8 +755,11 @@ func noDirListing(next http.Handler) http.Handler {
func securityHeaders(next http.Handler) http.Handler { func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header() h := w.Header()
// Security: 'unsafe-hashes' with the htmx swap indicator style hash
// allows htmx to apply its settling/swapping CSS transitions without
// opening the door to arbitrary inline styles.
h.Set("Content-Security-Policy", h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'") "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-hashes' 'sha256-bsV5JivYxvGywDAZ22EZJKBFip65Ng9xoJVLbBg7bdo='; img-src 'self' data:; font-src 'self'")
h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY") h.Set("X-Frame-Options", "DENY")
h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains") h.Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
@@ -810,6 +822,7 @@ type PageData struct {
type LoginData struct { type LoginData struct {
Error string Error string
Username string // pre-filled on TOTP step Username string // pre-filled on TOTP step
SSONonce string // SSO session nonce (hidden field for SSO redirect flow)
// Security (F-02): Password is no longer carried in the HTML form. Instead // Security (F-02): Password is no longer carried in the HTML form. Instead
// a short-lived server-side nonce is issued after successful password // a short-lived server-side nonce is issued after successful password
// verification, and only the nonce is embedded in the TOTP step form. // verification, and only the nonce is embedded in the TOTP step form.
@@ -916,6 +929,12 @@ type PolicyRuleView struct {
IsPending bool // true if not_before is in the future 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. // PoliciesData is the view model for the policies list page.
type PoliciesData struct { type PoliciesData struct {
PageData PageData

View File

@@ -6,5 +6,5 @@
// //
// Prerequisites: protoc, protoc-gen-go, protoc-gen-go-grpc must be in PATH. // Prerequisites: protoc, protoc-gen-go, protoc-gen-go-grpc must be in PATH.
// //
//go:generate protoc --proto_path=../proto --go_out=../gen --go_opt=paths=source_relative --go-grpc_out=../gen --go-grpc_opt=paths=source_relative mcias/v1/common.proto mcias/v1/admin.proto mcias/v1/auth.proto mcias/v1/token.proto mcias/v1/account.proto mcias/v1/policy.proto //go:generate protoc --proto_path=../proto --go_out=../gen --go_opt=paths=source_relative --go-grpc_out=../gen --go-grpc_opt=paths=source_relative mcias/v1/common.proto mcias/v1/admin.proto mcias/v1/auth.proto mcias/v1/token.proto mcias/v1/account.proto mcias/v1/policy.proto mcias/v1/sso_client.proto
package proto package proto

View File

@@ -0,0 +1,86 @@
// SSOClientService: CRUD management of SSO client registrations.
syntax = "proto3";
package mcias.v1;
option go_package = "git.wntrmute.dev/mc/mcias/gen/mcias/v1;mciasv1";
// SSOClient is the wire representation of an SSO client registration.
message SSOClient {
string client_id = 1;
string redirect_uri = 2;
repeated string tags = 3;
bool enabled = 4;
string created_at = 5; // RFC3339
string updated_at = 6; // RFC3339
}
// --- List ---
message ListSSOClientsRequest {}
message ListSSOClientsResponse {
repeated SSOClient clients = 1;
}
// --- Create ---
message CreateSSOClientRequest {
string client_id = 1;
string redirect_uri = 2;
repeated string tags = 3;
}
message CreateSSOClientResponse {
SSOClient client = 1;
}
// --- Get ---
message GetSSOClientRequest {
string client_id = 1;
}
message GetSSOClientResponse {
SSOClient client = 1;
}
// --- Update ---
message UpdateSSOClientRequest {
string client_id = 1;
optional string redirect_uri = 2;
repeated string tags = 3;
optional bool enabled = 4;
bool update_tags = 5; // when true, tags field is applied (allows clearing)
}
message UpdateSSOClientResponse {
SSOClient client = 1;
}
// --- Delete ---
message DeleteSSOClientRequest {
string client_id = 1;
}
message DeleteSSOClientResponse {}
// SSOClientService manages SSO client registrations (admin only).
service SSOClientService {
// ListSSOClients returns all registered SSO clients.
rpc ListSSOClients(ListSSOClientsRequest) returns (ListSSOClientsResponse);
// CreateSSOClient registers a new SSO client.
rpc CreateSSOClient(CreateSSOClientRequest) returns (CreateSSOClientResponse);
// GetSSOClient returns a single SSO client by client_id.
rpc GetSSOClient(GetSSOClientRequest) returns (GetSSOClientResponse);
// UpdateSSOClient applies a partial update to an SSO client.
rpc UpdateSSOClient(UpdateSSOClientRequest) returns (UpdateSSOClientResponse);
// DeleteSSOClient removes an SSO client registration.
rpc DeleteSSOClient(DeleteSSOClientRequest) returns (DeleteSSOClientResponse);
}

View File

@@ -32,7 +32,7 @@ var byteType = reflect.TypeOf(byte(0))
func Unmarshal(tag string, goType reflect.Type, evs protoreflect.EnumValueDescriptors) protoreflect.FieldDescriptor { func Unmarshal(tag string, goType reflect.Type, evs protoreflect.EnumValueDescriptors) protoreflect.FieldDescriptor {
f := new(filedesc.Field) f := new(filedesc.Field)
f.L0.ParentFile = filedesc.SurrogateProto2 f.L0.ParentFile = filedesc.SurrogateProto2
f.L1.EditionFeatures = f.L0.ParentFile.L1.EditionFeatures packed := false
for len(tag) > 0 { for len(tag) > 0 {
i := strings.IndexByte(tag, ',') i := strings.IndexByte(tag, ',')
if i < 0 { if i < 0 {
@@ -108,7 +108,7 @@ func Unmarshal(tag string, goType reflect.Type, evs protoreflect.EnumValueDescri
f.L1.StringName.InitJSON(jsonName) f.L1.StringName.InitJSON(jsonName)
} }
case s == "packed": case s == "packed":
f.L1.EditionFeatures.IsPacked = true packed = true
case strings.HasPrefix(s, "def="): case strings.HasPrefix(s, "def="):
// The default tag is special in that everything afterwards is the // The default tag is special in that everything afterwards is the
// default regardless of the presence of commas. // default regardless of the presence of commas.
@@ -121,6 +121,13 @@ func Unmarshal(tag string, goType reflect.Type, evs protoreflect.EnumValueDescri
tag = strings.TrimPrefix(tag[i:], ",") tag = strings.TrimPrefix(tag[i:], ",")
} }
// Update EditionFeatures after the loop and after we know whether this is
// a proto2 or proto3 field.
f.L1.EditionFeatures = f.L0.ParentFile.L1.EditionFeatures
if packed {
f.L1.EditionFeatures.IsPacked = true
}
// The generator uses the group message name instead of the field name. // The generator uses the group message name instead of the field name.
// We obtain the real field name by lowercasing the group name. // We obtain the real field name by lowercasing the group name.
if f.L1.Kind == protoreflect.GroupKind { if f.L1.Kind == protoreflect.GroupKind {

View File

@@ -424,27 +424,34 @@ func (d *Decoder) parseFieldName() (tok Token, err error) {
return Token{}, d.newSyntaxError("invalid field name: %s", errId(d.in)) return Token{}, d.newSyntaxError("invalid field name: %s", errId(d.in))
} }
// parseTypeName parses Any type URL or extension field name. The name is // parseTypeName parses an Any type URL or an extension field name. The name is
// enclosed in [ and ] characters. The C++ parser does not handle many legal URL // enclosed in [ and ] characters. We allow almost arbitrary type URL prefixes,
// strings. This implementation is more liberal and allows for the pattern // closely following the text-format spec [1,2]. We implement "ExtensionName |
// ^[-_a-zA-Z0-9]+([./][-_a-zA-Z0-9]+)*`). Whitespaces and comments are allowed // AnyName" as follows (with some exceptions for backwards compatibility):
// in between [ ], '.', '/' and the sub names. //
// char = [-_a-zA-Z0-9]
// url_char = char | [.~!$&'()*+,;=] | "%", hex, hex
//
// Ident = char, { char }
// TypeName = Ident, { ".", Ident } ;
// UrlPrefix = url_char, { url_char | "/" } ;
// ExtensionName = "[", TypeName, "]" ;
// AnyName = "[", UrlPrefix, "/", TypeName, "]" ;
//
// Additionally, we allow arbitrary whitespace and comments between [ and ].
//
// [1] https://protobuf.dev/reference/protobuf/textformat-spec/#characters
// [2] https://protobuf.dev/reference/protobuf/textformat-spec/#field-names
func (d *Decoder) parseTypeName() (Token, error) { func (d *Decoder) parseTypeName() (Token, error) {
startPos := len(d.orig) - len(d.in)
// Use alias s to advance first in order to use d.in for error handling. // Use alias s to advance first in order to use d.in for error handling.
// Caller already checks for [ as first character. // Caller already checks for [ as first character (d.in[0] == '[').
s := consume(d.in[1:], 0) s := consume(d.in[1:], 0)
if len(s) == 0 { if len(s) == 0 {
return Token{}, ErrUnexpectedEOF return Token{}, ErrUnexpectedEOF
} }
// Collect everything between [ and ] in name.
var name []byte var name []byte
for len(s) > 0 && isTypeNameChar(s[0]) {
name = append(name, s[0])
s = s[1:]
}
s = consume(s, 0)
var closed bool var closed bool
for len(s) > 0 && !closed { for len(s) > 0 && !closed {
switch { switch {
@@ -452,23 +459,20 @@ func (d *Decoder) parseTypeName() (Token, error) {
s = s[1:] s = s[1:]
closed = true closed = true
case s[0] == '/', s[0] == '.': case s[0] == '/' || isTypeNameChar(s[0]) || isUrlExtraChar(s[0]):
if len(name) > 0 && (name[len(name)-1] == '/' || name[len(name)-1] == '.') {
return Token{}, d.newSyntaxError("invalid type URL/extension field name: %s",
d.orig[startPos:len(d.orig)-len(s)+1])
}
name = append(name, s[0]) name = append(name, s[0])
s = s[1:] s = consume(s[1:], 0)
s = consume(s, 0)
for len(s) > 0 && isTypeNameChar(s[0]) { // URL percent-encoded chars
name = append(name, s[0]) case s[0] == '%':
s = s[1:] if len(s) < 3 || !isHexChar(s[1]) || !isHexChar(s[2]) {
return Token{}, d.parseTypeNameError(s, 3)
} }
s = consume(s, 0) name = append(name, s[0], s[1], s[2])
s = consume(s[3:], 0)
default: default:
return Token{}, d.newSyntaxError( return Token{}, d.parseTypeNameError(s, 1)
"invalid type URL/extension field name: %s", d.orig[startPos:len(d.orig)-len(s)+1])
} }
} }
@@ -476,15 +480,38 @@ func (d *Decoder) parseTypeName() (Token, error) {
return Token{}, ErrUnexpectedEOF return Token{}, ErrUnexpectedEOF
} }
// First character cannot be '.'. Last character cannot be '.' or '/'. // Split collected name on last '/' into urlPrefix and typeName (if '/' is
size := len(name) // present).
if size == 0 || name[0] == '.' || name[size-1] == '.' || name[size-1] == '/' { typeName := name
return Token{}, d.newSyntaxError("invalid type URL/extension field name: %s", if i := bytes.LastIndexByte(name, '/'); i != -1 {
d.orig[startPos:len(d.orig)-len(s)]) urlPrefix := name[:i]
typeName = name[i+1:]
// urlPrefix may be empty (for backwards compatibility).
// If non-empty, it must not start with '/'.
if len(urlPrefix) > 0 && urlPrefix[0] == '/' {
return Token{}, d.parseTypeNameError(s, 0)
}
} }
// typeName must not be empty (note: "" splits to [""]) and all identifier
// parts must not be empty.
for _, ident := range bytes.Split(typeName, []byte{'.'}) {
if len(ident) == 0 {
return Token{}, d.parseTypeNameError(s, 0)
}
}
// typeName must not contain any percent-encoded or special URL chars.
for _, b := range typeName {
if b == '%' || (b != '.' && isUrlExtraChar(b)) {
return Token{}, d.parseTypeNameError(s, 0)
}
}
startPos := len(d.orig) - len(d.in)
endPos := len(d.orig) - len(s)
d.in = s d.in = s
endPos := len(d.orig) - len(d.in)
d.consume(0) d.consume(0)
return Token{ return Token{
@@ -496,16 +523,32 @@ func (d *Decoder) parseTypeName() (Token, error) {
}, nil }, nil
} }
func isTypeNameChar(b byte) bool { func (d *Decoder) parseTypeNameError(s []byte, numUnconsumedChars int) error {
return (b == '-' || b == '_' || return d.newSyntaxError(
('0' <= b && b <= '9') || "invalid type URL/extension field name: %s",
('a' <= b && b <= 'z') || d.in[:len(d.in)-len(s)+min(numUnconsumedChars, len(s))],
('A' <= b && b <= 'Z')) )
} }
func isWhiteSpace(b byte) bool { func isHexChar(b byte) bool {
return ('0' <= b && b <= '9') ||
('a' <= b && b <= 'f') ||
('A' <= b && b <= 'F')
}
func isTypeNameChar(b byte) bool {
return b == '-' || b == '_' ||
('0' <= b && b <= '9') ||
('a' <= b && b <= 'z') ||
('A' <= b && b <= 'Z')
}
// isUrlExtraChar complements isTypeNameChar with extra characters that we allow
// in URLs but not in type names. Note that '/' is not included so that it can
// be treated specially.
func isUrlExtraChar(b byte) bool {
switch b { switch b {
case ' ', '\n', '\r', '\t': case '.', '~', '!', '$', '&', '(', ')', '*', '+', ',', ';', '=':
return true return true
default: default:
return false return false

View File

@@ -32,6 +32,7 @@ const (
EditionProto3 Edition = 999 EditionProto3 Edition = 999
Edition2023 Edition = 1000 Edition2023 Edition = 1000
Edition2024 Edition = 1001 Edition2024 Edition = 1001
EditionUnstable Edition = 9999
EditionUnsupported Edition = 100000 EditionUnsupported Edition = 100000
) )

View File

@@ -330,7 +330,6 @@ func (md *Message) unmarshalFull(b []byte, sb *strs.Builder) {
md.L1.Extensions.List[extensionIdx].unmarshalFull(v, sb) md.L1.Extensions.List[extensionIdx].unmarshalFull(v, sb)
extensionIdx++ extensionIdx++
case genid.DescriptorProto_Options_field_number: case genid.DescriptorProto_Options_field_number:
md.unmarshalOptions(v)
rawOptions = appendOptions(rawOptions, v) rawOptions = appendOptions(rawOptions, v)
} }
default: default:
@@ -356,27 +355,6 @@ func (md *Message) unmarshalFull(b []byte, sb *strs.Builder) {
md.L2.Options = md.L0.ParentFile.builder.optionsUnmarshaler(&descopts.Message, rawOptions) md.L2.Options = md.L0.ParentFile.builder.optionsUnmarshaler(&descopts.Message, rawOptions)
} }
func (md *Message) unmarshalOptions(b []byte) {
for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b)
b = b[n:]
switch typ {
case protowire.VarintType:
v, m := protowire.ConsumeVarint(b)
b = b[m:]
switch num {
case genid.MessageOptions_MapEntry_field_number:
md.L1.IsMapEntry = protowire.DecodeBool(v)
case genid.MessageOptions_MessageSetWireFormat_field_number:
md.L1.IsMessageSet = protowire.DecodeBool(v)
}
default:
m := protowire.ConsumeFieldValue(num, typ, b)
b = b[m:]
}
}
}
func unmarshalMessageReservedRange(b []byte) (r [2]protoreflect.FieldNumber) { func unmarshalMessageReservedRange(b []byte) (r [2]protoreflect.FieldNumber) {
for len(b) > 0 { for len(b) > 0 {
num, typ, n := protowire.ConsumeTag(b) num, typ, n := protowire.ConsumeTag(b)

View File

@@ -26,6 +26,7 @@ const (
Edition_EDITION_PROTO3_enum_value = 999 Edition_EDITION_PROTO3_enum_value = 999
Edition_EDITION_2023_enum_value = 1000 Edition_EDITION_2023_enum_value = 1000
Edition_EDITION_2024_enum_value = 1001 Edition_EDITION_2024_enum_value = 1001
Edition_EDITION_UNSTABLE_enum_value = 9999
Edition_EDITION_1_TEST_ONLY_enum_value = 1 Edition_EDITION_1_TEST_ONLY_enum_value = 1
Edition_EDITION_2_TEST_ONLY_enum_value = 2 Edition_EDITION_2_TEST_ONLY_enum_value = 2
Edition_EDITION_99997_TEST_ONLY_enum_value = 99997 Edition_EDITION_99997_TEST_ONLY_enum_value = 99997

View File

@@ -113,6 +113,9 @@ func sizeMap(mapv reflect.Value, mapi *mapInfo, f *coderFieldInfo, opts marshalO
} }
func consumeMap(b []byte, mapv reflect.Value, wtyp protowire.Type, mapi *mapInfo, f *coderFieldInfo, opts unmarshalOptions) (out unmarshalOutput, err error) { func consumeMap(b []byte, mapv reflect.Value, wtyp protowire.Type, mapi *mapInfo, f *coderFieldInfo, opts unmarshalOptions) (out unmarshalOutput, err error) {
if opts.depth--; opts.depth < 0 {
return out, errRecursionDepth
}
if wtyp != protowire.BytesType { if wtyp != protowire.BytesType {
return out, errUnknown return out, errUnknown
} }
@@ -170,6 +173,9 @@ func consumeMap(b []byte, mapv reflect.Value, wtyp protowire.Type, mapi *mapInfo
} }
func consumeMapOfMessage(b []byte, mapv reflect.Value, wtyp protowire.Type, mapi *mapInfo, f *coderFieldInfo, opts unmarshalOptions) (out unmarshalOutput, err error) { func consumeMapOfMessage(b []byte, mapv reflect.Value, wtyp protowire.Type, mapi *mapInfo, f *coderFieldInfo, opts unmarshalOptions) (out unmarshalOutput, err error) {
if opts.depth--; opts.depth < 0 {
return out, errRecursionDepth
}
if wtyp != protowire.BytesType { if wtyp != protowire.BytesType {
return out, errUnknown return out, errUnknown
} }

View File

@@ -102,8 +102,7 @@ var errUnknown = errors.New("unknown")
func (mi *MessageInfo) unmarshalPointer(b []byte, p pointer, groupTag protowire.Number, opts unmarshalOptions) (out unmarshalOutput, err error) { func (mi *MessageInfo) unmarshalPointer(b []byte, p pointer, groupTag protowire.Number, opts unmarshalOptions) (out unmarshalOutput, err error) {
mi.init() mi.init()
opts.depth-- if opts.depth--; opts.depth < 0 {
if opts.depth < 0 {
return out, errRecursionDepth return out, errRecursionDepth
} }
if flags.ProtoLegacy && mi.isMessageSet { if flags.ProtoLegacy && mi.isMessageSet {

View File

@@ -68,9 +68,13 @@ func Validate(mt protoreflect.MessageType, in protoiface.UnmarshalInput) (out pr
if in.Resolver == nil { if in.Resolver == nil {
in.Resolver = protoregistry.GlobalTypes in.Resolver = protoregistry.GlobalTypes
} }
if in.Depth == 0 {
in.Depth = protowire.DefaultRecursionLimit
}
o, st := mi.validate(in.Buf, 0, unmarshalOptions{ o, st := mi.validate(in.Buf, 0, unmarshalOptions{
flags: in.Flags, flags: in.Flags,
resolver: in.Resolver, resolver: in.Resolver,
depth: in.Depth,
}) })
if o.initialized { if o.initialized {
out.Flags |= protoiface.UnmarshalInitialized out.Flags |= protoiface.UnmarshalInitialized
@@ -257,6 +261,9 @@ func (mi *MessageInfo) validate(b []byte, groupTag protowire.Number, opts unmars
states[0].typ = validationTypeGroup states[0].typ = validationTypeGroup
states[0].endGroup = groupTag states[0].endGroup = groupTag
} }
if opts.depth--; opts.depth < 0 {
return out, ValidationInvalid
}
initialized := true initialized := true
start := len(b) start := len(b)
State: State:
@@ -451,6 +458,13 @@ State:
mi: vi.mi, mi: vi.mi,
tail: b, tail: b,
}) })
if vi.typ == validationTypeMessage ||
vi.typ == validationTypeGroup ||
vi.typ == validationTypeMap {
if opts.depth--; opts.depth < 0 {
return out, ValidationInvalid
}
}
b = v b = v
continue State continue State
case validationTypeRepeatedVarint: case validationTypeRepeatedVarint:
@@ -499,6 +513,9 @@ State:
mi: vi.mi, mi: vi.mi,
endGroup: num, endGroup: num,
}) })
if opts.depth--; opts.depth < 0 {
return out, ValidationInvalid
}
continue State continue State
case flags.ProtoLegacy && vi.typ == validationTypeMessageSetItem: case flags.ProtoLegacy && vi.typ == validationTypeMessageSetItem:
typeid, v, n, err := messageset.ConsumeFieldValue(b, false) typeid, v, n, err := messageset.ConsumeFieldValue(b, false)
@@ -521,6 +538,13 @@ State:
mi: xvi.mi, mi: xvi.mi,
tail: b[n:], tail: b[n:],
}) })
if xvi.typ == validationTypeMessage ||
xvi.typ == validationTypeGroup ||
xvi.typ == validationTypeMap {
if opts.depth--; opts.depth < 0 {
return out, ValidationInvalid
}
}
b = v b = v
continue State continue State
} }
@@ -547,12 +571,14 @@ State:
switch st.typ { switch st.typ {
case validationTypeMessage, validationTypeGroup: case validationTypeMessage, validationTypeGroup:
numRequiredFields = int(st.mi.numRequiredFields) numRequiredFields = int(st.mi.numRequiredFields)
opts.depth++
case validationTypeMap: case validationTypeMap:
// If this is a map field with a message value that contains // If this is a map field with a message value that contains
// required fields, require that the value be present. // required fields, require that the value be present.
if st.mi != nil && st.mi.numRequiredFields > 0 { if st.mi != nil && st.mi.numRequiredFields > 0 {
numRequiredFields = 1 numRequiredFields = 1
} }
opts.depth++
} }
// If there are more than 64 required fields, this check will // If there are more than 64 required fields, this check will
// always fail and we will report that the message is potentially // always fail and we will report that the message is potentially

View File

@@ -52,7 +52,7 @@ import (
const ( const (
Major = 1 Major = 1
Minor = 36 Minor = 36
Patch = 10 Patch = 11
PreRelease = "" PreRelease = ""
) )

View File

@@ -121,9 +121,8 @@ func (o UnmarshalOptions) unmarshal(b []byte, m protoreflect.Message) (out proto
out, err = methods.Unmarshal(in) out, err = methods.Unmarshal(in)
} else { } else {
o.RecursionLimit-- if o.RecursionLimit--; o.RecursionLimit < 0 {
if o.RecursionLimit < 0 { return out, errRecursionDepth
return out, errors.New("exceeded max recursion depth")
} }
err = o.unmarshalMessageSlow(b, m) err = o.unmarshalMessageSlow(b, m)
} }
@@ -220,6 +219,9 @@ func (o UnmarshalOptions) unmarshalSingular(b []byte, wtyp protowire.Type, m pro
} }
func (o UnmarshalOptions) unmarshalMap(b []byte, wtyp protowire.Type, mapv protoreflect.Map, fd protoreflect.FieldDescriptor) (n int, err error) { func (o UnmarshalOptions) unmarshalMap(b []byte, wtyp protowire.Type, mapv protoreflect.Map, fd protoreflect.FieldDescriptor) (n int, err error) {
if o.RecursionLimit--; o.RecursionLimit < 0 {
return 0, errRecursionDepth
}
if wtyp != protowire.BytesType { if wtyp != protowire.BytesType {
return 0, errUnknown return 0, errUnknown
} }
@@ -305,3 +307,5 @@ func (o UnmarshalOptions) unmarshalMap(b []byte, wtyp protowire.Type, mapv proto
var errUnknown = errors.New("BUG: internal error (unknown)") var errUnknown = errors.New("BUG: internal error (unknown)")
var errDecode = errors.New("cannot parse invalid wire-format data") var errDecode = errors.New("cannot parse invalid wire-format data")
var errRecursionDepth = errors.New("exceeded maximum recursion depth")

View File

@@ -172,13 +172,14 @@ import (
// ) to obtain a formatter capable of generating timestamps in this format. // ) to obtain a formatter capable of generating timestamps in this format.
type Timestamp struct { type Timestamp struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Represents seconds of UTC time since Unix epoch // Represents seconds of UTC time since Unix epoch 1970-01-01T00:00:00Z. Must
// 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to // be between -315576000000 and 315576000000 inclusive (which corresponds to
// 9999-12-31T23:59:59Z inclusive. // 0001-01-01T00:00:00Z to 9999-12-31T23:59:59Z).
Seconds int64 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"` Seconds int64 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"`
// Non-negative fractions of a second at nanosecond resolution. Negative // Non-negative fractions of a second at nanosecond resolution. This field is
// second values with fractions must still have non-negative nanos values // the nanosecond portion of the duration, not an alternative to seconds.
// that count forward in time. Must be from 0 to 999,999,999 // Negative second values with fractions must still have non-negative nanos
// values that count forward in time. Must be between 0 and 999,999,999
// inclusive. // inclusive.
Nanos int32 `protobuf:"varint,2,opt,name=nanos,proto3" json:"nanos,omitempty"` Nanos int32 `protobuf:"varint,2,opt,name=nanos,proto3" json:"nanos,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields

2
vendor/modules.txt vendored
View File

@@ -172,7 +172,7 @@ google.golang.org/grpc/stats
google.golang.org/grpc/status google.golang.org/grpc/status
google.golang.org/grpc/tap google.golang.org/grpc/tap
google.golang.org/grpc/test/bufconn google.golang.org/grpc/test/bufconn
# google.golang.org/protobuf v1.36.10 # google.golang.org/protobuf v1.36.11
## explicit; go 1.23 ## explicit; go 1.23
google.golang.org/protobuf/encoding/protojson google.golang.org/protobuf/encoding/protojson
google.golang.org/protobuf/encoding/prototext google.golang.org/protobuf/encoding/prototext

View File

@@ -110,18 +110,22 @@
}; };
// mciasWebAuthnLogin initiates a passkey login. // mciasWebAuthnLogin initiates a passkey login.
window.mciasWebAuthnLogin = function (username, onSuccess, onError) { // ssoNonce is optional — when non-empty, it is included in the begin/finish
// requests so the server can redirect back to the SSO client after login.
window.mciasWebAuthnLogin = function (username, ssoNonce, onSuccess, onError) {
if (!window.PublicKeyCredential) { if (!window.PublicKeyCredential) {
onError('WebAuthn is not supported in this browser.'); onError('WebAuthn is not supported in this browser.');
return; return;
} }
var savedNonce = ''; var savedNonce = '';
var beginBody = { username: username || '' };
if (ssoNonce) { beginBody.sso_nonce = ssoNonce; }
fetch('/login/webauthn/begin', { fetch('/login/webauthn/begin', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username || '' }) body: JSON.stringify(beginBody)
}) })
.then(function (resp) { .then(function (resp) {
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); }); if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); });
@@ -163,7 +167,7 @@
if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); }); if (!resp.ok) return resp.text().then(function (t) { throw new Error(t || 'Login failed'); });
return resp.json(); return resp.json();
}) })
.then(function () { onSuccess(); }) .then(function (data) { onSuccess(data); })
.catch(function (err) { onError(err.message || 'Login failed'); }); .catch(function (err) { onError(err.message || 'Login failed'); });
}; };
@@ -208,11 +212,14 @@
hideError('webauthn-login-error'); hideError('webauthn-login-error');
var usernameInput = document.getElementById('username'); var usernameInput = document.getElementById('username');
var username = usernameInput ? usernameInput.value.trim() : ''; var username = usernameInput ? usernameInput.value.trim() : '';
var ssoNonce = loginBtn.getAttribute('data-sso-nonce') || '';
loginBtn.disabled = true; loginBtn.disabled = true;
loginBtn.textContent = 'Waiting for authenticator...'; loginBtn.textContent = 'Waiting for authenticator...';
window.mciasWebAuthnLogin(username, function () { window.mciasWebAuthnLogin(username, ssoNonce, function (data) {
window.location.href = '/dashboard'; // The server returns a redirect URL — either /dashboard for direct
// login, or the SSO client callback URL with code and state params.
window.location.href = (data && data.redirect) || '/dashboard';
}, function (err) { }, function (err) {
loginBtn.disabled = false; loginBtn.disabled = false;
loginBtn.textContent = 'Sign in with passkey'; loginBtn.textContent = 'Sign in with passkey';

View File

@@ -15,6 +15,7 @@
{{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li> {{if .IsAdmin}}<li><a href="/accounts">Accounts</a></li>
<li><a href="/audit">Audit</a></li> <li><a href="/audit">Audit</a></li>
<li><a href="/policies">Policies</a></li> <li><a href="/policies">Policies</a></li>
<li><a href="/sso-clients">SSO Clients</a></li>
<li><a href="/pgcreds">PG Creds</a></li>{{else}}<li><a href="/service-accounts">Service Accounts</a></li>{{end}} <li><a href="/pgcreds">PG Creds</a></li>{{else}}<li><a href="/service-accounts">Service Accounts</a></li>{{end}}
{{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}} {{if .ActorName}}<li><a href="/profile">{{.ActorName}}</a></li>{{end}}
<li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li> <li><form method="POST" action="/logout" style="margin:0"><button class="btn btn-sm btn-secondary" type="submit">Logout</button></form></li>

View File

@@ -0,0 +1,31 @@
{{define "sso_client_row"}}
<tr id="sso-client-row-{{.ClientID}}">
<td><strong>{{.ClientID}}</strong></td>
<td><code style="font-size:0.85em">{{.RedirectURI}}</code></td>
<td>{{range .Tags}}<span class="badge">{{.}}</span> {{end}}</td>
<td>
{{if .Enabled}}<span class="badge badge-active">enabled</span>
{{else}}<span class="badge badge-inactive">disabled</span>{{end}}
</td>
<td class="d-flex gap-1">
{{if .Enabled}}
<button class="btn btn-sm btn-secondary"
hx-patch="/sso-clients/{{.ClientID}}/toggle"
hx-vals='{"enabled":"false"}'
hx-target="#sso-client-row-{{.ClientID}}"
hx-swap="outerHTML">Disable</button>
{{else}}
<button class="btn btn-sm btn-primary"
hx-patch="/sso-clients/{{.ClientID}}/toggle"
hx-vals='{"enabled":"true"}'
hx-target="#sso-client-row-{{.ClientID}}"
hx-swap="outerHTML">Enable</button>
{{end}}
<button class="btn btn-sm btn-danger"
hx-delete="/sso-clients/{{.ClientID}}"
hx-target="#sso-client-row-{{.ClientID}}"
hx-swap="outerHTML"
hx-confirm="Delete SSO client '{{.ClientID}}'? This cannot be undone.">Delete</button>
</td>
</tr>
{{end}}

View File

@@ -5,6 +5,7 @@
<input type="hidden" name="username" value="{{.Username}}"> <input type="hidden" name="username" value="{{.Username}}">
<input type="hidden" name="totp_nonce" value="{{.Nonce}}"> <input type="hidden" name="totp_nonce" value="{{.Nonce}}">
<input type="hidden" name="totp_step" value="1"> <input type="hidden" name="totp_step" value="1">
{{if .SSONonce}}<input type="hidden" name="sso_nonce" value="{{.SSONonce}}">{{end}}
<div class="form-group"> <div class="form-group">
<label for="totp_code">Authenticator Code</label> <label for="totp_code">Authenticator Code</label>
<input class="form-control" type="text" id="totp_code" name="totp_code" <input class="form-control" type="text" id="totp_code" name="totp_code"

View File

@@ -15,6 +15,7 @@
{{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}} {{if .Error}}<div class="alert alert-error" role="alert">{{.Error}}</div>{{end}}
<form id="login-form" method="POST" action="/login" <form id="login-form" method="POST" action="/login"
hx-post="/login" hx-target="#login-card" hx-swap="outerHTML" hx-select="#login-card"> hx-post="/login" hx-target="#login-card" hx-swap="outerHTML" hx-select="#login-card">
{{if .SSONonce}}<input type="hidden" name="sso_nonce" value="{{.SSONonce}}">{{end}}
<div class="form-group"> <div class="form-group">
<label for="username">Username</label> <label for="username">Username</label>
<input class="form-control" type="text" id="username" name="username" <input class="form-control" type="text" id="username" name="username"
@@ -37,7 +38,8 @@
<hr style="flex:1;border:0;border-top:1px solid #ddd"> <hr style="flex:1;border:0;border-top:1px solid #ddd">
</div> </div>
<div id="webauthn-login-error" class="alert alert-error" style="display:none" role="alert"></div> <div id="webauthn-login-error" class="alert alert-error" style="display:none" role="alert"></div>
<button class="btn btn-secondary" type="button" id="webauthn-login-btn" style="width:100%"> <button class="btn btn-secondary" type="button" id="webauthn-login-btn" style="width:100%"
{{if .SSONonce}}data-sso-nonce="{{.SSONonce}}"{{end}}>
Sign in with passkey Sign in with passkey
</button> </button>
</div> </div>

View File

@@ -0,0 +1,54 @@
{{define "sso_clients"}}{{template "base" .}}{{end}}
{{define "title"}} - SSO Clients{{end}}
{{define "content"}}
<div class="page-header d-flex justify-between align-center">
<div>
<h2>SSO Clients</h2>
<p class="text-muted text-small">Registered applications that use MCIAS for single sign-on.</p>
</div>
<button class="btn btn-primary" onclick="var f=document.getElementById('create-form');f.hidden=!f.hidden;this.textContent=f.hidden?'Add Client':'Cancel'">Add Client</button>
</div>
<div id="create-form" class="card mt-2" hidden>
<div class="card-title">Register SSO Client</div>
<form hx-post="/sso-clients" hx-target="#sso-clients-tbody" hx-swap="afterbegin">
<div class="d-flex gap-1" style="flex-wrap:wrap">
<div class="form-group" style="flex:1;min-width:200px">
<label class="form-label">Client ID / Service Name</label>
<input class="form-control" type="text" name="client_id" required placeholder="e.g. mcr">
</div>
<div class="form-group" style="flex:2;min-width:300px">
<label class="form-label">Redirect URI</label>
<input class="form-control" type="url" name="redirect_uri" required placeholder="https://service.example.com/sso/callback">
</div>
<div class="form-group" style="flex:1;min-width:200px">
<label class="form-label">Tags <span class="text-muted text-small">(comma-separated)</span></label>
<input class="form-control" type="text" name="tags" placeholder="env:prod,tier:web">
</div>
</div>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Create</button>
</div>
</form>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Client ID</th>
<th>Redirect URI</th>
<th>Tags</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="sso-clients-tbody">
{{range .Clients}}
{{template "sso_client_row" .}}
{{end}}
</tbody>
</table>
{{if not .Clients}}<p class="text-muted" style="text-align:center;padding:2rem">No SSO clients registered.</p>{{end}}
</div>
{{end}}