first commit

This commit is contained in:
Max Erenberg 2021-12-29 00:36:47 -05:00
commit c4f6ffce92
9 changed files with 480 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.key
*.crt
/saml-passthrough

21
README.md Normal file
View File

@ -0,0 +1,21 @@
# saml-passthrough
This program is intended to run behind the Apache mod_auth_mellon module.
It receives ADFS user information from Apache over FastCGI, then
passes it back to Keycloak (which is acting as a SAML SP).
## Create a new keypair
```sh
openssl req -newkey rsa:2048 -nodes -keyout idp.key -x509 -out idp.crt -days 3680 -subj '/CN=SAML Passthrough/O=Computer Science Club'
```
Make sure to renew the cert in ten years.
## Apache config
Add the following snippet to /etc/apache2/sites-real/csc (and make sure mod_proxy_fcgi is enabled):
```
<Location /keycloak/saml/ >
AuthType Mellon
MellonEnable auth
Require valid-user
SetHandler "proxy:unix:/run/saml-passthrough/server.sock|fcgi://localhost"
</Location>
```

14
config.json Normal file
View File

@ -0,0 +1,14 @@
{
"idp_key_path": "./idp.key",
"idp_cert_path": "./idp.crt",
"username_key": "MELLON_samaccountname",
"email_key": "MELLON_emailaddress",
"given_name_key": "MELLON_givenname",
"surname_key": "MELLON_http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
"groups_key": "ADFS_GROUP",
"base_url": "https://csclub.uwaterloo.ca/keycloak/saml",
"sp_metadata_paths": [
"https://keycloak.csclub.uwaterloo.ca/auth/realms/csc/broker/adfs/endpoint/descriptor"
],
"socket_path": "/run/saml-passthrough/server.sock"
}

8
go.mod Normal file
View File

@ -0,0 +1,8 @@
module git.csclub.uwaterloo.ca/merenber/saml-passthrough
go 1.15
require (
github.com/crewjam/saml v0.4.6
github.com/mattermost/xml-roundtrip-validator v0.1.0
)

64
go.sum Normal file
View File

@ -0,0 +1,64 @@
github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4=
github.com/crewjam/saml v0.4.6 h1:XCUFPkQSJLvzyl4cW9OvpWUbRf0gE7VUpU8ZnilbeM4=
github.com/crewjam/saml v0.4.6/go.mod h1:ZBOXnNPFzB3CgOkRm7Nd6IVdkG+l/wF+0ZXLqD96t1A=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4=
github.com/golang-jwt/jwt/v4 v4.1.0 h1:XUgk2Ex5veyVFVeLm0xhusUTQybEbexJXrvPNOKkSY0=
github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
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 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/russellhaering/goxmldsig v1.1.1 h1:vI0r2osGF1A9PLvsGdPUAGwEIrKa4Pj5sesSBsebIxM=
github.com/russellhaering/goxmldsig v1.1.1/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/zenazn/goji v1.0.1 h1:4lbD8Mx2h7IvloP7r2C0D6ltZP6Ufip8Hn0wmSK5LR8=
github.com/zenazn/goji v1.0.1/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 h1:It14KIkyBFYkHkwZ7k45minvA9aorojkyjGk9KJ5B/w=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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-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/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=

161
main.go Normal file
View File

