connector: add a SAML connector
This commit is contained in:
		| @@ -66,6 +66,23 @@ type CallbackConnector interface { | ||||
| 	HandleCallback(s Scopes, r *http.Request) (identity Identity, err error) | ||||
| } | ||||
|  | ||||
| // SAMLConnector represents SAML connectors which implement the HTTP POST binding. | ||||
| // | ||||
| // RelayState is handled by the server. | ||||
| type SAMLConnector interface { | ||||
| 	// POSTData returns an encoded SAML request and SSO URL for the server to | ||||
| 	// render a POST form with. | ||||
| 	POSTData(s Scopes) (sooURL, samlRequest string, err error) | ||||
|  | ||||
| 	// TODO(ericchiang): Provide expected "InResponseTo" ID value. | ||||
| 	// | ||||
| 	// See: https://www.oasis-open.org/committees/download.php/35711/sstc-saml-core-errata-2.0-wd-06-diff.pdf | ||||
| 	// "3.2.2 Complex Type StatusResponseType" | ||||
|  | ||||
| 	// HandlePOST decodes, verifies, and maps attributes from the SAML response. | ||||
| 	HandlePOST(s Scopes, samlResponse string) (identity Identity, err error) | ||||
| } | ||||
|  | ||||
| // RefreshConnector is a connector that can update the client claims. | ||||
| type RefreshConnector interface { | ||||
| 	// Refresh is called when a client attempts to claim a refresh token. The | ||||
|   | ||||
							
								
								
									
										387
									
								
								connector/saml/saml.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										387
									
								
								connector/saml/saml.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,387 @@ | ||||
| // Package saml contains login methods for SAML. | ||||
| package saml | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"compress/flate" | ||||
| 	"crypto/rand" | ||||
| 	"crypto/x509" | ||||
| 	"encoding/base64" | ||||
| 	"encoding/hex" | ||||
| 	"encoding/pem" | ||||
| 	"encoding/xml" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io/ioutil" | ||||
| 	"strings" | ||||
| 	"time" | ||||
|  | ||||
| 	"github.com/Sirupsen/logrus" | ||||
| 	"github.com/beevik/etree" | ||||
| 	dsig "github.com/russellhaering/goxmldsig" | ||||
|  | ||||
| 	"github.com/coreos/dex/connector" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	bindingRedirect = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" | ||||
| 	bindingPOST     = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" | ||||
|  | ||||
| 	nameIDFormatEmailAddress = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" | ||||
| 	nameIDFormatUnspecified  = "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" | ||||
| 	nameIDFormatX509Subject  = "urn:oasis:names:tc:SAML:1.1:nameid-format:X509SubjectName" | ||||
| 	nameIDFormatWindowsDN    = "urn:oasis:names:tc:SAML:1.1:nameid-format:WindowsDomainQualifiedName" | ||||
| 	nameIDFormatEncrypted    = "urn:oasis:names:tc:SAML:2.0:nameid-format:encrypted" | ||||
| 	nameIDFormatEntity       = "urn:oasis:names:tc:SAML:2.0:nameid-format:entity" | ||||
| 	nameIDFormatKerberos     = "urn:oasis:names:tc:SAML:2.0:nameid-format:kerberos" | ||||
| 	nameIDFormatPersistent   = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" | ||||
| 	nameIDformatTransient    = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	nameIDFormats = []string{ | ||||
| 		nameIDFormatEmailAddress, | ||||
| 		nameIDFormatUnspecified, | ||||
| 		nameIDFormatX509Subject, | ||||
| 		nameIDFormatWindowsDN, | ||||
| 		nameIDFormatEncrypted, | ||||
| 		nameIDFormatEntity, | ||||
| 		nameIDFormatKerberos, | ||||
| 		nameIDFormatPersistent, | ||||
| 		nameIDformatTransient, | ||||
| 	} | ||||
| 	nameIDFormatLookup = make(map[string]string) | ||||
| ) | ||||
|  | ||||
| func init() { | ||||
| 	suffix := func(s, sep string) string { | ||||
| 		if i := strings.LastIndex(s, sep); i > 0 { | ||||
| 			return s[i+1:] | ||||
| 		} | ||||
| 		return s | ||||
| 	} | ||||
| 	for _, format := range nameIDFormats { | ||||
| 		nameIDFormatLookup[suffix(format, ":")] = format | ||||
| 		nameIDFormatLookup[format] = format | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Config represents configuration options for the SAML provider. | ||||
| type Config struct { | ||||
| 	// TODO(ericchiang): A bunch of these fields could be auto-filled if | ||||
| 	// we supported SAML metadata discovery. | ||||
| 	// | ||||
| 	// https://www.oasis-open.org/committees/download.php/35391/sstc-saml-metadata-errata-2.0-wd-04-diff.pdf | ||||
|  | ||||
| 	Issuer string `json:"issuer"` | ||||
| 	SSOURL string `json:"ssoURL"` | ||||
|  | ||||
| 	// X509 CA file or raw data to verify XML signatures. | ||||
| 	CA     string `json:"ca"` | ||||
| 	CAData []byte `json:"caData"` | ||||
|  | ||||
| 	InsecureSkipSignatureValidation bool `json:"insecureSkipSignatureValidation"` | ||||
|  | ||||
| 	// Assertion attribute names to lookup various claims with. | ||||
| 	UsernameAttr string `json:"usernameAttr"` | ||||
| 	EmailAttr    string `json:"emailAttr"` | ||||
| 	GroupsAttr   string `json:"groupsAttr"` | ||||
| 	// If GroupsDelim is supplied the connector assumes groups are returned as a | ||||
| 	// single string instead of multiple attribute values. This delimiter will be | ||||
| 	// used split the groups string. | ||||
| 	GroupsDelim string `json:"groupsDelim"` | ||||
|  | ||||
| 	RedirectURI string `json:"redirectURI"` | ||||
|  | ||||
| 	// Requested format of the NameID. The NameID value is is mapped to the ID Token | ||||
| 	// 'sub' claim. | ||||
| 	// | ||||
| 	// This can be an abbreviated form of the full URI with just the last component. For | ||||
| 	// example, if this value is set to "emailAddress" the format will resolve to: | ||||
| 	// | ||||
| 	//		urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress | ||||
| 	// | ||||
| 	// If no value is specified, this value defaults to: | ||||
| 	// | ||||
| 	//		urn:oasis:names:tc:SAML:2.0:nameid-format:persistent | ||||
| 	// | ||||
| 	NameIDPolicyFormat string `json:"nameIDPolicyFormat"` | ||||
| } | ||||
|  | ||||
| type certStore struct { | ||||
| 	certs []*x509.Certificate | ||||
| } | ||||
|  | ||||
| func (c certStore) Certificates() (roots []*x509.Certificate, err error) { | ||||
| 	return c.certs, nil | ||||
| } | ||||
|  | ||||
| // Open validates the config and returns a connector. It does not actually | ||||
| // validate connectivity with the provider. | ||||
| func (c *Config) Open(logger logrus.FieldLogger) (connector.Connector, error) { | ||||
| 	return c.openConnector(logger) | ||||
| } | ||||
|  | ||||
| func (c *Config) openConnector(logger logrus.FieldLogger) (interface { | ||||
| 	connector.SAMLConnector | ||||
| }, error) { | ||||
| 	requiredFields := []struct { | ||||
| 		name, val string | ||||
| 	}{ | ||||
| 		{"issuer", c.Issuer}, | ||||
| 		{"ssoURL", c.SSOURL}, | ||||
| 		{"usernameAttr", c.UsernameAttr}, | ||||
| 		{"emailAttr", c.EmailAttr}, | ||||
| 		{"redirectURI", c.RedirectURI}, | ||||
| 	} | ||||
| 	var missing []string | ||||
| 	for _, f := range requiredFields { | ||||
| 		if f.val == "" { | ||||
| 			missing = append(missing, f.name) | ||||
| 		} | ||||
| 	} | ||||
| 	switch len(missing) { | ||||
| 	case 0: | ||||
| 	case 1: | ||||
| 		return nil, fmt.Errorf("missing required field %q", missing[0]) | ||||
| 	default: | ||||
| 		return nil, fmt.Errorf("missing required fields %q", missing) | ||||
| 	} | ||||
|  | ||||
| 	p := &provider{ | ||||
| 		issuer:       c.Issuer, | ||||
| 		ssoURL:       c.SSOURL, | ||||
| 		now:          time.Now, | ||||
| 		usernameAttr: c.UsernameAttr, | ||||
| 		emailAttr:    c.EmailAttr, | ||||
| 		groupsAttr:   c.GroupsAttr, | ||||
| 		groupsDelim:  c.GroupsDelim, | ||||
| 		redirectURI:  c.RedirectURI, | ||||
| 		logger:       logger, | ||||
|  | ||||
| 		nameIDPolicyFormat: c.NameIDPolicyFormat, | ||||
| 	} | ||||
|  | ||||
| 	if p.nameIDPolicyFormat == "" { | ||||
| 		p.nameIDPolicyFormat = nameIDFormatPersistent | ||||
| 	} else { | ||||
| 		if format, ok := nameIDFormatLookup[p.nameIDPolicyFormat]; ok { | ||||
| 			p.nameIDPolicyFormat = format | ||||
| 		} else { | ||||
| 			return nil, fmt.Errorf("invalid nameIDPolicyFormat: %q", p.nameIDPolicyFormat) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if !c.InsecureSkipSignatureValidation { | ||||
| 		if (c.CA == "") == (c.CAData == nil) { | ||||
| 			return nil, errors.New("must provide either 'ca' or 'caData'") | ||||
| 		} | ||||
|  | ||||
| 		var caData []byte | ||||
| 		if c.CA != "" { | ||||
| 			data, err := ioutil.ReadFile(c.CA) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("read ca file: %v", err) | ||||
| 			} | ||||
| 			caData = data | ||||
| 		} else { | ||||
| 			caData = c.CAData | ||||
| 		} | ||||
|  | ||||
| 		var ( | ||||
| 			certs []*x509.Certificate | ||||
| 			block *pem.Block | ||||
| 		) | ||||
| 		for { | ||||
| 			block, caData = pem.Decode(caData) | ||||
| 			if block == nil { | ||||
| 				break | ||||
| 			} | ||||
| 			cert, err := x509.ParseCertificate(block.Bytes) | ||||
| 			if err != nil { | ||||
| 				return nil, fmt.Errorf("parse cert: %v", err) | ||||
| 			} | ||||
| 			certs = append(certs, cert) | ||||
| 		} | ||||
| 		if len(certs) == 0 { | ||||
| 			return nil, errors.New("no certificates found in ca data") | ||||
| 		} | ||||
| 		p.validator = dsig.NewDefaultValidationContext(certStore{certs}) | ||||
| 	} | ||||
| 	return p, nil | ||||
| } | ||||
|  | ||||
| type provider struct { | ||||
| 	issuer string | ||||
| 	ssoURL string | ||||
|  | ||||
| 	now func() time.Time | ||||
|  | ||||
| 	// If nil, don't do signature validation. | ||||
| 	validator *dsig.ValidationContext | ||||
|  | ||||
| 	// Attribute mappings | ||||
| 	usernameAttr string | ||||
| 	emailAttr    string | ||||
| 	groupsAttr   string | ||||
| 	groupsDelim  string | ||||
|  | ||||
| 	redirectURI string | ||||
|  | ||||
| 	nameIDPolicyFormat string | ||||
|  | ||||
| 	logger logrus.FieldLogger | ||||
| } | ||||
|  | ||||
| func (p *provider) POSTData(s connector.Scopes) (action, value string, err error) { | ||||
|  | ||||
| 	// NOTE(ericchiang): If we can't follow up with the identity provider, can we | ||||
| 	// support refresh tokens? | ||||
| 	if s.OfflineAccess { | ||||
| 		return "", "", fmt.Errorf("SAML does not support offline access") | ||||
| 	} | ||||
|  | ||||
| 	r := &authnRequest{ | ||||
| 		ProtocolBinding: bindingPOST, | ||||
| 		ID:              "_" + uuidv4(), | ||||
| 		IssueInstant:    xmlTime(p.now()), | ||||
| 		Destination:     p.ssoURL, | ||||
| 		Issuer: &issuer{ | ||||
| 			Issuer: p.issuer, | ||||
| 		}, | ||||
| 		NameIDPolicy: &nameIDPolicy{ | ||||
| 			AllowCreate: true, | ||||
| 			Format:      p.nameIDPolicyFormat, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	data, err := xml.MarshalIndent(r, "", "  ") | ||||
| 	if err != nil { | ||||
| 		return "", "", fmt.Errorf("marshal authn request: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	buff := new(bytes.Buffer) | ||||
| 	fw, err := flate.NewWriter(buff, flate.DefaultCompression) | ||||
| 	if err != nil { | ||||
| 		return "", "", fmt.Errorf("new flate writer: %v", err) | ||||
| 	} | ||||
| 	if _, err := fw.Write(data); err != nil { | ||||
| 		return "", "", fmt.Errorf("compress message: %v", err) | ||||
| 	} | ||||
| 	if err := fw.Close(); err != nil { | ||||
| 		return "", "", fmt.Errorf("flush message: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	return p.ssoURL, base64.StdEncoding.EncodeToString(buff.Bytes()), nil | ||||
| } | ||||
|  | ||||
| func (p *provider) HandlePOST(s connector.Scopes, samlResponse string) (ident connector.Identity, err error) { | ||||
| 	rawResp, err := base64.StdEncoding.DecodeString(samlResponse) | ||||
| 	if err != nil { | ||||
| 		return ident, fmt.Errorf("decode response: %v", err) | ||||
| 	} | ||||
| 	if p.validator != nil { | ||||
| 		if rawResp, err = verify(p.validator, rawResp); err != nil { | ||||
| 			return ident, fmt.Errorf("verify signature: %v", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	var resp response | ||||
| 	if err := xml.Unmarshal(rawResp, &resp); err != nil { | ||||
| 		return ident, fmt.Errorf("unmarshal response: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	if resp.Destination != "" && resp.Destination != p.redirectURI { | ||||
| 		return ident, fmt.Errorf("expected destination %q got %q", p.redirectURI, resp.Destination) | ||||
|  | ||||
| 	} | ||||
|  | ||||
| 	assertion := resp.Assertion | ||||
| 	if assertion == nil { | ||||
| 		return ident, fmt.Errorf("response did not contain an assertion") | ||||
| 	} | ||||
| 	subject := assertion.Subject | ||||
| 	if subject == nil { | ||||
| 		return ident, fmt.Errorf("response did not contain a subject") | ||||
| 	} | ||||
|  | ||||
| 	switch { | ||||
| 	case subject.NameID != nil: | ||||
| 		if ident.UserID = subject.NameID.Value; ident.UserID == "" { | ||||
| 			return ident, fmt.Errorf("NameID element does not contain a value") | ||||
| 		} | ||||
| 	default: | ||||
| 		return ident, fmt.Errorf("subject does not contain an NameID element") | ||||
| 	} | ||||
|  | ||||
| 	attributes := assertion.AttributeStatement | ||||
| 	if attributes == nil { | ||||
| 		return ident, fmt.Errorf("response did not contain a AttributeStatement") | ||||
| 	} | ||||
|  | ||||
| 	if ident.Email, _ = attributes.get(p.emailAttr); ident.Email == "" { | ||||
| 		return ident, fmt.Errorf("no attribute with name %q", p.emailAttr) | ||||
| 	} | ||||
| 	ident.EmailVerified = true | ||||
|  | ||||
| 	if ident.Username, _ = attributes.get(p.usernameAttr); ident.Username == "" { | ||||
| 		return ident, fmt.Errorf("no attribute with name %q", p.usernameAttr) | ||||
| 	} | ||||
|  | ||||
| 	if s.Groups && p.groupsAttr != "" { | ||||
| 		if p.groupsDelim != "" { | ||||
| 			groupsStr, ok := attributes.get(p.groupsAttr) | ||||
| 			if !ok { | ||||
| 				return ident, fmt.Errorf("no attribute with name %q", p.groupsAttr) | ||||
| 			} | ||||
| 			// TOOD(ericchiang): Do we need to further trim whitespace? | ||||
| 			ident.Groups = strings.Split(groupsStr, p.groupsDelim) | ||||
| 		} else { | ||||
| 			groups, ok := attributes.all(p.groupsAttr) | ||||
| 			if !ok { | ||||
| 				return ident, fmt.Errorf("no attribute with name %q", p.groupsAttr) | ||||
| 			} | ||||
| 			ident.Groups = groups | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return ident, nil | ||||
| } | ||||
|  | ||||
| // verify checks the signature info of a XML document and returns | ||||
| // the signed elements. | ||||
| func verify(validator *dsig.ValidationContext, data []byte) (signed []byte, err error) { | ||||
| 	doc := etree.NewDocument() | ||||
| 	if err := doc.ReadFromBytes(data); err != nil { | ||||
| 		return nil, fmt.Errorf("parse document: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	result, err := validator.Validate(doc.Root()) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	doc.SetRoot(result) | ||||
| 	return doc.WriteToBytes() | ||||
| } | ||||
|  | ||||
| func uuidv4() string { | ||||
| 	u := make([]byte, 16) | ||||
| 	if _, err := rand.Read(u); err != nil { | ||||
| 		panic(err) | ||||
| 	} | ||||
| 	u[6] = (u[6] | 0x40) & 0x4F | ||||
| 	u[8] = (u[8] | 0x80) & 0xBF | ||||
|  | ||||
| 	r := make([]byte, 36) | ||||
| 	r[8] = '-' | ||||
| 	r[13] = '-' | ||||
| 	r[18] = '-' | ||||
| 	r[23] = '-' | ||||
| 	hex.Encode(r, u[0:4]) | ||||
| 	hex.Encode(r[9:], u[4:6]) | ||||
| 	hex.Encode(r[14:], u[6:8]) | ||||
| 	hex.Encode(r[19:], u[8:10]) | ||||
| 	hex.Encode(r[24:], u[10:]) | ||||
|  | ||||
| 	return string(r) | ||||
| } | ||||
							
								
								
									
										42
									
								
								connector/saml/saml_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								connector/saml/saml_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| package saml | ||||
|  | ||||
| import ( | ||||
| 	"crypto/x509" | ||||
| 	"encoding/pem" | ||||
| 	"errors" | ||||
| 	"io/ioutil" | ||||
| 	"testing" | ||||
|  | ||||
| 	sdig "github.com/russellhaering/goxmldsig" | ||||
| ) | ||||
|  | ||||
| func loadCert(ca string) (*x509.Certificate, error) { | ||||
| 	data, err := ioutil.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 TestVerify(t *testing.T) { | ||||
| 	cert, err := loadCert("testdata/okta-ca.pem") | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	s := certStore{[]*x509.Certificate{cert}} | ||||
|  | ||||
| 	validator := sdig.NewDefaultValidationContext(s) | ||||
|  | ||||
| 	data, err := ioutil.ReadFile("testdata/okta-resp.xml") | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
|  | ||||
| 	if _, err := verify(validator, data); err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										19
									
								
								connector/saml/testdata/okta-ca.pem
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								connector/saml/testdata/okta-ca.pem
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| -----BEGIN CERTIFICATE----- | ||||
| MIIDpDCCAoygAwIBAgIGAVjgvNroMA0GCSqGSIb3DQEBBQUAMIGSMQswCQYDVQQGEwJVUzETMBEG | ||||
| A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU | ||||
| MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi05NjkyNDQxHDAaBgkqhkiG9w0BCQEW | ||||
| DWluZm9Ab2t0YS5jb20wHhcNMTYxMjA4MjMxOTIzWhcNMjYxMjA4MjMyMDIzWjCBkjELMAkGA1UE | ||||
| BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV | ||||
| BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtOTY5MjQ0MRwwGgYJ | ||||
| KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA | ||||
| 4dW2YlcXjZwnTmLnV7IOBq8hhrdbqlNwdjCHRyx1BizOk3RbVP56grgPdyWScTCPpJ6vZZ8rtrY0 | ||||
| m1rwr+cxifNuQGKTlE33g2hReo/N9f3LFUMITlnnNH80Yium3SYuEqGeHLYerelXOnEKx6x+X5qD | ||||
| eg2DRW6I9/v/mfN2KAQEDqF9aSNlNFWZWmb52kukMv3tLWw0puaevicIZ/nZrW+D3CLDVVfWHeVt | ||||
| 46EF2bkLdgbIJOU3GzLoolgBOCkydX9x6xTw6knwQaqYsRGflacw6571IzWEwjmd17uJXkarnhM1 | ||||
| 51pqwIoksTzycbjinIg6B1rNpGFDN7Ah+9EnVQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQDQOtlj | ||||
| 7Yk1j4GV/135zLOlsaonq8zfsu05VfY5XydNPFEkyIVcegLa8RiqALyFf+FjY/wVyhXtARw7NBUo | ||||
| u/Jh63cVya/VEoP1SVa0XQLf/ial+XuwdBBL1yc+7o2XHOfluDaXw/v2FWYpZtICf3a39giGaNWp | ||||
| eCT4g2TDWM4Jf/X7/mRbLX9tQO7XRural1CXx8VIMcmbKNbUtQiO8yEtVQ+FJKOsl7KOSzkgqNiL | ||||
| rJy+Y0D9biLZVKp07KWAY2FPCEtCkBrvo8BhvWbxWMA8CVQNAiTylM27Pc6kbc64pNr7C1Jx1wuE | ||||
| mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcy | ||||
| -----END CERTIFICATE----- | ||||
							
								
								
									
										33
									
								
								connector/saml/testdata/okta-resp.xml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								connector/saml/testdata/okta-resp.xml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?><saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" Destination="http://localhost:5556/dex/callback" ID="id108965453120986171998428970" InResponseTo="_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0" IssueInstant="2016-12-20T22:18:23.771Z" Version="2.0"><saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">http://www.okta.com/exk91cb99lKkKSYoy0h7</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id108965453120986171998428970"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>Phu93l0D97JSMIYDZBdVeNLN0pwBVHhzUDWxbh4sc6g=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>M2gMHOmnMAFgh2apq/2jHwDYmisUkYMUqxrWkQJf3RHFotl4EeDlcqq/FzOboJc3NcbKBqQY3CWsWhWh5cNWHDgNneaahW4czww+9DCM0R/zz5c6GuMYFEh5df2sDn/dWk/jbKMiAMgPdKJ2x/+5Xk9q4axC52TdQrrbZtzAAAn4CgrT6Kf11qfMl5wpDarg3qPw7ANxWn2DKzCsvCkOIwM2+AXh+sEXmTvvZIQ0vpv098FH/ZTGt4sCwb1bmRZ3UZLhBcxVc/sjuEW/sQ6pbQHkjrXIR5bxXzGNUxYpcGjrp9HGF+In0BAc+Ds/A0H142e1rgtcX8LH2pbG8URJSQ==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDpDCCAoygAwIBAgIGAVjgvNroMA0GCSqGSIb3DQEBBQUAMIGSMQswCQYDVQQGEwJVUzETMBEG | ||||
| A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU | ||||
| MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi05NjkyNDQxHDAaBgkqhkiG9w0BCQEW | ||||
| DWluZm9Ab2t0YS5jb20wHhcNMTYxMjA4MjMxOTIzWhcNMjYxMjA4MjMyMDIzWjCBkjELMAkGA1UE | ||||
| BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV | ||||
| BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtOTY5MjQ0MRwwGgYJ | ||||
| KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA | ||||
| 4dW2YlcXjZwnTmLnV7IOBq8hhrdbqlNwdjCHRyx1BizOk3RbVP56grgPdyWScTCPpJ6vZZ8rtrY0 | ||||
| m1rwr+cxifNuQGKTlE33g2hReo/N9f3LFUMITlnnNH80Yium3SYuEqGeHLYerelXOnEKx6x+X5qD | ||||
| eg2DRW6I9/v/mfN2KAQEDqF9aSNlNFWZWmb52kukMv3tLWw0puaevicIZ/nZrW+D3CLDVVfWHeVt | ||||
| 46EF2bkLdgbIJOU3GzLoolgBOCkydX9x6xTw6knwQaqYsRGflacw6571IzWEwjmd17uJXkarnhM1 | ||||
| 51pqwIoksTzycbjinIg6B1rNpGFDN7Ah+9EnVQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQDQOtlj | ||||
| 7Yk1j4GV/135zLOlsaonq8zfsu05VfY5XydNPFEkyIVcegLa8RiqALyFf+FjY/wVyhXtARw7NBUo | ||||
| u/Jh63cVya/VEoP1SVa0XQLf/ial+XuwdBBL1yc+7o2XHOfluDaXw/v2FWYpZtICf3a39giGaNWp | ||||
| eCT4g2TDWM4Jf/X7/mRbLX9tQO7XRural1CXx8VIMcmbKNbUtQiO8yEtVQ+FJKOsl7KOSzkgqNiL | ||||
| rJy+Y0D9biLZVKp07KWAY2FPCEtCkBrvo8BhvWbxWMA8CVQNAiTylM27Pc6kbc64pNr7C1Jx1wuE | ||||
| mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcy</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2p:Status xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"><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="id10896545312129779529177535" IssueInstant="2016-12-20T22:18:23.771Z" Version="2.0"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://www.okta.com/exk91cb99lKkKSYoy0h7</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id10896545312129779529177535"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>ufwWUjecX6I/aQb4WW9P9ZMLG3C8hN6LaZyyb/EATIs=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>jKtNBzxAL67ssuzWkkbf0yzqRyZ51y2JjBQ9C6bW8io/JOYQB2v7Bix7Eu/RjJslO7OBqD+3tPrK7ZBOy2+LFuAh3cDNa3U5NhO0raLrn/2YoJXfjj3XX3hyQv6GVxo0EY1KJNXOzWxjp9RVDpHslPTIL1yDC/oy0Mlzxu6pXBEerz9J2/Caenq66Skb5/DAT8FvrJ2s1bxuMagShs3APhC1hD8mvktZ+ZcN8ujs2SebteGK4IoOCx+e8+v2CyycBv1l5l+v5I+D2HnbAw4LfvHnW4rZOJT2AvoI47p1YBK1qDsJutG3jUPKy4Yx5YF73Xi1oytr+rrHyx/lfFPd2A==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDpDCCAoygAwIBAgIGAVjgvNroMA0GCSqGSIb3DQEBBQUAMIGSMQswCQYDVQQGEwJVUzETMBEG | ||||
| A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU | ||||
| MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi05NjkyNDQxHDAaBgkqhkiG9w0BCQEW | ||||
| DWluZm9Ab2t0YS5jb20wHhcNMTYxMjA4MjMxOTIzWhcNMjYxMjA4MjMyMDIzWjCBkjELMAkGA1UE | ||||
| BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV | ||||
| BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtOTY5MjQ0MRwwGgYJ | ||||
| KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA | ||||
| 4dW2YlcXjZwnTmLnV7IOBq8hhrdbqlNwdjCHRyx1BizOk3RbVP56grgPdyWScTCPpJ6vZZ8rtrY0 | ||||
| m1rwr+cxifNuQGKTlE33g2hReo/N9f3LFUMITlnnNH80Yium3SYuEqGeHLYerelXOnEKx6x+X5qD | ||||
| eg2DRW6I9/v/mfN2KAQEDqF9aSNlNFWZWmb52kukMv3tLWw0puaevicIZ/nZrW+D3CLDVVfWHeVt | ||||
| 46EF2bkLdgbIJOU3GzLoolgBOCkydX9x6xTw6knwQaqYsRGflacw6571IzWEwjmd17uJXkarnhM1 | ||||
| 51pqwIoksTzycbjinIg6B1rNpGFDN7Ah+9EnVQIDAQABMA0GCSqGSIb3DQEBBQUAA4IBAQDQOtlj | ||||
| 7Yk1j4GV/135zLOlsaonq8zfsu05VfY5XydNPFEkyIVcegLa8RiqALyFf+FjY/wVyhXtARw7NBUo | ||||
| u/Jh63cVya/VEoP1SVa0XQLf/ial+XuwdBBL1yc+7o2XHOfluDaXw/v2FWYpZtICf3a39giGaNWp | ||||
| eCT4g2TDWM4Jf/X7/mRbLX9tQO7XRural1CXx8VIMcmbKNbUtQiO8yEtVQ+FJKOsl7KOSzkgqNiL | ||||
| rJy+Y0D9biLZVKp07KWAY2FPCEtCkBrvo8BhvWbxWMA8CVQNAiTylM27Pc6kbc64pNr7C1Jx1wuE | ||||
| mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcy</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2:Subject xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">eric.chiang+okta@coreos.com</saml2:NameID><saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml2:SubjectConfirmationData InResponseTo="_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0" NotOnOrAfter="2016-12-20T22:23:23.772Z" Recipient="http://localhost:5556/dex/callback"/></saml2:SubjectConfirmation></saml2:Subject><saml2:Conditions NotBefore="2016-12-20T22:13:23.772Z" NotOnOrAfter="2016-12-20T22:23:23.772Z" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AudienceRestriction><saml2:Audience>http://localhost:5556/dex/callback</saml2:Audience></saml2:AudienceRestriction></saml2:Conditions><saml2:AuthnStatement AuthnInstant="2016-12-20T22:18:23.771Z" SessionIndex="_fd1b3ef9-ec09-44a7-a66b-0d39c250f6a0" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AuthnContext><saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef></saml2:AuthnContext></saml2:AuthnStatement></saml2:Assertion></saml2p:Response> | ||||
							
								
								
									
										177
									
								
								connector/saml/types.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										177
									
								
								connector/saml/types.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,177 @@ | ||||
| package saml | ||||
|  | ||||
| import ( | ||||
| 	"encoding/xml" | ||||
| 	"fmt" | ||||
| 	"time" | ||||
| ) | ||||
|  | ||||
| const timeFormat = "2006-01-02T15:04:05Z" | ||||
|  | ||||
| type xmlTime time.Time | ||||
|  | ||||
| func (t xmlTime) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { | ||||
| 	return xml.Attr{ | ||||
| 		Name:  name, | ||||
| 		Value: time.Time(t).UTC().Format(timeFormat), | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (t *xmlTime) UnmarshalXMLAttr(attr xml.Attr) error { | ||||
| 	got, err := time.Parse(timeFormat, attr.Value) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	*t = xmlTime(got) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| type samlVersion struct{} | ||||
|  | ||||
| func (s samlVersion) MarshalXMLAttr(name xml.Name) (xml.Attr, error) { | ||||
| 	return xml.Attr{ | ||||
| 		Name:  name, | ||||
| 		Value: "2.0", | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (s *samlVersion) UnmarshalXMLAttr(attr xml.Attr) error { | ||||
| 	if attr.Value != "2.0" { | ||||
| 		return fmt.Errorf(`saml version expected "2.0" got %q`, attr.Value) | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| type authnRequest struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol AuthnRequest"` | ||||
|  | ||||
| 	ID      string      `xml:"ID,attr"` | ||||
| 	Version samlVersion `xml:"Version,attr"` | ||||
|  | ||||
| 	ProviderName string  `xml:"ProviderName,attr,omitempty"` | ||||
| 	IssueInstant xmlTime `xml:"IssueInstant,attr,omitempty"` | ||||
| 	Consent      bool    `xml:"Consent,attr,omitempty"` | ||||
| 	Destination  string  `xml:"Destination,attr,omitempty"` | ||||
|  | ||||
| 	ForceAuthn      bool   `xml:"ForceAuthn,attr,omitempty"` | ||||
| 	IsPassive       bool   `xml:"IsPassive,attr,omitempty"` | ||||
| 	ProtocolBinding string `xml:"ProtocolBinding,attr,omitempty"` | ||||
|  | ||||
| 	Subject      *subject      `xml:"Subject,omitempty"` | ||||
| 	Issuer       *issuer       `xml:"Issuer,omitempty"` | ||||
| 	NameIDPolicy *nameIDPolicy `xml:"NameIDPolicy,omitempty"` | ||||
|  | ||||
| 	// TODO(ericchiang): Make this configurable and determine appropriate default values. | ||||
| 	RequestAuthnContext *requestAuthnContext `xml:"RequestAuthnContext,omitempty"` | ||||
| } | ||||
|  | ||||
| type subject struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Subject"` | ||||
|  | ||||
| 	NameID *nameID `xml:"NameID,omitempty"` | ||||
|  | ||||
| 	// TODO(ericchiang): Do we need to deal with baseID? | ||||
| } | ||||
|  | ||||
| type nameID struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion NameID"` | ||||
|  | ||||
| 	Format string `xml:"Format,omitempty"` | ||||
| 	Value  string `xml:",chardata"` | ||||
| } | ||||
|  | ||||
| type issuer struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Issuer"` | ||||
| 	Issuer  string   `xml:",chardata"` | ||||
| } | ||||
|  | ||||
| type nameIDPolicy struct { | ||||
| 	XMLName     xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol NameIDPolicy"` | ||||
| 	AllowCreate bool     `xml:"AllowCreate,attr,omitempty"` | ||||
| 	Format      string   `xml:"Format,attr,omitempty"` | ||||
| } | ||||
|  | ||||
| type requestAuthnContext struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol RequestAuthnContext"` | ||||
|  | ||||
| 	AuthnContextClassRefs []authnContextClassRef | ||||
| } | ||||
|  | ||||
| type authnContextClassRef struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol AuthnContextClassRef"` | ||||
| 	Value   string   `xml:",chardata"` | ||||
| } | ||||
|  | ||||
| type response struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:protocol Response"` | ||||
|  | ||||
| 	ID      string      `xml:"ID,attr"` | ||||
| 	Version samlVersion `xml:"Version,attr"` | ||||
|  | ||||
| 	Destination string `xml:"Destination,attr,omitempty"` | ||||
|  | ||||
| 	Issuer *issuer `xml:"Issuer,omitempty"` | ||||
|  | ||||
| 	// TODO(ericchiang): How do deal with multiple assertions? | ||||
| 	Assertion *assertion `xml:"Assertion,omitempty"` | ||||
| } | ||||
|  | ||||
| type assertion struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Assertion"` | ||||
|  | ||||
| 	Version       samlVersion `xml:"Version,attr"` | ||||
| 	ID            string      `xml:"ID,attr"` | ||||
| 	IssueInstance xmlTime     `xml:"IssueInstance,attr"` | ||||
|  | ||||
| 	Issuer issuer `xml:"Issuer"` | ||||
|  | ||||
| 	Subject *subject `xml:"Subject,omitempty"` | ||||
|  | ||||
| 	AttributeStatement *attributeStatement `xml:"AttributeStatement,omitempty"` | ||||
| } | ||||
|  | ||||
| type attributeStatement struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion AttributeStatement"` | ||||
|  | ||||
| 	Attributes []attribute `xml:"Attribute"` | ||||
| } | ||||
|  | ||||
| func (a *attributeStatement) get(name string) (s string, ok bool) { | ||||
| 	for _, attr := range a.Attributes { | ||||
| 		if attr.Name == name { | ||||
| 			ok = true | ||||
| 			if len(attr.AttributeValues) > 0 { | ||||
| 				return attr.AttributeValues[0].Value, true | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
|  | ||||
| func (a *attributeStatement) all(name string) (s []string, ok bool) { | ||||
| 	for _, attr := range a.Attributes { | ||||
| 		if attr.Name == name { | ||||
| 			ok = true | ||||
| 			for _, val := range attr.AttributeValues { | ||||
| 				s = append(s, val.Value) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
|  | ||||
| type attribute struct { | ||||
| 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"` | ||||
|  | ||||
| 	Name string `xml:"Name,attr"` | ||||
|  | ||||
| 	NameFormat   string `xml:"NameFormat,attr,omitempty"` | ||||
| 	FriendlyName string `xml:"FriendlyName,attr,omitempty"` | ||||
|  | ||||
| 	AttributeValues []attributeValue `xml:"AttributeValue,omitempty"` | ||||
| } | ||||
|  | ||||
| type attributeValue struct { | ||||
| 	XMLName xml.Name `xml:"AttributeValue"` | ||||
| 	Value   string   `xml:",chardata"` | ||||
| } | ||||
		Reference in New Issue
	
	Block a user