diff --git a/cli/prepare.go b/cli/prepare.go index d8cc300ee6..f46d78d2e9 100644 --- a/cli/prepare.go +++ b/cli/prepare.go @@ -110,7 +110,7 @@ To setup kolide infrastructure, use one of the available commands. Enabled: &enabled, Admin: &isAdmin, } - svc, err := service.NewService(ds, pubsub.NewInmemQueryResults(), kitlog.NewNopLogger(), config, nil, clock.C, nil) + svc, err := service.NewService(ds, pubsub.NewInmemQueryResults(), kitlog.NewNopLogger(), config, nil, clock.C, nil, nil) if err != nil { initFatal(err, "creating service") } diff --git a/cli/serve.go b/cli/serve.go index a8bb6c6b72..d9e7e6ddaa 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "io/ioutil" "net/http" "net/url" "os" @@ -22,6 +23,7 @@ import ( "github.com/kolide/kolide/server/mail" "github.com/kolide/kolide/server/pubsub" "github.com/kolide/kolide/server/service" + "github.com/kolide/kolide/server/sso" "github.com/kolide/kolide/server/version" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -136,8 +138,9 @@ the way that the kolide server works. var resultStore kolide.QueryResultStore redisPool := pubsub.NewRedisPool(config.Redis.Address, config.Redis.Password) resultStore = pubsub.NewRedisQueryResults(redisPool) + ssoSessionStore := sso.NewSessionStore(redisPool) - svc, err := service.NewService(ds, resultStore, logger, config, mailService, clock.C, licenseService) + svc, err := service.NewService(ds, resultStore, logger, config, mailService, clock.C, licenseService, ssoSessionStore) if err != nil { initFatal(err, "initializing service") } @@ -204,6 +207,22 @@ the way that the kolide server works. r.Handle("/metrics", prometheus.InstrumentHandler("metrics", promhttp.Handler())) r.Handle("/api/", apiHandler) r.Handle("/", frontendHandler) + if path, ok := os.LookupEnv("KOLIDE_TEST_PAGE_PATH"); ok { + // test that we can load this + _, err := ioutil.ReadFile(path) + if err != nil { + initFatal(err, "loading KOLIDE_TEST_PAGE_PATH") + } + r.HandleFunc("/test", func(rw http.ResponseWriter, req *http.Request) { + testPage, err := ioutil.ReadFile(path) + if err != nil { + rw.WriteHeader(http.StatusNotFound) + return + } + rw.Write(testPage) + rw.WriteHeader(http.StatusOK) + }) + } if debug { // Add debug endpoints with a random diff --git a/docs/third-party/licenses.md b/docs/third-party/licenses.md index f4a941e3ed..7418a708f4 100644 --- a/docs/third-party/licenses.md +++ b/docs/third-party/licenses.md @@ -364,6 +364,7 @@ Third-Party Licenses | [github.com/WatchBeam/clock](https://github.com/WatchBeam/clock) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/alecthomas/template](https://github.com/alecthomas/template) | [NewBSD](https://opensource.org/licenses/BSD-3-Clause) | | [github.com/alecthomas/units](https://github.com/alecthomas/units) | [MIT](https://opensource.org/licenses/MIT) | +| [github.com/beevik/etree](https://github.com/beevik/etree) | [FreeBSD](https://opensource.org/licenses/BSD-2-Clause) | | [github.com/beorn7/perks](https://github.com/beorn7/perks) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/davecgh/go-spew](https://github.com/davecgh/go-spew) | [ISC](https://opensource.org/licenses/ISC) | | [github.com/dgrijalva/jwt-go](https://github.com/dgrijalva/jwt-go) | [MIT](https://opensource.org/licenses/MIT) | @@ -385,6 +386,7 @@ Third-Party Licenses | [github.com/igm/sockjs-go](https://github.com/igm/sockjs-go) | [NewBSD](https://opensource.org/licenses/BSD-3-Clause) | | [github.com/inconshreveable/mousetrap](https://github.com/inconshreveable/mousetrap) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/jmoiron/sqlx](https://github.com/jmoiron/sqlx) | [MIT](https://opensource.org/licenses/MIT) | +| [github.com/jonboulle/clockwork](https://github.com/jonboulle/clockwork) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/jordan-wright/email](https://github.com/jordan-wright/email) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/kolide/goose](https://github.com/kolide/goose) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/kr/logfmt](https://github.com/kr/logfmt) | [MIT](https://opensource.org/licenses/MIT) | @@ -400,6 +402,8 @@ Third-Party Licenses | [github.com/prometheus/client_model](https://github.com/prometheus/client_model) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/prometheus/common](https://github.com/prometheus/common) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/prometheus/procfs](https://github.com/prometheus/procfs) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | +| [github.com/russellhaering/gosaml2](https://github.com/russellhaering/gosaml2) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | +| [github.com/russellhaering/goxmldsig](https://github.com/russellhaering/goxmldsig) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/ryanuber/go-license](https://github.com/ryanuber/go-license) | [MIT](https://opensource.org/licenses/MIT) | | [github.com/spf13/afero](https://github.com/spf13/afero) | [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) | | [github.com/spf13/cast](https://github.com/spf13/cast) | [MIT](https://opensource.org/licenses/MIT) | diff --git a/glide.lock b/glide.lock index 154d637c32..f07b0b7a91 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ -hash: 1baa8047c7c6da3ca484e0bfebd881a17181a76ccccbddb2cff087c3f6de97c9 -updated: 2017-04-05T15:21:30.066640809-07:00 +hash: 7526b737524d134e1aa305fdae1f05ee92ff88c9d5b884b04d0aefcdb5ead7f6 +updated: 2017-05-02T11:10:06.237517785-05:00 imports: - name: github.com/alecthomas/template version: a0175ee3bccc567396460bf5acd36800cb10c49c @@ -7,6 +7,8 @@ imports: - parse - name: github.com/alecthomas/units version: 2efee857e7cfd4f3d0138cc3cbb1b4966962b93a +- name: github.com/beevik/etree + version: cda1c0026246bd095961ef9a3c430e50d0e80fba - name: github.com/beorn7/perks version: 4c0e84591b9aa9e6dcfdf3e020114cd81f89d5f9 subpackages: @@ -78,6 +80,8 @@ imports: version: 5f97679e23f75f42b265fec8d3bdb1c8de90b79d subpackages: - reflectx +- name: github.com/jonboulle/clockwork + version: bcac9884e7502bb2b474c0339d889cb981a2f27f - name: github.com/jordan-wright/email version: fd703108daeb23d77c124d12978e9b6c4f28f034 - name: github.com/kolide/goose @@ -121,6 +125,15 @@ imports: - model - name: github.com/prometheus/procfs version: 1878d9fbb537119d24b21ca07effd591627cd160 +- name: github.com/russellhaering/gosaml2 + version: 6074095354a9bead88f041558b2150ce47676371 + subpackages: + - types +- name: github.com/russellhaering/goxmldsig + version: eaac44c63fe007124f8f6255b09febc906784981 + subpackages: + - etreeutils + - types - name: github.com/ryanuber/go-license version: 027a317812857d68bb611443b5c08bbe96e88598 - name: github.com/spf13/afero @@ -166,7 +179,7 @@ imports: - transform - unicode/norm - name: gopkg.in/alecthomas/kingpin.v2 - version: e9044be3ab2a8e11d4e1f418d12f0790d57e8d70 + version: 7f0871f2e17818990e4eed73f9b5c2f429501228 - name: gopkg.in/go-playground/validator.v8 version: 5f57d2222ad794d0dffb07e664ea05e2ee07d60c - name: gopkg.in/natefinch/lumberjack.v2 diff --git a/glide.yaml b/glide.yaml index 242acacbd1..4c7d2cee38 100644 --- a/glide.yaml +++ b/glide.yaml @@ -59,7 +59,7 @@ import: - redis - package: github.com/jmoiron/sqlx - package: github.com/kolide/goose - version: master + version: 4a7848793d4402d338de853019ad72b9a4b3e68e - package: github.com/VividCortex/mysqlerr version: master - package: github.com/go-kit/kit @@ -70,3 +70,4 @@ import: - package: github.com/e-dard/netbug - package: github.com/spf13/cast version: ~1.0.0 +- package: github.com/russellhaering/gosaml2 diff --git a/server/datastore/datastore_identity_providers_test.go b/server/datastore/datastore_identity_providers_test.go deleted file mode 100644 index 572b70f1af..0000000000 --- a/server/datastore/datastore_identity_providers_test.go +++ /dev/null @@ -1,81 +0,0 @@ -package datastore - -import ( - "testing" - - "github.com/kolide/kolide/server/kolide" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func testIdentityProvider(t *testing.T, ds kolide.Datastore) { - if ds.Name() == "inmem" { - t.Skip("imem is being deprecated") - } - idps := []*kolide.IdentityProvider{ - &kolide.IdentityProvider{ - SingleSignOnURL: "https://idp1.com/sso", - IssuerURI: "http://idp1.com/issuer/xyz123", - Certificate: "DEADBEEFXXXXX12344", - Name: "idp1", - ImageURL: "https://idp1.com/logo.png", - }, - &kolide.IdentityProvider{ - SingleSignOnURL: "https://idp2.com/sso", - IssuerURI: "http://idp2.com/issuer/xyz123", - Certificate: "DEADBEEFXXXXX12344", - Name: "idp2", - ImageURL: "https://idp2.com/logo.png", - }, - &kolide.IdentityProvider{ - SingleSignOnURL: "https://idp3.com/sso", - IssuerURI: "http://idp3.com/issuer/xyz123", - Certificate: "DEADBEEFXXXXX12344", - Name: "idp3", - ImageURL: "https://idp3.com/logo.png", - }, - } - var err error - for i, idp := range idps { - idps[i], err = ds.NewIdentityProvider(*idp) - require.Nil(t, err) - require.NotEqual(t, 0, idp.ID, "id assignment") - } - // duplicate name not allowed - _, err = ds.NewIdentityProvider(*idps[0]) - assert.NotNil(t, err) - // test get - idp, err := ds.IdentityProvider(idps[0].ID) - require.Nil(t, err) - require.NotNil(t, idp) - require.Equal(t, "idp1", idp.Name) - // test update - idp.ImageURL = "https://idpnew.com/logo.png" - idp.SingleSignOnURL = "https://idpnew.com/sso" - idp.IssuerURI = "https://idpnew.com/issuer" - idp.Certificate = "123456789" - idp.Name = "idpnew" - err = ds.SaveIdentityProvider(*idp) - require.Nil(t, err) - upd, err := ds.IdentityProvider(idp.ID) - require.Nil(t, err) - require.NotNil(t, upd) - assert.Equal(t, idp.ImageURL, upd.ImageURL) - assert.Equal(t, idp.SingleSignOnURL, upd.SingleSignOnURL) - assert.Equal(t, idp.IssuerURI, upd.IssuerURI) - assert.Equal(t, idp.Certificate, upd.Certificate) - assert.Equal(t, idp.Name, upd.Name) - // test list - results, err := ds.ListIdentityProviders() - require.Nil(t, err) - require.NotNil(t, results) - assert.Len(t, results, 3) - // test delete - err = ds.DeleteIdentityProvider(results[0].ID) - assert.Nil(t, err) - err = ds.DeleteIdentityProvider(results[0].ID) - assert.NotNil(t, err) - results, err = ds.ListIdentityProviders() - require.Nil(t, err) - assert.NotNil(t, results, 2) -} diff --git a/server/datastore/datastore_test.go b/server/datastore/datastore_test.go index beb5501733..040d2196d7 100644 --- a/server/datastore/datastore_test.go +++ b/server/datastore/datastore_test.go @@ -80,5 +80,4 @@ var testFunctions = [...]func(*testing.T, kolide.Datastore){ testCountHostsInTargets, testHostStatus, testResetOptions, - testIdentityProvider, } diff --git a/server/datastore/inmem/identity_providers.go b/server/datastore/inmem/identity_providers.go deleted file mode 100644 index 04b4a24239..0000000000 --- a/server/datastore/inmem/identity_providers.go +++ /dev/null @@ -1,25 +0,0 @@ -package inmem - -import ( - "github.com/kolide/kolide/server/kolide" -) - -func (d *Datastore) NewIdentityProvider(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) { - panic("inmem is being deprecated") -} - -func (d *Datastore) SaveIdentityProvider(idb kolide.IdentityProvider) error { - panic("inmem is being deprecated") -} - -func (d *Datastore) IdentityProvider(id uint) (*kolide.IdentityProvider, error) { - panic("inmem is being deprecated") -} - -func (d *Datastore) DeleteIdentityProvider(id uint) error { - panic("inmem is being deprecated") -} - -func (d *Datastore) ListIdentityProviders() ([]kolide.IdentityProvider, error) { - panic("inmem is being deprecated") -} diff --git a/server/datastore/mysql/app_configs.go b/server/datastore/mysql/app_configs.go index 3badcce7f3..68cd36cf35 100644 --- a/server/datastore/mysql/app_configs.go +++ b/server/datastore/mysql/app_configs.go @@ -28,44 +28,58 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error { // exist, a row will be created with INSERT, if a row does exist the key // will be violate uniqueness constraint and an UPDATE will occur insertStatement := ` - INSERT INTO app_configs ( - id, - org_name, - org_logo_url, - kolide_server_url, - osquery_enroll_secret, - smtp_configured, - smtp_sender_address, - smtp_server, - smtp_port, - smtp_authentication_type, - smtp_enable_ssl_tls, - smtp_authentication_method, - smtp_domain, - smtp_user_name, - smtp_password, - smtp_verify_ssl_certs, - smtp_enable_start_tls - ) - VALUES( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) - ON DUPLICATE KEY UPDATE - org_name = VALUES(org_name), - org_logo_url = VALUES(org_logo_url), - kolide_server_url = VALUES(kolide_server_url), - osquery_enroll_secret = VALUES(osquery_enroll_secret), - smtp_configured = VALUES(smtp_configured), - smtp_sender_address = VALUES(smtp_sender_address), - smtp_server = VALUES(smtp_server), - smtp_port = VALUES(smtp_port), - smtp_authentication_type = VALUES(smtp_authentication_type), - smtp_enable_ssl_tls = VALUES(smtp_enable_ssl_tls), - smtp_authentication_method = VALUES(smtp_authentication_method), - smtp_domain = VALUES(smtp_domain), - smtp_user_name = VALUES(smtp_user_name), - smtp_password = VALUES(smtp_password), - smtp_verify_ssl_certs = VALUES(smtp_verify_ssl_certs), - smtp_enable_start_tls = VALUES(smtp_enable_start_tls) - ` + INSERT INTO app_configs ( + id, + org_name, + org_logo_url, + kolide_server_url, + osquery_enroll_secret, + smtp_configured, + smtp_sender_address, + smtp_server, + smtp_port, + smtp_authentication_type, + smtp_enable_ssl_tls, + smtp_authentication_method, + smtp_domain, + smtp_user_name, + smtp_password, + smtp_verify_ssl_certs, + smtp_enable_start_tls, + entity_id, + issuer_uri, + idp_image_url, + metadata, + metadata_url, + idp_name, + enable_sso + ) + VALUES( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) + ON DUPLICATE KEY UPDATE + org_name = VALUES(org_name), + org_logo_url = VALUES(org_logo_url), + kolide_server_url = VALUES(kolide_server_url), + osquery_enroll_secret = VALUES(osquery_enroll_secret), + smtp_configured = VALUES(smtp_configured), + smtp_sender_address = VALUES(smtp_sender_address), + smtp_server = VALUES(smtp_server), + smtp_port = VALUES(smtp_port), + smtp_authentication_type = VALUES(smtp_authentication_type), + smtp_enable_ssl_tls = VALUES(smtp_enable_ssl_tls), + smtp_authentication_method = VALUES(smtp_authentication_method), + smtp_domain = VALUES(smtp_domain), + smtp_user_name = VALUES(smtp_user_name), + smtp_password = VALUES(smtp_password), + smtp_verify_ssl_certs = VALUES(smtp_verify_ssl_certs), + smtp_enable_start_tls = VALUES(smtp_enable_start_tls), + entity_id = VALUES(entity_id), + issuer_uri = VALUES(issuer_uri), + idp_image_url = VALUES(idp_image_url), + metadata = VALUES(metadata), + metadata_url = VALUES(metadata_url), + idp_name = VALUES(idp_name), + enable_sso = VALUES(enable_sso) + ` _, err := d.db.Exec(insertStatement, info.OrgName, @@ -84,6 +98,13 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error { info.SMTPPassword, info.SMTPVerifySSLCerts, info.SMTPEnableStartTLS, + info.EntityID, + info.IssuerURI, + info.IDPImageURL, + info.Metadata, + info.MetadataURL, + info.IDPName, + info.EnableSSO, ) return err diff --git a/server/datastore/mysql/identity_providers.go b/server/datastore/mysql/identity_providers.go deleted file mode 100644 index 7120c09505..0000000000 --- a/server/datastore/mysql/identity_providers.go +++ /dev/null @@ -1,91 +0,0 @@ -package mysql - -import ( - "database/sql" - - "github.com/kolide/kolide/server/kolide" - "github.com/pkg/errors" -) - -func (d *Datastore) NewIdentityProvider(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) { - query := ` - INSERT INTO identity_providers ( - sso_url, - issuer_uri, - cert, - name, - image_url - ) - VALUES ( ?, ?, ?, ?, ? ) - ` - result, err := d.db.Exec(query, idp.SingleSignOnURL, idp.IssuerURI, idp.Certificate, idp.Name, idp.ImageURL) - if err != nil { - return nil, errors.Wrap(err, "creating identity provider") - } - id, err := result.LastInsertId() - if err != nil { - return nil, errors.Wrap(err, "retrieving id for new identity provider") - } - idp.ID = uint(id) - return &idp, nil -} - -func (d *Datastore) SaveIdentityProvider(idp kolide.IdentityProvider) error { - query := ` - UPDATE identity_providers - SET - sso_url = ?, - issuer_uri = ?, - cert = ?, - name = ?, - image_url = ? - WHERE id = ? - ` - result, err := d.db.Exec(query, idp.SingleSignOnURL, idp.IssuerURI, idp.Certificate, - idp.Name, idp.ImageURL, idp.ID) - if err != nil { - return errors.Wrap(err, "updating identity provider") - } - rows, err := result.RowsAffected() - if err != nil { - return errors.Wrap(err, "fetching updated row count for identity provider") - } - if rows == 0 { - return notFound("IdentityProvider").WithID(idp.ID) - } - return nil -} - -func (d *Datastore) IdentityProvider(id uint) (*kolide.IdentityProvider, error) { - query := ` - SELECT * - FROM identity_providers - WHERE id = ? AND NOT deleted - ` - var idp kolide.IdentityProvider - err := d.db.Get(&idp, query, id) - if err == sql.ErrNoRows { - return nil, notFound("IdentityProvider").WithID(id) - } - if err != nil { - return nil, errors.Wrap(err, "selecting identity provider") - } - return &idp, nil -} - -func (d *Datastore) DeleteIdentityProvider(id uint) error { - return d.deleteEntity("identity_providers", id) -} - -func (d *Datastore) ListIdentityProviders() ([]kolide.IdentityProvider, error) { - query := ` - SELECT * - FROM identity_providers - WHERE NOT deleted - ` - var idps []kolide.IdentityProvider - if err := d.db.Select(&idps, query); err != nil { - return nil, errors.Wrap(err, "listing identity providers") - } - return idps, nil -} diff --git a/server/datastore/mysql/migrations/tables/20170411155225_CreateTableIdentityProviders.go b/server/datastore/mysql/migrations/tables/20170411155225_CreateTableIdentityProviders.go deleted file mode 100644 index 9339db33cb..0000000000 --- a/server/datastore/mysql/migrations/tables/20170411155225_CreateTableIdentityProviders.go +++ /dev/null @@ -1,34 +0,0 @@ -package tables - -import ( - "database/sql" -) - -func init() { - MigrationClient.AddMigration(Up_20170411155225, Down_20170411155225) -} - -func Up_20170411155225(tx *sql.Tx) error { - statement := - "CREATE TABLE `identity_providers` ( " + - "`id` int(11) NOT NULL AUTO_INCREMENT, " + - "`sso_url` varchar(1024) NOT NULL DEFAULT '', " + - "`issuer_uri` varchar(1024) NOT NULL DEFAULT '', " + - "`cert` text NOT NULL, " + - "`name` varchar(128) NOT NULL DEFAULT '', " + - "`image_url` varchar(1024) NOT NULL DEFAULT '', " + - "`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, " + - "`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " + - "`deleted_at` timestamp NULL DEFAULT NULL, " + - "`deleted` tinyint(1) NOT NULL DEFAULT FALSE, " + - "PRIMARY KEY (`id`), " + - "UNIQUE KEY `idx_unique_identity_providers_name` (`name`) USING BTREE " + - ") ENGINE=InnoDB DEFAULT CHARSET=utf8;" - _, err := tx.Exec(statement) - return err -} - -func Down_20170411155225(tx *sql.Tx) error { - _, err := tx.Exec("DROP TABLE IF EXISTS `identity_providers`;") - return err -} diff --git a/server/datastore/mysql/migrations/tables/20170502143928_AddIDPColsToAppConfig.go b/server/datastore/mysql/migrations/tables/20170502143928_AddIDPColsToAppConfig.go new file mode 100644 index 0000000000..576772e5a1 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20170502143928_AddIDPColsToAppConfig.go @@ -0,0 +1,37 @@ +package tables + +import ( + "database/sql" +) + +func init() { + MigrationClient.AddMigration(Up_20170502143928, Down_20170502143928) +} + +func Up_20170502143928(tx *sql.Tx) error { + _, err := tx.Exec( + "ALTER TABLE `kolide`.`app_configs` " + + "ADD COLUMN `entity_id` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `osquery_enroll_secret`, " + + "ADD COLUMN `issuer_uri` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `entity_id`, " + + "ADD COLUMN `idp_image_url` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `issuer_uri`, " + + "ADD COLUMN `metadata` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL AFTER `idp_image_url`, " + + "ADD COLUMN `metadata_url` VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `metadata`, " + + "ADD COLUMN `idp_name` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' AFTER `metadata_url`, " + + "ADD COLUMN `enable_sso` TINYINT(1) NOT NULL DEFAULT FALSE AFTER `idp_name`; ", + ) + return err +} + +func Down_20170502143928(tx *sql.Tx) error { + _, err := tx.Exec( + "ALTER TABLE `kolide`.`app_configs` " + + "DROP COLUMN `entity_id`, " + + "DROP COLUMN `issuer_uri`, " + + "DROP COLUMN `idp_image_url`, " + + "DROP COLUMN `metadata`, " + + "DROP COLUMN `metadata_url`, " + + "DROP COLUMN `idp_name`, " + + "DROP COLUMN `enable_sso`;", + ) + return err +} diff --git a/server/kolide/app.go b/server/kolide/app.go index 7c5655352c..5b3a7b7b36 100644 --- a/server/kolide/app.go +++ b/server/kolide/app.go @@ -112,6 +112,21 @@ type AppConfig struct { SMTPVerifySSLCerts bool `db:"smtp_verify_ssl_certs"` // SMTPEnableStartTLS detects of TLS is enabled on mail server and starts to use it (default true) SMTPEnableStartTLS bool `db:"smtp_enable_start_tls"` + // EntityID is a uri that identifies this service provider + EntityID string `db:"entity_id"` + // IssuerURI is the uri that identifies the identity provider + IssuerURI string `db:"issuer_uri"` + // IDPImageURL is a link to a logo or other image that is used for UX + IDPImageURL string `db:"idp_image_url"` + // Metadata contains IDP metadata XML + Metadata string `db:"metadata"` + // MetadataURL is a URL provided by the IDP which can be used to download + // metadata + MetadataURL string `db:"metadata_url"` + // IDPName is a human freindly name for the IDP + IDPName string `db:"idp_name"` + // EnableSSO flag to determine whether or not to enable SSO + EnableSSO bool `db:"enable_sso"` } // ModifyAppConfigRequest contains application configuration information @@ -124,6 +139,25 @@ type ModifyAppConfigRequest struct { AppConfig AppConfig `json:"app_config"` } +// SSOSettingsPayload wire format for SSO settings +type SSOSettingsPayload struct { + // EntityID is a uri that identifies this service provider + EntityID *string `json:"entity_id"` + // IssuerURI is the uri that identifies the identity provider + IssuerURI *string `json:"issuer_uri"` + // IDPImageURL is a link to a logo or other image that is used for UX + IDPImageURL *string `json:"idp_image_url"` + // Metadata contains IDP metadata XML + Metadata *string `json:"metadata"` + // MetadataURL is a URL provided by the IDP which can be used to download + // metadata + MetadataURL *string `json:"metadata_url"` + // IDPName is a human freindly name for the IDP + IDPName *string `json:"idp_name"` + // EnableSSO flag to determine whether or not to enable SSO + EnableSSO *bool `json:"enable_sso"` +} + // SMTPSettingsPayload is part of the AppConfigPayload which defines the wire representation // of the app config endpoints type SMTPSettingsPayload struct { @@ -165,6 +199,8 @@ type AppConfigPayload struct { SMTPSettings *SMTPSettingsPayload `json:"smtp_settings"` // SMTPTest is a flag that if set will cause the server to test email configuration SMTPTest *bool `json:"smtp_test,omitempty"` + // SSOSettings single sign settings + SSOSettings *SSOSettingsPayload `json:"sso_settings"` } // OrgInfo contains general info about the organization using Kolide. diff --git a/server/kolide/datastore.go b/server/kolide/datastore.go index 791eff87e5..de43e5e70c 100644 --- a/server/kolide/datastore.go +++ b/server/kolide/datastore.go @@ -19,7 +19,6 @@ type Datastore interface { FileIntegrityMonitoringStore YARAStore LicenseStore - IdentityProviderStore Name() string Drop() error // MigrateTables creates and migrates the table schemas diff --git a/server/kolide/identity_providers.go b/server/kolide/identity_providers.go deleted file mode 100644 index 271bac19a6..0000000000 --- a/server/kolide/identity_providers.go +++ /dev/null @@ -1,62 +0,0 @@ -package kolide - -import "context" - -// IdentityProviderStore exposes methods to persist IdentityProviders. -// IdentityProvider is an entity used for single sign on. -type IdentityProviderStore interface { - // NewIdentityProvider creates a new IdentityProvider. - NewIdentityProvider(idp IdentityProvider) (*IdentityProvider, error) - // SaveIdentityProvider saves changes to an IdentityProvider. - SaveIdentityProvider(idb IdentityProvider) error - // IdentityProvider retrieves an IdentityProvider identified by id. - IdentityProvider(id uint) (*IdentityProvider, error) - // DeleteIdentityProvider soft deletes an IdentityProvider - DeleteIdentityProvider(id uint) error - // ListIdentityProviders returns all IdentityProvider entities - ListIdentityProviders() ([]IdentityProvider, error) -} - -// IdentityProvider represents a SAML identity provider. -type IdentityProvider struct { - UpdateCreateTimestamps - DeleteFields - ID uint `json:"id"` - // SingleSignOnURL is the URL for the identity provider. - SingleSignOnURL string `json:"sso_url" db:"sso_url"` - // IssuerURI identity provider issuer - IssuerURI string `json:"issuer_uri" db:"issuer_uri"` - // Certificate is the identity provider's public certificate. - Certificate string `json:"cert" db:"cert"` - // Name is the descriptive name for the identity provider that will - // be displayed in the UI. - Name string `json:"name"` - // ImageURL is a link to an icon that will be displayed on the SSO - // button for a particular identity provider. - ImageURL string `json:"image_url" db:"image_url"` -} - -// IdentityProviderPayload user to update one or more fields of an IdentityProvider -// by supplying values that correspond to fields that will be changed. -type IdentityProviderPayload struct { - SingleSignOnURL *string `json:"sso_url"` - IssuerURI *string `json:"issuer_uri"` - Certificate *string `json:"cert"` - Name *string `json:"name"` - ImageURL *string `json:"image_url"` -} - -// IdentityProviderService exposes methods to manage IdentityProvider entities -type IdentityProviderService interface { - // NewIdentityProvider creates a IdentityProvider - NewIdentityProvider(ctx context.Context, payload IdentityProviderPayload) (*IdentityProvider, error) - // SaveIdentityProvider is used to modify an existing IdentityProvider. Nonnil - // fields in the payload argument will be changed for an existing IdentityProvider - ModifyIdentityProvider(ctx context.Context, id uint, payload IdentityProviderPayload) (*IdentityProvider, error) - // GetIdentityProvider retrieves an IdentityProvider given it's ID. - GetIdentityProvider(ctx context.Context, id uint) (*IdentityProvider, error) - // DeleteIdentityProvider removes an IdentityProvider - DeleteIdentityProvider(ctx context.Context, id uint) error - // ListIdentityProviders returns a list of all IdentityProvider entities - ListIdentityProviders(ctx context.Context, id uint) ([]IdentityProvider, error) -} diff --git a/server/kolide/sessions.go b/server/kolide/sessions.go index 0271d53bbe..c5039153da 100644 --- a/server/kolide/sessions.go +++ b/server/kolide/sessions.go @@ -32,10 +32,21 @@ type SessionStore interface { MarkSessionAccessed(session *Session) error } +type Auth interface { + UserID() string + RequestID() string +} + type SessionService interface { - // SSOLogin handles creating a session for a user who is authenticated by - // a SAML identity provider, returning the user and a token on success - SSOLogin(ctx context.Context, userId string) (*User, string, error) + // InitiateSSO is used to initiate an SSO session and returns a URL that + // can be used in a redirect to the IDP. + // Arguments: redirectURL is the URL of the protected resource that the user + // was trying to access when they were promted to log in. + InitiateSSO(ctx context.Context, redirectURL string) (string, error) + // CallbackSSO handles the IDP response. The original URL the viewer attempted + // to access is returned from this function so we can redirect back to the front end and + // load the page the viewer originally attempted to access when prompted for login. + CallbackSSO(ctx context.Context, auth Auth) (*SSOSession, error) Login(ctx context.Context, username, password string) (user *User, token string, err error) Logout(ctx context.Context) (err error) DestroySession(ctx context.Context) (err error) @@ -46,6 +57,11 @@ type SessionService interface { DeleteSession(ctx context.Context, id uint) (err error) } +type SSOSession struct { + Token string + RedirectURL string +} + // Session is the model object which represents what an active session is type Session struct { CreateTimestamp diff --git a/server/mock/datastore.go b/server/mock/datastore.go index 36b615a188..a5800c9768 100644 --- a/server/mock/datastore.go +++ b/server/mock/datastore.go @@ -9,7 +9,6 @@ package mock //go:generate mockimpl -o datastore_options.go "s *OptionStore" "kolide.OptionStore" //go:generate mockimpl -o datastore_packs.go "s *PackStore" "kolide.PackStore" //go:generate mockimpl -o datastore_hosts.go "s *HostStore" "kolide.HostStore" -//go:generate mockimpl -o datastore_identity_providers.go "s *IdentityProviderStore" "kolide.IdentityProviderStore" import "github.com/kolide/kolide/server/kolide" @@ -33,7 +32,6 @@ type Store struct { OptionStore PackStore UserStore - IdentityProviderStore } func (m *Store) Drop() error { diff --git a/server/mock/datastore_hosts.go b/server/mock/datastore_hosts.go index 8200490b97..20998970ba 100644 --- a/server/mock/datastore_hosts.go +++ b/server/mock/datastore_hosts.go @@ -26,10 +26,10 @@ type AuthenticateHostFunc func(nodeKey string) (*kolide.Host, error) type MarkHostSeenFunc func(host *kolide.Host, t time.Time) error -type GenerateHostStatusStatisticsFunc func(now time.Time) (online uint, offline uint, mia uint, new uint, err error) - type SearchHostsFunc func(query string, omit ...uint) ([]*kolide.Host, error) +type GenerateHostStatusStatisticsFunc func(now time.Time) (online uint, offline uint, mia uint, new uint, err error) + type DistributedQueriesForHostFunc func(host *kolide.Host) (map[uint]string, error) type HostStore struct { @@ -57,12 +57,12 @@ type HostStore struct { MarkHostSeenFunc MarkHostSeenFunc MarkHostSeenFuncInvoked bool - GenerateHostStatusStatisticsFunc GenerateHostStatusStatisticsFunc - GenerateHostStatusStatisticsFuncInvoked bool - SearchHostsFunc SearchHostsFunc SearchHostsFuncInvoked bool + GenerateHostStatusStatisticsFunc GenerateHostStatusStatisticsFunc + GenerateHostStatusStatisticsFuncInvoked bool + DistributedQueriesForHostFunc DistributedQueriesForHostFunc DistributedQueriesForHostFuncInvoked bool } @@ -107,16 +107,16 @@ func (s *HostStore) MarkHostSeen(host *kolide.Host, t time.Time) error { return s.MarkHostSeenFunc(host, t) } -func (s *HostStore) GenerateHostStatusStatistics(now time.Time) (online uint, offline uint, mia uint, new uint, err error) { - s.GenerateHostStatusStatisticsFuncInvoked = true - return s.GenerateHostStatusStatisticsFunc(now) -} - func (s *HostStore) SearchHosts(query string, omit ...uint) ([]*kolide.Host, error) { s.SearchHostsFuncInvoked = true return s.SearchHostsFunc(query, omit...) } +func (s *HostStore) GenerateHostStatusStatistics(now time.Time) (online uint, offline uint, mia uint, new uint, err error) { + s.GenerateHostStatusStatisticsFuncInvoked = true + return s.GenerateHostStatusStatisticsFunc(now) +} + func (s *HostStore) DistributedQueriesForHost(host *kolide.Host) (map[uint]string, error) { s.DistributedQueriesForHostFuncInvoked = true return s.DistributedQueriesForHostFunc(host) diff --git a/server/mock/datastore_identity_providers.go b/server/mock/datastore_identity_providers.go deleted file mode 100644 index f906afcb1b..0000000000 --- a/server/mock/datastore_identity_providers.go +++ /dev/null @@ -1,59 +0,0 @@ -// Automatically generated by mockimpl. DO NOT EDIT! - -package mock - -import "github.com/kolide/kolide/server/kolide" - -var _ kolide.IdentityProviderStore = (*IdentityProviderStore)(nil) - -type NewIdentityProviderFunc func(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) - -type SaveIdentityProviderFunc func(idb kolide.IdentityProvider) error - -type IdentityProviderFunc func(id uint) (*kolide.IdentityProvider, error) - -type DeleteIdentityProviderFunc func(id uint) error - -type ListIdentityProvidersFunc func() ([]kolide.IdentityProvider, error) - -type IdentityProviderStore struct { - NewIdentityProviderFunc NewIdentityProviderFunc - NewIdentityProviderFuncInvoked bool - - SaveIdentityProviderFunc SaveIdentityProviderFunc - SaveIdentityProviderFuncInvoked bool - - IdentityProviderFunc IdentityProviderFunc - IdentityProviderFuncInvoked bool - - DeleteIdentityProviderFunc DeleteIdentityProviderFunc - DeleteIdentityProviderFuncInvoked bool - - ListIdentityProvidersFunc ListIdentityProvidersFunc - ListIdentityProvidersFuncInvoked bool -} - -func (s *IdentityProviderStore) NewIdentityProvider(idp kolide.IdentityProvider) (*kolide.IdentityProvider, error) { - s.NewIdentityProviderFuncInvoked = true - return s.NewIdentityProviderFunc(idp) -} - -func (s *IdentityProviderStore) SaveIdentityProvider(idb kolide.IdentityProvider) error { - s.SaveIdentityProviderFuncInvoked = true - return s.SaveIdentityProviderFunc(idb) -} - -func (s *IdentityProviderStore) IdentityProvider(id uint) (*kolide.IdentityProvider, error) { - s.IdentityProviderFuncInvoked = true - return s.IdentityProviderFunc(id) -} - -func (s *IdentityProviderStore) DeleteIdentityProvider(id uint) error { - s.DeleteIdentityProviderFuncInvoked = true - return s.DeleteIdentityProviderFunc(id) -} - -func (s *IdentityProviderStore) ListIdentityProviders() ([]kolide.IdentityProvider, error) { - s.ListIdentityProvidersFuncInvoked = true - return s.ListIdentityProvidersFunc() -} diff --git a/server/service/endpoint_appconfig.go b/server/service/endpoint_appconfig.go index 5463e63f6b..ef505d7bc3 100644 --- a/server/service/endpoint_appconfig.go +++ b/server/service/endpoint_appconfig.go @@ -17,6 +17,7 @@ type appConfigResponse struct { OrgInfo *kolide.OrgInfo `json:"org_info,omitemtpy"` ServerSettings *kolide.ServerSettings `json:"server_settings,omitempty"` SMTPSettings *kolide.SMTPSettingsPayload `json:"smtp_settings,omitempty"` + SSOSettings *kolide.SSOSettingsPayload `json:"sso_settings,omitempty"` Err error `json:"error,omitempty"` } @@ -33,12 +34,22 @@ func makeGetAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint { return nil, err } var smtpSettings *kolide.SMTPSettingsPayload + var ssoSettings *kolide.SSOSettingsPayload // only admin can see smtp settings if vc.IsAdmin() { smtpSettings = smtpSettingsFromAppConfig(config) if smtpSettings.SMTPPassword != nil { *smtpSettings.SMTPPassword = "********" } + ssoSettings = &kolide.SSOSettingsPayload{ + EntityID: &config.EntityID, + IssuerURI: &config.IssuerURI, + IDPImageURL: &config.IDPImageURL, + Metadata: &config.Metadata, + MetadataURL: &config.MetadataURL, + IDPName: &config.IDPName, + EnableSSO: &config.EnableSSO, + } } response := appConfigResponse{ OrgInfo: &kolide.OrgInfo{ @@ -50,6 +61,7 @@ func makeGetAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint { EnrollSecret: &config.EnrollSecret, }, SMTPSettings: smtpSettings, + SSOSettings: ssoSettings, } return response, nil } @@ -72,6 +84,15 @@ func makeModifyAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint { EnrollSecret: &config.EnrollSecret, }, SMTPSettings: smtpSettingsFromAppConfig(config), + SSOSettings: &kolide.SSOSettingsPayload{ + EntityID: &config.EntityID, + IssuerURI: &config.IssuerURI, + IDPImageURL: &config.IDPImageURL, + Metadata: &config.Metadata, + MetadataURL: &config.MetadataURL, + IDPName: &config.IDPName, + EnableSSO: &config.EnableSSO, + }, } if response.SMTPSettings.SMTPPassword != nil { *response.SMTPSettings.SMTPPassword = "********" diff --git a/server/service/endpoint_appconfig_test.go b/server/service/endpoint_appconfig_test.go index a2c84ae4ca..6fc60c7cd7 100644 --- a/server/service/endpoint_appconfig_test.go +++ b/server/service/endpoint_appconfig_test.go @@ -53,6 +53,11 @@ func testModifyAppConfig(t *testing.T, r *testResource) { SMTPEnableTLS: true, SMTPVerifySSLCerts: true, SMTPEnableStartTLS: true, + EnableSSO: true, + IDPName: "idpname", + Metadata: "metadataxxxxxx", + IssuerURI: "http://issuer.idp.com", + EntityID: "kolide", } payload := appConfigPayloadFromAppConfig(config) payload.SMTPTest = new(bool) @@ -76,6 +81,12 @@ func testModifyAppConfig(t *testing.T, r *testResource) { require.Nil(t, err) // verify email test succeeded assert.True(t, saved.SMTPConfigured) + // verify that SSO stuff was saved + assert.True(t, saved.EnableSSO) + assert.Equal(t, "idpname", saved.IDPName) + assert.Equal(t, "metadataxxxxxx", saved.Metadata) + assert.Equal(t, "http://issuer.idp.com", saved.IssuerURI) + assert.Equal(t, "kolide", saved.EntityID) } @@ -115,5 +126,13 @@ func appConfigPayloadFromAppConfig(config *kolide.AppConfig) *kolide.AppConfigPa KolideServerURL: &config.KolideServerURL, }, SMTPSettings: smtpSettingsFromAppConfig(config), + SSOSettings: &kolide.SSOSettingsPayload{ + EnableSSO: &config.EnableSSO, + IDPName: &config.IDPName, + Metadata: &config.Metadata, + MetadataURL: &config.MetadataURL, + IssuerURI: &config.IssuerURI, + EntityID: &config.EntityID, + }, } } diff --git a/server/service/endpoint_decorators.go b/server/service/endpoint_decorators.go index ecc5611e1e..bcd0cbcc38 100644 --- a/server/service/endpoint_decorators.go +++ b/server/service/endpoint_decorators.go @@ -60,6 +60,7 @@ func makeDeleteDecoratorEndpoint(svc kolide.Service) endpoint.Endpoint { return func(ctx context.Context, request interface{}) (interface{}, error) { r := request.(deleteDecoratorRequest) err := svc.DeleteDecorator(ctx, r.ID) + if err != nil { return deleteDecoratorResponse{Err: err}, nil } @@ -75,5 +76,6 @@ func makeModifyDecoratorEndpoint(svc kolide.Service) endpoint.Endpoint { return decoratorResponse{Err: err}, nil } return decoratorResponse{Decorator: dec}, nil + } } diff --git a/server/service/endpoint_decorators_test.go b/server/service/endpoint_decorators_test.go index f8b14a42a7..f3298b7682 100644 --- a/server/service/endpoint_decorators_test.go +++ b/server/service/endpoint_decorators_test.go @@ -157,7 +157,6 @@ func testNewDecoratorFailType(t *testing.T, r *testResource) { require.Nil(t, err) require.Len(t, errStruct.Errors, 1) assert.Equal(t, "invalid value, must be load, always, or interval", errStruct.Errors[0].Reason) - } func testNewDecoratorFailValidation(t *testing.T, r *testResource) { diff --git a/server/service/endpoint_sessions.go b/server/service/endpoint_sessions.go index 9402989092..c07b479ec1 100644 --- a/server/service/endpoint_sessions.go +++ b/server/service/endpoint_sessions.go @@ -1,7 +1,9 @@ package service import ( + "bytes" "context" + "html/template" "time" "github.com/go-kit/kit/endpoint" @@ -172,3 +174,74 @@ func makeDeleteSessionsForUserEndpoint(svc kolide.Service) endpoint.Endpoint { return deleteSessionsForUserResponse{}, nil } } + +type initiateSSORequest struct { + RelayURL string `json:"relay_url"` +} + +type initiateSSOResponse struct { + URL string `json:"url,omitempty"` + Err error `json:"error,omitempty"` +} + +func (r initiateSSOResponse) error() error { return r.Err } + +func makeInitiateSSOEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + req := request.(initiateSSORequest) + idProviderURL, err := svc.InitiateSSO(ctx, req.RelayURL) + if err != nil { + return initiateSSOResponse{Err: err}, nil + } + return initiateSSOResponse{URL: idProviderURL}, nil + } +} + +type callbackSSOResponse struct { + content string + Err error `json:"error,omitempty"` +} + +func (r callbackSSOResponse) error() error { return r.Err } + +// If html is present we return a web page +func (r callbackSSOResponse) html() string { return r.content } + +func makeCallbackSSOEndpoint(svc kolide.Service) endpoint.Endpoint { + return func(ctx context.Context, request interface{}) (interface{}, error) { + authResponse := request.(kolide.Auth) + session, err := svc.CallbackSSO(ctx, authResponse) + var resp callbackSSOResponse + if err != nil { + // redirect to login page on front end if there was some problem, + // errors should still be logged + session = &kolide.SSOSession{ + RedirectURL: "/login", + Token: "", + } + resp.Err = err + } + relayStateLoadPage := ` + + + Redirecting to Kolide... + + + ` + tmpl, err := template.New("relayStateLoader").Parse(relayStateLoadPage) + if err != nil { + return nil, err + } + var writer bytes.Buffer + err = tmpl.Execute(&writer, session) + if err != nil { + return nil, err + } + resp.content = writer.String() + return resp, nil + } +} diff --git a/server/service/handler.go b/server/service/handler.go index 6d32ffabaa..cb68af8c18 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -83,6 +83,8 @@ type KolideEndpoints struct { ChangeEmail endpoint.Endpoint UpdateLicense endpoint.Endpoint GetLicense endpoint.Endpoint + InitiateSSO endpoint.Endpoint + CallbackSSO endpoint.Endpoint } // MakeKolideServerEndpoints creates the Kolide API endpoints. @@ -94,6 +96,8 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey string) KolideEndpoint ResetPassword: makeResetPasswordEndpoint(svc), CreateUser: makeCreateUserEndpoint(svc), VerifyInvite: makeVerifyInviteEndpoint(svc), + InitiateSSO: makeInitiateSSOEndpoint(svc), + CallbackSSO: makeCallbackSSOEndpoint(svc), // Authenticated user endpoints // Each of these endpoints should have exactly one @@ -240,6 +244,8 @@ type kolideHandlers struct { ChangeEmail http.Handler UpdateLicense http.Handler GetLicense http.Handler + InitiateSSO http.Handler + CallbackSSO http.Handler } func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *kolideHandlers { @@ -315,6 +321,8 @@ func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *koli ChangeEmail: newServer(e.ChangeEmail, decodeChangeEmailRequest), UpdateLicense: newServer(e.UpdateLicense, decodeLicenseRequest), GetLicense: newServer(e.GetLicense, decodeNoParamsRequest), + InitiateSSO: newServer(e.InitiateSSO, decodeInitiateSSORequest), + CallbackSSO: newServer(e.CallbackSSO, decodeCallbackSSORequest), } } @@ -363,7 +371,8 @@ func attachKolideAPIRoutes(r *mux.Router, h *kolideHandlers) { r.Handle("/api/v1/kolide/me", h.Me).Methods("GET").Name("me") r.Handle("/api/v1/kolide/change_password", h.ChangePassword).Methods("POST").Name("change_password") r.Handle("/api/v1/kolide/perform_required_password_reset", h.PerformRequiredPasswordReset).Methods("POST").Name("perform_required_password_reset") - + r.Handle("/api/v1/kolide/sso", h.InitiateSSO).Methods("POST").Name("intiate_sso") + r.Handle("/api/v1/kolide/sso/callback", h.CallbackSSO).Methods("POST").Name("callback_sso") r.Handle("/api/v1/kolide/users", h.ListUsers).Methods("GET").Name("list_users") r.Handle("/api/v1/kolide/users", h.CreateUser).Methods("POST").Name("create_user") r.Handle("/api/v1/kolide/users/{id}", h.GetUser).Methods("GET").Name("get_user") diff --git a/server/service/logging_sessions.go b/server/service/logging_sessions.go index d80ac90b1a..fc98996f36 100644 --- a/server/service/logging_sessions.go +++ b/server/service/logging_sessions.go @@ -34,3 +34,28 @@ func (mw loggingMiddleware) Logout(ctx context.Context) (err error) { err = mw.Service.Logout(ctx) return } + +func (mw loggingMiddleware) InitiateSSO(ctx context.Context, relayURL string) (idpURL string, err error) { + defer func(begin time.Time) { + _ = mw.logger.Log( + "method", "InitiateSSO", + "err", err, + "took", time.Since(begin), + ) + }(time.Now()) + + idpURL, err = mw.Service.InitiateSSO(ctx, relayURL) + return +} + +func (mw loggingMiddleware) CallbackSSO(ctx context.Context, auth kolide.Auth) (sess *kolide.SSOSession, err error) { + defer func(begin time.Time) { + _ = mw.logger.Log( + "method", "CallbackSSO", + "err", err, + "took", time.Since(begin), + ) + }(time.Now()) + sess, err = mw.Service.CallbackSSO(ctx, auth) + return +} diff --git a/server/service/metrics_sessions.go b/server/service/metrics_sessions.go index 97a43428f5..fd0ac55050 100644 --- a/server/service/metrics_sessions.go +++ b/server/service/metrics_sessions.go @@ -8,6 +8,26 @@ import ( "github.com/kolide/kolide/server/kolide" ) +func (mw metricsMiddleware) InitiateSSO(ctx context.Context, relayValue string) (idpURL string, err error) { + defer func(begin time.Time) { + lvs := []string{"method", "InitiateSSO", "error", fmt.Sprint(err != nil)} + mw.requestCount.With(lvs...).Add(1) + mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds()) + }(time.Now()) + idpURL, err = mw.Service.InitiateSSO(ctx, relayValue) + return +} + +func (mw metricsMiddleware) CallbackSSO(ctx context.Context, auth kolide.Auth) (sess *kolide.SSOSession, err error) { + defer func(begin time.Time) { + lvs := []string{"method", "CallbackSSO", "error", fmt.Sprint(err != nil)} + mw.requestCount.With(lvs...).Add(1) + mw.requestLatency.With(lvs...).Observe(time.Since(begin).Seconds()) + }(time.Now()) + sess, err = mw.Service.CallbackSSO(ctx, auth) + return +} + func (mw metricsMiddleware) Login(ctx context.Context, username string, password string) (*kolide.User, string, error) { var ( user *kolide.User diff --git a/server/service/service.go b/server/service/service.go index 5ee2fcaa6f..5c039f5ce3 100644 --- a/server/service/service.go +++ b/server/service/service.go @@ -13,11 +13,14 @@ import ( "github.com/kolide/kolide/server/config" "github.com/kolide/kolide/server/kolide" "github.com/kolide/kolide/server/logwriter" + "github.com/kolide/kolide/server/sso" lumberjack "gopkg.in/natefinch/lumberjack.v2" ) // NewService creates a new service from the config struct -func NewService(ds kolide.Datastore, resultStore kolide.QueryResultStore, logger kitlog.Logger, kolideConfig config.KolideConfig, mailService kolide.MailService, c clock.Clock, checker kolide.LicenseChecker) (kolide.Service, error) { +func NewService(ds kolide.Datastore, resultStore kolide.QueryResultStore, + logger kitlog.Logger, kolideConfig config.KolideConfig, mailService kolide.MailService, + c clock.Clock, checker kolide.LicenseChecker, sso sso.SessionStore) (kolide.Service, error) { var svc kolide.Service statusWriter, err := osqueryLogFile(kolideConfig.Osquery.StatusLogFile, logger, kolideConfig.Osquery.EnableLogRotation) if err != nil { @@ -39,8 +42,9 @@ func NewService(ds kolide.Datastore, resultStore kolide.QueryResultStore, logger osqueryStatusLogWriter: statusWriter, osqueryResultLogWriter: resultWriter, mailService: mailService, + ssoSessionStore: sso, } - svc = validationMiddleware{svc, ds} + svc = validationMiddleware{svc, ds, sso} return svc, nil } @@ -83,7 +87,8 @@ type service struct { osqueryStatusLogWriter io.Writer osqueryResultLogWriter io.Writer - mailService kolide.MailService + mailService kolide.MailService + ssoSessionStore sso.SessionStore } func (s service) SendEmail(mail kolide.Email) error { @@ -93,3 +98,9 @@ func (s service) SendEmail(mail kolide.Email) error { func (s service) Clock() clock.Clock { return s.clock } + +type validationMiddleware struct { + kolide.Service + ds kolide.Datastore + ssoSessionStore sso.SessionStore +} diff --git a/server/service/service_appconfig.go b/server/service/service_appconfig.go index 4ed2dad17f..84a70adf0d 100644 --- a/server/service/service_appconfig.go +++ b/server/service/service_appconfig.go @@ -114,6 +114,30 @@ func appConfigFromAppConfigPayload(p kolide.AppConfigPayload, config kolide.AppC config.EnrollSecret = *p.ServerSettings.EnrollSecret } + if p.SSOSettings != nil { + if p.SSOSettings.EnableSSO != nil { + config.EnableSSO = *p.SSOSettings.EnableSSO + } + if p.SSOSettings.EntityID != nil { + config.EntityID = *p.SSOSettings.EntityID + } + if p.SSOSettings.IDPImageURL != nil { + config.IDPImageURL = *p.SSOSettings.IDPImageURL + } + if p.SSOSettings.IDPName != nil { + config.IDPName = *p.SSOSettings.IDPName + } + if p.SSOSettings.IssuerURI != nil { + config.IssuerURI = *p.SSOSettings.IssuerURI + } + if p.SSOSettings.Metadata != nil { + config.Metadata = *p.SSOSettings.Metadata + } + if p.SSOSettings.MetadataURL != nil { + config.MetadataURL = *p.SSOSettings.MetadataURL + } + } + populateSMTP := func(p *kolide.SMTPSettingsPayload) { if p.SMTPAuthenticationMethod != nil { switch *p.SMTPAuthenticationMethod { diff --git a/server/service/service_invites_test.go b/server/service/service_invites_test.go index 545d4fcd7d..86527a880d 100644 --- a/server/service/service_invites_test.go +++ b/server/service/service_invites_test.go @@ -94,7 +94,7 @@ func setupInviteTest(t *testing.T) (kolide.Service, *mock.Store, *mockMailServic config: config.TestConfig(), mailService: mailer, clock: clock.NewMockClock(), - }, ms} + }, ms, nil} return svc, ms, mailer } diff --git a/server/service/service_sessions.go b/server/service/service_sessions.go index 5d403c4120..0c76de6c0b 100644 --- a/server/service/service_sessions.go +++ b/server/service/service_sessions.go @@ -4,17 +4,101 @@ import ( "context" "crypto/rand" "encoding/base64" + "net/url" "strings" "time" jwt "github.com/dgrijalva/jwt-go" "github.com/kolide/kolide/server/contexts/viewer" "github.com/kolide/kolide/server/kolide" + "github.com/kolide/kolide/server/sso" "github.com/pkg/errors" ) -func (svc service) SSOLogin(ctx context.Context, userId string) (*kolide.User, string, error) { - return nil, "", errors.New("not implemented") +func (svc service) InitiateSSO(ctx context.Context, redirectURL string) (string, error) { + appConfig, err := svc.ds.AppConfig() + if err != nil { + return "", errors.Wrap(err, "InitiateSSO getting app config") + } + + metadata, err := getMetadata(appConfig) + if err != nil { + return "", errors.Wrap(err, "InitiateSSO getting metadata") + } + + settings := sso.Settings{ + Metadata: metadata, + // Construct call back url to send to idp + AssertionConsumerServiceURL: appConfig.KolideServerURL + "/api/v1/kolide/sso/callback", + SessionStore: svc.ssoSessionStore, + OriginalURL: redirectURL, + } + + // If issuer is not explicitly set, default to host name. + var issuer string + if appConfig.EntityID == "" { + u, err := url.Parse(appConfig.KolideServerURL) + if err != nil { + return "", errors.Wrap(err, "parsing kolide server url") + } + issuer = u.Hostname() + } else { + issuer = appConfig.EntityID + } + idpURL, err := sso.CreateAuthorizationRequest(&settings, issuer) + if err != nil { + return "", errors.Wrap(err, "InitiateSSO creating authorization") + } + + return idpURL, nil +} + +func getMetadata(config *kolide.AppConfig) (*sso.Metadata, error) { + if config.MetadataURL != "" { + metadata, err := sso.GetMetadata(config.MetadataURL, 5*time.Second) + if err != nil { + return nil, err + } + return metadata, nil + } + if config.Metadata != "" { + metadata, err := sso.ParseMetadata(config.Metadata) + if err != nil { + return nil, err + } + return metadata, nil + } + return nil, errors.Errorf("missing metadata for idp %s", config.IDPName) +} + +func (svc service) CallbackSSO(ctx context.Context, auth kolide.Auth) (*kolide.SSOSession, error) { + // The signature and validity of auth response has been checked already in + // validation middleware. + sess, err := svc.ssoSessionStore.Get(auth.RequestID()) + if err != nil { + return nil, errors.Wrap(err, "fetching sso session in callback") + } + // Remove session to so that is can't be reused before it expires. + err = svc.ssoSessionStore.Expire(auth.RequestID()) + if err != nil { + return nil, errors.Wrap(err, "expiring sso session in callback") + } + user, err := svc.userByEmailOrUsername(auth.UserID()) + if err != nil { + return nil, errors.Wrap(err, "finding user in sso callback") + } + token, err := svc.makeSession(user.ID) + if err != nil { + return nil, errors.Wrap(err, "making user session in sso callback") + } + result := &kolide.SSOSession{ + Token: token, + RedirectURL: sess.OriginalURL, + } + if !strings.HasPrefix(result.RedirectURL, "/") { + result.RedirectURL = "/" + result.RedirectURL + } + return result, nil } func (svc service) Login(ctx context.Context, username, password string) (*kolide.User, string, error) { diff --git a/server/service/transport.go b/server/service/transport.go index ecff7a3ccf..3fd0485f59 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "strconv" @@ -29,6 +30,12 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa } } + if page, ok := response.(htmlPage); ok { + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + _, err := io.WriteString(w, page.html()) + return err + } + enc := json.NewEncoder(w) enc.SetIndent("", " ") return enc.Encode(response) @@ -40,6 +47,12 @@ type statuser interface { status() int } +// loads a html page +type htmlPage interface { + html() string + error() error +} + func idFromRequest(r *http.Request, name string) (uint, error) { vars := mux.Vars(r) id, ok := vars[name] diff --git a/server/service/transport_sessions.go b/server/service/transport_sessions.go index 62970f45df..c11af7a5e8 100644 --- a/server/service/transport_sessions.go +++ b/server/service/transport_sessions.go @@ -5,6 +5,9 @@ import ( "encoding/json" "net/http" "strings" + + "github.com/kolide/kolide/server/sso" + "github.com/pkg/errors" ) func decodeGetInfoAboutSessionRequest(ctx context.Context, r *http.Request) (interface{}, error) { @@ -47,3 +50,24 @@ func decodeLoginRequest(ctx context.Context, r *http.Request) (interface{}, erro req.Username = strings.ToLower(req.Username) return req, nil } + +func decodeInitiateSSORequest(ctx context.Context, r *http.Request) (interface{}, error) { + var req initiateSSORequest + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + return nil, err + } + return req, nil +} + +func decodeCallbackSSORequest(ctx context.Context, r *http.Request) (interface{}, error) { + err := r.ParseForm() + if err != nil { + return nil, errors.Wrap(err, "decode sso callback") + } + authResponse, err := sso.DecodeAuthResponse(r.FormValue("SAMLResponse")) + if err != nil { + return nil, errors.Wrap(err, "decoding sso callback") + } + return authResponse, nil +} diff --git a/server/service/util_test.go b/server/service/util_test.go index 1fabd85adc..62b1e85be0 100644 --- a/server/service/util_test.go +++ b/server/service/util_test.go @@ -12,12 +12,12 @@ import ( func newTestService(ds kolide.Datastore, rs kolide.QueryResultStore) (kolide.Service, error) { mailer := &mockMailService{SendEmailFn: func(e kolide.Email) error { return nil }} - return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, clock.C, nil) + return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, clock.C, nil, nil) } func newTestServiceWithClock(ds kolide.Datastore, rs kolide.QueryResultStore, c clock.Clock) (kolide.Service, error) { mailer := &mockMailService{SendEmailFn: func(e kolide.Email) error { return nil }} - return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, c, nil) + return NewService(ds, rs, kitlog.NewNopLogger(), config.TestConfig(), mailer, c, nil, nil) } func createTestAppConfig(t *testing.T, ds kolide.Datastore) *kolide.AppConfig { diff --git a/server/service/validation_app_config.go b/server/service/validation_app_config.go new file mode 100644 index 0000000000..75dff2ab15 --- /dev/null +++ b/server/service/validation_app_config.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + + "github.com/kolide/kolide/server/kolide" +) + +func (mw validationMiddleware) ModifyAppConfig(ctx context.Context, p kolide.AppConfigPayload) (*kolide.AppConfig, error) { + invalid := &invalidArgumentError{} + validateSSOSettings(p, invalid) + if invalid.HasErrors() { + return nil, invalid + } + return mw.Service.ModifyAppConfig(ctx, p) +} + +func isSet(val *string) bool { + if val != nil { + return len(*val) > 0 + } + return false +} + +func validateSSOSettings(p kolide.AppConfigPayload, invalid *invalidArgumentError) { + if p.SSOSettings != nil && p.SSOSettings.EnableSSO != nil { + if *p.SSOSettings.EnableSSO { + if !isSet(p.SSOSettings.Metadata) && !isSet(p.SSOSettings.MetadataURL) { + invalid.Append("metadata", "either metadata or metadata_url must be defined") + } + if isSet(p.SSOSettings.Metadata) && isSet(p.SSOSettings.MetadataURL) { + invalid.Append("metadata", "both metadata and metadata_url are defined, only one is allowed") + } + if !isSet(p.SSOSettings.EntityID) { + invalid.Append("entity_id", "required") + } else { + if len(*p.SSOSettings.EntityID) < 5 { + invalid.Append("entity_id", "must be 5 or more characters") + } + } + if !isSet(p.SSOSettings.IDPName) { + invalid.Append("idp_name", "required") + } else { + if len(*p.SSOSettings.IDPName) < 5 { + invalid.Append("idp_name", "must be 5 or more characters") + } + } + } + } +} diff --git a/server/service/validation_app_config_test.go b/server/service/validation_app_config_test.go new file mode 100644 index 0000000000..ad5355710c --- /dev/null +++ b/server/service/validation_app_config_test.go @@ -0,0 +1,47 @@ +package service + +import ( + "testing" + + "github.com/kolide/kolide/server/kolide" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSONotPresent(t *testing.T) { + invalid := &invalidArgumentError{} + var p kolide.AppConfigPayload + validateSSOSettings(p, invalid) + assert.False(t, invalid.HasErrors()) + +} + +func TestNeedFieldsPresent(t *testing.T) { + invalid := &invalidArgumentError{} + config := kolide.AppConfig{ + EnableSSO: true, + EntityID: "kolide", + IssuerURI: "http://issuer.idp.com", + MetadataURL: "http://isser.metadata.com", + IDPName: "onelogin", + } + p := appConfigPayloadFromAppConfig(&config) + validateSSOSettings(*p, invalid) + assert.False(t, invalid.HasErrors()) +} + +func TestMissingMetadata(t *testing.T) { + invalid := invalidArgumentError{} + config := kolide.AppConfig{ + EnableSSO: true, + EntityID: "kolide", + IssuerURI: "http://issuer.idp.com", + IDPName: "onelogin", + } + p := appConfigPayloadFromAppConfig(&config) + validateSSOSettings(*p, &invalid) + require.True(t, invalid.HasErrors()) + require.Len(t, invalid, 1) + assert.Equal(t, "metadata", invalid[0].name) + assert.Equal(t, "either metadata or metadata_url must be defined", invalid[0].reason) +} diff --git a/server/service/validation_sessions.go b/server/service/validation_sessions.go new file mode 100644 index 0000000000..1b46c31c5c --- /dev/null +++ b/server/service/validation_sessions.go @@ -0,0 +1,35 @@ +package service + +import ( + "context" + + "github.com/kolide/kolide/server/kolide" + "github.com/kolide/kolide/server/sso" + "github.com/pkg/errors" +) + +func (mw validationMiddleware) CallbackSSO(ctx context.Context, auth kolide.Auth) (*kolide.SSOSession, error) { + invalid := &invalidArgumentError{} + session, err := mw.ssoSessionStore.Get(auth.RequestID()) + if err != nil { + invalid.Append("session", "missing for request") + return nil, invalid + } + validator, err := sso.NewValidator(session.Metadata) + if err != nil { + return nil, errors.Wrap(err, "creating validator from metadata") + } + // make sure the response hasn't been tampered with + auth, err = validator.ValidateSignature(auth) + if err != nil { + invalid.Appendf("sso response", "signature validation failed %s", err.Error()) + return nil, invalid + } + // make sure the response isn't stale + err = validator.ValidateResponse(auth) + if err != nil { + invalid.Appendf("sso response", "response validation failed %s", err.Error()) + } + + return mw.Service.CallbackSSO(ctx, auth) +} diff --git a/server/service/validation_users.go b/server/service/validation_users.go index cb06386cd7..ca2288aaad 100644 --- a/server/service/validation_users.go +++ b/server/service/validation_users.go @@ -10,11 +10,6 @@ import ( "github.com/kolide/kolide/server/kolide" ) -type validationMiddleware struct { - kolide.Service - ds kolide.Datastore -} - func (mw validationMiddleware) NewUser(ctx context.Context, p kolide.UserPayload) (*kolide.User, error) { invalid := &invalidArgumentError{} if p.Username == nil { diff --git a/server/sso/authorization_request.go b/server/sso/authorization_request.go new file mode 100644 index 0000000000..ea871560a9 --- /dev/null +++ b/server/sso/authorization_request.go @@ -0,0 +1,136 @@ +package sso + +import ( + "bytes" + "compress/flate" + "encoding/base64" + "encoding/xml" + "net/url" + "time" + + "github.com/kolide/kolide/server/kolide" + "github.com/pkg/errors" +) + +const ( + samlVersion = "2.0" + cacheLifetime = 300 // five minutes +) + +// RelayState sets optional relay state +func RelayState(v string) func(*opts) { + return func(o *opts) { + o.relayState = v + } +} + +type opts struct { + relayState string +} + +// CreateAuthorizationRequest creates a url suitable for use to satisfy the SAML +// redirect binding. +// See http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf Section 3.4 +func CreateAuthorizationRequest(settings *Settings, issuer string, options ...func(o *opts)) (string, error) { + var optionalParams opts + for _, opt := range options { + opt(&optionalParams) + } + if settings.Metadata == nil { + return "", errors.New("missing settings metadata") + } + requestID, err := kolide.RandomText(16) + if err != nil { + return "", errors.Wrap(err, "creating auth request id") + } + destinationURL, err := getDestinationURL(settings) + if err != nil { + return "", errors.Wrap(err, "creating auth request") + } + request := AuthnRequest{ + XMLName: xml.Name{ + Local: "samlp:AuthnRequest", + }, + ID: requestID, + SAMLP: "urn:oasis:names:tc:SAML:2.0:protocol", + SAML: "urn:oasis:names:tc:SAML:2.0:assertion", + AssertionConsumerServiceURL: settings.AssertionConsumerServiceURL, + Destination: destinationURL, + IssueInstant: time.Now().UTC().Format("2006-01-02T15:04:05Z"), + ProtocolBinding: RedirectBinding, + Version: samlVersion, + ProviderName: "Kolide", + Issuer: Issuer{ + XMLName: xml.Name{ + Local: "saml:Issuer", + }, + Url: issuer, + }, + } + var reader bytes.Buffer + err = xml.NewEncoder(&reader).Encode(settings.Metadata) + if err != nil { + return "", errors.Wrap(err, "encoding metadata creating auth request") + } + // cache metadata so we can check the signatures on the response we get from the IDP + err = settings.SessionStore.create(requestID, + settings.OriginalURL, + reader.String(), + cacheLifetime, + ) + if err != nil { + return "", errors.Wrap(err, "caching cert while creating auth request") + } + u, err := url.Parse(destinationURL) + if err != nil { + return "", errors.Wrap(err, "parsing destination url") + } + qry := u.Query() + + var writer bytes.Buffer + err = xml.NewEncoder(&writer).Encode(request) + if err != nil { + return "", errors.Wrap(err, "encoding auth request xml") + } + authQueryVal, err := deflate(&writer) + if err != nil { + return "", errors.Wrap(err, "unable to compress auth info") + } + qry.Set("SAMLRequest", authQueryVal) + if optionalParams.relayState != "" { + qry.Set("RelayState", optionalParams.relayState) + } + u.RawQuery = qry.Encode() + return u.String(), nil +} + +func getDestinationURL(settings *Settings) (string, error) { + for _, sso := range settings.Metadata.IDPSSODescriptor.SingleSignOnService { + if sso.Binding == RedirectBinding { + return sso.Location, nil + } + } + return "", errors.New("IDP does not support redirect binding") +} + +// See SAML Bindings http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf +// Section 3.4.4.1 +func deflate(xmlBuffer *bytes.Buffer) (string, error) { + var deflated bytes.Buffer + writer, err := flate.NewWriter(&deflated, flate.DefaultCompression) + if err != nil { + return "", err + } + defer writer.Close() + n, err := writer.Write(xmlBuffer.Bytes()) + if n != xmlBuffer.Len() { + return "", errors.New("incomplete write during compression") + } + if err != nil { + return "", errors.Wrap(err, "compressing auth request") + } + writer.Flush() + encbuff := deflated.Bytes() + encoded := base64.StdEncoding.EncodeToString(encbuff) + return encoded, nil +} diff --git a/server/sso/authorization_request_test.go b/server/sso/authorization_request_test.go new file mode 100644 index 0000000000..ecb4fdf6ff --- /dev/null +++ b/server/sso/authorization_request_test.go @@ -0,0 +1,18 @@ +package sso + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRequestCompression(t *testing.T) { + input := "https://sp.example.com/saml2" + expected := "fJJf79IwFIa/Su961f0pG4yGLZkQ4xLUBaYX3piyHaTJ2s6eTvHbmw2McPHjtnne9u1zzgal7gdRjv5iDvBzBPSkRATnlTVba3DU4I7gfqkWvhz2Ob14P6AIQxwCuEo99BC0VoeyRUp2gF4ZOUX/g6p7JhEtJdUup9/jLM7ShKfs3C05S1arlp3WXcKSU9qlC5mtsySlpEIcoTLopfE55VG8YlHC4mUTp2LBRcK/UVI7621r+3fKdMr8yOnojLASFQojNaDwrTiWH/eCB5E43SAUH5qmZvXnY0PJV3A4t+ZBRMlV9wbFZOb1TfKfqMfI8Doz3KvSYlYv5u+54g2tE8I34SN5n9gnqaHa1bZX7R9S9r39vXUgPeTUuxEoeW+dlv51l+lEdew8o8I7aVCB8TQsbk8+70XxFwAA//8=" + buff := bytes.NewBufferString(input) + compressed, err := deflate(buff) + require.Nil(t, err) + assert.Equal(t, expected, compressed) +} diff --git a/server/sso/authorization_response.go b/server/sso/authorization_response.go new file mode 100644 index 0000000000..b5d2439423 --- /dev/null +++ b/server/sso/authorization_response.go @@ -0,0 +1,132 @@ +package sso + +import ( + "bytes" + "encoding/base64" + "encoding/xml" + + "github.com/kolide/kolide/server/kolide" + "github.com/pkg/errors" +) + +const ( + // These are response status codes described in the core SAML spec section + // 3.2.2.1 See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf + Success int = iota + Requestor + Responder + VersionMismatch + AuthnFailed + InvalidAttrNameOrValue + InvalidNameIDPolicy + NoAuthnContext + NoAvailableIDP + NoPassive + NoSupportedIDP + PartialLogout + ProxyCountExceeded + RequestDenied + RequestUnsupported + RequestVersionDeprecated + RequestVersionTooHigh + RequestVersionTooLow + ResourceNotRecognized + TooManyResponses + UnknownAttrProfile + UnknownPrincipal + UnsupportedBinding +) + +var statusMap = map[string]int{ + "urn:oasis:names:tc:SAML:2.0:status:Success": Success, + "urn:oasis:names:tc:SAML:2.0:status:Requester": Requestor, + "urn:oasis:names:tc:SAML:2.0:status:Responder": Responder, + "urn:oasis:names:tc:SAML:2.0:status:VersionMismatch": VersionMismatch, + "urn:oasis:names:tc:SAML:2.0:status:AuthnFailed": AuthnFailed, + "urn:oasis:names:tc:SAML:2.0:status:InvalidAttrNameOrValue": InvalidAttrNameOrValue, + "urn:oasis:names:tc:SAML:2.0:status:InvalidNameIDPolicy": InvalidNameIDPolicy, + "urn:oasis:names:tc:SAML:2.0:status:NoAuthnContext": NoAuthnContext, + "urn:oasis:names:tc:SAML:2.0:status:NoAvailableIDP": NoAvailableIDP, + "urn:oasis:names:tc:SAML:2.0:status:NoPassive": NoPassive, + "urn:oasis:names:tc:SAML:2.0:status:NoSupportedIDP": NoSupportedIDP, + "urn:oasis:names:tc:SAML:2.0:status:PartialLogout": PartialLogout, + "urn:oasis:names:tc:SAML:2.0:status:ProxyCountExceeded": ProxyCountExceeded, + "urn:oasis:names:tc:SAML:2.0:status:RequestDenied": RequestDenied, + "urn:oasis:names:tc:SAML:2.0:status:RequestUnsupported": RequestUnsupported, + "urn:oasis:names:tc:SAML:2.0:status:RequestVersionDeprecated": RequestVersionDeprecated, + "urn:oasis:names:tc:SAML:2.0:status:RequestVersionTooLow": RequestVersionTooLow, + "urn:oasis:names:tc:SAML:2.0:status:ResourceNotRecognized": ResourceNotRecognized, + "urn:oasis:names:tc:SAML:2.0:status:TooManyResponses": TooManyResponses, + "urn:oasis:names:tc:SAML:2.0:status:UnknownAttrProfile": UnknownAttrProfile, + "urn:oasis:names:tc:SAML:2.0:status:UnknownPrincipal": UnknownPrincipal, + "urn:oasis:names:tc:SAML:2.0:status:UnsupportedBinding": UnsupportedBinding, +} + +type resp struct { + response *Response + rawResp string +} + +func (r *resp) setResponse(val *Response) { + r.response = val +} + +func (r resp) statusDescription() string { + if r.response != nil { + return r.response.Status.StatusCode.Value + } + return "missing response" +} + +func (r resp) UserID() string { + if r.response != nil { + return r.response.Assertion.Subject.NameID.Value + } + return "" +} + +func (r resp) status() (int, error) { + if r.response != nil { + statusURI := r.response.Status.StatusCode.Value + if code, ok := statusMap[statusURI]; ok { + return code, nil + } + } + return AuthnFailed, errors.New("malformed or missing auth response") +} + +func (r resp) RequestID() string { + if r.response != nil { + return r.response.InResponseTo + } + return "" +} + +func (r resp) rawResponse() string { + return r.rawResp +} + +func (r resp) authResponse() (*Response, error) { + if r.response != nil { + return r.response, nil + } + return nil, errors.New("missing SAML response") +} + +// DecodeAuthResponse extracts SAML assertions from IDP response +func DecodeAuthResponse(samlResponse string) (kolide.Auth, error) { + var authInfo resp + authInfo.rawResp = samlResponse + + decoded, err := base64.StdEncoding.DecodeString(samlResponse) + if err != nil { + return nil, errors.Wrap(err, "decoding saml response") + } + var saml Response + err = xml.NewDecoder(bytes.NewBuffer(decoded)).Decode(&saml) + if err != nil { + return nil, errors.Wrap(err, "decoding response xml") + } + authInfo.response = &saml + return &authInfo, nil +} diff --git a/server/sso/authorization_response_test.go b/server/sso/authorization_response_test.go new file mode 100644 index 0000000000..af8765277e --- /dev/null +++ b/server/sso/authorization_response_test.go @@ -0,0 +1,21 @@ +package sso + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeSuccessfulSalesforceResponse(t *testing.T) { + samlResponse := `PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbHA6UmVzcG9uc2UgeG1sbnM6c2FtbHA9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgRGVzdGluYXRpb249Imh0dHBzOi8vbG9jYWxob3N0OjgwODAvYXBpL3YxL2tvbGlkZS9zc28vY2FsbGJhY2siIElEPSJfNTJmMjUxNWM1MzE5ZjJhZGYzZjA3MmQ5ZDlmMmI2ODgxNDkzMzA1Mzk2NzQ2IiBJblJlc3BvbnNlVG89IjQ5ODJiNDMwLTczZTEtNGFkMi04ODVhLTRhNzc1YTkxZjgyMCIgSXNzdWVJbnN0YW50PSIyMDE3LTA0LTI3VDE1OjAzOjE2Ljc0N1oiIFZlcnNpb249IjIuMCI+PHNhbWw6SXNzdWVyIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwczovL2tvbGlkZS1kZXYtZWQubXkuc2FsZXNmb3JjZS5jb208L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgo8ZHM6U2lnbmVkSW5mbz4KPGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4KPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPgo8ZHM6UmVmZXJlbmNlIFVSST0iI181MmYyNTE1YzUzMTlmMmFkZjNmMDcyZDlkOWYyYjY4ODE0OTMzMDUzOTY3NDYiPgo8ZHM6VHJhbnNmb3Jtcz4KPGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+CjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiPjxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIHhtbG5zOmVjPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIFByZWZpeExpc3Q9ImRzIHNhbWwgc2FtbHAgeHMgeHNpIi8+PC9kczpUcmFuc2Zvcm0+CjwvZHM6VHJhbnNmb3Jtcz4KPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIi8+CjxkczpEaWdlc3RWYWx1ZT5zeUtBOXhlNHZMSitXMVd4WXJURFY4Z2pYZGM9PC9kczpEaWdlc3RWYWx1ZT4KPC9kczpSZWZlcmVuY2U+CjwvZHM6U2lnbmVkSW5mbz4KPGRzOlNpZ25hdHVyZVZhbHVlPgpTSFhORW5SbFJtVE9wZ2ZBdFMxNFZOd0FGbXpSOHUyM3JMTmNyL0s4T2g1ZTNsOUxUdGJMOVF0dkxzeVlOVUZvaXpEczRmYkhmeUJICkRjQkQzekNFWEZWbnZTKzNUQTNTcFVNSCs0dXNkVHNMa1JoUzFLNUlyYS9NSy9hdW1SNDNJZE1GaWxNY2VjRjhKNFlBYmxSdEpJeWgKS3ZTZDFWS3VrVW9URHY3WU9NRXdjbzRoeHpMK2dWckU5SHpIZkF2L2ZTeXhPTVhvaEVITFBPOFFlZEJzWDRaS0lyNFp1T1B1VmlpSgpBdSswMUE4QU8wMWdiWld1WG1US21JL1dESDY2dEJRVWNQUkYyUkJXd3Z6aXJwWTg2TjRTZHY1OFZMZE01SU1hL2hodkxITU9IbEdNCitrRVJyN0txTGhNTkZUVHc5Vm5veWJCbW5pUjBpb0FnMmx3cFpBPT0KPC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRXJEQ0NBNVNnQXdJQkFnSU9BVnVoSDNXa0FBQUFBQjVOcHZJd0RRWUpLb1pJaHZjTkFRRUxCUUF3Z1pBeEtEQW1CZ05WQkFNTQpIMU5sYkdaVGFXZHVaV1JEWlhKMFh6STBRWEJ5TWpBeE4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwCk9ERVhNQlVHQTFVRUNnd09VMkZzWlhObWIzSmpaUzVqYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlYKQkFnTUFrTkJNUXd3Q2dZRFZRUUdFd05WVTBFd0hoY05NVGN3TkRJME1UZ3dNRFExV2hjTk1UZ3dOREkwTVRJd01EQXdXakNCa0RFbwpNQ1lHQTFVRUF3d2ZVMlZzWmxOcFoyNWxaRU5sY25SZk1qUkJjSEl5TURFM1h6RTRNREEwTkRFWU1CWUdBMVVFQ3d3UE1EQkVOa0V3Ck1EQXdNREF4TjNRNE1SY3dGUVlEVlFRS0RBNVRZV3hsYzJadmNtTmxMbU52YlRFV01CUUdBMVVFQnd3TlUyRnVJRVp5WVc1amFYTmoKYnpFTE1Ba0dBMVVFQ0F3Q1EwRXhEREFLQmdOVkJBWVRBMVZUUVRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQwpnZ0VCQUlPUjdoOEJGMmVGT2xRSGhWLzFTN3VPQk4yMkp2N1BEQ1hNejJmVTB1TGMrbXJ2OXhER2o2RWxmVys5ZFNkWGFDYlF6RDMrClhxNHJlUzRwWVJhZkpaLzI3T3R5Z1hsM3Jwb1BqU2xoUmlXK29ZVnVEY0NVUkpwdTBLdVo0STBmbTVxMUJEWXF4Y0J4TlBTZTg1T0gKRTMrdWNtS3F2UG96aFFnWUxQQ3JlZ01Jb21DM3l5QU5abkxDb0dmQ3Y5VHBRbDYvK0kxODJ0U1Q0V1BOaFZQeEt4aWpvUFU0Umg2eApZMzRFejgrSnI4S2Rtem1ZU05lNHVra0lBU3BscHZHN3JLa2E4MjRIZjh6STFCV25qV0xEeGI1SUF4Z1VCYmRyNHg4ZDhDM2tQZlRmCiszLzZ5QzV3U09tOU5TczBCQTRPSk5vd3RYWkZyeU16RmZYekR6amw2OWtDQXdFQUFhT0NBUUF3Z2Ywd0hRWURWUjBPQkJZRUZPK0QKa29QNnFreXNpOVpDNzR5VFB1SlZWZzJ5TUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3Z2NvR0ExVWRJd1NCd2pDQnY0QVU3NE9TZy9xcQpUS3lMMWtMdmpKTSs0bFZXRGJLaGdaYWtnWk13Z1pBeEtEQW1CZ05WQkFNTUgxTmxiR1pUYVdkdVpXUkRaWEowWHpJMFFYQnlNakF4Ck4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwT0RFWE1CVUdBMVVFQ2d3T1UyRnNaWE5tYjNKalpTNWoKYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlZCQWdNQWtOQk1Rd3dDZ1lEVlFRR0V3TlZVMEdDRGdGYgpvUjkxcEFBQUFBQWVUYWJ5TUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBVmhZQnY1R0p2aGx0a3MyajdaYzl3ZEZIVzd5QjQvaFBGCm8wNXkweWlPZjcxdExqT2xCdWNTeXh0bVhMUGpyRUNKdklKd0toc0FJZ1lYblZwN2RpdHhmYXVDY3hjekpnZmVMMS9keEgvR2U4ZVAKa21INlNkc083MWNKTDhkWEV6T3NvRitQQVZRelVocWg4enhJaXBudEwwd3dOR1REMHpJVlFlVFNvem0wS0YwU3NTSElmYk55Mjc5dQpSZUdvbkM2MWk0T3VrNUFNS0E3UmU5ZlZlVXM2dHFNMmF0MjJoOVphai9yL09oWG9EY1poemtkOFdxMEVSL1VLTFpBMUN5Skhnd09DCjdSRUVaT3VLcnFnZldjWXQ0ZEdvNXE2Z3FHSEhQTXYwTjdzL014cUN2SkN3R0E4ZUpHdk9PNTZJMzIxdmhXSFE2WlNKRFdVcVFGTS8KWmU3QTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1scDpTdGF0dXM+PHNhbWxwOlN0YXR1c0NvZGUgVmFsdWU9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpzdGF0dXM6U3VjY2VzcyIvPjwvc2FtbHA6U3RhdHVzPjxzYW1sOkFzc2VydGlvbiB4bWxuczpzYW1sPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBJRD0iXzlmZmFkOTBhYjM2N2YzMmE1MmI3NDlkNWM0YjJiN2RmMTQ5MzMwNTM5Njc0OSIgSXNzdWVJbnN0YW50PSIyMDE3LTA0LTI3VDE1OjAzOjE2Ljc0OVoiIFZlcnNpb249IjIuMCI+PHNhbWw6SXNzdWVyIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwczovL2tvbGlkZS1kZXYtZWQubXkuc2FsZXNmb3JjZS5jb208L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgo8ZHM6U2lnbmVkSW5mbz4KPGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4KPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPgo8ZHM6UmVmZXJlbmNlIFVSST0iI185ZmZhZDkwYWIzNjdmMzJhNTJiNzQ5ZDVjNGIyYjdkZjE0OTMzMDUzOTY3NDkiPgo8ZHM6VHJhbnNmb3Jtcz4KPGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+CjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiPjxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIHhtbG5zOmVjPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIFByZWZpeExpc3Q9ImRzIHNhbWwgeHMgeHNpIi8+PC9kczpUcmFuc2Zvcm0+CjwvZHM6VHJhbnNmb3Jtcz4KPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIi8+CjxkczpEaWdlc3RWYWx1ZT5CT2htcWtkLy9LWUJtQkpJWmZVcWdFeDZpTGM9PC9kczpEaWdlc3RWYWx1ZT4KPC9kczpSZWZlcmVuY2U+CjwvZHM6U2lnbmVkSW5mbz4KPGRzOlNpZ25hdHVyZVZhbHVlPgpVYVN5ZVBvUWROYzhBcGNMN0FrN05oV3VaWTlpbG1xYkpEYmtJRmpmWW9pa1BXcGlxcTBaNURIeFBWQ0hnUmk0MktDOW9jbFhQaldoCnY4YWNCWnJNWmxxbjB5VmFFZVZ3b3pjS1lHd3hoN21oblduVTJ6cmQ0aG5uZkRaYnd5VTNwY2hVVk5YeW5kUG1md25SUjh3QkRjSUQKKy91TDEwdTZ6QnpHYnR6dngxckczM09kOGY0aCtSRERPVFJWWDFpVkt3NXBibnZqcnJZY1kxZ3FJNU9RS0Jva2kxWDZMaFpFNHFrNQo3N0RHM1U5WjNxdXQyR1RZenVwUnA5bnN6Yk92MWwwalh1YXZ5Kzk0elozSzNvcWVMTkgzWlcxZkI4WEc4YjNuWDlyRkVZenRvNUNRClNTUWFVeXBBbGptZzlYcm1mVm9salVEcGFiUldLV2kwZWlFdnhRPT0KPC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRXJEQ0NBNVNnQXdJQkFnSU9BVnVoSDNXa0FBQUFBQjVOcHZJd0RRWUpLb1pJaHZjTkFRRUxCUUF3Z1pBeEtEQW1CZ05WQkFNTQpIMU5sYkdaVGFXZHVaV1JEWlhKMFh6STBRWEJ5TWpBeE4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwCk9ERVhNQlVHQTFVRUNnd09VMkZzWlhObWIzSmpaUzVqYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlYKQkFnTUFrTkJNUXd3Q2dZRFZRUUdFd05WVTBFd0hoY05NVGN3TkRJME1UZ3dNRFExV2hjTk1UZ3dOREkwTVRJd01EQXdXakNCa0RFbwpNQ1lHQTFVRUF3d2ZVMlZzWmxOcFoyNWxaRU5sY25SZk1qUkJjSEl5TURFM1h6RTRNREEwTkRFWU1CWUdBMVVFQ3d3UE1EQkVOa0V3Ck1EQXdNREF4TjNRNE1SY3dGUVlEVlFRS0RBNVRZV3hsYzJadmNtTmxMbU52YlRFV01CUUdBMVVFQnd3TlUyRnVJRVp5WVc1amFYTmoKYnpFTE1Ba0dBMVVFQ0F3Q1EwRXhEREFLQmdOVkJBWVRBMVZUUVRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQwpnZ0VCQUlPUjdoOEJGMmVGT2xRSGhWLzFTN3VPQk4yMkp2N1BEQ1hNejJmVTB1TGMrbXJ2OXhER2o2RWxmVys5ZFNkWGFDYlF6RDMrClhxNHJlUzRwWVJhZkpaLzI3T3R5Z1hsM3Jwb1BqU2xoUmlXK29ZVnVEY0NVUkpwdTBLdVo0STBmbTVxMUJEWXF4Y0J4TlBTZTg1T0gKRTMrdWNtS3F2UG96aFFnWUxQQ3JlZ01Jb21DM3l5QU5abkxDb0dmQ3Y5VHBRbDYvK0kxODJ0U1Q0V1BOaFZQeEt4aWpvUFU0Umg2eApZMzRFejgrSnI4S2Rtem1ZU05lNHVra0lBU3BscHZHN3JLa2E4MjRIZjh6STFCV25qV0xEeGI1SUF4Z1VCYmRyNHg4ZDhDM2tQZlRmCiszLzZ5QzV3U09tOU5TczBCQTRPSk5vd3RYWkZyeU16RmZYekR6amw2OWtDQXdFQUFhT0NBUUF3Z2Ywd0hRWURWUjBPQkJZRUZPK0QKa29QNnFreXNpOVpDNzR5VFB1SlZWZzJ5TUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3Z2NvR0ExVWRJd1NCd2pDQnY0QVU3NE9TZy9xcQpUS3lMMWtMdmpKTSs0bFZXRGJLaGdaYWtnWk13Z1pBeEtEQW1CZ05WQkFNTUgxTmxiR1pUYVdkdVpXUkRaWEowWHpJMFFYQnlNakF4Ck4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwT0RFWE1CVUdBMVVFQ2d3T1UyRnNaWE5tYjNKalpTNWoKYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlZCQWdNQWtOQk1Rd3dDZ1lEVlFRR0V3TlZVMEdDRGdGYgpvUjkxcEFBQUFBQWVUYWJ5TUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBVmhZQnY1R0p2aGx0a3MyajdaYzl3ZEZIVzd5QjQvaFBGCm8wNXkweWlPZjcxdExqT2xCdWNTeXh0bVhMUGpyRUNKdklKd0toc0FJZ1lYblZwN2RpdHhmYXVDY3hjekpnZmVMMS9keEgvR2U4ZVAKa21INlNkc083MWNKTDhkWEV6T3NvRitQQVZRelVocWg4enhJaXBudEwwd3dOR1REMHpJVlFlVFNvem0wS0YwU3NTSElmYk55Mjc5dQpSZUdvbkM2MWk0T3VrNUFNS0E3UmU5ZlZlVXM2dHFNMmF0MjJoOVphai9yL09oWG9EY1poemtkOFdxMEVSL1VLTFpBMUN5Skhnd09DCjdSRUVaT3VLcnFnZldjWXQ0ZEdvNXE2Z3FHSEhQTXYwTjdzL014cUN2SkN3R0E4ZUpHdk9PNTZJMzIxdmhXSFE2WlNKRFdVcVFGTS8KWmU3QTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1sOlN1YmplY3Q+PHNhbWw6TmFtZUlEIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4xOm5hbWVpZC1mb3JtYXQ6dW5zcGVjaWZpZWQiPmpvaG5Aa29saWRlLmNvPC9zYW1sOk5hbWVJRD48c2FtbDpTdWJqZWN0Q29uZmlybWF0aW9uIE1ldGhvZD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmNtOmJlYXJlciI+PHNhbWw6U3ViamVjdENvbmZpcm1hdGlvbkRhdGEgSW5SZXNwb25zZVRvPSI0OTgyYjQzMC03M2UxLTRhZDItODg1YS00YTc3NWE5MWY4MjAiIE5vdE9uT3JBZnRlcj0iMjAxNy0wNC0yN1QxNTowODoxNi43NjBaIiBSZWNpcGllbnQ9Imh0dHBzOi8vbG9jYWxob3N0OjgwODAvYXBpL3YxL2tvbGlkZS9zc28vY2FsbGJhY2siLz48L3NhbWw6U3ViamVjdENvbmZpcm1hdGlvbj48L3NhbWw6U3ViamVjdD48c2FtbDpDb25kaXRpb25zIE5vdEJlZm9yZT0iMjAxNy0wNC0yN1QxNTowMjo0Ni43NjBaIiBOb3RPbk9yQWZ0ZXI9IjIwMTctMDQtMjdUMTU6MDg6MTYuNzYwWiI+PHNhbWw6QXVkaWVuY2VSZXN0cmljdGlvbj48c2FtbDpBdWRpZW5jZT5rb2xpZGU8L3NhbWw6QXVkaWVuY2U+PC9zYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PC9zYW1sOkNvbmRpdGlvbnM+PHNhbWw6QXV0aG5TdGF0ZW1lbnQgQXV0aG5JbnN0YW50PSIyMDE3LTA0LTI3VDE1OjAzOjE2Ljc1MFoiPjxzYW1sOkF1dGhuQ29udGV4dD48c2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj51cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YWM6Y2xhc3Nlczp1bnNwZWNpZmllZDwvc2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj48L3NhbWw6QXV0aG5Db250ZXh0Pjwvc2FtbDpBdXRoblN0YXRlbWVudD48c2FtbDpBdHRyaWJ1dGVTdGF0ZW1lbnQ+PHNhbWw6QXR0cmlidXRlIE5hbWU9InVzZXJJZCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1bnNwZWNpZmllZCI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6YW55VHlwZSI+MDA1NkEwMDAwMDBRNlJsPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PHNhbWw6QXR0cmlidXRlIE5hbWU9InVzZXJuYW1lIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVuc3BlY2lmaWVkIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czphbnlUeXBlIj5qb2huQGtvbGlkZS5jbzwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJlbWFpbCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1bnNwZWNpZmllZCI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6YW55VHlwZSI+am9obkBrb2xpZGUuY288L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iaXNfcG9ydGFsX3VzZXIiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dW5zcGVjaWZpZWQiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOmFueVR5cGUiPmZhbHNlPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PC9zYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48L3NhbWw6QXNzZXJ0aW9uPjwvc2FtbHA6UmVzcG9uc2U+` + auth, err := DecodeAuthResponse(samlResponse) + require.Nil(t, err) + require.NotNil(t, auth) + info, ok := auth.(*resp) + require.True(t, ok) + status, err := info.status() + assert.Nil(t, err) + assert.Equal(t, Success, status) + assert.Equal(t, "john@kolide.co", auth.UserID()) +} diff --git a/server/sso/session_store.go b/server/sso/session_store.go new file mode 100644 index 0000000000..887c691bdf --- /dev/null +++ b/server/sso/session_store.go @@ -0,0 +1,90 @@ +package sso + +import ( + "bytes" + "encoding/json" + "time" + + "github.com/garyburd/redigo/redis" + "github.com/pkg/errors" +) + +// Session stores state for the lifetime of a single sign on session +type Session struct { + // OriginalURL is the resource being accessed when login request was triggered + OriginalURL string `json:"original_url"` + // UserName is only assigned from the IDP auth response, if present it + // indicates the that user has authenticated against the IDP. + UserName string `json:"user_name"` + // ExpiresAt session will be removed after this time. + ExpiresAt time.Time `json:"expires_at"` + Metadata string `json:"metadata"` +} + +// SessionStore persists state of a sso session across process boundries and +// method calls by associating the state of the sign on session with a unique +// token created by the user agent (browser SPA). The lifetime of the state object +// is constrained in the backing store (Redis) so if the sso process is not completed in +// a reasonable amount of time, it automatically expires and is removed. +type SessionStore interface { + create(requestID, originalURL, x509Cert string, lifetimeSecs uint) error + Get(requestID string) (*Session, error) + Expire(requestID string) error +} + +// NewSessionStore creates a SessionStore +func NewSessionStore(pool *redis.Pool) SessionStore { + return &store{pool} +} + +type store struct { + pool *redis.Pool +} + +func (s *store) create(requestID, originalURL, metadata string, lifetimeSecs uint) error { + if len(requestID) < 8 { + return errors.New("request id must be 8 or more characters in length") + } + conn := s.pool.Get() + defer conn.Close() + sess := Session{OriginalURL: originalURL, Metadata: metadata} + var writer bytes.Buffer + err := json.NewEncoder(&writer).Encode(sess) + if err != nil { + return err + } + _, err = conn.Do("SETEX", requestID, lifetimeSecs, writer.String()) + if err != nil { + return err + } + return err +} + +func (s *store) Get(requestID string) (*Session, error) { + conn := s.pool.Get() + defer conn.Close() + val, err := redis.String(conn.Do("GET", requestID)) + if err != nil { + if err == redis.ErrNil { + return nil, ErrSessionNotFound + } + return nil, err + } + + var sess Session + reader := bytes.NewBufferString(val) + err = json.NewDecoder(reader).Decode(&sess) + if err != nil { + return nil, err + } + return &sess, nil +} + +var ErrSessionNotFound = errors.New("session not found") + +func (s *store) Expire(requestID string) error { + conn := s.pool.Get() + defer conn.Close() + _, err := conn.Do("DEL", requestID) + return err +} diff --git a/server/sso/session_store_test.go b/server/sso/session_store_test.go new file mode 100644 index 0000000000..03e0e3c367 --- /dev/null +++ b/server/sso/session_store_test.go @@ -0,0 +1,55 @@ +package sso + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/garyburd/redigo/redis" + "github.com/kolide/kolide/server/pubsub" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newPool(t *testing.T) *redis.Pool { + if _, ok := os.LookupEnv("REDIS_TEST"); ok { + var ( + addr = "127.0.0.1:6379" + password = "" + ) + if a, ok := os.LookupEnv("REDIS_PORT_6379_TCP_ADDR"); ok { + addr = fmt.Sprintf("%s:6379", a) + } + + p := pubsub.NewRedisPool(addr, password) + _, err := p.Get().Do("PING") + require.Nil(t, err) + return p + } + return nil +} + +func TestSessionStore(t *testing.T) { + if _, ok := os.LookupEnv("REDIS_TEST"); !ok { + t.Skip("skipping sso session store tests") + } + p := newPool(t) + require.NotNil(t, p) + defer p.Close() + store := NewSessionStore(p) + require.NotNil(t, store) + // Create session that lives for 1 second. + err := store.create("request123", "https://originalurl.com", "some metadata", 1) + require.Nil(t, err) + sess, err := store.Get("request123") + require.Nil(t, err) + require.NotNil(t, sess) + assert.Equal(t, "https://originalurl.com", sess.OriginalURL) + assert.Equal(t, "some metadata", sess.Metadata) + // Wait a little bit more than one second, session should no longer be present. + time.Sleep(1100 * time.Millisecond) + sess, err = store.Get("request123") + assert.Equal(t, ErrSessionNotFound, err) + assert.Nil(t, sess) +} diff --git a/server/sso/settings.go b/server/sso/settings.go new file mode 100644 index 0000000000..5f9e8977c7 --- /dev/null +++ b/server/sso/settings.go @@ -0,0 +1,99 @@ +package sso + +import ( + "encoding/xml" + "io/ioutil" + "net/http" + "time" + + "github.com/pkg/errors" + + dsigtypes "github.com/russellhaering/goxmldsig/types" +) + +type Metadata struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"` + EntityID string `xml:"entityID,attr"` + IDPSSODescriptor IDPSSODescriptor `xml:"IDPSSODescriptor"` +} + +type IDPSSODescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"` + KeyDescriptors []KeyDescriptor `xml:"KeyDescriptor"` + NameIDFormats []NameIDFormat `xml:"NameIDFormat"` + SingleSignOnService []SingleSignOnService `xml:"SingleSignOnService"` + Attributes []Attribute `xml:"Attribute"` +} + +type KeyDescriptor struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata KeyDescriptor"` + Use string `xml:"use,attr"` + KeyInfo dsigtypes.KeyInfo `xml:"KeyInfo"` +} + +type NameIDFormat struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata NameIDFormat"` + Value string `xml:",chardata"` +} + +type SingleSignOnService struct { + XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SingleSignOnService"` + Binding string `xml:"Binding,attr"` + Location string `xml:"Location,attr"` +} + +const ( + PasswordProtectedTransport = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport" + PostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + RedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" +) + +type Settings struct { + Metadata *Metadata + // AssertionConsumerServiceURL is the call back on the service provider which responds + // to the IDP + AssertionConsumerServiceURL string + SessionStore SessionStore + OriginalURL string +} + +// ParseMetadata writes metadata xml to a struct +func ParseMetadata(metadata string) (*Metadata, error) { + var md Metadata + err := xml.Unmarshal([]byte(metadata), &md) + if err != nil { + return nil, err + } + return &md, nil +} + +// GetMetadata retrieves information describing how to interact with a particular +// IDP via a remote URL. metadataURL is the location where the metadata is located +// and timeout defines how long to wait to get a response form the metadata +// server. +func GetMetadata(metadataURL string, timeout time.Duration) (*Metadata, error) { + request, err := http.NewRequest(http.MethodGet, metadataURL, nil) + if err != nil { + return nil, err + } + client := http.Client{Timeout: timeout} + resp, err := client.Do(request) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("SAML metadata server at %s returned %s", metadataURL, resp.Status) + } + xmlData, err := ioutil.ReadAll(resp.Body) + + if err != nil { + return nil, err + } + var md Metadata + err = xml.Unmarshal(xmlData, &md) + if err != nil { + return nil, err + } + return &md, nil +} diff --git a/server/sso/settings_test.go b/server/sso/settings_test.go new file mode 100644 index 0000000000..b58c82bb41 --- /dev/null +++ b/server/sso/settings_test.go @@ -0,0 +1,75 @@ +package sso + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var metadata = ` + + + + + + MIIDpDCCAoygAwIBAgIGAVtYB4c1MA0GCSqGSIb3DQEBCwUAMIGSMQswCQYDVQQGEwJVUzETMBEG +A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU +MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi0xMzIwMzgxHDAaBgkqhkiG9w0BCQEW +DWluZm9Ab2t0YS5jb20wHhcNMTcwNDEwMTMyMTIwWhcNMjcwNDEwMTMyMjIwWjCBkjELMAkGA1UE +BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV +BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtMTMyMDM4MRwwGgYJ +KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +p+FVPm80AKh7HyTrVA05NHz8tMKIjtt0TmbRmjp6Mol+jGLYb5ILzPKQAmdh//a0hEXTTsPNw5H1 +35fl5auXI1n6i8ZnoGc9/Ym7gSWKz93plbu1i3QbxhGHZmKvO66Ba7ag6z6ko6kg9A8k2UK4+5O4 +T0toRUZ54YkH/ugDtfhspjlF5NjNwktL4Dj/EOel5A9I11WnHb2l3tYZkl0/viKSBOHfraPlbFUS +aG8kduQkW7+4bY18JUqDcNtQEVvlz0zm+g3WsfNM/Bhi1bxkI0aDCyZpBsXedaMv4KbZzOudx6LS +epixcLHWku5idBVRXqQTGLQdNW/P1qGQgdAeTQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBvpAp8 +ExV9Wr/Q3x7fvmcNj9qnkONjTFs5k4Qhh5Ms/adq9kM7IgEeY7s6ZksC1v5nuQOAFWOWgzZS3aX3 +Tgl1fvPaZFmq+wcykUPnaBFbY2awRyOeIgdNbUjgr6fvi/D8xvgunFG4TIGqfS33O/+h9bXaCMQB +EyHJq5F+u/h/L8f6CYiEY21qpl9bjL3g+li1tQTP7FnxAR/uj5cUsBp1ZdVUSyEvCWg0hJx6NQQF +3lLbb1x1Xj1Y+GKFjnNudyai660kM02xI4D5kgjz9Yp0c7UQ0Qufnq8OzpdIrVHsw4NIzJpLtO8D +rICQDchR6/cxoQCkoyf+/YTpY492MafV + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + +` + +func TestParseMetadata(t *testing.T) { + + settings, err := ParseMetadata(metadata) + require.Nil(t, err) + + assert.Equal(t, "http://www.okta.com/exka4zkf6dxm8pF220h7", settings.EntityID) + assert.Len(t, settings.IDPSSODescriptor.NameIDFormats, 2) + require.Len(t, settings.IDPSSODescriptor.KeyDescriptors, 1) + assert.True(t, settings.IDPSSODescriptor.KeyDescriptors[0].KeyInfo.X509Data.X509Certificate.Data != "") + require.Len(t, settings.IDPSSODescriptor.SingleSignOnService, 2) + assert.Equal(t, "https://dev-132038.oktapreview.com/app/kolidedev132038_kolide_1/exka4zkf6dxm8pF220h7/sso/saml", + settings.IDPSSODescriptor.SingleSignOnService[0].Location) + assert.Equal(t, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", settings.IDPSSODescriptor.SingleSignOnService[0].Binding) +} + +func TestGetMetadata(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(metadata)) + })) + settings, err := GetMetadata(ts.URL, 2*time.Second) + require.Nil(t, err) + assert.Equal(t, "http://www.okta.com/exka4zkf6dxm8pF220h7", settings.EntityID) + assert.Len(t, settings.IDPSSODescriptor.NameIDFormats, 2) + require.Len(t, settings.IDPSSODescriptor.KeyDescriptors, 1) + assert.True(t, settings.IDPSSODescriptor.KeyDescriptors[0].KeyInfo.X509Data.X509Certificate.Data != "") + require.Len(t, settings.IDPSSODescriptor.SingleSignOnService, 2) + assert.Equal(t, "https://dev-132038.oktapreview.com/app/kolidedev132038_kolide_1/exka4zkf6dxm8pF220h7/sso/saml", + settings.IDPSSODescriptor.SingleSignOnService[0].Location) + assert.Equal(t, "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", settings.IDPSSODescriptor.SingleSignOnService[0].Binding) +} diff --git a/server/sso/types.go b/server/sso/types.go new file mode 100644 index 0000000000..b2469b2721 --- /dev/null +++ b/server/sso/types.go @@ -0,0 +1,267 @@ +package sso + +import "encoding/xml" + +// AuthnRequest contains information needed to request authorization from +// an IDP +// See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf Section 3.4.1 +type AuthnRequest struct { + XMLName xml.Name + SAMLP string `xml:"xmlns:samlp,attr"` + SAML string `xml:"xmlns:saml,attr"` + SAMLSIG string `xml:"xmlns:samlsig,attr,omitempty"` + ID string `xml:"ID,attr"` + Version string `xml:"Version,attr"` + ProtocolBinding string `xml:"ProtocolBinding,attr"` + AssertionConsumerServiceURL string `xml:"AssertionConsumerServiceURL,attr"` + Destination string `xml:"Destination,attr"` + IssueInstant string `xml:"IssueInstant,attr"` + ProviderName string `xml:"ProviderName,attr"` + Issuer Issuer `xml:"Issuer"` + NameIDPolicy *NameIDPolicy `xml:"NameIDPolicy,omitempty"` + RequestedAuthnContext *RequestedAuthnContext `xml:"RequestedAuthnContext,omitempty"` + Signature *Signature `xml:"Signature,omitempty"` + originalString string +} + +// Response is submitted to the service provider (Kolide) from the IDP via a callback. +// It will contain information about a authenticated user that can in turn +// be used to generate a session token. +// See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf Section 3.3.3. +type Response struct { + XMLName xml.Name + SAMLP string `xml:"xmlns:samlp,attr"` + SAML string `xml:"xmlns:saml,attr"` + SAMLSIG string `xml:"xmlns:samlsig,attr"` + Destination string `xml:"Destination,attr"` + ID string `xml:"ID,attr"` + Version string `xml:"Version,attr"` + IssueInstant string `xml:"IssueInstant,attr"` + InResponseTo string `xml:"InResponseTo,attr"` + + Assertion Assertion `xml:"Assertion"` + Signature Signature `xml:"Signature"` + Issuer Issuer `xml:"Issuer"` + Status Status `xml:"Status"` + + originalString string +} + +type Issuer struct { + XMLName xml.Name + Url string `xml:",innerxml"` +} + +type NameIDPolicy struct { + XMLName xml.Name + AllowCreate bool `xml:"AllowCreate,attr"` + Format string `xml:"Format,attr"` +} + +type RequestedAuthnContext struct { + XMLName xml.Name + SAMLP string `xml:"xmlns:samlp,attr"` + Comparison string `xml:"Comparison,attr"` + AuthnContextClassRef AuthnContextClassRef `xml:"AuthnContextClassRef"` +} + +type AuthnContextClassRef struct { + XMLName xml.Name + SAML string `xml:"xmlns:saml,attr"` + Transport string `xml:",innerxml"` +} + +type Signature struct { + XMLName xml.Name + Id string `xml:"Id,attr"` + SignedInfo SignedInfo + SignatureValue SignatureValue + KeyInfo KeyInfo +} + +type SignedInfo struct { + XMLName xml.Name + CanonicalizationMethod CanonicalizationMethod + SignatureMethod SignatureMethod + SamlsigReference SamlsigReference +} + +type SignatureValue struct { + XMLName xml.Name + Value string `xml:",innerxml"` +} + +type KeyInfo struct { + XMLName xml.Name + X509Data X509Data `xml:",innerxml"` +} + +type CanonicalizationMethod struct { + XMLName xml.Name + Algorithm string `xml:"Algorithm,attr"` +} + +type SignatureMethod struct { + XMLName xml.Name + Algorithm string `xml:"Algorithm,attr"` +} + +type SamlsigReference struct { + XMLName xml.Name + URI string `xml:"URI,attr"` + Transforms Transforms `xml:",innerxml"` + DigestMethod DigestMethod `xml:",innerxml"` + DigestValue DigestValue `xml:",innerxml"` +} + +type X509Data struct { + XMLName xml.Name + X509Certificate X509Certificate `xml:",innerxml"` +} + +type Transforms struct { + XMLName xml.Name + Transform Transform +} + +type DigestMethod struct { + XMLName xml.Name + Algorithm string `xml:"Algorithm,attr"` +} + +type DigestValue struct { + XMLName xml.Name +} + +type X509Certificate struct { + XMLName xml.Name + Cert string `xml:",innerxml"` +} + +type Transform struct { + XMLName xml.Name + Algorithm string `xml:"Algorithm,attr"` +} + +type EntityDescriptor struct { + XMLName xml.Name + DS string `xml:"xmlns:ds,attr"` + XMLNS string `xml:"xmlns,attr"` + MD string `xml:"xmlns:md,attr"` + EntityId string `xml:"entityID,attr"` + + Extensions Extensions `xml:"Extensions"` + SPSSODescriptor SPSSODescriptor `xml:"SPSSODescriptor"` +} + +type Extensions struct { + XMLName xml.Name + Alg string `xml:"xmlns:alg,attr"` + MDAttr string `xml:"xmlns:mdattr,attr"` + MDRPI string `xml:"xmlns:mdrpi,attr"` + + EntityAttributes string `xml:"EntityAttributes"` +} + +type SPSSODescriptor struct { + XMLName xml.Name + ProtocolSupportEnumeration string `xml:"protocolSupportEnumeration,attr"` + SigningKeyDescriptor KeyDescriptor + EncryptionKeyDescriptor KeyDescriptor + AssertionConsumerServices []AssertionConsumerService +} + +type EntityAttributes struct { + XMLName xml.Name + SAML string `xml:"xmlns:saml,attr"` + + EntityAttributes []Attribute `xml:"Attribute"` // should be array?? +} + +type SPSSODescriptors struct { +} + +type SingleLogoutService struct { + Binding string `xml:"Binding,attr"` + Location string `xml:"Location,attr"` +} + +type AssertionConsumerService struct { + XMLName xml.Name + Binding string `xml:"Binding,attr"` + Location string `xml:"Location,attr"` + Index string `xml:"index,attr"` +} + +type Assertion struct { + XMLName xml.Name + ID string `xml:"ID,attr"` + Version string `xml:"Version,attr"` + XS string `xml:"xmlns:xs,attr"` + XSI string `xml:"xmlns:xsi,attr"` + SAML string `xml:"saml,attr"` + IssueInstant string `xml:"IssueInstant,attr"` + Issuer Issuer `xml:"Issuer"` + Subject Subject + Conditions Conditions + AttributeStatement AttributeStatement +} + +type Conditions struct { + XMLName xml.Name + NotBefore string `xml:",attr"` + NotOnOrAfter string `xml:",attr"` +} + +type Subject struct { + XMLName xml.Name + NameID NameID + SubjectConfirmation SubjectConfirmation +} + +type SubjectConfirmation struct { + XMLName xml.Name + Method string `xml:",attr"` + SubjectConfirmationData SubjectConfirmationData +} + +type Status struct { + XMLName xml.Name + StatusCode StatusCode `xml:"StatusCode"` +} + +type SubjectConfirmationData struct { + InResponseTo string `xml:",attr"` + NotOnOrAfter string `xml:",attr"` + Recipient string `xml:",attr"` +} + +type NameID struct { + XMLName xml.Name + Format string `xml:",attr"` + Value string `xml:",innerxml"` +} + +type StatusCode struct { + XMLName xml.Name + Value string `xml:",attr"` +} + +type AttributeValue struct { + XMLName xml.Name + Type string `xml:"xsi:type,attr"` + Value string `xml:",innerxml"` +} + +type Attribute struct { + XMLName xml.Name + Name string `xml:",attr"` + FriendlyName string `xml:",attr"` + NameFormat string `xml:",attr"` + AttributeValues []AttributeValue `xml:"AttributeValue"` +} + +type AttributeStatement struct { + XMLName xml.Name + Attributes []Attribute `xml:"Attribute"` +} diff --git a/server/sso/validate.go b/server/sso/validate.go new file mode 100644 index 0000000000..a99ee70b9d --- /dev/null +++ b/server/sso/validate.go @@ -0,0 +1,168 @@ +package sso + +import ( + "crypto/x509" + "encoding/base64" + "encoding/xml" + "time" + + "github.com/beevik/etree" + "github.com/kolide/kolide/server/kolide" + "github.com/pkg/errors" + gosamltypes "github.com/russellhaering/gosaml2/types" + dsig "github.com/russellhaering/goxmldsig" + "github.com/russellhaering/goxmldsig/etreeutils" +) + +type Validator interface { + ValidateSignature(auth kolide.Auth) (kolide.Auth, error) + ValidateResponse(auth kolide.Auth) error +} + +type validator struct { + context *dsig.ValidationContext + clock *dsig.Clock + metadata gosamltypes.EntityDescriptor +} + +func Clock(clock *dsig.Clock) func(v *validator) { + return func(v *validator) { + v.clock = clock + } +} + +// NewValidator is used to validate the response to an auth request. +// metadata is from the IDP. +func NewValidator(metadata string, opts ...func(v *validator)) (Validator, error) { + var v validator + + err := xml.Unmarshal([]byte(metadata), &v.metadata) + if err != nil { + return nil, errors.Wrap(err, "unmarshalling metadata") + } + var idpCertStore dsig.MemoryX509CertificateStore + for _, key := range v.metadata.IDPSSODescriptor.KeyDescriptors { + certData, err := base64.StdEncoding.DecodeString(key.KeyInfo.X509Data.X509Certificate.Data) + if err != nil { + return nil, errors.Wrap(err, "decoding idp x509 cert") + } + cert, err := x509.ParseCertificate(certData) + if err != nil { + return nil, errors.Wrap(err, "parsing idp x509 cert") + } + idpCertStore.Roots = append(idpCertStore.Roots, cert) + } + for _, opt := range opts { + opt(&v) + } + if v.clock == nil { + v.clock = dsig.NewRealClock() + } + v.context = dsig.NewDefaultValidationContext(&idpCertStore) + v.context.Clock = v.clock + return &v, nil +} + +func (v *validator) ValidateResponse(auth kolide.Auth) error { + info := auth.(*resp) + // make sure response is current + onOrAfter, err := time.Parse(time.RFC3339, info.response.Assertion.Conditions.NotOnOrAfter) + if err != nil { + return errors.Wrap(err, "missing timestamp from condition") + } + notBefore, err := time.Parse(time.RFC3339, info.response.Assertion.Conditions.NotBefore) + if err != nil { + return errors.Wrap(err, "missing timestamp from condition") + } + currentTime := v.clock.Now() + if currentTime.After(onOrAfter) { + return errors.New("response expired") + } + if currentTime.Before(notBefore) { + return errors.New("response too early") + } + if auth.UserID() == "" { + return errors.New("missing user id") + } + return nil +} + +func (v *validator) ValidateSignature(auth kolide.Auth) (kolide.Auth, error) { + info := auth.(*resp) + status, err := info.status() + if err != nil { + return nil, errors.New("missing or malformed response") + } + if status != Success { + return nil, errors.Errorf("response status %s", info.statusDescription()) + } + decoded, err := base64.StdEncoding.DecodeString(info.rawResponse()) + if err != nil { + return nil, errors.Wrap(err, "based64 decoding response") + } + doc := etree.NewDocument() + err = doc.ReadFromBytes(decoded) + if err != nil || doc.Root() == nil { + return nil, errors.Wrap(err, "parsing xml response") + } + elt := doc.Root() + signed, err := v.validateSignature(elt) + if err != nil { + return nil, errors.Wrap(err, "signing verification failed") + } + // We've verified that the response hasn't been tampered with at this point + signedDoc := etree.NewDocument() + signedDoc.SetRoot(signed) + buffer, err := doc.WriteToBytes() + if err != nil { + return nil, errors.Wrap(err, "creating signed doc buffer") + } + var response Response + err = xml.Unmarshal(buffer, &response) + if err != nil { + return nil, errors.Wrap(err, "unmarshalling signed doc") + } + info.setResponse(&response) + return info, nil +} + +func (v *validator) validateSignature(elt *etree.Element) (*etree.Element, error) { + validated, err := v.context.Validate(elt) + if err == nil { + // If entire doc is signed, success, we're done. + return validated, nil + } + + if err == dsig.ErrMissingSignature { + // If entire document is not signed find signed assertions, remove assertions + // that are not signed. + err = v.validateAssertionSignature(elt) + if err != nil { + return nil, err + } + return elt, nil + } + + return nil, err +} + +func (v *validator) validateAssertionSignature(elt *etree.Element) error { + validateAssertion := func(ctx etreeutils.NSContext, unverified *etree.Element) error { + if unverified.Parent() != elt { + return errors.Errorf("assertion with unexpected parent: %s", unverified.Parent().Tag) + } + // Remove assertions that are not signed. + detached, err := etreeutils.NSDetatch(ctx, unverified) + if err != nil { + return err + } + signed, err := v.context.Validate(detached) + if err != nil { + return err + } + elt.RemoveChild(unverified) + elt.AddChild(signed) + return nil + } + return etreeutils.NSFindIterate(elt, "urn:oasis:names:tc:SAML:2.0:assertion", "Assertion", validateAssertion) +} diff --git a/server/sso/validate_test.go b/server/sso/validate_test.go new file mode 100644 index 0000000000..5526fe25b7 --- /dev/null +++ b/server/sso/validate_test.go @@ -0,0 +1,107 @@ +package sso + +import ( + "bytes" + "encoding/base64" + "encoding/xml" + "testing" + "time" + + dsig "github.com/russellhaering/goxmldsig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testMetadata = ` + + + + + + + MIIErDCCA5SgAwIBAgIOAVuhH3WkAAAAAB5NpvIwDQYJKoZIhvcNAQELBQAwgZAxKDAmBgNVBAMMH1NlbGZTaWduZWRDZXJ0XzI0QXByMjAxN18xODAwNDQxGDAWBgNVBAsMDzAwRDZBMDAwMDAwMTd0ODEXMBUGA1UECgwOU2FsZXNmb3JjZS5jb20xFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xCzAJBgNVBAgMAkNBMQwwCgYDVQQGEwNVU0EwHhcNMTcwNDI0MTgwMDQ1WhcNMTgwNDI0MTIwMDAwWjCBkDEoMCYGA1UEAwwfU2VsZlNpZ25lZENlcnRfMjRBcHIyMDE3XzE4MDA0NDEYMBYGA1UECwwPMDBENkEwMDAwMDAxN3Q4MRcwFQYDVQQKDA5TYWxlc2ZvcmNlLmNvbTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzELMAkGA1UECAwCQ0ExDDAKBgNVBAYTA1VTQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIOR7h8BF2eFOlQHhV/1S7uOBN22Jv7PDCXMz2fU0uLc+mrv9xDGj6ElfW+9dSdXaCbQzD3+Xq4reS4pYRafJZ/27OtygXl3rpoPjSlhRiW+oYVuDcCURJpu0KuZ4I0fm5q1BDYqxcBxNPSe85OHE3+ucmKqvPozhQgYLPCregMIomC3yyANZnLCoGfCv9TpQl6/+I182tST4WPNhVPxKxijoPU4Rh6xY34Ez8+Jr8KdmzmYSNe4ukkIASplpvG7rKka824Hf8zI1BWnjWLDxb5IAxgUBbdr4x8d8C3kPfTf+3/6yC5wSOm9NSs0BA4OJNowtXZFryMzFfXzDzjl69kCAwEAAaOCAQAwgf0wHQYDVR0OBBYEFO+DkoP6qkysi9ZC74yTPuJVVg2yMA8GA1UdEwEB/wQFMAMBAf8wgcoGA1UdIwSBwjCBv4AU74OSg/qqTKyL1kLvjJM+4lVWDbKhgZakgZMwgZAxKDAmBgNVBAMMH1NlbGZTaWduZWRDZXJ0XzI0QXByMjAxN18xODAwNDQxGDAWBgNVBAsMDzAwRDZBMDAwMDAwMTd0ODEXMBUGA1UECgwOU2FsZXNmb3JjZS5jb20xFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xCzAJBgNVBAgMAkNBMQwwCgYDVQQGEwNVU0GCDgFboR91pAAAAAAeTabyMA0GCSqGSIb3DQEBCwUAA4IBAQAVhYBv5GJvhltks2j7Zc9wdFHW7yB4/hPFo05y0yiOf71tLjOlBucSyxtmXLPjrECJvIJwKhsAIgYXnVp7ditxfauCcxczJgfeL1/dxH/Ge8ePkmH6SdsO71cJL8dXEzOsoF+PAVQzUhqh8zxIipntL0wwNGTD0zIVQeTSozm0KF0SsSHIfbNy279uReGonC61i4Ouk5AMKA7Re9fVeUs6tqM2at22h9Zaj/r/OhXoDcZhzkd8Wq0ER/UKLZA1CyJHgwOC7REEZOuKrqgfWcYt4dGo5q6gqGHHPMv0N7s/MxqCvJCwGA8eJGvOO56I321vhWHQ6ZSJDWUqQFM/Ze7A + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + +` + +func TestNewValidator(t *testing.T) { + v, err := NewValidator(testMetadata) + assert.Nil(t, err) + assert.NotNil(t, v) +} + +var testResponse = `PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbHA6UmVzcG9uc2UgeG1sbnM6c2FtbHA9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgRGVzdGluYXRpb249Imh0dHBzOi8vbG9jYWxob3N0OjgwODAvYXBpL3YxL2tvbGlkZS9zc28vY2FsbGJhY2siIElEPSJfYzgyMWM1MmUzZDJkN2NhMjFiMmJlNmJlOTQ4Y2RiYjAxNDkzNTkwMTA2NjM1IiBJblJlc3BvbnNlVG89IjM5MTY5NzllLTNhZTItNGU4My04N2I0LWZlMmViODg0Yjg5MSIgSXNzdWVJbnN0YW50PSIyMDE3LTA0LTMwVDIyOjA4OjI2LjYzNVoiIFZlcnNpb249IjIuMCI+PHNhbWw6SXNzdWVyIHhtbG5zOnNhbWw9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphc3NlcnRpb24iIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwczovL2tvbGlkZS1kZXYtZWQubXkuc2FsZXNmb3JjZS5jb208L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgo8ZHM6U2lnbmVkSW5mbz4KPGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4KPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPgo8ZHM6UmVmZXJlbmNlIFVSST0iI19jODIxYzUyZTNkMmQ3Y2EyMWIyYmU2YmU5NDhjZGJiMDE0OTM1OTAxMDY2MzUiPgo8ZHM6VHJhbnNmb3Jtcz4KPGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+CjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiPjxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIHhtbG5zOmVjPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIFByZWZpeExpc3Q9ImRzIHNhbWwgc2FtbHAgeHMgeHNpIi8+PC9kczpUcmFuc2Zvcm0+CjwvZHM6VHJhbnNmb3Jtcz4KPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIi8+CjxkczpEaWdlc3RWYWx1ZT5PWnMxdHRQamtKYnF6Mk8yb3UraDJZK3FFYms9PC9kczpEaWdlc3RWYWx1ZT4KPC9kczpSZWZlcmVuY2U+CjwvZHM6U2lnbmVkSW5mbz4KPGRzOlNpZ25hdHVyZVZhbHVlPgpWMmlSWlRIdUpCajBnekYzYzFHVzBGY0JiRlR0QXlWUml4VWdtR3ZrR0xhRzBBeElBZkY4ejdxbUlMTkV4cDlEUjJJU1F5a2lCU3p6CkpBQllRREU2T0V6ak5XTnYyS2NsUTRjQ0VOOUdIbHh6bEo4dkFwQzRsdkV3aGlQL04zd0VKb3RGTlN2MVZRd0YvdWFmZ1Z6b1NIeVIKb3RYaEJ0akFjcktBV25kRWo5L3QvdWV0SkI4dHZ5OXdybzhtS3RIZVNiTmJoZ0dwVEgyVHpuUnFxRnhwS1lRT29adFExaEpOVSsvdApMWGVpbWxjc0QrSHFQejlIT0crN0JuQllZZTkyN1dZRWRqREFLODVmMDY3ekN5T1RmK3pnVFNJNCs5WUVsbUo1OVZ6RFRwR3kyZENVCkwvZHJrSjhDSUJKZzZ4ekc1aVAwbFVGay81TmFrclRxZG0wNkF3PT0KPC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRXJEQ0NBNVNnQXdJQkFnSU9BVnVoSDNXa0FBQUFBQjVOcHZJd0RRWUpLb1pJaHZjTkFRRUxCUUF3Z1pBeEtEQW1CZ05WQkFNTQpIMU5sYkdaVGFXZHVaV1JEWlhKMFh6STBRWEJ5TWpBeE4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwCk9ERVhNQlVHQTFVRUNnd09VMkZzWlhObWIzSmpaUzVqYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlYKQkFnTUFrTkJNUXd3Q2dZRFZRUUdFd05WVTBFd0hoY05NVGN3TkRJME1UZ3dNRFExV2hjTk1UZ3dOREkwTVRJd01EQXdXakNCa0RFbwpNQ1lHQTFVRUF3d2ZVMlZzWmxOcFoyNWxaRU5sY25SZk1qUkJjSEl5TURFM1h6RTRNREEwTkRFWU1CWUdBMVVFQ3d3UE1EQkVOa0V3Ck1EQXdNREF4TjNRNE1SY3dGUVlEVlFRS0RBNVRZV3hsYzJadmNtTmxMbU52YlRFV01CUUdBMVVFQnd3TlUyRnVJRVp5WVc1amFYTmoKYnpFTE1Ba0dBMVVFQ0F3Q1EwRXhEREFLQmdOVkJBWVRBMVZUUVRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQwpnZ0VCQUlPUjdoOEJGMmVGT2xRSGhWLzFTN3VPQk4yMkp2N1BEQ1hNejJmVTB1TGMrbXJ2OXhER2o2RWxmVys5ZFNkWGFDYlF6RDMrClhxNHJlUzRwWVJhZkpaLzI3T3R5Z1hsM3Jwb1BqU2xoUmlXK29ZVnVEY0NVUkpwdTBLdVo0STBmbTVxMUJEWXF4Y0J4TlBTZTg1T0gKRTMrdWNtS3F2UG96aFFnWUxQQ3JlZ01Jb21DM3l5QU5abkxDb0dmQ3Y5VHBRbDYvK0kxODJ0U1Q0V1BOaFZQeEt4aWpvUFU0Umg2eApZMzRFejgrSnI4S2Rtem1ZU05lNHVra0lBU3BscHZHN3JLa2E4MjRIZjh6STFCV25qV0xEeGI1SUF4Z1VCYmRyNHg4ZDhDM2tQZlRmCiszLzZ5QzV3U09tOU5TczBCQTRPSk5vd3RYWkZyeU16RmZYekR6amw2OWtDQXdFQUFhT0NBUUF3Z2Ywd0hRWURWUjBPQkJZRUZPK0QKa29QNnFreXNpOVpDNzR5VFB1SlZWZzJ5TUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3Z2NvR0ExVWRJd1NCd2pDQnY0QVU3NE9TZy9xcQpUS3lMMWtMdmpKTSs0bFZXRGJLaGdaYWtnWk13Z1pBeEtEQW1CZ05WQkFNTUgxTmxiR1pUYVdkdVpXUkRaWEowWHpJMFFYQnlNakF4Ck4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwT0RFWE1CVUdBMVVFQ2d3T1UyRnNaWE5tYjNKalpTNWoKYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlZCQWdNQWtOQk1Rd3dDZ1lEVlFRR0V3TlZVMEdDRGdGYgpvUjkxcEFBQUFBQWVUYWJ5TUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBVmhZQnY1R0p2aGx0a3MyajdaYzl3ZEZIVzd5QjQvaFBGCm8wNXkweWlPZjcxdExqT2xCdWNTeXh0bVhMUGpyRUNKdklKd0toc0FJZ1lYblZwN2RpdHhmYXVDY3hjekpnZmVMMS9keEgvR2U4ZVAKa21INlNkc083MWNKTDhkWEV6T3NvRitQQVZRelVocWg4enhJaXBudEwwd3dOR1REMHpJVlFlVFNvem0wS0YwU3NTSElmYk55Mjc5dQpSZUdvbkM2MWk0T3VrNUFNS0E3UmU5ZlZlVXM2dHFNMmF0MjJoOVphai9yL09oWG9EY1poemtkOFdxMEVSL1VLTFpBMUN5Skhnd09DCjdSRUVaT3VLcnFnZldjWXQ0ZEdvNXE2Z3FHSEhQTXYwTjdzL014cUN2SkN3R0E4ZUpHdk9PNTZJMzIxdmhXSFE2WlNKRFdVcVFGTS8KWmU3QTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1scDpTdGF0dXM+PHNhbWxwOlN0YXR1c0NvZGUgVmFsdWU9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpzdGF0dXM6U3VjY2VzcyIvPjwvc2FtbHA6U3RhdHVzPjxzYW1sOkFzc2VydGlvbiB4bWxuczpzYW1sPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBJRD0iX2YyZTljM2Y3MDAwYzQwZjAyNDEwMjhlYzkwNDMxNDg2MTQ5MzU5MDEwNjYzNiIgSXNzdWVJbnN0YW50PSIyMDE3LTA0LTMwVDIyOjA4OjI2LjYzNloiIFZlcnNpb249IjIuMCI+PHNhbWw6SXNzdWVyIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOm5hbWVpZC1mb3JtYXQ6ZW50aXR5Ij5odHRwczovL2tvbGlkZS1kZXYtZWQubXkuc2FsZXNmb3JjZS5jb208L3NhbWw6SXNzdWVyPjxkczpTaWduYXR1cmUgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiPgo8ZHM6U2lnbmVkSW5mbz4KPGRzOkNhbm9uaWNhbGl6YXRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiLz4KPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPgo8ZHM6UmVmZXJlbmNlIFVSST0iI19mMmU5YzNmNzAwMGM0MGYwMjQxMDI4ZWM5MDQzMTQ4NjE0OTM1OTAxMDY2MzYiPgo8ZHM6VHJhbnNmb3Jtcz4KPGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+CjxkczpUcmFuc2Zvcm0gQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiPjxlYzpJbmNsdXNpdmVOYW1lc3BhY2VzIHhtbG5zOmVjPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzEwL3htbC1leGMtYzE0biMiIFByZWZpeExpc3Q9ImRzIHNhbWwgeHMgeHNpIi8+PC9kczpUcmFuc2Zvcm0+CjwvZHM6VHJhbnNmb3Jtcz4KPGRzOkRpZ2VzdE1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNzaGExIi8+CjxkczpEaWdlc3RWYWx1ZT5vbjdQZ1B4TEpiOG1sbS9pK01EUU1yMVZZeVk9PC9kczpEaWdlc3RWYWx1ZT4KPC9kczpSZWZlcmVuY2U+CjwvZHM6U2lnbmVkSW5mbz4KPGRzOlNpZ25hdHVyZVZhbHVlPgpSVEF0MkJHc2ZRRXlJWXU1VUNRZmhYcWwxWE9mWHgraDgrNnNhM1JNMENVNVVYUVBFVjd5RWxFdXZWUWhHZVpNamN0eFZMR1BjZ01WCktYVGowM1Y0RHdmai95aHQwSHVHdktoQXZQUklScnBVYlJOUWEzZWZDMnZDQ05HUjdQR2czSVllZXI4Rk41ZEJiZk5CQW1oQUltdk8KWmdXd2QwSmlpV1FleWNreEdBZmZjZEZ4OC9Ra0dxdVp1S0dGQkMyZ2pJV0ZncXhMcWs1RTJYeDlybW5GamY2TGVJajlIc0duWlUxeApqTE1seTJLRjJyUGRsVmR1UGlTWko3T1JjZVVycmo2YUI4RlNQVGdoa3pnWVV1cU1rbWFjYzlXYVZtc01lTGI3TC9XSTlBOHhIcUhzClZsTkp3ZzZiMWpXMnd5VW1mTzVoVWtxMW9rdDlTZjFST0lNYmFnPT0KPC9kczpTaWduYXR1cmVWYWx1ZT4KPGRzOktleUluZm8+PGRzOlg1MDlEYXRhPjxkczpYNTA5Q2VydGlmaWNhdGU+TUlJRXJEQ0NBNVNnQXdJQkFnSU9BVnVoSDNXa0FBQUFBQjVOcHZJd0RRWUpLb1pJaHZjTkFRRUxCUUF3Z1pBeEtEQW1CZ05WQkFNTQpIMU5sYkdaVGFXZHVaV1JEWlhKMFh6STBRWEJ5TWpBeE4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwCk9ERVhNQlVHQTFVRUNnd09VMkZzWlhObWIzSmpaUzVqYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlYKQkFnTUFrTkJNUXd3Q2dZRFZRUUdFd05WVTBFd0hoY05NVGN3TkRJME1UZ3dNRFExV2hjTk1UZ3dOREkwTVRJd01EQXdXakNCa0RFbwpNQ1lHQTFVRUF3d2ZVMlZzWmxOcFoyNWxaRU5sY25SZk1qUkJjSEl5TURFM1h6RTRNREEwTkRFWU1CWUdBMVVFQ3d3UE1EQkVOa0V3Ck1EQXdNREF4TjNRNE1SY3dGUVlEVlFRS0RBNVRZV3hsYzJadmNtTmxMbU52YlRFV01CUUdBMVVFQnd3TlUyRnVJRVp5WVc1amFYTmoKYnpFTE1Ba0dBMVVFQ0F3Q1EwRXhEREFLQmdOVkJBWVRBMVZUUVRDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQwpnZ0VCQUlPUjdoOEJGMmVGT2xRSGhWLzFTN3VPQk4yMkp2N1BEQ1hNejJmVTB1TGMrbXJ2OXhER2o2RWxmVys5ZFNkWGFDYlF6RDMrClhxNHJlUzRwWVJhZkpaLzI3T3R5Z1hsM3Jwb1BqU2xoUmlXK29ZVnVEY0NVUkpwdTBLdVo0STBmbTVxMUJEWXF4Y0J4TlBTZTg1T0gKRTMrdWNtS3F2UG96aFFnWUxQQ3JlZ01Jb21DM3l5QU5abkxDb0dmQ3Y5VHBRbDYvK0kxODJ0U1Q0V1BOaFZQeEt4aWpvUFU0Umg2eApZMzRFejgrSnI4S2Rtem1ZU05lNHVra0lBU3BscHZHN3JLa2E4MjRIZjh6STFCV25qV0xEeGI1SUF4Z1VCYmRyNHg4ZDhDM2tQZlRmCiszLzZ5QzV3U09tOU5TczBCQTRPSk5vd3RYWkZyeU16RmZYekR6amw2OWtDQXdFQUFhT0NBUUF3Z2Ywd0hRWURWUjBPQkJZRUZPK0QKa29QNnFreXNpOVpDNzR5VFB1SlZWZzJ5TUE4R0ExVWRFd0VCL3dRRk1BTUJBZjh3Z2NvR0ExVWRJd1NCd2pDQnY0QVU3NE9TZy9xcQpUS3lMMWtMdmpKTSs0bFZXRGJLaGdaYWtnWk13Z1pBeEtEQW1CZ05WQkFNTUgxTmxiR1pUYVdkdVpXUkRaWEowWHpJMFFYQnlNakF4Ck4xOHhPREF3TkRReEdEQVdCZ05WQkFzTUR6QXdSRFpCTURBd01EQXdNVGQwT0RFWE1CVUdBMVVFQ2d3T1UyRnNaWE5tYjNKalpTNWoKYjIweEZqQVVCZ05WQkFjTURWTmhiaUJHY21GdVkybHpZMjh4Q3pBSkJnTlZCQWdNQWtOQk1Rd3dDZ1lEVlFRR0V3TlZVMEdDRGdGYgpvUjkxcEFBQUFBQWVUYWJ5TUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBVmhZQnY1R0p2aGx0a3MyajdaYzl3ZEZIVzd5QjQvaFBGCm8wNXkweWlPZjcxdExqT2xCdWNTeXh0bVhMUGpyRUNKdklKd0toc0FJZ1lYblZwN2RpdHhmYXVDY3hjekpnZmVMMS9keEgvR2U4ZVAKa21INlNkc083MWNKTDhkWEV6T3NvRitQQVZRelVocWg4enhJaXBudEwwd3dOR1REMHpJVlFlVFNvem0wS0YwU3NTSElmYk55Mjc5dQpSZUdvbkM2MWk0T3VrNUFNS0E3UmU5ZlZlVXM2dHFNMmF0MjJoOVphai9yL09oWG9EY1poemtkOFdxMEVSL1VLTFpBMUN5Skhnd09DCjdSRUVaT3VLcnFnZldjWXQ0ZEdvNXE2Z3FHSEhQTXYwTjdzL014cUN2SkN3R0E4ZUpHdk9PNTZJMzIxdmhXSFE2WlNKRFdVcVFGTS8KWmU3QTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPjxzYW1sOlN1YmplY3Q+PHNhbWw6TmFtZUlEIEZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6MS4xOm5hbWVpZC1mb3JtYXQ6dW5zcGVjaWZpZWQiPmpvaG5Aa29saWRlLmNvPC9zYW1sOk5hbWVJRD48c2FtbDpTdWJqZWN0Q29uZmlybWF0aW9uIE1ldGhvZD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmNtOmJlYXJlciI+PHNhbWw6U3ViamVjdENvbmZpcm1hdGlvbkRhdGEgSW5SZXNwb25zZVRvPSIzOTE2OTc5ZS0zYWUyLTRlODMtODdiNC1mZTJlYjg4NGI4OTEiIE5vdE9uT3JBZnRlcj0iMjAxNy0wNC0zMFQyMjoxMzoyNi42NDNaIiBSZWNpcGllbnQ9Imh0dHBzOi8vbG9jYWxob3N0OjgwODAvYXBpL3YxL2tvbGlkZS9zc28vY2FsbGJhY2siLz48L3NhbWw6U3ViamVjdENvbmZpcm1hdGlvbj48L3NhbWw6U3ViamVjdD48c2FtbDpDb25kaXRpb25zIE5vdEJlZm9yZT0iMjAxNy0wNC0zMFQyMjowNzo1Ni42NDNaIiBOb3RPbk9yQWZ0ZXI9IjIwMTctMDQtMzBUMjI6MTM6MjYuNjQzWiI+PHNhbWw6QXVkaWVuY2VSZXN0cmljdGlvbj48c2FtbDpBdWRpZW5jZT5rb2xpZGU8L3NhbWw6QXVkaWVuY2U+PC9zYW1sOkF1ZGllbmNlUmVzdHJpY3Rpb24+PC9zYW1sOkNvbmRpdGlvbnM+PHNhbWw6QXV0aG5TdGF0ZW1lbnQgQXV0aG5JbnN0YW50PSIyMDE3LTA0LTMwVDIyOjA4OjI2LjYzN1oiPjxzYW1sOkF1dGhuQ29udGV4dD48c2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj51cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YWM6Y2xhc3Nlczp1bnNwZWNpZmllZDwvc2FtbDpBdXRobkNvbnRleHRDbGFzc1JlZj48L3NhbWw6QXV0aG5Db250ZXh0Pjwvc2FtbDpBdXRoblN0YXRlbWVudD48c2FtbDpBdHRyaWJ1dGVTdGF0ZW1lbnQ+PHNhbWw6QXR0cmlidXRlIE5hbWU9InVzZXJJZCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1bnNwZWNpZmllZCI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6YW55VHlwZSI+MDA1NkEwMDAwMDBRNlJsPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PHNhbWw6QXR0cmlidXRlIE5hbWU9InVzZXJuYW1lIiBOYW1lRm9ybWF0PSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXR0cm5hbWUtZm9ybWF0OnVuc3BlY2lmaWVkIj48c2FtbDpBdHRyaWJ1dGVWYWx1ZSB4bWxuczp4cz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEiIHhtbG5zOnhzaT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS9YTUxTY2hlbWEtaW5zdGFuY2UiIHhzaTp0eXBlPSJ4czphbnlUeXBlIj5qb2huQGtvbGlkZS5jbzwvc2FtbDpBdHRyaWJ1dGVWYWx1ZT48L3NhbWw6QXR0cmlidXRlPjxzYW1sOkF0dHJpYnV0ZSBOYW1lPSJlbWFpbCIgTmFtZUZvcm1hdD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmF0dHJuYW1lLWZvcm1hdDp1bnNwZWNpZmllZCI+PHNhbWw6QXR0cmlidXRlVmFsdWUgeG1sbnM6eHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hIiB4bWxuczp4c2k9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvWE1MU2NoZW1hLWluc3RhbmNlIiB4c2k6dHlwZT0ieHM6YW55VHlwZSI+am9obkBrb2xpZGUuY288L3NhbWw6QXR0cmlidXRlVmFsdWU+PC9zYW1sOkF0dHJpYnV0ZT48c2FtbDpBdHRyaWJ1dGUgTmFtZT0iaXNfcG9ydGFsX3VzZXIiIE5hbWVGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDphdHRybmFtZS1mb3JtYXQ6dW5zcGVjaWZpZWQiPjxzYW1sOkF0dHJpYnV0ZVZhbHVlIHhtbG5zOnhzPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYSIgeG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIgeHNpOnR5cGU9InhzOmFueVR5cGUiPmZhbHNlPC9zYW1sOkF0dHJpYnV0ZVZhbHVlPjwvc2FtbDpBdHRyaWJ1dGU+PC9zYW1sOkF0dHJpYnV0ZVN0YXRlbWVudD48L3NhbWw6QXNzZXJ0aW9uPjwvc2FtbHA6UmVzcG9uc2U+` + +func TestValidate(t *testing.T) { + tm, err := time.Parse(time.UnixDate, "Sun Apr 30 22:10:00 UTC 2017") + require.Nil(t, err) + clock := dsig.NewFakeClockAt(tm) + validator, err := NewValidator(testMetadata, Clock(clock)) + require.Nil(t, err) + require.NotNil(t, validator) + auth, err := DecodeAuthResponse(testResponse) + signed, err := validator.ValidateSignature(auth) + require.Nil(t, err) + require.NotNil(t, signed) + + err = validator.ValidateResponse(auth) + assert.Nil(t, err) +} + +func tamperedResponse(original string) (string, error) { + decoded, err := base64.StdEncoding.DecodeString(original) + if err != nil { + return "", err + } + var resp Response + rdr := bytes.NewBuffer(decoded) + err = xml.NewDecoder(rdr).Decode(&resp) + if err != nil { + return "", err + } + // change name + resp.Assertion.Subject.NameID.Value = "bob@kolide.co" + var wrtr bytes.Buffer + err = xml.NewEncoder(&wrtr).Encode(resp) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(wrtr.Bytes()), nil +} + +func TestVerfiyValidTamperedWithDocFails(t *testing.T) { + tampered, err := tamperedResponse(testResponse) + require.Nil(t, err) + tm, err := time.Parse(time.UnixDate, "Sun Apr 30 22:10:00 UTC 2017") + require.Nil(t, err) + clock := dsig.NewFakeClockAt(tm) + validator, err := NewValidator(testMetadata, Clock(clock)) + require.Nil(t, err) + require.NotNil(t, validator) + auth, err := DecodeAuthResponse(tampered) + _, err = validator.ValidateSignature(auth) + require.NotNil(t, err) +} + +// Message hasn't been tampered with but is stale +func TestVerfiyStaleMessageFails(t *testing.T) { + tm, err := time.Parse(time.UnixDate, "Sun Apr 30 22:14:00 UTC 2017") + require.Nil(t, err) + clock := dsig.NewFakeClockAt(tm) + validator, err := NewValidator(testMetadata, Clock(clock)) + require.Nil(t, err) + require.NotNil(t, validator) + auth, err := DecodeAuthResponse(testResponse) + signed, err := validator.ValidateSignature(auth) + require.Nil(t, err) + require.NotNil(t, signed) + + err = validator.ValidateResponse(auth) + assert.NotNil(t, err) +} diff --git a/tools/app/authtest.html b/tools/app/authtest.html new file mode 100644 index 0000000000..d9ced0754b --- /dev/null +++ b/tools/app/authtest.html @@ -0,0 +1,108 @@ + + + + + + + +

Single Sign On Test Page

+
+ This page is used to test single sign on identity providers. The Relay URL field contains + the URL of the Kolide resource to invoke after authentication with the IDP. It defaults to this page. + Click Request Authorization + to trigger the authorization process. The browser's javascript console may contain useful + debugging information. The SAML Tracer add-on + for Firefox is also useful in diagnosing problems with a particular identity provider. +
+
+
+ Relay URL: +
+
+ +
+
+
+

Request Authorization

+
+ +