@ -0,0 +1,161 @@
package main
import (
"crypto"
"crypto/x509"
"encoding/json"
"encoding/pem"
"flag"
"io/ioutil"
"net"
"net/http"
"net/http/fcgi"
"net/url"
"os"
"github.com/crewjam/saml"
"github.com/crewjam/saml/logger"
)
// adapted from https://github.com/crewjam/saml/blob/main/example/idp/idp.go
func parsePEM(filename string) *pem.Block {
raw, err := ioutil.ReadFile(filename)
if err != nil {
panic(err)
}
block, _ := pem.Decode(raw)
if block == nil {
panic("Could not decode PEM from " + filename)
}
return block
}
type Config struct {
// The filesystem path to the private key to use for the IDP.
// The key should be in PKCS8 PEM format.
IDPKeyPath string `json:"idp_key_path"`
// The filesystem path to the certificate to use for the IDP.
// The certificate should be in PEM format.
IDPCertPath string `json:"idp_cert_path"`
// The name of the FCGI variable which has the user's username.
UsernameKey string `json:"username_key"`
// The name of the FCGI variable which has the user's email.
EmailKey string `json:"email_key"`
// The name of the FCGI variable which has the user's first name.
GivenNameKey string `json:"given_name_key"`
// The name of the FCGI variable which has the user's last name.
SurnameKey string `json:"surname_key"`
// The name of the FCGI variable which has the user's groups.
// The groups should be concatenated together into one string
// separated by semicolons, e.g. "group1;group2".
GroupsKey string `json:"groups_key"`
// The base URL for the IDP. The following endpoints will be
// derived from it:
// Metadata: baseURL + "/metadata"
// SSO login: baseURL + "/sso"
BaseURL string `json:"base_url"`
// An array of filepaths or HTTP URLs of the service providers'
// metadata XML files.
SPMetadataPaths []string `json:"sp_metadata_paths"`
// The filesystem path of the Unix domain socket on which this
// program will listen for incoming FCGI connections.
SocketPath string `json:"socket_path"`
}
func newIDP(cfg *Config) *saml.IdentityProvider {
key := func() crypto.PrivateKey {
block := parsePEM(cfg.IDPKeyPath)
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
panic(err)
}
return k
}()
cert := func() *x509.Certificate {
block := parsePEM(cfg.IDPCertPath)
c, err := x509.ParseCertificate(block.Bytes)
if err != nil {
panic(err)
}
return c
}()
sessionProvider := &BasicSessionProvider{
UsernameKey: cfg.UsernameKey,
EmailKey: cfg.EmailKey,
GivenNameKey: cfg.GivenNameKey,
SurnameKey: cfg.SurnameKey,
GroupsKey: cfg.GroupsKey,
}
serviceProviderProvider := NewServiceProviderProvider(cfg.SPMetadataPaths)
metadataURL, err := url.Parse(cfg.BaseURL + "/metadata")
if err != nil {
panic(err)
}
ssoURL, err := url.Parse(cfg.BaseURL + "/sso")
if err != nil {
panic(err)
}
return &saml.IdentityProvider{
Key: key,
Certificate: cert,
Logger: logger.DefaultLogger,
MetadataURL: *metadataURL,
SSOURL: *ssoURL,
SessionProvider: sessionProvider,
ServiceProviderProvider: serviceProviderProvider,
}
}
func newHTTPHandler(idp *saml.IdentityProvider) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(idp.MetadataURL.Path, idp.ServeMetadata)
mux.HandleFunc(idp.SSOURL.Path, idp.ServeSSO)
return mux
}
func newListener(cfg *Config) net.Listener {
var err error
sockPath := cfg.SocketPath
if _, err = os.Stat(sockPath); err == nil {
err = os.Remove(sockPath)
if err != nil {
panic(err)
}
}
ln, err := net.Listen("unix", sockPath)
if err != nil {
panic(err)
}
os.Chmod(sockPath, 0777)
return ln
}
func main() {
// Allow the key path to be overriden via a CLI flag so that we
// can use systemd's LoadCredential feature
idpKeyPathPtr := flag.String("k", "", "IDP key path")
configPathPtr := flag.String("c", "./config.json", "config file path")
flag.Parse()
cfgBytes, err := ioutil.ReadFile(*configPathPtr)
if err != nil {
panic(err)
}
cfg := Config{}
err = json.Unmarshal(cfgBytes, &cfg)
if err != nil {
panic(err)
}
if *idpKeyPathPtr != "" {
cfg.IDPKeyPath = *idpKeyPathPtr
}
idp := newIDP(&cfg)
handler := newHTTPHandler(idp)
listener := newListener(&cfg)
err = fcgi.Serve(listener, handler)
if err != nil {
panic(err)
}
}

View File

