connector/saml: refactor tests and add self-signed responses
Introduces SAML tests which execute full response processing and compare user attributes. tesdata now includes a full, self-signed CA and documents signed using xmlsec1. Adds deprication notices to existing tests, but don't remove them since they still provide coverage.
This commit is contained in:
@@ -6,24 +6,216 @@ import (
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io/ioutil"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
|
||||
"github.com/kylelemons/godebug/pretty"
|
||||
dsig "github.com/russellhaering/goxmldsig"
|
||||
|
||||
"github.com/coreos/dex/connector"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultIssuer = "http://www.okta.com/exk91cb99lKkKSYoy0h7"
|
||||
defaultRedirectURI = "http://localhost:5556/dex/callback"
|
||||
// responseTest maps a SAML 2.0 response object to a set of expected values.
|
||||
//
|
||||
// Tests are defined in the "testdata" directory and are self-signed using xmlsec1.
|
||||
//
|
||||
// To add a new test, define a new, unsigned SAML 2.0 response that exercises some
|
||||
// case, then sign it using the "testdata/gen.sh" script.
|
||||
//
|
||||
// cp testdata/good-resp.tmpl testdata/( testname ).tmpl
|
||||
// vim ( testname ).tmpl # Modify your template for your test case.
|
||||
// vim testdata/gen.sh # Add a xmlsec1 command to the generation script.
|
||||
// ./testdata/gen.sh # Sign your template.
|
||||
//
|
||||
// To install xmlsec1 on Fedora run:
|
||||
//
|
||||
// sudo dnf install xmlsec1 xmlsec1-openssl
|
||||
//
|
||||
// On mac:
|
||||
//
|
||||
// brew install Libxmlsec1
|
||||
//
|
||||
type responseTest struct {
|
||||
// CA file and XML file of the response.
|
||||
caFile string
|
||||
respFile string
|
||||
|
||||
// Response ID embedded in our testdata.
|
||||
testDataResponseID = "_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0"
|
||||
)
|
||||
// Values that should be used to validate the signature.
|
||||
now string
|
||||
inResponseTo string
|
||||
redirectURI string
|
||||
|
||||
// Attribute customization.
|
||||
usernameAttr string
|
||||
emailAttr string
|
||||
groupsAttr string
|
||||
|
||||
// Expected outcome of the test.
|
||||
wantErr bool
|
||||
wantIdent connector.Identity
|
||||
}
|
||||
|
||||
func TestGoodResponse(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestGroups(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
groupsAttr: "groups",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
Groups: []string{"Admins", "Everyone"},
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
// TestOkta tests against an actual response from Okta.
|
||||
func TestOkta(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/okta-ca.pem",
|
||||
respFile: "testdata/okta-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestBadStatus(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/bad-status.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestInvalidCA(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/bad-ca.crt", // Not the CA that signed this response.
|
||||
respFile: "testdata/good-resp.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestUnsignedResponse(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/good-resp.tmpl", // Use the unsigned template, not the signed document.
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func TestExpiredAssertion(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/assertion-signed.xml",
|
||||
now: "2020-04-04T04:34:59.330Z", // Assertion has expired.
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantErr: true,
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
// TestAssertionSignedNotResponse ensures the connector validates SAML 2.0
|
||||
// responses where the assertion is signed but the root element, the
|
||||
// response, isn't.
|
||||
func TestAssertionSignedNotResponse(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/assertion-signed.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
// TestTwoAssertionFirstSigned tries to catch an edge case where an attacker
|
||||
// provides a second assertion that's not signed.
|
||||
func TestTwoAssertionFirstSigned(t *testing.T) {
|
||||
test := responseTest{
|
||||
caFile: "testdata/ca.crt",
|
||||
respFile: "testdata/two-assertions-first-signed.xml",
|
||||
now: "2017-04-04T04:34:59.330Z",
|
||||
usernameAttr: "Name",
|
||||
emailAttr: "email",
|
||||
inResponseTo: "6zmm5mguyebwvajyf2sdwwcw6m",
|
||||
redirectURI: "http://127.0.0.1:5556/dex/callback",
|
||||
wantIdent: connector.Identity{
|
||||
UserID: "eric.chiang+okta@coreos.com",
|
||||
Username: "Eric",
|
||||
Email: "eric.chiang+okta@coreos.com",
|
||||
EmailVerified: true,
|
||||
},
|
||||
}
|
||||
test.run(t)
|
||||
}
|
||||
|
||||
func loadCert(ca string) (*x509.Certificate, error) {
|
||||
data, err := ioutil.ReadFile(ca)
|
||||
@@ -37,6 +229,63 @@ func loadCert(ca string) (*x509.Certificate, error) {
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
func (r responseTest) run(t *testing.T) {
|
||||
c := Config{
|
||||
CA: r.caFile,
|
||||
UsernameAttr: r.usernameAttr,
|
||||
EmailAttr: r.emailAttr,
|
||||
GroupsAttr: r.groupsAttr,
|
||||
RedirectURI: r.redirectURI,
|
||||
// Never logging in, don't need this.
|
||||
SSOURL: "http://foo.bar/",
|
||||
}
|
||||
now, err := time.Parse(timeFormat, r.now)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test time: %v", err)
|
||||
}
|
||||
|
||||
conn, err := c.openConnector(logrus.New())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.now = func() time.Time { return now }
|
||||
resp, err := ioutil.ReadFile(r.respFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
samlResp := base64.StdEncoding.EncodeToString(resp)
|
||||
|
||||
scopes := connector.Scopes{
|
||||
OfflineAccess: false,
|
||||
Groups: true,
|
||||
}
|
||||
ident, err := conn.HandlePOST(scopes, samlResp, r.inResponseTo)
|
||||
if err != nil {
|
||||
if !r.wantErr {
|
||||
t.Fatalf("handle response: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if r.wantErr {
|
||||
t.Fatalf("wanted error")
|
||||
}
|
||||
sort.Strings(ident.Groups)
|
||||
sort.Strings(r.wantIdent.Groups)
|
||||
if diff := pretty.Compare(ident, r.wantIdent); diff != "" {
|
||||
t.Error(diff)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
defaultIssuer = "http://www.okta.com/exk91cb99lKkKSYoy0h7"
|
||||
defaultRedirectURI = "http://localhost:5556/dex/callback"
|
||||
|
||||
// Response ID embedded in our testdata.
|
||||
testDataResponseID = "_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0"
|
||||
)
|
||||
|
||||
// Depricated: Use testing framework established above.
|
||||
func runVerify(t *testing.T, ca string, resp string, shouldSucceed bool) {
|
||||
cert, err := loadCert(ca)
|
||||
if err != nil {
|
||||
@@ -51,7 +300,7 @@ func runVerify(t *testing.T, ca string, resp string, shouldSucceed bool) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := verify(validator, data); err != nil {
|
||||
if _, _, err := verifyResponseSig(validator, data); err != nil {
|
||||
if shouldSucceed {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -62,6 +311,7 @@ func runVerify(t *testing.T, ca string, resp string, shouldSucceed bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// Depricated: Use testing framework established above.
|
||||
func newProvider(issuer string, redirectURI string) *provider {
|
||||
if issuer == "" {
|
||||
issuer = defaultIssuer
|
||||
@@ -106,28 +356,6 @@ func TestVerifyUnsignedMessageAndUnsignedAssertion(t *testing.T) {
|
||||
runVerify(t, "testdata/idp-cert.pem", "testdata/idp-resp.xml", false)
|
||||
}
|
||||
|
||||
func TestHandlePOST(t *testing.T) {
|
||||
p := newProvider("", "")
|
||||
scopes := connector.Scopes{
|
||||
OfflineAccess: false,
|
||||
Groups: true,
|
||||
}
|
||||
data, err := ioutil.ReadFile("testdata/idp-resp.xml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ident, err := p.HandlePOST(scopes, base64.StdEncoding.EncodeToString(data), testDataResponseID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ident.UserID != "eric.chiang+okta@coreos.com" {
|
||||
t.Fatalf("unexpected UserID %q", ident.UserID)
|
||||
}
|
||||
if ident.Username != "admin" {
|
||||
t.Fatalf("unexpected Username: %q", ident.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStatus(t *testing.T) {
|
||||
p := newProvider("", "")
|
||||
var err error
|
||||
|
Reference in New Issue
Block a user