OpenID Connect (OIDC) identity and OAuth 2.0 provider with pluggable connectors
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

918 lines
26 KiB

package saml
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"log/slog"
"os"
"sort"
"testing"
"time"
"github.com/kylelemons/godebug/pretty"
dsig "github.com/russellhaering/goxmldsig"
"github.com/dexidp/dex/connector"
)
// 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
// Values that should be used to validate the signature.
now string
inResponseTo string
redirectURI string
entityIssuer string
// Attribute customization.
usernameAttr string
emailAttr string
groupsAttr string
allowedGroups []string
filterGroups bool
// 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)
}
func TestGroupsWhitelist(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",
allowedGroups: []string{"Admins"},
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)
}
func TestGroupsWhitelistWithFiltering(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",
allowedGroups: []string{"Admins"},
filterGroups: true,
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" is filtered
},
}
test.run(t)
}
func TestGroupsWhitelistEmpty(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",
allowedGroups: []string{},
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)
}
func TestGroupsWhitelistDisallowed(t *testing.T) {
test := responseTest{
wantErr: true,
caFile: "testdata/ca.crt",
respFile: "testdata/good-resp.xml",
now: "2017-04-04T04:34:59.330Z",
usernameAttr: "Name",
emailAttr: "email",
groupsAttr: "groups",
allowedGroups: []string{"Nope"},
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)
}
func TestGroupsWhitelistDisallowedNoGroupsOnIdent(t *testing.T) {
test := responseTest{
wantErr: true,
caFile: "testdata/ca.crt",
respFile: "testdata/good-resp.xml",
now: "2017-04-04T04:34:59.330Z",
usernameAttr: "Name",
emailAttr: "email",
groupsAttr: "groups",
allowedGroups: []string{"Nope"},
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{},
},
}
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)
}
func TestInvalidSubjectInResponseTo(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: "invalid-id", // Bad InResponseTo value.
redirectURI: "http://127.0.0.1:5556/dex/callback",
wantErr: true,
}
test.run(t)
}
func TestInvalidSubjectRecipient(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://bad.com/dex/callback", // Doesn't match Recipient value.
wantErr: true,
}
test.run(t)
}
func TestInvalidAssertionAudience(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",
// EntityIssuer overrides RedirectURI when determining the expected
// audience. In this case, ensure the audience is invalid.
entityIssuer: "http://localhost:5556/dex/callback",
wantErr: 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 TestTamperedResponseNameID(t *testing.T) {
test := responseTest{
caFile: "testdata/ca.crt",
respFile: "testdata/tampered-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 loadCert(ca string) (*x509.Certificate, error) {
data, err := os.ReadFile(ca)
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("ca file didn't contain any PEM data")
}
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,
EntityIssuer: r.entityIssuer,
AllowedGroups: r.allowedGroups,
FilterGroups: r.filterGroups,
// 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(slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
conn.now = func() time.Time { return now }
resp, err := os.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)
// Verify ConnectorData contains valid cached identity, then clear it
// for the main identity comparison (ConnectorData is an implementation
// detail of refresh token support).
if len(ident.ConnectorData) > 0 {
var ci cachedIdentity
if err := json.Unmarshal(ident.ConnectorData, &ci); err != nil {
t.Fatalf("failed to unmarshal ConnectorData: %v", err)
}
if ci.UserID != ident.UserID {
t.Errorf("cached identity UserID mismatch: got %q, want %q", ci.UserID, ident.UserID)
}
if ci.Email != ident.Email {
t.Errorf("cached identity Email mismatch: got %q, want %q", ci.Email, ident.Email)
}
}
ident.ConnectorData = nil
if diff := pretty.Compare(ident, r.wantIdent); diff != "" {
t.Error(diff)
}
}
func TestConfigCAData(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
validPEM, err := os.ReadFile("testdata/ca.crt")
if err != nil {
t.Fatal(err)
}
valid2ndPEM, err := os.ReadFile("testdata/okta-ca.pem")
if err != nil {
t.Fatal(err)
}
// copy helper, avoid messing with the byte slice among different cases
c := func(bs []byte) []byte {
return append([]byte(nil), bs...)
}
tests := []struct {
name string
caData []byte
wantErr bool
}{
{
name: "one valid PEM entry",
caData: c(validPEM),
},
{
name: "one valid PEM entry with trailing newline",
caData: append(c(validPEM), []byte("\n")...),
},
{
name: "one valid PEM entry with trailing spaces",
caData: append(c(validPEM), []byte(" ")...),
},
{
name: "one valid PEM entry with two trailing newlines",
caData: append(c(validPEM), []byte("\n\n")...),
},
{
name: "two valid PEM entries",
caData: append(c(validPEM), c(valid2ndPEM)...),
},
{
name: "two valid PEM entries with newline in between",
caData: append(append(c(validPEM), []byte("\n")...), c(valid2ndPEM)...),
},
{
name: "two valid PEM entries with trailing newline",
caData: append(c(valid2ndPEM), append(c(validPEM), []byte("\n")...)...),
},
{
name: "empty",
caData: []byte{},
wantErr: true,
},
{
name: "one valid PEM entry with trailing data",
caData: append(c(validPEM), []byte("yaddayadda")...),
wantErr: true,
},
{
name: "one valid PEM entry with bad data before",
caData: append([]byte("yaddayadda"), c(validPEM)...),
wantErr: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := Config{
CAData: tc.caData,
UsernameAttr: "user",
EmailAttr: "email",
RedirectURI: "http://127.0.0.1:5556/dex/callback",
SSOURL: "http://foo.bar/",
}
_, err := (&c).Open("samltest", logger)
if tc.wantErr {
if err == nil {
t.Error("expected error, got nil")
}
} else if err != nil {
t.Errorf("expected no error, got %v", err)
}
})
}
}
// Deprecated: Use testing framework established above.
func runVerify(t *testing.T, ca string, resp string, shouldSucceed bool) {
cert, err := loadCert(ca)
if err != nil {
t.Fatal(err)
}
s := certStore{[]*x509.Certificate{cert}}
validator := dsig.NewDefaultValidationContext(s)
data, err := os.ReadFile(resp)
if err != nil {
t.Fatal(err)
}
if _, _, err := verifyResponseSig(validator, data); err != nil {
if shouldSucceed {
t.Fatal(err)
}
} else {
if !shouldSucceed {
t.Fatalf("expected an invalid signature but verification has been successful")
}
}
}
func TestVerify(t *testing.T) {
runVerify(t, "testdata/okta-ca.pem", "testdata/okta-resp.xml", true)
}
func TestVerifyUnsignedMessageAndSignedAssertionWithRootXmlNs(t *testing.T) {
runVerify(t, "testdata/oam-ca.pem", "testdata/oam-resp.xml", true)
}
func TestVerifySignedMessageAndUnsignedAssertion(t *testing.T) {
runVerify(t, "testdata/idp-cert.pem", "testdata/idp-resp-signed-message.xml", true)
}
func TestVerifyUnsignedMessageAndSignedAssertion(t *testing.T) {
runVerify(t, "testdata/idp-cert.pem", "testdata/idp-resp-signed-assertion.xml", true)
}
func TestVerifySignedMessageAndSignedAssertion(t *testing.T) {
runVerify(t, "testdata/idp-cert.pem", "testdata/idp-resp-signed-message-and-assertion.xml", true)
}
func TestVerifyUnsignedMessageAndUnsignedAssertion(t *testing.T) {
runVerify(t, "testdata/idp-cert.pem", "testdata/idp-resp.xml", false)
}
func TestSAMLRefresh(t *testing.T) {
// Create a provider using the same pattern as existing tests.
c := Config{
CA: "testdata/ca.crt",
UsernameAttr: "Name",
EmailAttr: "email",
GroupsAttr: "groups",
RedirectURI: "http://127.0.0.1:5556/dex/callback",
SSOURL: "http://foo.bar/",
}
conn, err := c.openConnector(slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
t.Run("SuccessfulRefresh", func(t *testing.T) {
ci := cachedIdentity{
UserID: "test-user-id",
Username: "testuser",
PreferredUsername: "testuser",
Email: "test@example.com",
EmailVerified: true,
Groups: []string{"group1", "group2"},
}
connectorData, err := json.Marshal(ci)
if err != nil {
t.Fatal(err)
}
ident := connector.Identity{
UserID: "old-id",
Username: "old-name",
ConnectorData: connectorData,
}
refreshed, err := conn.Refresh(context.Background(), connector.Scopes{Groups: true}, ident)
if err != nil {
t.Fatalf("Refresh failed: %v", err)
}
if refreshed.UserID != "test-user-id" {
t.Errorf("expected UserID %q, got %q", "test-user-id", refreshed.UserID)
}
if refreshed.Username != "testuser" {
t.Errorf("expected Username %q, got %q", "testuser", refreshed.Username)
}
if refreshed.PreferredUsername != "testuser" {
t.Errorf("expected PreferredUsername %q, got %q", "testuser", refreshed.PreferredUsername)
}
if refreshed.Email != "test@example.com" {
t.Errorf("expected Email %q, got %q", "test@example.com", refreshed.Email)
}
if !refreshed.EmailVerified {
t.Error("expected EmailVerified to be true")
}
if len(refreshed.Groups) != 2 || refreshed.Groups[0] != "group1" || refreshed.Groups[1] != "group2" {
t.Errorf("expected groups [group1, group2], got %v", refreshed.Groups)
}
// ConnectorData should be preserved through refresh
if len(refreshed.ConnectorData) == 0 {
t.Error("expected ConnectorData to be preserved")
}
})
t.Run("RefreshPreservesConnectorData", func(t *testing.T) {
ci := cachedIdentity{
UserID: "user-123",
Username: "alice",
Email: "alice@example.com",
EmailVerified: true,
}
connectorData, err := json.Marshal(ci)
if err != nil {
t.Fatal(err)
}
ident := connector.Identity{
UserID: "old-id",
ConnectorData: connectorData,
}
refreshed, err := conn.Refresh(context.Background(), connector.Scopes{}, ident)
if err != nil {
t.Fatalf("Refresh failed: %v", err)
}
// Verify the refreshed identity can be refreshed again (round-trip)
var roundTrip cachedIdentity
if err := json.Unmarshal(refreshed.ConnectorData, &roundTrip); err != nil {
t.Fatalf("failed to unmarshal ConnectorData after refresh: %v", err)
}
if roundTrip.UserID != "user-123" {
t.Errorf("round-trip UserID mismatch: got %q, want %q", roundTrip.UserID, "user-123")
}
})
t.Run("EmptyConnectorData", func(t *testing.T) {
ident := connector.Identity{
UserID: "test-id",
ConnectorData: nil,
}
_, err := conn.Refresh(context.Background(), connector.Scopes{}, ident)
if err == nil {
t.Error("expected error for empty ConnectorData")
}
})
t.Run("InvalidJSON", func(t *testing.T) {
ident := connector.Identity{
UserID: "test-id",
ConnectorData: []byte("not-json"),
}
_, err := conn.Refresh(context.Background(), connector.Scopes{}, ident)
if err == nil {
t.Error("expected error for invalid JSON")
}
})
t.Run("HandlePOSTThenRefresh", func(t *testing.T) {
// Full integration: HandlePOST → get ConnectorData → Refresh → verify identity
now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z")
if err != nil {
t.Fatal(err)
}
conn.now = func() time.Time { return now }
resp, err := os.ReadFile("testdata/good-resp.xml")
if err != nil {
t.Fatal(err)
}
samlResp := base64.StdEncoding.EncodeToString(resp)
scopes := connector.Scopes{
OfflineAccess: true,
Groups: true,
}
ident, err := conn.HandlePOST(scopes, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m")
if err != nil {
t.Fatalf("HandlePOST failed: %v", err)
}
if len(ident.ConnectorData) == 0 {
t.Fatal("expected ConnectorData to be set after HandlePOST")
}
// Now refresh using the ConnectorData from HandlePOST
refreshed, err := conn.Refresh(context.Background(), scopes, ident)
if err != nil {
t.Fatalf("Refresh failed: %v", err)
}
if refreshed.UserID != ident.UserID {
t.Errorf("UserID mismatch: got %q, want %q", refreshed.UserID, ident.UserID)
}
if refreshed.Username != ident.Username {
t.Errorf("Username mismatch: got %q, want %q", refreshed.Username, ident.Username)
}
if refreshed.Email != ident.Email {
t.Errorf("Email mismatch: got %q, want %q", refreshed.Email, ident.Email)
}
if refreshed.EmailVerified != ident.EmailVerified {
t.Errorf("EmailVerified mismatch: got %v, want %v", refreshed.EmailVerified, ident.EmailVerified)
}
sort.Strings(refreshed.Groups)
sort.Strings(ident.Groups)
if len(refreshed.Groups) != len(ident.Groups) {
t.Errorf("Groups length mismatch: got %d, want %d", len(refreshed.Groups), len(ident.Groups))
}
for i := range ident.Groups {
if i < len(refreshed.Groups) && refreshed.Groups[i] != ident.Groups[i] {
t.Errorf("Groups[%d] mismatch: got %q, want %q", i, refreshed.Groups[i], ident.Groups[i])
}
}
})
t.Run("HandlePOSTThenDoubleRefresh", func(t *testing.T) {
// Verify that refresh tokens can be chained: HandlePOST → Refresh → Refresh
now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z")
if err != nil {
t.Fatal(err)
}
conn.now = func() time.Time { return now }
resp, err := os.ReadFile("testdata/good-resp.xml")
if err != nil {
t.Fatal(err)
}
samlResp := base64.StdEncoding.EncodeToString(resp)
scopes := connector.Scopes{OfflineAccess: true, Groups: true}
ident, err := conn.HandlePOST(scopes, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m")
if err != nil {
t.Fatalf("HandlePOST failed: %v", err)
}
// First refresh
refreshed1, err := conn.Refresh(context.Background(), scopes, ident)
if err != nil {
t.Fatalf("first Refresh failed: %v", err)
}
if len(refreshed1.ConnectorData) == 0 {
t.Fatal("expected ConnectorData after first refresh")
}
// Second refresh using output of first refresh
refreshed2, err := conn.Refresh(context.Background(), scopes, refreshed1)
if err != nil {
t.Fatalf("second Refresh failed: %v", err)
}
// All fields should match original
if refreshed2.UserID != ident.UserID {
t.Errorf("UserID mismatch after double refresh: got %q, want %q", refreshed2.UserID, ident.UserID)
}
if refreshed2.Email != ident.Email {
t.Errorf("Email mismatch after double refresh: got %q, want %q", refreshed2.Email, ident.Email)
}
if refreshed2.Username != ident.Username {
t.Errorf("Username mismatch after double refresh: got %q, want %q", refreshed2.Username, ident.Username)
}
})
t.Run("HandlePOSTWithAssertionSignedThenRefresh", func(t *testing.T) {
// Test with assertion-signed.xml (signature on assertion, not response)
now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z")
if err != nil {
t.Fatal(err)
}
conn.now = func() time.Time { return now }
resp, err := os.ReadFile("testdata/assertion-signed.xml")
if err != nil {
t.Fatal(err)
}
samlResp := base64.StdEncoding.EncodeToString(resp)
scopes := connector.Scopes{OfflineAccess: true, Groups: true}
ident, err := conn.HandlePOST(scopes, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m")
if err != nil {
t.Fatalf("HandlePOST with assertion-signed failed: %v", err)
}
if len(ident.ConnectorData) == 0 {
t.Fatal("expected ConnectorData after HandlePOST with assertion-signed")
}
refreshed, err := conn.Refresh(context.Background(), scopes, ident)
if err != nil {
t.Fatalf("Refresh after assertion-signed HandlePOST failed: %v", err)
}
if refreshed.Email != ident.Email {
t.Errorf("Email mismatch: got %q, want %q", refreshed.Email, ident.Email)
}
if refreshed.Username != ident.Username {
t.Errorf("Username mismatch: got %q, want %q", refreshed.Username, ident.Username)
}
})
t.Run("HandlePOSTRefreshWithoutGroupsScope", func(t *testing.T) {
// Verify that groups are NOT returned when groups scope is not requested during refresh
now, err := time.Parse(timeFormat, "2017-04-04T04:34:59.330Z")
if err != nil {
t.Fatal(err)
}
conn.now = func() time.Time { return now }
resp, err := os.ReadFile("testdata/good-resp.xml")
if err != nil {
t.Fatal(err)
}
samlResp := base64.StdEncoding.EncodeToString(resp)
// Initial auth WITH groups
scopesWithGroups := connector.Scopes{OfflineAccess: true, Groups: true}
ident, err := conn.HandlePOST(scopesWithGroups, samlResp, "6zmm5mguyebwvajyf2sdwwcw6m")
if err != nil {
t.Fatalf("HandlePOST failed: %v", err)
}
if len(ident.Groups) == 0 {
t.Fatal("expected groups in initial identity")
}
// Refresh WITHOUT groups scope
scopesNoGroups := connector.Scopes{OfflineAccess: true, Groups: false}
refreshed, err := conn.Refresh(context.Background(), scopesNoGroups, ident)
if err != nil {
t.Fatalf("Refresh failed: %v", err)
}
if len(refreshed.Groups) != 0 {
t.Errorf("expected no groups when groups scope not requested, got %v", refreshed.Groups)
}
// Refresh WITH groups scope — groups should be back
refreshedWithGroups, err := conn.Refresh(context.Background(), scopesWithGroups, ident)
if err != nil {
t.Fatalf("Refresh with groups failed: %v", err)
}
if len(refreshedWithGroups.Groups) == 0 {
t.Error("expected groups when groups scope is requested")
}
})
}