@ -0,0 +1,118 @@
// adapted from https://github.com/crewjam/saml/blob/main/samlidp/service.go
package main
import (
"bytes"
"encoding/xml"
"errors"
"io"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"github.com/crewjam/saml"
xrv "github.com/mattermost/xml-roundtrip-validator"
)
// copied from https://github.com/crewjam/saml/blob/main/samlidp/util.go
func getSPMetadata(r io.Reader) (spMetadata *saml.EntityDescriptor, err error) {
var data []byte
if data, err = ioutil.ReadAll(r); err != nil {
return nil, err
}
spMetadata = &saml.EntityDescriptor{}
if err := xrv.Validate(bytes.NewBuffer(data)); err != nil {
return nil, err
}
if err := xml.Unmarshal(data, &spMetadata); err != nil {
if err.Error() == "expected element type <EntityDescriptor> but have <EntitiesDescriptor>" {
entities := &saml.EntitiesDescriptor{}
if err := xml.Unmarshal(data, &entities); err != nil {
return nil, err
}
for _, e := range entities.EntityDescriptors {
if len(e.SPSSODescriptors) > 0 {
return &e, nil
}
}
// there were no SPSSODescriptors in the response
return nil, errors.New("metadata contained no service provider metadata")
}
return nil, err
}
return spMetadata, nil
}
type BasicServiceProviderProvider struct {
serviceProviders map[string]*saml.EntityDescriptor
}
func modifyACSBindings(entityDescriptor *saml.EntityDescriptor) {
// The SAML library only allows the HTTP-POST Binding (from the IdP
// to the SP), so we need to modify the AssertionConsumerService
// endpoints which use HTTP-Redirect to use HTTP-POST.
for i := 0; i < len(entityDescriptor.SPSSODescriptors); i++ {
spSSODescriptor := &entityDescriptor.SPSSODescriptors[i]
for j := 0; j < len(spSSODescriptor.AssertionConsumerServices); j++ {
acs := &spSSODescriptor.AssertionConsumerServices[j]
if acs.Binding == "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" {
log.Printf("Replacing Binding for %s from HTTP-Redirect to HTTP-POST", acs.Location)
acs.Binding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
}
}
}
}
func NewServiceProviderProvider(spMetadataPaths []string) saml.ServiceProviderProvider {
spp := &BasicServiceProviderProvider{
serviceProviders: make(map[string]*saml.EntityDescriptor),
}
client := http.Client{}
loadSPMetadata := func(filename string) (*saml.EntityDescriptor, error) {
var r io.ReadCloser
var err error
if strings.HasPrefix(filename, "http://") || strings.HasPrefix(filename, "https://") {
resp, err := client.Get(filename)
if err != nil {
return nil, err
}
r = resp.Body
} else {
r, err = os.Open(filename)
if err != nil {
return nil, err
}
}
defer r.Close()
return getSPMetadata(r)
}
for _, filename := range spMetadataPaths {
metadata, err := loadSPMetadata(filename)
if err != nil {
panic(err)
}
modifyACSBindings(metadata)
spp.serviceProviders[metadata.EntityID] = metadata
}
return spp
}
// GetServiceProvider returns the Service Provider metadata for the
// service provider ID, which is typically the service provider's
// metadata URL. If an appropriate service provider cannot be found then
// the returned error must be os.ErrNotExist.
func (s *BasicServiceProviderProvider) GetServiceProvider(r *http.Request, serviceProviderID string) (*saml.EntityDescriptor, error) {
rv, ok := s.serviceProviders[serviceProviderID]
if !ok {
return nil, os.ErrNotExist
}
return rv, nil
}

75
session_provider.go Normal file
View File

@ -0,0 +1,75 @@
// adapted from https://github.com/crewjam/saml/blob/main/samlidp/session.go
package main
import (
"encoding/base64"
"encoding/hex"
"log"
"net/http"
"net/http/fcgi"
"strings"
"time"
"github.com/crewjam/saml"
)
var sessionMaxAge = time.Hour
func randomBytes(n int) []byte {
rv := make([]byte, n)
if _, err := saml.RandReader.Read(rv); err != nil {
panic(err)
}
return rv
}
type User struct {
Username string
Email string
GivenName string
Surname string
Groups []string
}
type BasicSessionProvider struct {
UsernameKey string
EmailKey string
GivenNameKey string
SurnameKey string
GroupsKey string
}
func (s *BasicSessionProvider) getUser(r *http.Request) *User {
env := fcgi.ProcessEnv(r)
user := &User{}
user.Username, _ = env[s.UsernameKey]
user.Email, _ = env[s.EmailKey]
user.GivenName, _ = env[s.GivenNameKey]
user.Surname, _ = env[s.SurnameKey]
groupsStr, _ := env[s.GroupsKey]
user.Groups = strings.Split(groupsStr, ";")
return user
}
func (s *BasicSessionProvider) GetSession(w http.ResponseWriter, r *http.Request, req *saml.IdpAuthnRequest) *saml.Session {
user := s.getUser(r)
if user.Username == "" {
log.Println("Could not obtain username from FCGI")
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return nil
}
session := &saml.Session{
ID: base64.StdEncoding.EncodeToString(randomBytes(32)),
NameID: user.Username,
CreateTime: saml.TimeNow(),
ExpireTime: saml.TimeNow().Add(sessionMaxAge),
Index: hex.EncodeToString(randomBytes(32)),
UserName: user.Username,
Groups: user.Groups,
UserEmail: user.Email,
UserSurname: user.Surname,
UserGivenName: user.GivenName,
}
return session
}

View File

@ -0,0 +1,16 @@
[Unit]
Description=SAML passthrough for Keycloak
Documentation=https://git.csclub.uwaterloo.ca/merenber/saml-passthrough
Requires=apache2.service
After=apache2.service
[Service]
Type=exec
WorkingDirectory=/srv/saml-passthrough
RuntimeDirectory=saml-passthrough
DynamicUser=yes
LoadCredential=idp.key:/srv/saml-passthrough/idp.key
ExecStart=/srv/saml-passthrough/saml-passthrough -k "${CREDENTIALS_DIRECTORY}/idp.key"
[Install]
WantedBy=multi-user.target