diff --git a/engine_issue_session.go b/engine_issue_session.go index 4e3b1409..3e847b79 100644 --- a/engine_issue_session.go +++ b/engine_issue_session.go @@ -132,6 +132,17 @@ func (e *Engine) IssueSession(ctx context.Context, req *IssueSessionRequest) (*I req.AppID = req.User.AppID } + // Resolve the default environment when the caller didn't supply one. + // authsome_sessions.env_id is NOT NULL, so a session minted without an + // env_id fails to persist. SignUp/SignIn already do this; callers that go + // straight through IssueSession (email-verification auto-login, SSO) relied + // on it happening here. + if req.EnvID.IsNil() { + if env, _ := e.GetDefaultEnvironment(ctx, req.AppID); env != nil { //nolint:errcheck // best-effort env lookup + req.EnvID = env.ID + } + } + // MFA gate. When the per-app config sets MFARequired and the // caller hasn't already verified via the challenge endpoint, the // gate fires regardless of whether the user has previously diff --git a/go.mod b/go.mod index da2c8280..013f166c 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,11 @@ go 1.25.7 require ( github.com/a-h/templ v0.3.1001 + github.com/crewjam/saml v0.5.1 github.com/go-webauthn/webauthn v0.15.0 github.com/oschwald/maxminddb-golang v1.13.1 github.com/pquerna/otp v1.5.0 + github.com/russellhaering/goxmldsig v1.4.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 github.com/xraph/chronicle v1.5.3 @@ -31,10 +33,14 @@ require ( require ( github.com/Oudwins/tailwind-merge-go v0.2.1 // indirect + github.com/beevik/etree v1.5.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/moby/moby/api v1.54.1 // indirect github.com/moby/moby/client v0.4.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index c6d5288d..52deaf13 100644 --- a/go.sum +++ b/go.sum @@ -27,6 +27,9 @@ github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/beevik/etree v1.5.0 h1:iaQZFSDS+3kYZiGoc9uKeOkUY3nYMXOKLl6KIJxiJWs= +github.com/beevik/etree v1.5.0/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -60,6 +63,8 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= +github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -140,6 +145,8 @@ github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakr github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -220,6 +227,8 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -235,6 +244,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -253,6 +263,8 @@ github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8S github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -321,6 +333,7 @@ github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -365,10 +378,14 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/russellhaering/goxmldsig v1.4.0 h1:8UcDh/xGyQiyrW+Fq5t8f+l2DLB1+zlhYzkPUJ7Qhys= +github.com/russellhaering/goxmldsig v1.4.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= @@ -391,6 +408,7 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= @@ -591,9 +609,11 @@ google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBN google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= @@ -605,8 +625,11 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= k8s.io/api v0.35.5 h1:BrFeUDGY/LBtlA1R5RoxhlYRHs76RnQBc6xbm/y7hsQ= diff --git a/plugins/notification/config.go b/plugins/notification/config.go index 998a42ed..876b349f 100644 --- a/plugins/notification/config.go +++ b/plugins/notification/config.go @@ -29,6 +29,11 @@ type Config struct { // a ?token= query parameter. PasswordResetPath string + // SignInPath is the path (relative to BaseURL) of the sign-in page. It is + // used for the login_url in the welcome notification. Defaults to + // "/sign-in". + SignInPath string + // DefaultLocale is the default locale for notifications (e.g. "en"). // If empty, defaults to "en". DefaultLocale string diff --git a/plugins/notification/plugin.go b/plugins/notification/plugin.go index b0e9fade..6af6969c 100644 --- a/plugins/notification/plugin.go +++ b/plugins/notification/plugin.go @@ -119,6 +119,9 @@ func New(cfg ...Config) *Plugin { if c.PasswordResetPath == "" { c.PasswordResetPath = "/reset-password" } + if c.SignInPath == "" { + c.SignInPath = "/sign-in" + } // Merge user-provided mappings with defaults. mappings := DefaultMappings() @@ -234,7 +237,7 @@ func (p *Plugin) OnAfterSignUp(ctx context.Context, u *user.User, _ *session.Ses Data: map[string]any{ "user_name": name, "app_name": p.config.AppName, - "login_url": p.config.BaseURL + "/login", + "login_url": p.config.BaseURL + p.config.SignInPath, }, }); err != nil { p.logger.Warn("notification plugin: failed to send welcome notification", diff --git a/plugins/sso/migrations.go b/plugins/sso/migrations.go index ce632734..23c4c879 100644 --- a/plugins/sso/migrations.go +++ b/plugins/sso/migrations.go @@ -71,6 +71,41 @@ ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS client_secret TEXT }, ) + PostgresMigrations.MustRegister( + &migrate.Migration{ + Name: "add_saml_fields", + Version: "20240201000003", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, ` +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS idp_metadata_xml TEXT NOT NULL DEFAULT ''; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS idp_sso_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS idp_certificate TEXT NOT NULL DEFAULT ''; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS entity_id TEXT NOT NULL DEFAULT ''; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS acs_url TEXT NOT NULL DEFAULT ''; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS sp_certificate TEXT NOT NULL DEFAULT ''; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS sp_private_key TEXT NOT NULL DEFAULT ''; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS sign_requests BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS attribute_mappings TEXT NOT NULL DEFAULT ''; +`) + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, ` +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS idp_metadata_xml; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS idp_sso_url; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS idp_certificate; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS entity_id; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS acs_url; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS sp_certificate; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS sp_private_key; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS sign_requests; +ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS attribute_mappings; +`) + return err + }, + }, + ) + // ────────────────────────────────────────────────── // SQLite migrations // ────────────────────────────────────────────────── @@ -126,4 +161,35 @@ CREATE INDEX IF NOT EXISTS idx_authsome_sso_connections_provider }, }, ) + + SqliteMigrations.MustRegister( + &migrate.Migration{ + Name: "add_saml_fields", + Version: "20240201000003", + Up: func(ctx context.Context, exec migrate.Executor) error { + // SQLite requires one ADD COLUMN per statement. + cols := []string{ + `ALTER TABLE authsome_sso_connections ADD COLUMN idp_metadata_xml TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE authsome_sso_connections ADD COLUMN idp_sso_url TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE authsome_sso_connections ADD COLUMN idp_certificate TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE authsome_sso_connections ADD COLUMN entity_id TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE authsome_sso_connections ADD COLUMN acs_url TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE authsome_sso_connections ADD COLUMN sp_certificate TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE authsome_sso_connections ADD COLUMN sp_private_key TEXT NOT NULL DEFAULT '';`, + `ALTER TABLE authsome_sso_connections ADD COLUMN sign_requests INTEGER NOT NULL DEFAULT 0;`, + `ALTER TABLE authsome_sso_connections ADD COLUMN attribute_mappings TEXT NOT NULL DEFAULT '';`, + } + for _, stmt := range cols { + if _, err := exec.Exec(ctx, stmt); err != nil { + return err + } + } + return nil + }, + Down: func(_ context.Context, _ migrate.Executor) error { + // SQLite does not support DROP COLUMN in older versions; best-effort. + return nil + }, + }, + ) } diff --git a/plugins/sso/org_membership_test.go b/plugins/sso/org_membership_test.go new file mode 100644 index 00000000..a42e3015 --- /dev/null +++ b/plugins/sso/org_membership_test.go @@ -0,0 +1,60 @@ +package sso + +import ( + "context" + "testing" + + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/store/memory" +) + +// TestEnsureOrgMembership verifies the org-level SSO enrollment: the first SSO +// login into an org-scoped connection adds the user as a member, and repeated +// logins are idempotent (no duplicate membership). +func TestEnsureOrgMembership(t *testing.T) { + mem := memory.New() + p := New() + p.SetStore(mem) + + ctx := context.Background() + orgID := id.NewOrgID() + userID := id.NewUserID() + + // First SSO login → user is enrolled into the org. + p.ensureOrgMembership(ctx, orgID, userID) + + members, err := mem.ListMembers(ctx, orgID) + if err != nil { + t.Fatalf("ListMembers: %v", err) + } + if len(members) != 1 { + t.Fatalf("expected 1 member after enrollment, got %d", len(members)) + } + if members[0].UserID != userID { + t.Errorf("member UserID = %s, want %s", members[0].UserID, userID) + } + if members[0].OrgID != orgID { + t.Errorf("member OrgID = %s, want %s", members[0].OrgID, orgID) + } + + // Second login for the same user → idempotent, still exactly one member. + p.ensureOrgMembership(ctx, orgID, userID) + members, err = mem.ListMembers(ctx, orgID) + if err != nil { + t.Fatalf("ListMembers (2nd): %v", err) + } + if len(members) != 1 { + t.Fatalf("expected membership to stay idempotent (1), got %d", len(members)) + } + + // A different user logging in via the same connection is added alongside. + otherUser := id.NewUserID() + p.ensureOrgMembership(ctx, orgID, otherUser) + members, err = mem.ListMembers(ctx, orgID) + if err != nil { + t.Fatalf("ListMembers (3rd): %v", err) + } + if len(members) != 2 { + t.Fatalf("expected 2 members after a second user, got %d", len(members)) + } +} diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 401bdd07..da799cdb 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" "time" @@ -22,6 +23,8 @@ import ( "github.com/xraph/authsome/formconfig" "github.com/xraph/authsome/hook" "github.com/xraph/authsome/id" + "github.com/xraph/authsome/middleware" + "github.com/xraph/authsome/organization" "github.com/xraph/authsome/plugin" "github.com/xraph/authsome/session" "github.com/xraph/authsome/settings" @@ -82,6 +85,17 @@ type Config struct { // SessionRefreshTTL is the lifetime of refresh tokens (default: 30 days). SessionRefreshTTL time.Duration + + // PublicBaseURL is the externally-reachable base URL of this SSO service + // (e.g. "https://auth.muono.cloud"). SAML SP EntityID / ACS URL derive from + // it when a connection doesn't override them. Required for SAML. + PublicBaseURL string + + // AllowedReturnOrigins is the allowlist of scheme://host[:port] origins the + // browser SSO landing may redirect back to after ACS. Guards against open + // redirect. localhost/127.0.0.1 are always allowed (dev). The first entry is + // used to derive a default `/sso/callback` landing when none is supplied. + AllowedReturnOrigins []string } // Plugin is the SSO authentication plugin. @@ -158,6 +172,26 @@ func (p *Plugin) OnInit(_ context.Context, engine plugin.Engine) error { p.ceremonies = ceremony.NewMemory() } + // Wire the connection store from the engine's database. Without this the + // admin CRUD and DB-managed provider resolution have no backing store and + // every SSO connection is invisible. Mirrors the oauth2provider plugin. + // An explicit SetSSOStore (e.g. in tests) takes precedence. + if p.ssoStore == nil { + if db := engine.DB(); db != nil { + switch db.Driver().Name() { + case "pg": + p.ssoStore = NewPostgresStore(db) + case "sqlite": + p.ssoStore = NewSqliteStore(db) + case "mongo": + p.ssoStore = NewMongoStore(db) + } + } + } + if p.ssoStore == nil { + p.ssoStore = NewMemoryStore() + } + return nil } @@ -197,31 +231,67 @@ func (p *Plugin) ProviderNames() []string { return names } +// errProviderNotFound signals that no code- or DB-configured provider matched. +// Distinguished from a build error so callers can return 400 vs 500. +var errProviderNotFound = errors.New("unsupported SSO provider") + // resolveProvider looks up a provider by name — first from code-configured -// providers, then from database-managed SSO connections. -func (p *Plugin) resolveProvider(ctx context.Context, name string) (Provider, bool) { +// providers, then from database-managed SSO connections. A build failure +// (e.g. unreachable SAML metadata) surfaces as a non-sentinel error. The +// resolved *Connection is returned too (nil for code-configured providers) so +// the callback path can enroll the user into the connection's org. +func (p *Plugin) resolveProvider(ctx context.Context, appID id.AppID, name string) (Provider, *Connection, error) { // Check code-configured providers first. if prov, ok := p.providers[name]; ok { - return prov, true + return prov, nil, nil } - // Fall back to DB-managed connections. + // Fall back to DB-managed connections, scoped to the resolved app. if p.ssoStore == nil { - return nil, false + return nil, nil, errProviderNotFound + } + conn, err := p.ssoStore.GetConnectionByProvider(ctx, appID, name) + if err != nil || conn == nil || !conn.Active { + return nil, nil, errProviderNotFound } - appID, err := id.ParseAppID(p.appID) + prov, err := p.connectionToProvider(conn) if err != nil { - return nil, false + return nil, nil, err } - conn, err := p.ssoStore.GetConnectionByProvider(ctx, appID, name) + return prov, conn, nil +} + +// requestAppID resolves the app this request targets: the per-request app set by +// the publishable-key middleware (workspace-app logins) when present, else the +// statically-configured p.appID (platform app / code-configured providers). This +// is what lets a login hitting a workspace app resolve THAT app's connections. +func (p *Plugin) requestAppID(ctx forge.Context) (id.AppID, error) { + if appID, ok := middleware.AppIDFrom(ctx.Context()); ok { + return appID, nil + } + return id.ParseAppID(p.appID) +} + +// connectionByID loads an active connection by its id. Used by the browser +// landing paths (ACS/metadata), which carry a `?connection=` param instead of a +// publishable key so the app can be recovered without the pub-key middleware. +func (p *Plugin) connectionByID(ctx context.Context, connID string) (*Connection, error) { + if p.ssoStore == nil { + return nil, errProviderNotFound + } + parsed, err := id.ParseSSOConnectionID(connID) + if err != nil { + return nil, errProviderNotFound + } + conn, err := p.ssoStore.GetConnection(ctx, parsed) if err != nil || conn == nil || !conn.Active { - return nil, false + return nil, errProviderNotFound } - return p.connectionToProvider(conn), true + return conn, nil } // connectionToProvider creates a Provider from a stored Connection. -func (p *Plugin) connectionToProvider(conn *Connection) Provider { +func (p *Plugin) connectionToProvider(conn *Connection) (Provider, error) { switch conn.Protocol { case "oidc": return NewOIDCProvider(OIDCConfig{ @@ -229,17 +299,53 @@ func (p *Plugin) connectionToProvider(conn *Connection) Provider { Issuer: conn.Issuer, ClientID: conn.ClientID, ClientSecret: conn.ClientSecret, - }) + }), nil case "saml": return NewSAMLProvider(SAMLConfig{ - Name: conn.Provider, - MetadataURL: conn.MetadataURL, + Name: conn.Provider, + IDPMetadataXML: conn.IDPMetadataXML, + MetadataURL: conn.MetadataURL, + IDPSSOURL: conn.IDPSSOURL, + IDPCertificatePEM: conn.IDPCertificate, + EntityID: p.entityIDFor(conn), + ACSURL: p.acsURLFor(conn), + SPCertificatePEM: conn.SPCertificate, + SPPrivateKeyPEM: conn.SPPrivateKey, + SignRequests: conn.SignRequests, + AttributeMap: conn.AttributeMappings, }) default: - return nil + return nil, fmt.Errorf("sso: unsupported protocol %q", conn.Protocol) } } +// publicBaseURL returns the configured base URL without a trailing slash. +func (p *Plugin) publicBaseURL() string { + return strings.TrimRight(p.config.PublicBaseURL, "/") +} + +// acsURLFor returns the SP ACS URL for a connection: the stored override, or +// the canonical {base}/v1/sso/{provider}/acs. +func (p *Plugin) acsURLFor(conn *Connection) string { + if v := strings.TrimSpace(conn.ACSURL); v != "" { + return v + } + // Embed the connection id so the IdP's top-level POST (which carries no + // publishable key) still lets ACS recover the connection and its app. + return p.publicBaseURL() + "/v1/sso/" + conn.Provider + "/acs?connection=" + conn.ID.String() +} + +// entityIDFor returns the SP EntityID for a connection: the stored override, or +// the canonical {base}/v1/sso/{provider}/metadata. +func (p *Plugin) entityIDFor(conn *Connection) string { + if v := strings.TrimSpace(conn.EntityID); v != "" { + return v + } + // Doubles as the SP metadata URL the IdP fetches; connection-scoped so it + // resolves without a publishable key. Valid as a URI EntityID too. + return p.publicBaseURL() + "/v1/sso/" + conn.Provider + "/metadata?connection=" + conn.ID.String() +} + // RegisterRoutes registers SSO HTTP endpoints on a forge.Router. func (p *Plugin) RegisterRoutes(router forge.Router) error { g := router.Group("/v1/sso", forge.WithGroupTags("SSO")) @@ -253,6 +359,27 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { return err } + // Domain-routed login: caller submits an email, we resolve the IdP from its + // domain. Enables "type your work email → get sent to your IdP". + if err := g.POST("/login", p.handleLoginByDomain, + forge.WithSummary("Start SSO login by email domain"), + forge.WithOperationID("startSSOLoginByDomain"), + forge.WithRequestSchema(LoginByDomainRequest{}), + forge.WithResponseSchema(http.StatusOK, "SSO login URL", LoginResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return err + } + + // SP metadata: IdPs fetch this to auto-configure the Service Provider. + // Raw handler because it emits application/samlmetadata+xml, not JSON. + if err := g.GET("/:provider/metadata", p.handleSPMetadata, + forge.WithSummary("SAML SP metadata"), + forge.WithOperationID("ssoSPMetadata"), + ); err != nil { + return err + } + if err := g.POST("/:provider/callback", p.handleCallback, forge.WithSummary("SSO callback (OIDC)"), forge.WithOperationID("ssoCallback"), @@ -262,9 +389,24 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { return err } + // SAML ACS: the IdP HTTP-POSTs the assertion here as a top-level browser + // navigation (no publishable key). Raw handler because it 302-redirects the + // browser to the frontend return URL with a one-time code, rather than + // returning a JSON body the browser would render as a page. if err := g.POST("/:provider/acs", p.handleACS, forge.WithSummary("SSO SAML ACS endpoint"), forge.WithOperationID("ssoACS"), + ); err != nil { + return err + } + + // Exchange a one-time code (minted by ACS) for the session tokens. Called by + // the frontend landing page with its publishable key, so the app can be + // verified against the code's bound app. + if err := g.POST("/exchange", p.handleExchange, + forge.WithSummary("Exchange SSO one-time code for a session"), + forge.WithOperationID("ssoExchange"), + forge.WithRequestSchema(ExchangeRequest{}), forge.WithResponseSchema(http.StatusOK, "Authentication result", CallbackResponse{}), forge.WithErrorResponses(), ); err != nil { @@ -334,7 +476,44 @@ func (p *Plugin) RegisterRoutes(router forge.Router) error { // LoginRequest contains the path parameter for starting SSO. type LoginRequest struct { - Provider string `path:"provider"` + Provider string `path:"provider"` + ReturnURL string `json:"return_url,omitempty" query:"return_url,omitempty" description:"Frontend URL to land on after login (must be allowlisted)"` +} + +// ssoState is the CSRF/state ceremony payload. It also carries the resolved app +// and the frontend return URL so the browser-landing callback/ACS (which lack a +// publishable key) can recover both. +type ssoState struct { + Provider string `json:"provider"` + AppID string `json:"app_id,omitempty"` + ReturnURL string `json:"return_url,omitempty"` + // RequestID is the SAML AuthnRequest ID minted at login, matched against the + // assertion's InResponseTo at the ACS. Empty for OIDC. + RequestID string `json:"request_id,omitempty"` +} + +// requestIDProvider is implemented by SAML providers that expose the AuthnRequest +// ID generated when building the login URL, so it can be persisted in the state +// ceremony and validated at the ACS. +type requestIDProvider interface { + LoginURLWithRequestID(state string) (loginURL, requestID string, err error) +} + +// ExchangeRequest is the body for POST /v1/sso/exchange. +type ExchangeRequest struct { + Code string `json:"code" description:"One-time code issued by the ACS redirect"` +} + +// otcPayload is the session handoff stashed under a one-time code by ACS and +// redeemed once (app-bound) at /v1/sso/exchange. +type otcPayload struct { + User json.RawMessage `json:"user,omitempty"` + SessionToken string `json:"session_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt string `json:"expires_at,omitempty"` + Provider string `json:"provider,omitempty"` + IsNewUser bool `json:"is_new_user,omitempty"` + AppID string `json:"app_id"` } // LoginResponse is returned when the SSO flow is initiated. @@ -351,13 +530,6 @@ type CallbackRequest struct { Error string `json:"error,omitempty" query:"error,omitempty"` } -// ACSRequest contains the SAML Assertion Consumer Service parameters. -type ACSRequest struct { - Provider string `path:"provider"` - SAMLResponse string `json:"SAMLResponse" form:"SAMLResponse"` - RelayState string `json:"RelayState" form:"RelayState"` -} - // CallbackResponse is returned on successful SSO authentication. type CallbackResponse struct { User any `json:"user"` @@ -374,9 +546,24 @@ type CallbackResponse struct { // handleLogin initiates the SSO flow by returning the IdP login URL. func (p *Plugin) handleLogin(ctx forge.Context, req *LoginRequest) (*LoginResponse, error) { - provider, ok := p.resolveProvider(ctx.Context(), req.Provider) - if !ok { - return nil, forge.BadRequest(fmt.Sprintf("unsupported SSO provider: %s", req.Provider)) + appID, err := p.requestAppID(ctx) + if err != nil { + return nil, forge.InternalError(fmt.Errorf("invalid app_id configuration: %w", err)) + } + provider, _, err := p.resolveProvider(ctx.Context(), appID, req.Provider) + if err != nil { + return nil, providerResolveError(req.Provider, err) + } + return p.startLogin(ctx.Context(), appID, provider, req.Provider, req.ReturnURL) +} + +// startLogin generates a CSRF state (carrying the app + return URL), caches it, +// and returns the IdP login URL. Shared by provider-name and email-domain entry +// points. The return URL is validated here (login is publishable-key-authed), so +// the opaque state token that round-trips through the IdP can't be tampered with. +func (p *Plugin) startLogin(ctx context.Context, appID id.AppID, provider Provider, providerName, returnURL string) (*LoginResponse, error) { + if returnURL != "" && !p.isAllowedReturnURL(returnURL) { + return nil, forge.BadRequest("return_url is not allowed") } state, err := generateState() @@ -384,35 +571,129 @@ func (p *Plugin) handleLogin(ctx forge.Context, req *LoginRequest) (*LoginRespon return nil, forge.InternalError(fmt.Errorf("failed to generate state: %w", err)) } - // Store the state for CSRF protection - stateData, _ := json.Marshal(map[string]string{"provider": req.Provider}) //nolint:errcheck // best-effort cache - _ = p.ceremonies.Set(ctx.Context(), "sso:state:"+state, stateData, 10*time.Minute) //nolint:errcheck // best-effort cache - - loginURL, err := provider.LoginURL(state) + // Build the IdP login URL. SAML providers also return the AuthnRequest ID so + // we can persist it in the state ceremony and match the assertion's + // InResponseTo at the ACS. + var loginURL, requestID string + if rp, ok := provider.(requestIDProvider); ok { + loginURL, requestID, err = rp.LoginURLWithRequestID(state) + } else { + loginURL, err = provider.LoginURL(state) + } if err != nil { return nil, forge.InternalError(fmt.Errorf("failed to get login URL: %w", err)) } + stateData, _ := json.Marshal(ssoState{Provider: providerName, AppID: appID.String(), ReturnURL: returnURL, RequestID: requestID}) //nolint:errcheck // best-effort cache + _ = p.ceremonies.Set(ctx, "sso:state:"+state, stateData, 10*time.Minute) //nolint:errcheck // best-effort cache + return &LoginResponse{ LoginURL: loginURL, State: state, }, nil } -// handleCallback processes the OIDC callback. -func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*CallbackResponse, error) { - provider, ok := p.resolveProvider(ctx.Context(), req.Provider) +// providerResolveError maps a resolveProvider failure to the right HTTP status: +// 400 for an unknown provider, 500 for a build/config failure. +func providerResolveError(name string, err error) error { + if errors.Is(err, errProviderNotFound) { + return forge.BadRequest(fmt.Sprintf("unsupported SSO provider: %s", name)) + } + return forge.InternalError(fmt.Errorf("sso: resolve provider %q: %w", name, err)) +} + +// LoginByDomainRequest carries an email whose domain selects the IdP connection. +type LoginByDomainRequest struct { + Email string `json:"email" description:"Work email; its domain routes to the matching SSO connection"` + ReturnURL string `json:"return_url,omitempty" description:"Frontend URL to land on after login (must be allowlisted)"` +} + +// handleLoginByDomain resolves the SSO connection from the email's domain and +// starts the login flow, so users never have to know their provider's name. +func (p *Plugin) handleLoginByDomain(ctx forge.Context, req *LoginByDomainRequest) (*LoginResponse, error) { + email := strings.ToLower(strings.TrimSpace(req.Email)) + at := strings.LastIndexByte(email, '@') + if at <= 0 || at == len(email)-1 { + return nil, forge.BadRequest("a valid email is required") + } + if p.ssoStore == nil { + return nil, forge.BadRequest("no SSO connection for this domain") + } + appID, err := p.requestAppID(ctx) + if err != nil { + return nil, forge.InternalError(fmt.Errorf("invalid app_id configuration: %w", err)) + } + conn, err := p.ssoStore.GetConnectionByDomain(ctx.Context(), appID, email[at+1:]) + if err != nil || conn == nil || !conn.Active { + return nil, forge.BadRequest("no SSO connection for this domain") + } + provider, err := p.connectionToProvider(conn) + if err != nil { + return nil, forge.InternalError(fmt.Errorf("sso: build provider: %w", err)) + } + return p.startLogin(ctx.Context(), appID, provider, conn.Provider, req.ReturnURL) +} + +// handleSPMetadata serves the SAML SP metadata XML for an IdP to consume. Raw +// handler: the body is application/samlmetadata+xml, not the usual JSON envelope. +func (p *Plugin) handleSPMetadata(ctx forge.Context) error { + name := ctx.Param("provider") + var provider Provider + // The IdP fetches this without a publishable key, so resolve by the + // connection-scoped `?connection=` param when present; else fall back to + // provider-name resolution under the request/platform app. + if connID := ctx.Request().URL.Query().Get("connection"); connID != "" { + conn, err := p.connectionByID(ctx.Context(), connID) + if err != nil { + return providerResolveError(name, err) + } + if provider, err = p.connectionToProvider(conn); err != nil { + return forge.InternalError(fmt.Errorf("sso: build provider: %w", err)) + } + } else { + appID, err := p.requestAppID(ctx) + if err != nil { + return forge.InternalError(fmt.Errorf("invalid app_id configuration: %w", err)) + } + if provider, _, err = p.resolveProvider(ctx.Context(), appID, name); err != nil { + return providerResolveError(name, err) + } + } + mp, ok := provider.(SAMLMetadataProvider) if !ok { - return nil, forge.BadRequest(fmt.Sprintf("unsupported SSO provider: %s", req.Provider)) + return forge.BadRequest("provider does not expose SAML metadata") } + xmlBytes, contentType, err := mp.Metadata() + if err != nil { + return forge.InternalError(fmt.Errorf("sso: metadata: %w", err)) + } + ctx.Response().Header().Set("Content-Type", contentType) + ctx.Response().WriteHeader(http.StatusOK) + _, werr := ctx.Response().Write(xmlBytes) + return werr +} +// handleCallback processes the OIDC callback. +func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*CallbackResponse, error) { if req.State == "" { return nil, forge.BadRequest("missing state parameter") } - if err := p.validateState(ctx.Context(), req.State, req.Provider); err != nil { + // The OIDC callback is a browser redirect with no publishable key; recover + // the app (and validate CSRF) from the state ceremony stashed at login. + st, err := p.loadState(ctx.Context(), req.State, req.Provider) + if err != nil { return nil, forge.BadRequest(err.Error()) } + appID, err := p.appIDFromState(st) + if err != nil { + return nil, forge.InternalError(fmt.Errorf("invalid app_id configuration: %w", err)) + } + + provider, conn, err := p.resolveProvider(ctx.Context(), appID, req.Provider) + if err != nil { + return nil, providerResolveError(req.Provider, err) + } if req.Error != "" { return nil, forge.BadRequest(fmt.Sprintf("provider error: %s", req.Error)) @@ -427,32 +708,80 @@ func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*Callb "state": req.State, } - return p.authenticateUser(ctx, provider, params) + return p.authenticateUser(ctx, appID, provider, conn, params) } -// handleACS processes the SAML Assertion Consumer Service callback. -func (p *Plugin) handleACS(ctx forge.Context, req *ACSRequest) (*CallbackResponse, error) { - provider, ok := p.resolveProvider(ctx.Context(), req.Provider) - if !ok { - return nil, forge.BadRequest(fmt.Sprintf("unsupported SSO provider: %s", req.Provider)) +// handleACS processes the SAML Assertion Consumer Service POST. It is a raw +// handler: the IdP posts here as a top-level browser navigation (no publishable +// key), so we recover the connection/app from the connection-scoped ACS URL, +// provision the user, mint a one-time code carrying the session, and 302-redirect +// the browser to the frontend return URL. On any failure we redirect with an +// `?sso_error=` marker rather than leaking the assertion or an error body. +func (p *Plugin) handleACS(ctx forge.Context) error { + r := ctx.Request() + name := ctx.Param("provider") + samlResponse := r.FormValue("SAMLResponse") + relayState := r.FormValue("RelayState") + connID := r.URL.Query().Get("connection") + + // Recover the frontend return URL + the AuthnRequest ID from the state + // ceremony (SP-initiated). Both were validated + stored at login, so they're + // trusted here. Absent for IdP-initiated flows → return URL falls back to the + // configured default, and no request ID means the assertion must be + // IdP-initiated (gated by AllowIDPInitiated). + returnURL := "" + requestID := "" + if relayState != "" { + if st, serr := p.loadState(ctx.Context(), relayState, name); serr == nil { + returnURL = st.ReturnURL + requestID = st.RequestID + } } - if req.SAMLResponse == "" { - return nil, forge.BadRequest("missing SAMLResponse") + fail := func(reason string) error { + http.Redirect(ctx.Response(), r, p.errorRedirect(returnURL, reason), http.StatusFound) + return nil } - if req.RelayState != "" { - if err := p.validateState(ctx.Context(), req.RelayState, req.Provider); err != nil { - return nil, forge.BadRequest(err.Error()) + // Resolve the connection (and its app) from `?connection=`; fall back to + // provider-name under the request app for legacy/platform links. + var conn *Connection + var err error + if connID != "" { + conn, err = p.connectionByID(ctx.Context(), connID) + } else { + var appID id.AppID + if appID, err = p.requestAppID(ctx); err == nil { + _, conn, err = p.resolveProvider(ctx.Context(), appID, name) } } + if err != nil || conn == nil { + return fail("provider_not_found") + } + if samlResponse == "" { + return fail("missing_response") + } - params := map[string]string{ - "SAMLResponse": req.SAMLResponse, - "RelayState": req.RelayState, + provider, perr := p.connectionToProvider(conn) + if perr != nil { + return fail("provider_error") + } + + params := map[string]string{"SAMLResponse": samlResponse, "RelayState": relayState, "request_id": requestID} + result, aerr := p.authenticateUser(ctx, conn.AppID, provider, conn, params) + if aerr != nil { + if p.logger != nil { + p.logger.Warn("sso: acs authentication failed", log.String("error", aerr.Error())) + } + return fail("auth_failed") } - return p.authenticateUser(ctx, provider, params) + code, cerr := p.mintOTC(ctx.Context(), conn.AppID, result) + if cerr != nil { + return fail("handoff_failed") + } + http.Redirect(ctx.Response(), r, p.successRedirect(returnURL, code), http.StatusFound) + return nil } // authenticateUser processes an SSO identity and creates/links a user. @@ -485,18 +814,12 @@ func (p *Plugin) linkableExistingUser(ctx context.Context, appID id.AppID, envID return u, nil } -func (p *Plugin) authenticateUser(ctx forge.Context, provider Provider, params map[string]string) (*CallbackResponse, error) { +func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Provider, conn *Connection, params map[string]string) (*CallbackResponse, error) { ssoUser, err := provider.HandleCallback(ctx.Context(), params) if err != nil { return nil, forge.InternalError(fmt.Errorf("sso: callback failed: %w", err)) } - appIDStr := p.appID - appID, err := id.ParseAppID(appIDStr) - if err != nil { - return nil, forge.InternalError(fmt.Errorf("invalid app_id configuration: %w", err)) - } - goCtx := ctx.Context() // Resolve the app's default environment so the user and its email row are @@ -570,6 +893,13 @@ func (p *Plugin) authenticateUser(ctx forge.Context, provider Provider, params m return nil, forge.BadRequest("SSO provider did not return an email address") } + // Org-level SSO: enroll the user into the connection's org so signing in via + // the IdP adds them to the org (the login-based alternative to an invite). + // Idempotent and best-effort — a membership hiccup must not fail the login. + if conn != nil && conn.OrgID.Prefix() != "" { + p.ensureOrgMembership(goCtx, conn.OrgID, u.ID) + } + // Mint the session through Engine.IssueSession so the centralized // MFARequired gate fires for SAML/OIDC callbacks too. var sess *session.Session @@ -626,17 +956,176 @@ func (p *Plugin) authenticateUser(ctx forge.Context, provider Provider, params m // Helpers // ────────────────────────────────────────────────── -func (p *Plugin) validateState(ctx context.Context, state, providerName string) error { +// ensureOrgMembership adds the user to the org as a member if not already one. +// Idempotent and best-effort: a lookup/insert failure is logged, never fatal to +// the login (the user still gets a session; membership can be reconciled later). +func (p *Plugin) ensureOrgMembership(ctx context.Context, orgID id.OrgID, userID id.UserID) { + if members, err := p.store.ListMembers(ctx, orgID); err == nil { + for _, m := range members { + if m != nil && m.UserID == userID { + return // already a member + } + } + } + now := time.Now() + m := &organization.Member{ + ID: id.NewMemberID(), + OrgID: orgID, + UserID: userID, + Role: organization.RoleMember, + CreatedAt: now, + UpdatedAt: now, + } + if err := p.store.CreateMember(ctx, m); err != nil && p.logger != nil { + p.logger.Warn("sso: enroll user into org failed", + log.String("org_id", orgID.String()), + log.String("user_id", userID.String()), + log.String("error", err.Error())) + } +} + +// loadState validates the CSRF state token (single-use), enforces the provider +// match, and returns the stashed ceremony payload (app + return URL) so the +// browser-landing paths can recover context they can't get from a publishable key. +func (p *Plugin) loadState(ctx context.Context, state, providerName string) (*ssoState, error) { stateData, err := p.ceremonies.Get(ctx, "sso:state:"+state) if err != nil { - return fmt.Errorf("invalid state parameter") + return nil, fmt.Errorf("invalid state parameter") } _ = p.ceremonies.Delete(ctx, "sso:state:"+state) //nolint:errcheck // best-effort cleanup - var stateInfo map[string]string - if err := json.Unmarshal(stateData, &stateInfo); err != nil || stateInfo["provider"] != providerName { - return fmt.Errorf("invalid state parameter") + var s ssoState + if err := json.Unmarshal(stateData, &s); err != nil || s.Provider != providerName { + return nil, fmt.Errorf("invalid state parameter") } - return nil + return &s, nil +} + +// appIDFromState resolves the app carried in the state payload, falling back to +// the static platform app when absent (legacy states / code-configured flows). +func (p *Plugin) appIDFromState(s *ssoState) (id.AppID, error) { + if s != nil && strings.TrimSpace(s.AppID) != "" { + return id.ParseAppID(s.AppID) + } + return id.ParseAppID(p.appID) +} + +// mintOTC stashes the freshly-issued session under a single-use one-time code +// (short TTL, app-bound) for the frontend to redeem at /v1/sso/exchange. +func (p *Plugin) mintOTC(ctx context.Context, appID id.AppID, r *CallbackResponse) (string, error) { + code, err := generateState() + if err != nil { + return "", err + } + expires := "" + if t, ok := r.ExpiresAt.(time.Time); ok { + expires = t.Format(time.RFC3339) + } + var userJSON json.RawMessage + if b, merr := json.Marshal(r.User); merr == nil { + userJSON = b + } + payload, err := json.Marshal(otcPayload{ + User: userJSON, + SessionToken: r.SessionToken, + RefreshToken: r.RefreshToken, + ExpiresAt: expires, + Provider: r.Provider, + IsNewUser: r.IsNewUser, + AppID: appID.String(), + }) + if err != nil { + return "", err + } + if err := p.ceremonies.Set(ctx, "sso:otc:"+code, payload, 60*time.Second); err != nil { + return "", err + } + return code, nil +} + +// handleExchange redeems a one-time code (single-use) for the session tokens. +// The caller presents its publishable key; the code is bound to the app it was +// minted for, so it can't be redeemed against a different app. +func (p *Plugin) handleExchange(ctx forge.Context, req *ExchangeRequest) (*CallbackResponse, error) { + code := strings.TrimSpace(req.Code) + if code == "" { + return nil, forge.BadRequest("code is required") + } + raw, err := p.ceremonies.Get(ctx.Context(), "sso:otc:"+code) + if err != nil { + return nil, forge.BadRequest("invalid or expired code") + } + _ = p.ceremonies.Delete(ctx.Context(), "sso:otc:"+code) //nolint:errcheck // single-use + var pl otcPayload + if err := json.Unmarshal(raw, &pl); err != nil { + return nil, forge.BadRequest("invalid code") + } + // App-bind: reject redemption under a different app's publishable key. + if appID, ok := middleware.AppIDFrom(ctx.Context()); ok && appID.String() != pl.AppID { + return nil, forge.BadRequest("code is not valid for this app") + } + var u any + if len(pl.User) > 0 { + u = pl.User + } + return &CallbackResponse{ + User: u, + SessionToken: pl.SessionToken, + RefreshToken: pl.RefreshToken, + ExpiresAt: pl.ExpiresAt, + Provider: pl.Provider, + IsNewUser: pl.IsNewUser, + }, nil +} + +// isAllowedReturnURL guards the browser landing against open redirect. localhost +// is always allowed (dev); otherwise the URL must be https and its origin must +// exactly match a configured AllowedReturnOrigins entry. +func (p *Plugin) isAllowedReturnURL(raw string) bool { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return false + } + if host := u.Hostname(); host == "localhost" || host == "127.0.0.1" { + return true + } + if u.Scheme != "https" { + return false + } + origin := u.Scheme + "://" + u.Host + for _, a := range p.config.AllowedReturnOrigins { + if strings.EqualFold(strings.TrimRight(a, "/"), origin) { + return true + } + } + return false +} + +// defaultReturnURL returns a validated return URL, or a safe default landing +// derived from the first allowlisted origin (or the public base) otherwise. +func (p *Plugin) defaultReturnURL(returnURL string) string { + if returnURL != "" && p.isAllowedReturnURL(returnURL) { + return returnURL + } + if len(p.config.AllowedReturnOrigins) > 0 { + return strings.TrimRight(p.config.AllowedReturnOrigins[0], "/") + "/sso/callback" + } + return p.publicBaseURL() + "/sso/callback" +} + +func (p *Plugin) successRedirect(returnURL, code string) string { + return appendQuery(p.defaultReturnURL(returnURL), "code", code) +} + +func (p *Plugin) errorRedirect(returnURL, reason string) string { + return appendQuery(p.defaultReturnURL(returnURL), "sso_error", reason) +} + +func appendQuery(base, key, value string) string { + sep := "?" + if strings.Contains(base, "?") { + sep = "&" + } + return base + sep + key + "=" + url.QueryEscape(value) } func generateState() (string, error) { @@ -691,7 +1180,17 @@ type AdminCreateConnectionRequest struct { Issuer string `json:"issuer,omitempty" description:"OIDC issuer URL (required for oidc)"` ClientID string `json:"client_id,omitempty" description:"OIDC client ID"` ClientSecret string `json:"client_secret,omitempty" description:"OIDC client secret (omit for public flows)"` - MetadataURL string `json:"metadata_url,omitempty" description:"SAML metadata URL (required for saml)"` + MetadataURL string `json:"metadata_url,omitempty" description:"SAML metadata URL (one IdP source for saml)"` + + // SAML IdP configuration. Supply exactly one IdP source: metadata_url, + // idp_metadata_xml, or idp_sso_url + idp_certificate. + IDPMetadataXML string `json:"idp_metadata_xml,omitempty" description:"Pasted SAML IdP metadata XML"` + IDPSSOURL string `json:"idp_sso_url,omitempty" description:"SAML IdP SSO (redirect) URL"` + IDPCertificate string `json:"idp_certificate,omitempty" description:"SAML IdP signing certificate (PEM)"` + EntityID string `json:"entity_id,omitempty" description:"SP EntityID override; defaults to the metadata URL"` + ACSURL string `json:"acs_url,omitempty" description:"SP ACS URL override; defaults to the canonical /acs route"` + SignRequests bool `json:"sign_requests,omitempty" description:"Sign outbound AuthnRequests with the SP key"` + AttributeMappings map[string]string `json:"attribute_mappings,omitempty" description:"SAML attribute name → user field (email|first_name|last_name|groups)"` } // AdminCreateConnectionResponse is the response from @@ -734,10 +1233,19 @@ func (p *Plugin) handleAdminCreateConnection(ctx forge.Context, req *AdminCreate } } + // Scope the connection to the app's default environment. The + // authsome_sso_connections.env_id column is NOT NULL with an FK to + // authsome_environments, so a connection must carry a valid env. + env, err := p.store.GetDefaultEnvironment(ctx.Context(), appID) + if err != nil || env == nil { + return nil, forge.InternalError(fmt.Errorf("sso: resolve default environment for app: %w", err)) + } + now := time.Now() conn := &Connection{ ID: id.NewSSOConnectionID(), AppID: appID, + EnvID: env.ID.String(), OrgID: orgID, Provider: req.Provider, Protocol: req.Protocol, @@ -755,10 +1263,21 @@ func (p *Plugin) handleAdminCreateConnection(ctx forge.Context, req *AdminCreate conn.ClientID = req.ClientID conn.ClientSecret = req.ClientSecret case "saml": - if strings.TrimSpace(req.MetadataURL) == "" { - return nil, forge.BadRequest("SAML connections require metadata_url") + if err := applySAMLCreate(conn, req); err != nil { + return nil, err } - conn.MetadataURL = req.MetadataURL + // Persist the effective EntityID/ACS URL (derived from PublicBaseURL when + // not overridden) so admins can read them back to configure their IdP. + conn.EntityID = p.entityIDFor(conn) + conn.ACSURL = p.acsURLFor(conn) + // Generate a self-signed SP keypair when the admin supplies none, so + // signed AuthnRequests and SP metadata work out of the box. + certPEM, keyPEM, kerr := generateSPKeypair(conn.EntityID) + if kerr != nil { + return nil, forge.InternalError(fmt.Errorf("sso: generate SP keypair: %w", kerr)) + } + conn.SPCertificate = certPEM + conn.SPPrivateKey = keyPEM } if err := p.ssoStore.CreateConnection(ctx.Context(), conn); err != nil { @@ -775,6 +1294,26 @@ func (p *Plugin) handleAdminCreateConnection(ctx forge.Context, req *AdminCreate }, nil } +// applySAMLCreate validates the IdP source and copies the SAML fields from the +// create request onto the connection. +func applySAMLCreate(conn *Connection, req *AdminCreateConnectionRequest) error { + hasMetaURL := strings.TrimSpace(req.MetadataURL) != "" + hasMetaXML := strings.TrimSpace(req.IDPMetadataXML) != "" + hasCertURL := strings.TrimSpace(req.IDPSSOURL) != "" && strings.TrimSpace(req.IDPCertificate) != "" + if !hasMetaURL && !hasMetaXML && !hasCertURL { + return forge.BadRequest("SAML connections require one IdP source: metadata_url, idp_metadata_xml, or idp_sso_url + idp_certificate") + } + conn.MetadataURL = req.MetadataURL + conn.IDPMetadataXML = req.IDPMetadataXML + conn.IDPSSOURL = req.IDPSSOURL + conn.IDPCertificate = req.IDPCertificate + conn.EntityID = req.EntityID + conn.ACSURL = req.ACSURL + conn.SignRequests = req.SignRequests + conn.AttributeMappings = req.AttributeMappings + return nil +} + // AdminListConnectionsRequest binds the query for GET /v1/admin/sso/connections. type AdminListConnectionsRequest struct { AppID string `query:"app_id" description:"App identifier; required"` @@ -804,7 +1343,18 @@ type AdminUpdateConnectionRequest struct { ClientID string `json:"client_id,omitempty" description:"OIDC client ID"` ClientSecret string `json:"client_secret,omitempty" description:"OIDC client secret; pass empty string to leave unchanged"` MetadataURL string `json:"metadata_url,omitempty" description:"SAML metadata URL"` - Active *bool `json:"active,omitempty" description:"Activate/deactivate the connection without deleting it"` + + // SAML fields. Empty strings leave the stored value unchanged; send + // attribute_mappings/sign_requests to replace them wholesale. + IDPMetadataXML string `json:"idp_metadata_xml,omitempty" description:"Pasted SAML IdP metadata XML"` + IDPSSOURL string `json:"idp_sso_url,omitempty" description:"SAML IdP SSO URL"` + IDPCertificate string `json:"idp_certificate,omitempty" description:"SAML IdP signing certificate (PEM)"` + EntityID string `json:"entity_id,omitempty" description:"SP EntityID override"` + ACSURL string `json:"acs_url,omitempty" description:"SP ACS URL override"` + SignRequests *bool `json:"sign_requests,omitempty" description:"Toggle signing outbound AuthnRequests"` + AttributeMappings map[string]string `json:"attribute_mappings,omitempty" description:"Replace the SAML attribute → user field map"` + + Active *bool `json:"active,omitempty" description:"Activate/deactivate the connection without deleting it"` } // AdminDeleteConnectionResponse mirrors the StatusResponse shape used @@ -890,6 +1440,27 @@ func (p *Plugin) handleAdminUpdateConnection(ctx forge.Context, req *AdminUpdate if v := strings.TrimSpace(req.MetadataURL); v != "" { conn.MetadataURL = v } + if v := strings.TrimSpace(req.IDPMetadataXML); v != "" { + conn.IDPMetadataXML = v + } + if v := strings.TrimSpace(req.IDPSSOURL); v != "" { + conn.IDPSSOURL = v + } + if v := strings.TrimSpace(req.IDPCertificate); v != "" { + conn.IDPCertificate = v + } + if v := strings.TrimSpace(req.EntityID); v != "" { + conn.EntityID = v + } + if v := strings.TrimSpace(req.ACSURL); v != "" { + conn.ACSURL = v + } + if req.SignRequests != nil { + conn.SignRequests = *req.SignRequests + } + if req.AttributeMappings != nil { + conn.AttributeMappings = req.AttributeMappings + } if req.Active != nil { conn.Active = *req.Active } diff --git a/plugins/sso/provider.go b/plugins/sso/provider.go index 57100389..c3f64a1a 100644 --- a/plugins/sso/provider.go +++ b/plugins/sso/provider.go @@ -53,6 +53,7 @@ type User struct { type Connection struct { ID id.SSOConnectionID `json:"id"` AppID id.AppID `json:"app_id"` + EnvID string `json:"env_id,omitempty"` OrgID id.OrgID `json:"org_id,omitempty"` Provider string `json:"provider"` Protocol string `json:"protocol"` @@ -62,8 +63,20 @@ type Connection struct { ClientSecret string `json:"-"` Issuer string `json:"issuer,omitempty"` Active bool `json:"active"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + + // SAML-specific configuration. Populated only for SAML connections. + IDPMetadataXML string `json:"idp_metadata_xml,omitempty"` + IDPSSOURL string `json:"idp_sso_url,omitempty"` + IDPCertificate string `json:"idp_certificate,omitempty"` + EntityID string `json:"entity_id,omitempty"` + ACSURL string `json:"acs_url,omitempty"` + SPCertificate string `json:"sp_certificate,omitempty"` + SPPrivateKey string `json:"-"` // secret — never serialized + SignRequests bool `json:"sign_requests,omitempty"` + AttributeMappings map[string]string `json:"attribute_mappings,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Store persists SSO connections. diff --git a/plugins/sso/saml.go b/plugins/sso/saml.go index 2301329b..2facb5c6 100644 --- a/plugins/sso/saml.go +++ b/plugins/sso/saml.go @@ -2,101 +2,394 @@ package sso import ( "context" + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "encoding/xml" "fmt" + "math/big" + "net/http" "net/url" + "strings" + "time" + + "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" + dsig "github.com/russellhaering/goxmldsig" ) -// SAMLConfig configures a SAML 2.0 identity provider. +// metadataFetchTimeout bounds how long an IdP metadata fetch may take. +const metadataFetchTimeout = 10 * time.Second + +// SAMLConfig configures a SAML 2.0 identity provider connection. type SAMLConfig struct { - // Name is the unique provider identifier (e.g. "okta-saml", "azure-ad-saml"). + // Name is the unique provider identifier (e.g. "okta-saml"). Name string - // MetadataURL is the URL to the IdP's SAML metadata XML. - MetadataURL string + // IdP description — resolved in this order: pasted metadata XML, a + // metadata URL to fetch, or an explicit SSO URL + signing certificate. + IDPMetadataXML string + MetadataURL string + IDPSSOURL string + IDPCertificatePEM string - // ACSURL is the Assertion Consumer Service URL (your app's callback). - ACSURL string + // Service Provider identity. + EntityID string // SP entity ID (assertion audience) + ACSURL string // SP Assertion Consumer Service URL + SPCertificatePEM string // SP signing cert (PEM); optional + SPPrivateKeyPEM string // SP signing key (PEM); optional, enables signed AuthnRequests + SignRequests bool - // EntityID is your Service Provider entity ID. - EntityID string + // AllowIDPInitiated permits unsolicited assertions (no prior AuthnRequest). + AllowIDPInitiated bool - // SignRequests controls whether SAML AuthnRequests should be signed. - SignRequests bool + // AttributeMap maps SAML attribute names to user fields + // ("email" | "first_name" | "last_name" | "groups"). Empty uses defaults. + AttributeMap map[string]string } -// samlProvider implements the Provider interface for SAML 2.0. +// samlProvider implements Provider (and SAMLMetadataProvider) for SAML 2.0 +// backed by a configured crewjam ServiceProvider. type samlProvider struct { - name string - metadataURL string - acsURL string - entityID string + name string + sp *saml.ServiceProvider + attrMap map[string]string } -// NewSAMLProvider creates a SAML 2.0 SSO provider. -// -// This implementation provides a lightweight SAML flow: -// - LoginURL redirects to the IdP's SSO endpoint -// - HandleCallback processes the SAMLResponse form parameter -// -// For production SAML deployments, consider using a full SAML library -// (e.g. crewjam/saml) for XML signature validation and assertion parsing. -func NewSAMLProvider(cfg SAMLConfig) Provider { - return &samlProvider{ - name: cfg.Name, - metadataURL: cfg.MetadataURL, - acsURL: cfg.ACSURL, - entityID: cfg.EntityID, +// SAMLMetadataProvider is implemented by SAML providers that can emit SP +// metadata XML for IdP configuration. +type SAMLMetadataProvider interface { + Metadata() (xml []byte, contentType string, err error) +} + +// NewSAMLProvider builds a SAML provider from cfg. It resolves the IdP +// metadata/cert up front so a misconfigured connection fails fast rather than +// at first login. +func NewSAMLProvider(cfg SAMLConfig) (Provider, error) { + if strings.TrimSpace(cfg.EntityID) == "" || strings.TrimSpace(cfg.ACSURL) == "" { + return nil, fmt.Errorf("sso/saml: entity_id and acs_url are required") + } + acs, err := url.Parse(cfg.ACSURL) + if err != nil { + return nil, fmt.Errorf("sso/saml: invalid acs_url: %w", err) + } + + idpMeta, err := resolveIDPMetadata(cfg) + if err != nil { + return nil, err } + + sp := &saml.ServiceProvider{ + EntityID: cfg.EntityID, + AcsURL: *acs, + IDPMetadata: idpMeta, + AuthnNameIDFormat: saml.EmailAddressNameIDFormat, + AllowIDPInitiated: cfg.AllowIDPInitiated, + } + + // The SP keypair is only needed to sign AuthnRequests / decrypt assertions. + if cfg.SPCertificatePEM != "" && cfg.SPPrivateKeyPEM != "" { + keypair, kerr := tls.X509KeyPair([]byte(cfg.SPCertificatePEM), []byte(cfg.SPPrivateKeyPEM)) + if kerr != nil { + return nil, fmt.Errorf("sso/saml: invalid SP keypair: %w", kerr) + } + leaf, cerr := x509.ParseCertificate(keypair.Certificate[0]) + if cerr != nil { + return nil, fmt.Errorf("sso/saml: invalid SP certificate: %w", cerr) + } + key, ok := keypair.PrivateKey.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("sso/saml: SP private key must be RSA") + } + sp.Certificate = leaf + sp.Key = key + if cfg.SignRequests { + sp.SignatureMethod = dsig.RSASHA256SignatureMethod + } + } + + return &samlProvider{name: cfg.Name, sp: sp, attrMap: cfg.AttributeMap}, nil } func (p *samlProvider) Name() string { return p.name } func (p *samlProvider) Protocol() string { return "saml" } -// LoginURL returns the IdP SSO URL with SAMLRequest parameters. +// LoginURL builds a signed (when configured) AuthnRequest and returns the IdP +// HTTP-Redirect URL carrying the request + RelayState (= state). func (p *samlProvider) LoginURL(state string) (string, error) { - // For SAML, the login URL is typically the IdP's SSO endpoint. - // In a full implementation, this would construct a SAMLRequest. - // Here we provide the metadata URL as a reference and the state - // for relay state tracking. - if p.metadataURL == "" { - return "", fmt.Errorf("sso/saml: metadata URL is required") + u, _, err := p.LoginURLWithRequestID(state) + return u, err +} + +// LoginURLWithRequestID is LoginURL that also returns the generated AuthnRequest +// ID. The caller persists it (keyed by state) and passes it back at the ACS so +// ParseXMLResponse can match the assertion's InResponseTo — without this the SP +// has no request IDs to validate against and every solicited assertion is +// rejected. Satisfies the optional requestIDProvider interface in the plugin. +func (p *samlProvider) LoginURLWithRequestID(state string) (string, string, error) { + loc := p.sp.GetSSOBindingLocation(saml.HTTPRedirectBinding) + if loc == "" { + return "", "", fmt.Errorf("sso/saml: IdP exposes no HTTP-Redirect SSO endpoint") + } + req, err := p.sp.MakeAuthenticationRequest(loc, saml.HTTPRedirectBinding, saml.HTTPPostBinding) + if err != nil { + return "", "", fmt.Errorf("sso/saml: build AuthnRequest: %w", err) + } + u, err := req.Redirect(state, p.sp) + if err != nil { + return "", "", fmt.Errorf("sso/saml: build redirect: %w", err) + } + return u.String(), req.ID, nil +} + +// HandleCallback validates the SAMLResponse (signature against the pinned IdP +// cert, conditions, audience, recipient, timestamps) and maps it to a User. +func (p *samlProvider) HandleCallback(_ context.Context, params map[string]string) (*User, error) { + raw := params["SAMLResponse"] + if raw == "" { + return nil, fmt.Errorf("sso/saml: missing SAMLResponse") + } + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("sso/saml: decode SAMLResponse: %w", err) + } + // Match the assertion's InResponseTo against the AuthnRequest ID we minted at + // login (threaded back via params["request_id"]). Without a request ID this + // is a no-match unless AllowIDPInitiated is set — crewjam enforces that. The + // signature, conditions, audience, recipient, and timestamps are validated + // regardless. + var possibleRequestIDs []string + if reqID := params["request_id"]; reqID != "" { + possibleRequestIDs = []string{reqID} + } + assertion, err := p.sp.ParseXMLResponse(decoded, possibleRequestIDs, p.sp.AcsURL) + if err != nil { + return nil, fmt.Errorf("sso/saml: invalid assertion: %w", err) + } + return assertionToUser(assertion, p.attrMap) +} + +// Metadata returns the SP metadata XML for the IdP to consume. +func (p *samlProvider) Metadata() ([]byte, string, error) { + out, err := xml.MarshalIndent(p.sp.Metadata(), "", " ") + if err != nil { + return nil, "", fmt.Errorf("sso/saml: marshal metadata: %w", err) } + return out, "application/samlmetadata+xml", nil +} + +// ────────────────────────────────────────────────── +// IdP metadata resolution +// ────────────────────────────────────────────────── - // Construct IdP SSO URL using the metadata URL base. - // The actual SSO endpoint would be parsed from the metadata XML. - u, err := url.Parse(p.metadataURL) +func resolveIDPMetadata(cfg SAMLConfig) (*saml.EntityDescriptor, error) { + switch { + case strings.TrimSpace(cfg.IDPMetadataXML) != "": + return samlsp.ParseMetadata([]byte(cfg.IDPMetadataXML)) + case strings.TrimSpace(cfg.MetadataURL) != "": + u, err := url.Parse(cfg.MetadataURL) + if err != nil { + return nil, fmt.Errorf("sso/saml: invalid metadata_url: %w", err) + } + ctx, cancel := context.WithTimeout(context.Background(), metadataFetchTimeout) + defer cancel() + md, err := samlsp.FetchMetadata(ctx, http.DefaultClient, *u) + if err != nil { + return nil, fmt.Errorf("sso/saml: fetch metadata: %w", err) + } + return md, nil + case strings.TrimSpace(cfg.IDPSSOURL) != "" && strings.TrimSpace(cfg.IDPCertificatePEM) != "": + return buildIDPMetadata(cfg.IDPSSOURL, cfg.IDPCertificatePEM) + default: + return nil, fmt.Errorf("sso/saml: IdP config required (metadata_url, idp_metadata_xml, or idp_sso_url + idp_certificate)") + } +} + +// buildIDPMetadata synthesizes a minimal IdP EntityDescriptor from an SSO URL +// and a signing certificate — for IdPs configured by pasted cert rather than +// discoverable metadata. +func buildIDPMetadata(ssoURL, certPEM string) (*saml.EntityDescriptor, error) { + certDER, err := certDERFromPEM(certPEM) if err != nil { - return "", fmt.Errorf("sso/saml: invalid metadata URL: %w", err) + return nil, err } + return &saml.EntityDescriptor{ + EntityID: ssoURL, + IDPSSODescriptors: []saml.IDPSSODescriptor{{ + SSODescriptor: saml.SSODescriptor{ + RoleDescriptor: saml.RoleDescriptor{ + KeyDescriptors: []saml.KeyDescriptor{{ + Use: "signing", + KeyInfo: saml.KeyInfo{X509Data: saml.X509Data{ + X509Certificates: []saml.X509Certificate{{Data: certDER}}, + }}, + }}, + }, + }, + SingleSignOnServices: []saml.Endpoint{{ + Binding: saml.HTTPRedirectBinding, + Location: ssoURL, + }}, + }}, + }, nil +} - // Use the IdP host for the SSO endpoint. - ssoURL := fmt.Sprintf("%s://%s/saml/sso", u.Scheme, u.Host) +// certDERFromPEM returns the base64-encoded DER (metadata's X509Certificate +// form) of a PEM certificate. +func certDERFromPEM(certPEM string) (string, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return "", fmt.Errorf("sso/saml: invalid certificate PEM") + } + return base64.StdEncoding.EncodeToString(block.Bytes), nil +} - params := url.Values{ - "SAMLRequest": {""}, - "RelayState": {state}, +// ────────────────────────────────────────────────── +// Assertion → User mapping +// ────────────────────────────────────────────────── + +const ( + fieldEmail = "email" + fieldFirstName = "first_name" + fieldLastName = "last_name" + fieldGroups = "groups" +) + +// defaultAttrAliases maps common IdP attribute names (lowercased) to user +// fields, covering the friendly, WS-Fed claim, and LDAP OID forms. +var defaultAttrAliases = map[string]string{ + "email": fieldEmail, + "emailaddress": fieldEmail, + "mail": fieldEmail, + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": fieldEmail, + "urn:oid:0.9.2342.19200300.100.1.3": fieldEmail, + "firstname": fieldFirstName, + "givenname": fieldFirstName, + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname": fieldFirstName, + "urn:oid:2.5.4.42": fieldFirstName, + "lastname": fieldLastName, + "surname": fieldLastName, + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname": fieldLastName, + "urn:oid:2.5.4.4": fieldLastName, + "groups": fieldGroups, + "memberof": fieldGroups, + "http://schemas.xmlsoap.org/claims/group": fieldGroups, +} + +// assertionToUser extracts identity from a validated assertion. Pure and +// side-effect-free so it can be unit-tested without SAML crypto. +func assertionToUser(a *saml.Assertion, attrMap map[string]string) (*User, error) { + if a == nil || a.Subject == nil || a.Subject.NameID == nil { + return nil, fmt.Errorf("sso/saml: assertion missing subject NameID") + } + nameID := strings.TrimSpace(a.Subject.NameID.Value) + + u := &User{ProviderUserID: nameID, Attributes: map[string]string{}} + for _, stmt := range a.AttributeStatements { + for _, attr := range stmt.Attributes { + vals := attrValues(attr) + if len(vals) == 0 { + continue + } + if attr.Name != "" { + u.Attributes[attr.Name] = vals[0] + } + switch mapSAMLAttr(attr, attrMap) { + case fieldEmail: + u.Email = vals[0] + case fieldFirstName: + u.FirstName = vals[0] + case fieldLastName: + u.LastName = vals[0] + case fieldGroups: + u.Groups = append(u.Groups, vals...) + } + } } - return ssoURL + "?" + params.Encode(), nil + if u.Email == "" && looksLikeEmail(nameID) { + u.Email = nameID + } + return u, nil } -// HandleCallback processes the SAML assertion from the IdP. -func (p *samlProvider) HandleCallback(_ context.Context, params map[string]string) (*User, error) { - samlResponse := params["SAMLResponse"] - if samlResponse == "" { - return nil, fmt.Errorf("sso/saml: missing SAMLResponse") +// mapSAMLAttr resolves an attribute to a user field, preferring an explicit +// per-connection mapping over the built-in aliases. +func mapSAMLAttr(attr saml.Attribute, attrMap map[string]string) string { + for _, name := range []string{attr.Name, attr.FriendlyName} { + if name == "" { + continue + } + if attrMap != nil { + if f := normalizeField(attrMap[name]); f != "" { + return f + } + } + if f, ok := defaultAttrAliases[strings.ToLower(name)]; ok { + return f + } + } + return "" +} + +func normalizeField(f string) string { + switch strings.ToLower(strings.TrimSpace(f)) { + case "email", "email_address", "emailaddress": + return fieldEmail + case "first_name", "firstname", "given_name", "givenname": + return fieldFirstName + case "last_name", "lastname", "surname": + return fieldLastName + case "groups", "group": + return fieldGroups + default: + return "" + } +} + +func attrValues(attr saml.Attribute) []string { + out := make([]string, 0, len(attr.Values)) + for _, v := range attr.Values { + if s := strings.TrimSpace(v.Value); s != "" { + out = append(out, s) + } } + return out +} + +func looksLikeEmail(s string) bool { + at := strings.IndexByte(s, '@') + return at > 0 && at < len(s)-1 +} - // In a production implementation, this would: - // 1. Base64-decode the SAMLResponse - // 2. Parse the XML assertion - // 3. Validate the XML signature against the IdP's certificate - // 4. Validate assertion conditions (audience, timestamps) - // 5. Extract the NameID and attributes +// ────────────────────────────────────────────────── +// SP keypair generation +// ────────────────────────────────────────────────── - // For now, return an error indicating the SAML response needs to be - // processed by a full SAML library. This provider serves as the - // integration point and interface definition. - return nil, fmt.Errorf("sso/saml: SAML response parsing requires a SAML library " + - "(e.g. crewjam/saml); configure the provider with full SAML support") +// generateSPKeypair creates a self-signed RSA-2048 SP keypair, returned as PEM. +// Used at connection-create time when the admin supplies no SP keypair. +func generateSPKeypair(entityID string) (certPEM, keyPEM string, err error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: entityID}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key) + if err != nil { + return "", "", err + } + certPEM = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + keyPEM = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})) + return certPEM, keyPEM, nil } diff --git a/plugins/sso/saml_test.go b/plugins/sso/saml_test.go new file mode 100644 index 00000000..65e414b6 --- /dev/null +++ b/plugins/sso/saml_test.go @@ -0,0 +1,541 @@ +package sso + +import ( + "context" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "encoding/xml" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/crewjam/saml" +) + +// testIDPCertPEM returns a self-signed certificate PEM usable as a stand-in +// IdP signing certificate. Reuses the SP keypair generator since a cert is a +// cert — the private key is discarded. +func testIDPCertPEM(t *testing.T) string { + t.Helper() + certPEM, _, err := generateSPKeypair("https://idp.example.com") + if err != nil { + t.Fatalf("generate test cert: %v", err) + } + return certPEM +} + +// baseSAMLConfig is a minimal valid config (IdP by SSO URL + cert). +func baseSAMLConfig(t *testing.T) SAMLConfig { + t.Helper() + return SAMLConfig{ + Name: "okta", + IDPSSOURL: "https://idp.example.com/sso", + IDPCertificatePEM: testIDPCertPEM(t), + EntityID: "https://sp.example.com/v1/sso/okta/metadata", + ACSURL: "https://sp.example.com/v1/sso/okta/acs", + } +} + +func TestGenerateSPKeypair(t *testing.T) { + certPEM, keyPEM, err := generateSPKeypair("https://sp.example.com") + if err != nil { + t.Fatalf("generateSPKeypair: %v", err) + } + if !strings.Contains(certPEM, "BEGIN CERTIFICATE") { + t.Errorf("cert PEM missing certificate block:\n%s", certPEM) + } + if !strings.Contains(keyPEM, "PRIVATE KEY") { + t.Errorf("key PEM missing private key block") + } + // The pair must load as a valid X509 keypair. + if _, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM)); err != nil { + t.Errorf("X509KeyPair: %v", err) + } +} + +func TestNewSAMLProvider_validation(t *testing.T) { + tests := []struct { + name string + mutate func(*SAMLConfig) + wantErr string + }{ + { + name: "missing entity_id", + mutate: func(c *SAMLConfig) { c.EntityID = "" }, + wantErr: "entity_id and acs_url are required", + }, + { + name: "missing acs_url", + mutate: func(c *SAMLConfig) { c.ACSURL = "" }, + wantErr: "entity_id and acs_url are required", + }, + { + name: "no IdP source", + mutate: func(c *SAMLConfig) { + c.IDPSSOURL = "" + c.IDPCertificatePEM = "" + }, + wantErr: "IdP config required", + }, + { + name: "sso url without certificate", + mutate: func(c *SAMLConfig) { c.IDPCertificatePEM = "" }, + wantErr: "IdP config required", + }, + { + name: "bad idp certificate", + mutate: func(c *SAMLConfig) { c.IDPCertificatePEM = "not-a-pem" }, + wantErr: "invalid certificate PEM", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseSAMLConfig(t) + tt.mutate(&cfg) + _, err := NewSAMLProvider(cfg) + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestNewSAMLProvider_ok(t *testing.T) { + p, err := NewSAMLProvider(baseSAMLConfig(t)) + if err != nil { + t.Fatalf("NewSAMLProvider: %v", err) + } + if p.Name() != "okta" { + t.Errorf("Name() = %q, want okta", p.Name()) + } + if p.Protocol() != "saml" { + t.Errorf("Protocol() = %q, want saml", p.Protocol()) + } + if _, ok := p.(SAMLMetadataProvider); !ok { + t.Errorf("provider does not implement SAMLMetadataProvider") + } +} + +func TestSAMLProvider_LoginURL(t *testing.T) { + tests := []struct { + name string + sign bool + wantSignYes bool + }{ + {name: "unsigned", sign: false, wantSignYes: false}, + {name: "signed", sign: true, wantSignYes: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseSAMLConfig(t) + if tt.sign { + certPEM, keyPEM, err := generateSPKeypair(cfg.EntityID) + if err != nil { + t.Fatalf("keypair: %v", err) + } + cfg.SPCertificatePEM = certPEM + cfg.SPPrivateKeyPEM = keyPEM + cfg.SignRequests = true + } + p, err := NewSAMLProvider(cfg) + if err != nil { + t.Fatalf("NewSAMLProvider: %v", err) + } + + raw, err := p.LoginURL("state-xyz") + if err != nil { + t.Fatalf("LoginURL: %v", err) + } + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse login URL: %v", err) + } + if u.Host != "idp.example.com" { + t.Errorf("redirect host = %q, want idp.example.com", u.Host) + } + q := u.Query() + if q.Get("SAMLRequest") == "" { + t.Errorf("login URL missing SAMLRequest: %s", raw) + } + if q.Get("RelayState") != "state-xyz" { + t.Errorf("RelayState = %q, want state-xyz", q.Get("RelayState")) + } + if got := q.Get("Signature") != ""; got != tt.wantSignYes { + t.Errorf("has Signature = %v, want %v", got, tt.wantSignYes) + } + }) + } +} + +func TestSAMLProvider_Metadata(t *testing.T) { + p, err := NewSAMLProvider(baseSAMLConfig(t)) + if err != nil { + t.Fatalf("NewSAMLProvider: %v", err) + } + mp, ok := p.(SAMLMetadataProvider) + if !ok { + t.Fatalf("provider is not a SAMLMetadataProvider") + } + xmlBytes, contentType, err := mp.Metadata() + if err != nil { + t.Fatalf("Metadata: %v", err) + } + if contentType != "application/samlmetadata+xml" { + t.Errorf("content type = %q", contentType) + } + body := string(xmlBytes) + for _, want := range []string{ + "https://sp.example.com/v1/sso/okta/metadata", // EntityID + "https://sp.example.com/v1/sso/okta/acs", // ACS URL + "EntityDescriptor", + } { + if !strings.Contains(body, want) { + t.Errorf("metadata missing %q\n%s", want, body) + } + } +} + +func TestAssertionToUser(t *testing.T) { + tests := []struct { + name string + assertion *saml.Assertion + attrMap map[string]string + want User + wantErr bool + }{ + { + name: "friendly attribute names", + assertion: buildAssertion("user@corp.com", map[string][]string{ + "email": {"user@corp.com"}, + "givenName": {"Ada"}, + "surname": {"Lovelace"}, + "groups": {"admins", "eng"}, + }), + want: User{ + ProviderUserID: "user@corp.com", + Email: "user@corp.com", + FirstName: "Ada", + LastName: "Lovelace", + Groups: []string{"admins", "eng"}, + }, + }, + { + name: "urn oid attribute names", + assertion: buildAssertion("id-123", map[string][]string{ + "urn:oid:0.9.2342.19200300.100.1.3": {"person@corp.com"}, + "urn:oid:2.5.4.42": {"Grace"}, + "urn:oid:2.5.4.4": {"Hopper"}, + }), + want: User{ + ProviderUserID: "id-123", + Email: "person@corp.com", + FirstName: "Grace", + LastName: "Hopper", + }, + }, + { + name: "nameid email fallback", + assertion: buildAssertion("fallback@corp.com", nil), + want: User{ + ProviderUserID: "fallback@corp.com", + Email: "fallback@corp.com", + }, + }, + { + name: "opaque nameid no fallback", + assertion: buildAssertion("opaque-nameid", nil), + want: User{ + ProviderUserID: "opaque-nameid", + }, + }, + { + name: "custom attribute mapping", + assertion: buildAssertion("u1", map[string][]string{ + "mailPrimary": {"mapped@corp.com"}, + }), + attrMap: map[string]string{"mailPrimary": "email"}, + want: User{ + ProviderUserID: "u1", + Email: "mapped@corp.com", + }, + }, + { + name: "nil subject errors", + assertion: &saml.Assertion{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := assertionToUser(tt.assertion, tt.attrMap) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("assertionToUser: %v", err) + } + if got.ProviderUserID != tt.want.ProviderUserID { + t.Errorf("ProviderUserID = %q, want %q", got.ProviderUserID, tt.want.ProviderUserID) + } + if got.Email != tt.want.Email { + t.Errorf("Email = %q, want %q", got.Email, tt.want.Email) + } + if got.FirstName != tt.want.FirstName { + t.Errorf("FirstName = %q, want %q", got.FirstName, tt.want.FirstName) + } + if got.LastName != tt.want.LastName { + t.Errorf("LastName = %q, want %q", got.LastName, tt.want.LastName) + } + if strings.Join(got.Groups, ",") != strings.Join(tt.want.Groups, ",") { + t.Errorf("Groups = %v, want %v", got.Groups, tt.want.Groups) + } + }) + } +} + +func TestResolveIDPMetadata(t *testing.T) { + certPEM := testIDPCertPEM(t) + + t.Run("from sso url and cert", func(t *testing.T) { + md, err := resolveIDPMetadata(SAMLConfig{ + IDPSSOURL: "https://idp.example.com/sso", + IDPCertificatePEM: certPEM, + }) + if err != nil { + t.Fatalf("resolveIDPMetadata: %v", err) + } + if len(md.IDPSSODescriptors) != 1 { + t.Fatalf("expected 1 IDPSSODescriptor, got %d", len(md.IDPSSODescriptors)) + } + svcs := md.IDPSSODescriptors[0].SingleSignOnServices + if len(svcs) != 1 || svcs[0].Location != "https://idp.example.com/sso" { + t.Errorf("unexpected SSO services: %+v", svcs) + } + }) + + t.Run("from pasted metadata xml", func(t *testing.T) { + // Round-trip: build metadata, marshal, re-parse. + md, err := buildIDPMetadata("https://idp.example.com/sso", certPEM) + if err != nil { + t.Fatalf("buildIDPMetadata: %v", err) + } + xmlBytes, err := xml.Marshal(md) + if err != nil { + t.Fatalf("marshal metadata: %v", err) + } + got, err := resolveIDPMetadata(SAMLConfig{IDPMetadataXML: string(xmlBytes)}) + if err != nil { + t.Fatalf("resolveIDPMetadata(xml): %v", err) + } + if got.EntityID == "" { + t.Errorf("parsed metadata has empty EntityID") + } + }) + + t.Run("no source errors", func(t *testing.T) { + if _, err := resolveIDPMetadata(SAMLConfig{}); err == nil { + t.Errorf("expected error for empty config") + } + }) +} + +// samlE2EFixture wires a real crewjam IdP (with its own signing keypair) to an +// SP built by NewSAMLProvider and pinned to that IdP's certificate, so a minted +// assertion round-trips through HandleCallback → ParseXMLResponse for real: +// signature, audience, recipient, and timestamps are all genuinely validated. +type samlE2EFixture struct { + sp *samlProvider + idp *saml.IdentityProvider +} + +// spProviderShim adapts the fixture's SP metadata to saml.ServiceProviderProvider. +type spProviderShim struct{ md *saml.EntityDescriptor } + +func (s spProviderShim) GetServiceProvider(_ *http.Request, _ string) (*saml.EntityDescriptor, error) { + return s.md, nil +} + +func newSAMLE2EFixture(t *testing.T, allowIDPInitiated bool) *samlE2EFixture { + t.Helper() + + // IdP signing keypair. The same cert is pinned into the SP so signature + // validation exercises the real path. + idpCertPEM, idpKeyPEM, err := generateSPKeypair("https://idp.example.com") + if err != nil { + t.Fatalf("generate idp keypair: %v", err) + } + keypair, err := tls.X509KeyPair([]byte(idpCertPEM), []byte(idpKeyPEM)) + if err != nil { + t.Fatalf("load idp keypair: %v", err) + } + idpCert, err := x509.ParseCertificate(keypair.Certificate[0]) + if err != nil { + t.Fatalf("parse idp cert: %v", err) + } + idpKey, ok := keypair.PrivateKey.(*rsa.PrivateKey) + if !ok { + t.Fatalf("idp key is not RSA") + } + + cfg := SAMLConfig{ + Name: "okta", + IDPSSOURL: "https://idp.example.com/sso", + IDPCertificatePEM: idpCertPEM, + EntityID: "https://sp.example.com/v1/sso/okta/metadata", + ACSURL: "https://sp.example.com/v1/sso/okta/acs", + AllowIDPInitiated: allowIDPInitiated, + } + prov, err := NewSAMLProvider(cfg) + if err != nil { + t.Fatalf("NewSAMLProvider: %v", err) + } + sp := prov.(*samlProvider) + + // buildIDPMetadata (used inside NewSAMLProvider from IDPSSOURL) sets the + // pinned IdP EntityID to the SSO URL, and crewjam sets a response's Issuer to + // the IdP's MetadataURL. They must match or ParseXMLResponse rejects the + // Issuer — so point MetadataURL at the SSO URL too. + ssoURL, _ := url.Parse("https://idp.example.com/sso") + idp := &saml.IdentityProvider{ + Key: idpKey, + Certificate: idpCert, + MetadataURL: *ssoURL, + SSOURL: *ssoURL, + ServiceProviderProvider: spProviderShim{md: sp.sp.Metadata()}, + } + + return &samlE2EFixture{sp: sp, idp: idp} +} + +// mintResponse drives the SP's AuthnRequest through the test IdP and returns the +// base64 SAMLResponse the IdP would POST to the ACS, plus the AuthnRequest ID. +func (f *samlE2EFixture) mintResponse(t *testing.T, state string) (samlResponse, requestID string) { + t.Helper() + + loginURL, reqID, err := f.sp.LoginURLWithRequestID(state) + if err != nil { + t.Fatalf("LoginURLWithRequestID: %v", err) + } + + // Replay the redirect at the IdP as an inbound AuthnRequest. + r := httptest.NewRequest("GET", loginURL, nil) + authnReq, err := saml.NewIdpAuthnRequest(f.idp, r) + if err != nil { + t.Fatalf("NewIdpAuthnRequest: %v", err) + } + if err := authnReq.Validate(); err != nil { + t.Fatalf("authn request validate: %v", err) + } + + session := &saml.Session{ + ID: "sess-1", + NameID: "user@corp.com", + UserEmail: "user@corp.com", + UserGivenName: "Ada", + UserSurname: "Lovelace", + } + if err := (saml.DefaultAssertionMaker{}).MakeAssertion(authnReq, session); err != nil { + t.Fatalf("MakeAssertion: %v", err) + } + form, err := authnReq.PostBinding() + if err != nil { + t.Fatalf("PostBinding: %v", err) + } + return form.SAMLResponse, reqID +} + +// TestSAMLProvider_HandleCallback_e2e exercises the full assertion round-trip +// that the request-ID threading fix targets. Without a matching request ID a +// solicited (SP-initiated) assertion must be rejected; threading the ID minted +// at login through must let it validate. This is the regression guard for the +// bug where HandleCallback passed an empty possibleRequestIDs and every SAML +// login failed. +func TestSAMLProvider_HandleCallback_e2e(t *testing.T) { + t.Run("valid request_id succeeds (SP-initiated)", func(t *testing.T) { + f := newSAMLE2EFixture(t, false) + resp, reqID := f.mintResponse(t, "state-abc") + + u, err := f.sp.HandleCallback(context.Background(), map[string]string{ + "SAMLResponse": resp, + "request_id": reqID, + }) + if err != nil { + t.Fatalf("HandleCallback with matching request_id: %v", err) + } + if u.Email != "user@corp.com" { + t.Errorf("Email = %q, want user@corp.com", u.Email) + } + if u.FirstName != "Ada" || u.LastName != "Lovelace" { + t.Errorf("name = %q %q, want Ada Lovelace", u.FirstName, u.LastName) + } + }) + + t.Run("missing request_id rejected (no IdP-initiated)", func(t *testing.T) { + f := newSAMLE2EFixture(t, false) + resp, _ := f.mintResponse(t, "state-def") + + if _, err := f.sp.HandleCallback(context.Background(), map[string]string{ + "SAMLResponse": resp, + }); err == nil { + t.Fatal("expected rejection when request_id is absent and IdP-initiated is off") + } + }) + + t.Run("wrong request_id rejected", func(t *testing.T) { + f := newSAMLE2EFixture(t, false) + resp, _ := f.mintResponse(t, "state-ghi") + + if _, err := f.sp.HandleCallback(context.Background(), map[string]string{ + "SAMLResponse": resp, + "request_id": "id-does-not-match", + }); err == nil { + t.Fatal("expected rejection when request_id does not match InResponseTo") + } + }) + + t.Run("IdP-initiated accepted when allowed", func(t *testing.T) { + f := newSAMLE2EFixture(t, true) + resp, _ := f.mintResponse(t, "state-jkl") + + u, err := f.sp.HandleCallback(context.Background(), map[string]string{ + "SAMLResponse": resp, + }) + if err != nil { + t.Fatalf("HandleCallback with AllowIDPInitiated: %v", err) + } + if u.Email != "user@corp.com" { + t.Errorf("Email = %q, want user@corp.com", u.Email) + } + }) +} + +// buildAssertion constructs a validated-shape assertion for mapping tests. +// attrs maps attribute Name → values. +func buildAssertion(nameID string, attrs map[string][]string) *saml.Assertion { + a := &saml.Assertion{ + Subject: &saml.Subject{NameID: &saml.NameID{Value: nameID}}, + } + if len(attrs) == 0 { + return a + } + stmt := saml.AttributeStatement{} + for name, vals := range attrs { + attr := saml.Attribute{Name: name} + for _, v := range vals { + attr.Values = append(attr.Values, saml.AttributeValue{Value: v}) + } + stmt.Attributes = append(stmt.Attributes, attr) + } + a.AttributeStatements = []saml.AttributeStatement{stmt} + return a +} diff --git a/plugins/sso/store_models.go b/plugins/sso/store_models.go index 03035b7b..fded2323 100644 --- a/plugins/sso/store_models.go +++ b/plugins/sso/store_models.go @@ -1,6 +1,7 @@ package sso import ( + "encoding/json" "time" "github.com/xraph/grove" @@ -15,19 +16,32 @@ import ( type ssoConnectionModel struct { grove.BaseModel `grove:"table:authsome_sso_connections,alias:sc"` - ID string `grove:"id,pk"` - AppID string `grove:"app_id,notnull"` - OrgID string `grove:"org_id,notnull"` - Provider string `grove:"provider,notnull"` - Protocol string `grove:"protocol,notnull"` - Domain string `grove:"domain,notnull"` - MetadataURL string `grove:"metadata_url,notnull"` - ClientID string `grove:"client_id,notnull"` - ClientSecret string `grove:"client_secret,notnull"` - Issuer string `grove:"issuer,notnull"` - Active bool `grove:"active,notnull"` - CreatedAt time.Time `grove:"created_at,notnull,default:now()"` - UpdatedAt time.Time `grove:"updated_at,notnull,default:now()"` + ID string `grove:"id,pk"` + AppID string `grove:"app_id,notnull"` + EnvID string `grove:"env_id,notnull"` + OrgID string `grove:"org_id,notnull"` + Provider string `grove:"provider,notnull"` + Protocol string `grove:"protocol,notnull"` + Domain string `grove:"domain,notnull"` + MetadataURL string `grove:"metadata_url,notnull"` + ClientID string `grove:"client_id,notnull"` + ClientSecret string `grove:"client_secret,notnull"` + Issuer string `grove:"issuer,notnull"` + Active bool `grove:"active,notnull"` + + // SAML fields. attribute_mappings is stored as a JSON object. + IDPMetadataXML string `grove:"idp_metadata_xml,notnull"` + IDPSSOURL string `grove:"idp_sso_url,notnull"` + IDPCertificate string `grove:"idp_certificate,notnull"` + EntityID string `grove:"entity_id,notnull"` + ACSURL string `grove:"acs_url,notnull"` + SPCertificate string `grove:"sp_certificate,notnull"` + SPPrivateKey string `grove:"sp_private_key,notnull"` + SignRequests bool `grove:"sign_requests,notnull"` + AttributeMappings string `grove:"attribute_mappings,notnull"` + + CreatedAt time.Time `grove:"created_at,notnull,default:now()"` + UpdatedAt time.Time `grove:"updated_at,notnull,default:now()"` } // ────────────────────────────────────────────────── @@ -47,6 +61,7 @@ func toConnection(m *ssoConnectionModel) (*Connection, error) { c := &Connection{ ID: connID, AppID: appID, + EnvID: m.EnvID, Provider: m.Provider, Protocol: m.Protocol, Domain: m.Domain, @@ -55,8 +70,24 @@ func toConnection(m *ssoConnectionModel) (*Connection, error) { ClientSecret: m.ClientSecret, Issuer: m.Issuer, Active: m.Active, - CreatedAt: m.CreatedAt, - UpdatedAt: m.UpdatedAt, + + IDPMetadataXML: m.IDPMetadataXML, + IDPSSOURL: m.IDPSSOURL, + IDPCertificate: m.IDPCertificate, + EntityID: m.EntityID, + ACSURL: m.ACSURL, + SPCertificate: m.SPCertificate, + SPPrivateKey: m.SPPrivateKey, + SignRequests: m.SignRequests, + + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } + + if m.AttributeMappings != "" { + if err := json.Unmarshal([]byte(m.AttributeMappings), &c.AttributeMappings); err != nil { + return nil, err + } } if m.OrgID != "" { @@ -74,6 +105,7 @@ func fromConnection(c *Connection) *ssoConnectionModel { m := &ssoConnectionModel{ ID: c.ID.String(), AppID: c.AppID.String(), + EnvID: c.EnvID, Provider: c.Provider, Protocol: c.Protocol, Domain: c.Domain, @@ -82,8 +114,23 @@ func fromConnection(c *Connection) *ssoConnectionModel { ClientSecret: c.ClientSecret, Issuer: c.Issuer, Active: c.Active, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + + IDPMetadataXML: c.IDPMetadataXML, + IDPSSOURL: c.IDPSSOURL, + IDPCertificate: c.IDPCertificate, + EntityID: c.EntityID, + ACSURL: c.ACSURL, + SPCertificate: c.SPCertificate, + SPPrivateKey: c.SPPrivateKey, + SignRequests: c.SignRequests, + + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + if len(c.AttributeMappings) > 0 { + if b, err := json.Marshal(c.AttributeMappings); err == nil { + m.AttributeMappings = string(b) + } } if c.OrgID.Prefix() != "" { m.OrgID = c.OrgID.String() diff --git a/plugins/sso/store_mongo.go b/plugins/sso/store_mongo.go index e2295e2e..2415f54c 100644 --- a/plugins/sso/store_mongo.go +++ b/plugins/sso/store_mongo.go @@ -2,6 +2,7 @@ package sso import ( "context" + "encoding/json" "errors" "strings" "time" @@ -37,19 +38,30 @@ var _ Store = (*MongoStore)(nil) // ────────────────────────────────────────────────── type ssoConnectionDoc struct { - ID string `bson:"_id"` - AppID string `bson:"app_id"` - OrgID string `bson:"org_id"` - Provider string `bson:"provider"` - Protocol string `bson:"protocol"` - Domain string `bson:"domain"` - MetadataURL string `bson:"metadata_url"` - ClientID string `bson:"client_id"` - ClientSecret string `bson:"client_secret"` - Issuer string `bson:"issuer"` - Active bool `bson:"active"` - CreatedAt time.Time `bson:"created_at"` - UpdatedAt time.Time `bson:"updated_at"` + ID string `bson:"_id"` + AppID string `bson:"app_id"` + EnvID string `bson:"env_id"` + OrgID string `bson:"org_id"` + Provider string `bson:"provider"` + Protocol string `bson:"protocol"` + Domain string `bson:"domain"` + MetadataURL string `bson:"metadata_url"` + ClientID string `bson:"client_id"` + ClientSecret string `bson:"client_secret"` + Issuer string `bson:"issuer"` + // SAML fields. AttributeMappings is stored as a JSON object string. + IDPMetadataXML string `bson:"idp_metadata_xml"` + IDPSSOURL string `bson:"idp_sso_url"` + IDPCertificate string `bson:"idp_certificate"` + EntityID string `bson:"entity_id"` + ACSURL string `bson:"acs_url"` + SPCertificate string `bson:"sp_certificate"` + SPPrivateKey string `bson:"sp_private_key"` + SignRequests bool `bson:"sign_requests"` + AttributeMappings string `bson:"attribute_mappings"` + Active bool `bson:"active"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` } // ────────────────────────────────────────────────── @@ -67,18 +79,33 @@ func ssoDocToConnection(d *ssoConnectionDoc) (*Connection, error) { } c := &Connection{ - ID: connID, - AppID: appID, - Provider: d.Provider, - Protocol: d.Protocol, - Domain: d.Domain, - MetadataURL: d.MetadataURL, - ClientID: d.ClientID, - ClientSecret: d.ClientSecret, - Issuer: d.Issuer, - Active: d.Active, - CreatedAt: d.CreatedAt, - UpdatedAt: d.UpdatedAt, + ID: connID, + AppID: appID, + EnvID: d.EnvID, + Provider: d.Provider, + Protocol: d.Protocol, + Domain: d.Domain, + MetadataURL: d.MetadataURL, + ClientID: d.ClientID, + ClientSecret: d.ClientSecret, + Issuer: d.Issuer, + IDPMetadataXML: d.IDPMetadataXML, + IDPSSOURL: d.IDPSSOURL, + IDPCertificate: d.IDPCertificate, + EntityID: d.EntityID, + ACSURL: d.ACSURL, + SPCertificate: d.SPCertificate, + SPPrivateKey: d.SPPrivateKey, + SignRequests: d.SignRequests, + Active: d.Active, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } + + if d.AttributeMappings != "" { + if err := json.Unmarshal([]byte(d.AttributeMappings), &c.AttributeMappings); err != nil { + return nil, err + } } if d.OrgID != "" { @@ -94,18 +121,32 @@ func ssoDocToConnection(d *ssoConnectionDoc) (*Connection, error) { func ssoConnectionToDoc(c *Connection) *ssoConnectionDoc { doc := &ssoConnectionDoc{ - ID: c.ID.String(), - AppID: c.AppID.String(), - Provider: c.Provider, - Protocol: c.Protocol, - Domain: c.Domain, - MetadataURL: c.MetadataURL, - ClientID: c.ClientID, - ClientSecret: c.ClientSecret, - Issuer: c.Issuer, - Active: c.Active, - CreatedAt: c.CreatedAt, - UpdatedAt: c.UpdatedAt, + ID: c.ID.String(), + AppID: c.AppID.String(), + EnvID: c.EnvID, + Provider: c.Provider, + Protocol: c.Protocol, + Domain: c.Domain, + MetadataURL: c.MetadataURL, + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + Issuer: c.Issuer, + IDPMetadataXML: c.IDPMetadataXML, + IDPSSOURL: c.IDPSSOURL, + IDPCertificate: c.IDPCertificate, + EntityID: c.EntityID, + ACSURL: c.ACSURL, + SPCertificate: c.SPCertificate, + SPPrivateKey: c.SPPrivateKey, + SignRequests: c.SignRequests, + Active: c.Active, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + if len(c.AttributeMappings) > 0 { + if b, err := json.Marshal(c.AttributeMappings); err == nil { + doc.AttributeMappings = string(b) + } } if c.OrgID.Prefix() != "" { doc.OrgID = c.OrgID.String() @@ -204,17 +245,26 @@ func (s *MongoStore) UpdateConnection(ctx context.Context, c *Connection) error _, err := s.mdb.Collection(ssoConnectionsColl).UpdateOne(ctx, bson.M{"_id": c.ID.String()}, bson.M{"$set": bson.M{ - "app_id": doc.AppID, - "org_id": doc.OrgID, - "provider": doc.Provider, - "protocol": doc.Protocol, - "domain": doc.Domain, - "metadata_url": doc.MetadataURL, - "client_id": doc.ClientID, - "client_secret": doc.ClientSecret, - "issuer": doc.Issuer, - "active": doc.Active, - "updated_at": doc.UpdatedAt, + "app_id": doc.AppID, + "org_id": doc.OrgID, + "provider": doc.Provider, + "protocol": doc.Protocol, + "domain": doc.Domain, + "metadata_url": doc.MetadataURL, + "client_id": doc.ClientID, + "client_secret": doc.ClientSecret, + "issuer": doc.Issuer, + "idp_metadata_xml": doc.IDPMetadataXML, + "idp_sso_url": doc.IDPSSOURL, + "idp_certificate": doc.IDPCertificate, + "entity_id": doc.EntityID, + "acs_url": doc.ACSURL, + "sp_certificate": doc.SPCertificate, + "sp_private_key": doc.SPPrivateKey, + "sign_requests": doc.SignRequests, + "attribute_mappings": doc.AttributeMappings, + "active": doc.Active, + "updated_at": doc.UpdatedAt, }}, ) return ssoMongoError(err) diff --git a/sdk/go/types.go b/sdk/go/types.go index 551e89fb..7fee7af4 100644 --- a/sdk/go/types.go +++ b/sdk/go/types.go @@ -1749,11 +1749,21 @@ type SsoAdminCreateConnectionRequest struct { OrgID string `json:"org_id,omitempty"` Protocol string `json:"protocol"` Provider string `json:"provider"` + + // SAML IdP configuration. Supply exactly one IdP source: metadata_url, + // idp_metadata_xml, or idp_sso_url + idp_certificate. + IDPMetadataXML string `json:"idp_metadata_xml,omitempty"` + IDPSSOURL string `json:"idp_sso_url,omitempty"` + IDPCertificate string `json:"idp_certificate,omitempty"` + EntityID string `json:"entity_id,omitempty"` + ACSURL string `json:"acs_url,omitempty"` + SignRequests bool `json:"sign_requests,omitempty"` + AttributeMappings map[string]string `json:"attribute_mappings,omitempty"` } // SsoConnection represents a stored SSO connection. Mirrors -// plugins/sso.Connection. ClientSecret is omitted — the engine never -// returns it on read paths. +// plugins/sso.Connection. ClientSecret and SPPrivateKey are omitted — the +// engine never returns secrets on read paths. type SsoConnection struct { ID string `json:"id"` AppID string `json:"app_id"` @@ -1765,8 +1775,20 @@ type SsoConnection struct { ClientID string `json:"client_id,omitempty"` MetadataURL string `json:"metadata_url,omitempty"` Active bool `json:"active"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + + // SAML fields surfaced for the admin UI. SPCertificate is public (goes in + // SP metadata); the private key is never returned. + IDPMetadataXML string `json:"idp_metadata_xml,omitempty"` + IDPSSOURL string `json:"idp_sso_url,omitempty"` + IDPCertificate string `json:"idp_certificate,omitempty"` + EntityID string `json:"entity_id,omitempty"` + ACSURL string `json:"acs_url,omitempty"` + SPCertificate string `json:"sp_certificate,omitempty"` + SignRequests bool `json:"sign_requests,omitempty"` + AttributeMappings map[string]string `json:"attribute_mappings,omitempty"` + + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // SsoAdminListConnectionsResponse is the response from SsoAdminListConnections. @@ -1784,7 +1806,18 @@ type SsoAdminUpdateConnectionRequest struct { ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` MetadataURL string `json:"metadata_url,omitempty"` - Active *bool `json:"active,omitempty"` + + // SAML fields. Empty strings leave the stored value unchanged; pointers + // distinguish "unset" from an explicit false / replacement. + IDPMetadataXML string `json:"idp_metadata_xml,omitempty"` + IDPSSOURL string `json:"idp_sso_url,omitempty"` + IDPCertificate string `json:"idp_certificate,omitempty"` + EntityID string `json:"entity_id,omitempty"` + ACSURL string `json:"acs_url,omitempty"` + SignRequests *bool `json:"sign_requests,omitempty"` + AttributeMappings map[string]string `json:"attribute_mappings,omitempty"` + + Active *bool `json:"active,omitempty"` } // SocialCatalogProvider is one entry in the static provider catalog. diff --git a/sdkgen/spec.json b/sdkgen/spec.json index e4e6db02..d2c29550 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -1233,12 +1233,19 @@ }, "Connection": { "properties": { + "acs_url": { + "type": "string" + }, "active": { "type": "boolean" }, "app_id": { "type": "string" }, + "attribute_mappings": { + "additionalProperties": true, + "type": "object" + }, "client_id": { "type": "string" }, @@ -1249,9 +1256,21 @@ "domain": { "type": "string" }, + "entity_id": { + "type": "string" + }, "id": { "type": "string" }, + "idp_certificate": { + "type": "string" + }, + "idp_metadata_xml": { + "type": "string" + }, + "idp_sso_url": { + "type": "string" + }, "issuer": { "type": "string" }, @@ -1267,6 +1286,12 @@ "provider": { "type": "string" }, + "sign_requests": { + "type": "boolean" + }, + "sp_certificate": { + "type": "string" + }, "updated_at": { "format": "date-time", "type": "string" @@ -12614,10 +12639,19 @@ "application/json": { "schema": { "properties": { + "acs_url": { + "description": "SP ACS URL override; defaults to the canonical /acs route", + "type": "string" + }, "app_id": { "description": "Target Application ID", "type": "string" }, + "attribute_mappings": { + "additionalProperties": true, + "description": "SAML attribute name → user field (email|first_name|last_name|groups)", + "type": "object" + }, "client_id": { "description": "OIDC client ID", "type": "string" @@ -12630,12 +12664,28 @@ "description": "Email-domain or IdP host this connection covers", "type": "string" }, + "entity_id": { + "description": "SP EntityID override; defaults to the metadata URL", + "type": "string" + }, + "idp_certificate": { + "description": "SAML IdP signing certificate (PEM)", + "type": "string" + }, + "idp_metadata_xml": { + "description": "Pasted SAML IdP metadata XML", + "type": "string" + }, + "idp_sso_url": { + "description": "SAML IdP SSO (redirect) URL", + "type": "string" + }, "issuer": { "description": "OIDC issuer URL (required for oidc)", "type": "string" }, "metadata_url": { - "description": "SAML metadata URL (required for saml)", + "description": "SAML metadata URL (one IdP source for saml)", "type": "string" }, "org_id": { @@ -12649,6 +12699,10 @@ "provider": { "description": "Stable name for this IdP (e.g. 'studio', 'okta')", "type": "string" + }, + "sign_requests": { + "description": "Sign outbound AuthnRequests with the SP key", + "type": "boolean" } }, "required": [ @@ -13200,10 +13254,19 @@ "application/json": { "schema": { "properties": { + "acs_url": { + "description": "SP ACS URL override", + "type": "string" + }, "active": { "description": "Activate/deactivate the connection without deleting it", "type": "boolean" }, + "attribute_mappings": { + "additionalProperties": true, + "description": "Replace the SAML attribute → user field map", + "type": "object" + }, "client_id": { "description": "OIDC client ID", "type": "string" @@ -13216,6 +13279,22 @@ "description": "Email-domain or IdP host", "type": "string" }, + "entity_id": { + "description": "SP EntityID override", + "type": "string" + }, + "idp_certificate": { + "description": "SAML IdP signing certificate (PEM)", + "type": "string" + }, + "idp_metadata_xml": { + "description": "Pasted SAML IdP metadata XML", + "type": "string" + }, + "idp_sso_url": { + "description": "SAML IdP SSO URL", + "type": "string" + }, "issuer": { "description": "OIDC issuer URL", "type": "string" @@ -13227,6 +13306,10 @@ "provider": { "description": "Stable provider name", "type": "string" + }, + "sign_requests": { + "description": "Toggle signing outbound AuthnRequests", + "type": "boolean" } }, "type": "object" @@ -32286,13 +32369,10 @@ "schema": { "properties": { "refresh_token": { - "description": "Refresh token to exchange for new tokens", + "description": "Refresh token to exchange for new tokens (optional when a valid session cookie is present)", "type": "string" } }, - "required": [ - "refresh_token" - ], "type": "object" } } @@ -35843,6 +35923,192 @@ ] } }, + "/v1/sso/login": { + "post": { + "operationId": "startSSOLoginByDomain", + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "description": "Work email; its domain routes to the matching SSO connection", + "type": "string" + } + }, + "required": [ + "email" + ], + "type": "object" + } + } + }, + "description": "Request body", + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + }, + "description": "SSO login URL" + }, + "400": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Bad Request" + }, + "401": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Not Found" + }, + "500": { + "content": { + "application/json": { + "schema": { + "properties": { + "code": { + "description": "HTTP status code", + "type": "integer" + }, + "details": { + "description": "Additional error details", + "type": "string" + }, + "error": { + "description": "Error message", + "type": "string" + } + }, + "required": [ + "error", + "code" + ], + "type": "object" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "Start SSO login by email domain", + "tags": [ + "SSO" + ] + } + }, "/v1/sso/{provider}/acs": { "post": { "operationId": "ssoACS", @@ -36392,6 +36658,36 @@ ] } }, + "/v1/sso/{provider}/metadata": { + "get": { + "operationId": "ssoSPMetadata", + "parameters": [ + { + "description": "Path parameter: provider", + "in": "path", + "name": "provider", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "summary": "SAML SP metadata", + "tags": [ + "SSO" + ] + } + }, "/v1/users/{userId}/roles": { "get": { "description": "Returns all roles assigned to a user.", @@ -36582,21 +36878,30 @@ }, "/v1/verify-email": { "post": { - "description": "Verifies a user's email address using a verification token.", + "description": "Verifies a user's email address using a 6-digit OTP code (per-user, attempt-limited) or a verification token.", "operationId": "verifyEmail", "requestBody": { "content": { "application/json": { "schema": { "properties": { + "app_id": { + "description": "App ID (optional; resolved from publishable key when omitted)", + "type": "string" + }, + "code": { + "description": "6-digit OTP code (verifies the authenticated user's email)", + "type": "string" + }, + "email": { + "description": "Email to scope the OTP code to when unauthenticated (enables auto-login)", + "type": "string" + }, "token": { - "description": "Email verification token", + "description": "Email verification token (link-based flows)", "type": "string" } }, - "required": [ - "token" - ], "type": "object" } }