Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed2cecec5 | |||
| 9385c3846d | |||
| e450ade988 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -22,6 +22,24 @@ type Config struct { //nolint:govet // fieldalignment: TOML section order is mor
|
|||||||
Tokens TokensConfig `toml:"tokens"`
|
Tokens TokensConfig `toml:"tokens"`
|
||||||
Argon2 Argon2Config `toml:"argon2"`
|
Argon2 Argon2Config `toml:"argon2"`
|
||||||
WebAuthn WebAuthnConfig `toml:"webauthn"`
|
WebAuthn WebAuthnConfig `toml:"webauthn"`
|
||||||
|
SSO SSOConfig `toml:"sso"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSOConfig holds registered SSO clients that may use the authorization code
|
||||||
|
// flow to authenticate users via MCIAS. Omitting the [sso] section or leaving
|
||||||
|
// clients empty disables SSO.
|
||||||
|
type SSOConfig struct {
|
||||||
|
Clients []SSOClient `toml:"clients"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSOClient is a registered relying-party application that may redirect users
|
||||||
|
// to MCIAS for login. The redirect_uri is validated as an exact match (no
|
||||||
|
// wildcards) to prevent open-redirect attacks.
|
||||||
|
type SSOClient struct {
|
||||||
|
ClientID string `toml:"client_id"` // unique identifier (e.g. "mcr")
|
||||||
|
RedirectURI string `toml:"redirect_uri"` // exact callback URL, https required
|
||||||
|
ServiceName string `toml:"service_name"` // passed to policy engine on login
|
||||||
|
Tags []string `toml:"tags"` // passed to policy engine on login
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
|
// WebAuthnConfig holds FIDO2/WebAuthn settings. Omitting the entire [webauthn]
|
||||||
@@ -246,9 +264,48 @@ func (c *Config) validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSO clients — if any are configured, each must have a unique client_id,
|
||||||
|
// a non-empty redirect_uri with the https:// scheme, and a non-empty
|
||||||
|
// service_name.
|
||||||
|
seen := make(map[string]bool, len(c.SSO.Clients))
|
||||||
|
for i, cl := range c.SSO.Clients {
|
||||||
|
prefix := fmt.Sprintf("sso.clients[%d]", i)
|
||||||
|
if cl.ClientID == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: client_id is required", prefix))
|
||||||
|
} else if seen[cl.ClientID] {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: duplicate client_id %q", prefix, cl.ClientID))
|
||||||
|
} else {
|
||||||
|
seen[cl.ClientID] = true
|
||||||
|
}
|
||||||
|
if cl.RedirectURI == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: redirect_uri is required", prefix))
|
||||||
|
} else if !strings.HasPrefix(cl.RedirectURI, "https://") {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: redirect_uri must use the https:// scheme (got %q)", prefix, cl.RedirectURI))
|
||||||
|
}
|
||||||
|
if cl.ServiceName == "" {
|
||||||
|
errs = append(errs, fmt.Errorf("%s: service_name is required", prefix))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSOClient looks up a registered SSO client by client_id.
|
||||||
|
// Returns nil if no client with that ID is registered.
|
||||||
|
func (c *Config) SSOClient(clientID string) *SSOClient {
|
||||||
|
for i := range c.SSO.Clients {
|
||||||
|
if c.SSO.Clients[i].ClientID == clientID {
|
||||||
|
return &c.SSO.Clients[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSOEnabled reports whether any SSO clients are registered.
|
||||||
|
func (c *Config) SSOEnabled() bool {
|
||||||
|
return len(c.SSO.Clients) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// DefaultExpiry returns the configured default token expiry duration.
|
// DefaultExpiry returns the configured default token expiry duration.
|
||||||
func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration }
|
func (c *Config) DefaultExpiry() time.Duration { return c.Tokens.DefaultExpiry.Duration }
|
||||||
|
|
||||||
|
|||||||
@@ -244,6 +244,153 @@ func TestTrustedProxyValidation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSOClientValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
extra string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid single client",
|
||||||
|
extra: `
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
redirect_uri = "https://mcr.example.com/sso/callback"
|
||||||
|
service_name = "mcr"
|
||||||
|
tags = ["env:restricted"]
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid multiple clients",
|
||||||
|
extra: `
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
redirect_uri = "https://mcr.example.com/sso/callback"
|
||||||
|
service_name = "mcr"
|
||||||
|
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcat"
|
||||||
|
redirect_uri = "https://mcat.example.com/sso/callback"
|
||||||
|
service_name = "mcat"
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing client_id",
|
||||||
|
extra: `
|
||||||
|
[[sso.clients]]
|
||||||
|
redirect_uri = "https://mcr.example.com/sso/callback"
|
||||||
|
service_name = "mcr"
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing redirect_uri",
|
||||||
|
extra: `
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
service_name = "mcr"
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "http redirect_uri rejected",
|
||||||
|
extra: `
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
redirect_uri = "http://mcr.example.com/sso/callback"
|
||||||
|
service_name = "mcr"
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing service_name",
|
||||||
|
extra: `
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
redirect_uri = "https://mcr.example.com/sso/callback"
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate client_id",
|
||||||
|
extra: `
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
redirect_uri = "https://mcr.example.com/sso/callback"
|
||||||
|
service_name = "mcr"
|
||||||
|
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
redirect_uri = "https://other.example.com/sso/callback"
|
||||||
|
service_name = "mcr2"
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
path := writeTempConfig(t, validConfig()+tc.extra)
|
||||||
|
_, err := Load(path)
|
||||||
|
if tc.wantErr && err == nil {
|
||||||
|
t.Error("expected validation error, got nil")
|
||||||
|
}
|
||||||
|
if !tc.wantErr && err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSOClientLookup(t *testing.T) {
|
||||||
|
path := writeTempConfig(t, validConfig()+`
|
||||||
|
[[sso.clients]]
|
||||||
|
client_id = "mcr"
|
||||||
|
redirect_uri = "https://mcr.example.com/sso/callback"
|
||||||
|
service_name = "mcr"
|
||||||
|
tags = ["env:restricted"]
|
||||||
|
`)
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cl := cfg.SSOClient("mcr")
|
||||||
|
if cl == nil {
|
||||||
|
t.Fatal("SSOClient(mcr) returned nil")
|
||||||
|
}
|
||||||
|
if cl.RedirectURI != "https://mcr.example.com/sso/callback" {
|
||||||
|
t.Errorf("RedirectURI = %q", cl.RedirectURI)
|
||||||
|
}
|
||||||
|
if cl.ServiceName != "mcr" {
|
||||||
|
t.Errorf("ServiceName = %q", cl.ServiceName)
|
||||||
|
}
|
||||||
|
if len(cl.Tags) != 1 || cl.Tags[0] != "env:restricted" {
|
||||||
|
t.Errorf("Tags = %v", cl.Tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.SSOClient("nonexistent") != nil {
|
||||||
|
t.Error("SSOClient(nonexistent) should return nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.SSOEnabled() {
|
||||||
|
t.Error("SSOEnabled() should return true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSODisabledByDefault(t *testing.T) {
|
||||||
|
path := writeTempConfig(t, validConfig())
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.SSOEnabled() {
|
||||||
|
t.Error("SSOEnabled() should return false with no clients")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDurationParsing(t *testing.T) {
|
func TestDurationParsing(t *testing.T) {
|
||||||
var d duration
|
var d duration
|
||||||
if err := d.UnmarshalText([]byte("1h30m")); err != nil {
|
if err := d.UnmarshalText([]byte("1h30m")); err != nil {
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServiceAccountDelegate records that a specific account has been granted
|
// ServiceAccountDelegate records that a specific account has been granted
|
||||||
|
|||||||
141
internal/server/handlers_sso.go
Normal file
141
internal/server/handlers_sso.go
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
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 for policy context.
|
||||||
|
client := s.cfg.SSOClient(req.ClientID)
|
||||||
|
if client == nil {
|
||||||
|
// Should not happen if the authorize endpoint validated, but defend in depth.
|
||||||
|
middleware.WriteError(w, http.StatusUnauthorized, "unknown client", "invalid_code")
|
||||||
|
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 with the SSO client's service_name and tags.
|
||||||
|
{
|
||||||
|
input := policy.PolicyInput{
|
||||||
|
Subject: acct.UUID,
|
||||||
|
AccountType: string(acct.AccountType),
|
||||||
|
Roles: roles,
|
||||||
|
Action: policy.ActionLogin,
|
||||||
|
Resource: policy.Resource{
|
||||||
|
ServiceName: client.ServiceName,
|
||||||
|
Tags: client.Tags,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if effect, _ := s.polEng.Evaluate(input); effect == policy.Deny {
|
||||||
|
s.writeAudit(r, model.EventLoginFail, &acct.ID, nil,
|
||||||
|
audit.JSON("reason", "policy_deny", "service_name", client.ServiceName, "via", "sso"))
|
||||||
|
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"),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
91
internal/sso/session.go
Normal file
91
internal/sso/session.go
Normal 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
93
internal/sso/store.go
Normal 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
132
internal/sso/store_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
84
internal/ui/handlers_sso.go
Normal file
84
internal/ui/handlers_sso.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
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.
|
||||||
|
client := u.cfg.SSOClient(clientID)
|
||||||
|
if client == nil {
|
||||||
|
u.logger.Warn("sso: unknown client_id", "client_id", clientID)
|
||||||
|
http.Error(w, "unknown client_id", http.StatusBadRequest)
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -445,6 +445,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)))
|
||||||
@@ -810,6 +813,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.
|
||||||
|
|||||||
11
vendor/google.golang.org/protobuf/internal/encoding/tag/tag.go
generated
vendored
11
vendor/google.golang.org/protobuf/internal/encoding/tag/tag.go
generated
vendored
@@ -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 {
|
||||||
|
|||||||
121
vendor/google.golang.org/protobuf/internal/encoding/text/decode.go
generated
vendored
121
vendor/google.golang.org/protobuf/internal/encoding/text/decode.go
generated
vendored
@@ -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
|
||||||
|
|||||||
1
vendor/google.golang.org/protobuf/internal/filedesc/desc.go
generated
vendored
1
vendor/google.golang.org/protobuf/internal/filedesc/desc.go
generated
vendored
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
22
vendor/google.golang.org/protobuf/internal/filedesc/desc_lazy.go
generated
vendored
22
vendor/google.golang.org/protobuf/internal/filedesc/desc_lazy.go
generated
vendored
@@ -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)
|
||||||
|
|||||||
1
vendor/google.golang.org/protobuf/internal/genid/descriptor_gen.go
generated
vendored
1
vendor/google.golang.org/protobuf/internal/genid/descriptor_gen.go
generated
vendored
@@ -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
|
||||||
|
|||||||
6
vendor/google.golang.org/protobuf/internal/impl/codec_map.go
generated
vendored
6
vendor/google.golang.org/protobuf/internal/impl/codec_map.go
generated
vendored
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
3
vendor/google.golang.org/protobuf/internal/impl/decode.go
generated
vendored
3
vendor/google.golang.org/protobuf/internal/impl/decode.go
generated
vendored
@@ -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 {
|
||||||
|
|||||||
26
vendor/google.golang.org/protobuf/internal/impl/validate.go
generated
vendored
26
vendor/google.golang.org/protobuf/internal/impl/validate.go
generated
vendored
@@ -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
|
||||||
|
|||||||
2
vendor/google.golang.org/protobuf/internal/version/version.go
generated
vendored
2
vendor/google.golang.org/protobuf/internal/version/version.go
generated
vendored
@@ -52,7 +52,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Major = 1
|
Major = 1
|
||||||
Minor = 36
|
Minor = 36
|
||||||
Patch = 10
|
Patch = 11
|
||||||
PreRelease = ""
|
PreRelease = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
10
vendor/google.golang.org/protobuf/proto/decode.go
generated
vendored
10
vendor/google.golang.org/protobuf/proto/decode.go
generated
vendored
@@ -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")
|
||||||
|
|||||||
13
vendor/google.golang.org/protobuf/types/known/timestamppb/timestamp.pb.go
generated
vendored
13
vendor/google.golang.org/protobuf/types/known/timestamppb/timestamp.pb.go
generated
vendored
@@ -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
2
vendor/modules.txt
vendored
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user