forked from mystiq/dex
connector: add a SAML connector
This commit is contained in:
parent
ec9d1607b2
commit
31dfb54b6f
7 changed files with 683 additions and 0 deletions
|
@ -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"`
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue