Permalink
Browse files

Add e2e test for SAML provider

  • Loading branch information...
mraerino committed Aug 29, 2018
1 parent da8fbd1 commit e29a0e4815f3c3dc88589a44aa6cde66a1c9fed4
Showing with 234 additions and 0 deletions.
  1. +189 −0 api/external_saml_test.go
  2. +15 −0 api/testdata/saml-idp-metadata.xml
  3. +26 −0 api/testdata/saml-response.xml
  4. +4 −0 hack/test.env
View
@@ -0,0 +1,189 @@
package api
import (
"crypto/x509"
"encoding/base64"
"encoding/pem"
"html/template"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/beevik/etree"
"github.com/netlify/gotrue/conf"
"github.com/netlify/gotrue/models"
dsig "github.com/russellhaering/goxmldsig"
uuid "github.com/satori/go.uuid"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type ExternalSamlTestSuite struct {
suite.Suite
API *API
Config *conf.Configuration
instanceID uuid.UUID
}
func TestExternalSaml(t *testing.T) {
api, config, instanceID, err := setupAPIForTestForInstance()
require.NoError(t, err)
ts := &ExternalSamlTestSuite{
API: api,
Config: config,
instanceID: instanceID,
}
defer api.db.Close()
suite.Run(t, ts)
}
func (ts *ExternalSamlTestSuite) SetupTest() {
models.TruncateAll(ts.API.db)
}
func (ts *ExternalSamlTestSuite) docFromTemplate(path string, data interface{}) *etree.Document {
doc := etree.NewDocument()
templ, err := template.ParseFiles(path)
ts.Require().NoError(err)
read, write := io.Pipe()
go func() {
defer write.Close()
err := templ.Execute(write, data)
ts.Require().NoError(err)
}()
_, err = doc.ReadFrom(read)
ts.Require().NoError(err)
return doc
}
func (ts *ExternalSamlTestSuite) setupSamlExampleResponse(keyStore dsig.X509KeyStore) string {
path := filepath.Join("testdata", "saml-response.xml")
type ResponseParams struct {
Now string
NotBefore string
NotAfter string
}
now := time.Now()
doc := ts.docFromTemplate(path, ResponseParams{
Now: now.Format(time.RFC3339),
NotBefore: now.Add(-5 * time.Minute).Format(time.RFC3339),
NotAfter: now.Add(5 * time.Minute).Format(time.RFC3339),
})
// sign
resp := doc.SelectElement("Response")
ctx := dsig.NewDefaultSigningContext(keyStore)
sig, err := ctx.ConstructSignature(resp, true)
ts.Require().NoError(err, "Response signature failed")
respWithSig := resp.Copy()
var children []etree.Token
children = append(children, respWithSig.Child[0]) // issuer is always first
children = append(children, sig) // next is the signature
children = append(children, respWithSig.Child[1:]...) // then all other children
respWithSig.Child = children
doc.SetRoot(respWithSig)
docRaw, err := doc.WriteToBytes()
ts.Require().NoError(err)
return base64.StdEncoding.EncodeToString(docRaw)
}
func (ts *ExternalSamlTestSuite) setupSamlExampleState() string {
req := httptest.NewRequest(http.MethodGet, "http://localhost/authorize?provider=saml", nil)
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
ts.Require().Equal(http.StatusFound, w.Code)
u, err := url.Parse(w.Header().Get("Location"))
ts.Require().NoError(err, "redirect url parse failed")
urlBase, _ := url.Parse(u.String())
urlBase.RawQuery = ""
ts.Equal(urlBase.String(), "https://idp/saml2test/redirect")
q := u.Query()
state := q.Get("RelayState")
ts.Require().NotEmpty(state)
return state
}
func (ts *ExternalSamlTestSuite) setupSamlMetadata() (*httptest.Server, dsig.X509KeyStore) {
idpKeyStore := dsig.RandomKeyStoreForTest()
_, idpCert, _ := idpKeyStore.GetKeyPair()
path := filepath.Join("testdata", "saml-idp-metadata.xml")
type MetadataParams struct {
Cert string
}
doc := ts.docFromTemplate(path, MetadataParams{Cert: base64.StdEncoding.EncodeToString(idpCert)})
metadata, err := doc.WriteToString()
ts.Require().NoError(err)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(200)
io.WriteString(w, metadata)
}))
return server, idpKeyStore
}
func (ts *ExternalSamlTestSuite) setupSamlSPCert() (string, string) {
spKeyStore := dsig.RandomKeyStoreForTest()
key, cert, _ := spKeyStore.GetKeyPair()
keyBytes := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(key),
})
certBytes := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: cert,
})
return string(keyBytes), string(certBytes)
}
func (ts *ExternalSamlTestSuite) TestSignupExternalSaml_Callback() {
server, idpKeyStore := ts.setupSamlMetadata()
defer server.Close()
ts.Config.External.Saml.MetadataURL = server.URL
key, cert := ts.setupSamlSPCert()
ts.Config.External.Saml.SigningKey = key
ts.Config.External.Saml.SigningCert = cert
form := url.Values{}
form.Add("RelayState", ts.setupSamlExampleState())
form.Add("SAMLResponse", ts.setupSamlExampleResponse(idpKeyStore))
req := httptest.NewRequest(http.MethodPost, "http://localhost/saml/acs", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
ts.Require().Equal(http.StatusFound, w.Code)
u, err := url.Parse(w.Header().Get("Location"))
ts.Require().NoError(err, "redirect url parse failed")
v, err := url.ParseQuery(u.Fragment)
ts.Require().NoError(err)
ts.Empty(v.Get("error_description"))
ts.Empty(v.Get("error"))
ts.NotEmpty(v.Get("access_token"))
ts.NotEmpty(v.Get("refresh_token"))
ts.NotEmpty(v.Get("expires_in"))
ts.Equal("bearer", v.Get("token_type"))
// ensure user has been created
_, err = models.FindUserByEmailAndAudience(ts.API.db, ts.instanceID, "saml@example.com", ts.Config.JWT.Aud)
ts.Require().NoError(err)
}
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://idp/saml2test" validUntil="2050-01-01T18:00:00.000Z">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>{{.Cert}}</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://idp/saml2test/post"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://idp/saml2test/redirect"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://localhost/saml/acs" ID="_12345test" InResponseTo="_12345testresponse" IssueInstant="{{.Now}}" Version="2.0">
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://idp/saml2test</saml2:Issuer>
<saml2p:Status>
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</saml2p:Status>
<saml2:Assertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" ID="_f89625fb7df7f75d6ddba31f0f1b4e34" IssueInstant="{{.Now}}" Version="2.0">
<saml2:Issuer>https://idp/saml2test</saml2:Issuer>
<saml2:Subject>
<saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">saml@example.com</saml2:NameID>
<saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml2:SubjectConfirmationData InResponseTo="_12345testresppnse" NotOnOrAfter="{{.NotAfter}}" Recipient="http://localhost/saml/acs"/>
</saml2:SubjectConfirmation>
</saml2:Subject>
<saml2:Conditions NotBefore="{{.NotBefore}}" NotOnOrAfter="{{.NotAfter}}">
<saml2:AudienceRestriction>
<saml2:Audience>http://localhost/saml</saml2:Audience>
</saml2:AudienceRestriction>
</saml2:Conditions>
<saml2:AuthnStatement AuthnInstant="{{.Now}}">
<saml2:AuthnContext>
<saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:unspecified</saml2:AuthnContextClassRef>
</saml2:AuthnContext>
</saml2:AuthnStatement>
</saml2:Assertion>
</saml2p:Response>
View
@@ -25,3 +25,7 @@ GOTRUE_EXTERNAL_GOOGLE_ENABLED=true
GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID=testclientid
GOTRUE_EXTERNAL_GOOGLE_SECRET=testsecret
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI=https://identity.services.netlify.com/callback
GOTRUE_EXTERNAL_SAML_ENABLED=true
GOTRUE_EXTERNAL_SAML_METADATA_URL=
GOTRUE_EXTERNAL_SAML_API_BASE=http://localhost
GOTRUE_EXTERNAL_SAML_NAME=TestSamlName

0 comments on commit e29a0e4

Please sign in to comment.