diff --git a/connector/connector.go b/connector/connector.go index 95a7ec13..c92d7589 100644 --- a/connector/connector.go +++ b/connector/connector.go @@ -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 diff --git a/connector/saml/saml.go b/connector/saml/saml.go new file mode 100644 index 00000000..0c5b806b --- /dev/null +++ b/connector/saml/saml.go @@ -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) +} diff --git a/connector/saml/saml_test.go b/connector/saml/saml_test.go new file mode 100644 index 00000000..4e455688 --- /dev/null +++ b/connector/saml/saml_test.go @@ -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) + } +} diff --git a/connector/saml/testdata/okta-ca.pem b/connector/saml/testdata/okta-ca.pem new file mode 100644 index 00000000..de7f1c88 --- /dev/null +++ b/connector/saml/testdata/okta-ca.pem @@ -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----- diff --git a/connector/saml/testdata/okta-resp.xml b/connector/saml/testdata/okta-resp.xml new file mode 100644 index 00000000..9dff8e10 --- /dev/null +++ b/connector/saml/testdata/okta-resp.xml @@ -0,0 +1,33 @@ +http://www.okta.com/exk91cb99lKkKSYoy0h7Phu93l0D97JSMIYDZBdVeNLN0pwBVHhzUDWxbh4sc6g=M2gMHOmnMAFgh2apq/2jHwDYmisUkYMUqxrWkQJf3RHFotl4EeDlcqq/FzOboJc3NcbKBqQY3CWsWhWh5cNWHDgNneaahW4czww+9DCM0R/zz5c6GuMYFEh5df2sDn/dWk/jbKMiAMgPdKJ2x/+5Xk9q4axC52TdQrrbZtzAAAn4CgrT6Kf11qfMl5wpDarg3qPw7ANxWn2DKzCsvCkOIwM2+AXh+sEXmTvvZIQ0vpv098FH/ZTGt4sCwb1bmRZ3UZLhBcxVc/sjuEW/sQ6pbQHkjrXIR5bxXzGNUxYpcGjrp9HGF+In0BAc+Ds/A0H142e1rgtcX8LH2pbG8URJSQ==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 +mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcyhttp://www.okta.com/exk91cb99lKkKSYoy0h7ufwWUjecX6I/aQb4WW9P9ZMLG3C8hN6LaZyyb/EATIs=jKtNBzxAL67ssuzWkkbf0yzqRyZ51y2JjBQ9C6bW8io/JOYQB2v7Bix7Eu/RjJslO7OBqD+3tPrK7ZBOy2+LFuAh3cDNa3U5NhO0raLrn/2YoJXfjj3XX3hyQv6GVxo0EY1KJNXOzWxjp9RVDpHslPTIL1yDC/oy0Mlzxu6pXBEerz9J2/Caenq66Skb5/DAT8FvrJ2s1bxuMagShs3APhC1hD8mvktZ+ZcN8ujs2SebteGK4IoOCx+e8+v2CyycBv1l5l+v5I+D2HnbAw4LfvHnW4rZOJT2AvoI47p1YBK1qDsJutG3jUPKy4Yx5YF73Xi1oytr+rrHyx/lfFPd2A==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 +mVy9Fgb4PA2N3hPeD7mBmGGp7CfDbGcyeric.chiang+okta@coreos.comhttp://localhost:5556/dex/callbackurn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport diff --git a/connector/saml/types.go b/connector/saml/types.go new file mode 100644 index 00000000..7c1d89be --- /dev/null +++ b/connector/saml/types.go @@ -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"` +} diff --git a/glide.yaml b/glide.yaml index 05d6ec42..3e7d13e6 100644 --- a/glide.yaml +++ b/glide.yaml @@ -131,3 +131,11 @@ import: version: v0.11.0 - package: golang.org/x/sys/unix version: 833a04a10549a95dc34458c195cbad61bbb6cb4d + +# XML signature validation for SAML connector +- package: github.com/russellhaering/goxmldsig + version: d9f653eb27ee8b145f7d5a45172e81a93def0860 +- package: github.com/beevik/etree + version: 4cd0dd976db869f817248477718071a28e978df0 +- package: github.com/jonboulle/clockwork + version: bcac9884e7502bb2b474c0339d889cb981a2f27f