From 368b9d774c887db2b76eaa6b9be2ed063be1f0af Mon Sep 17 00:00:00 2001 From: John Murphy Date: Mon, 8 May 2017 19:43:48 -0500 Subject: [PATCH] Server Side SSO Support (#1498) This PR partially addresses #1456, providing SSO SAML support. The flow of the code is as follows. A Kolide user attempts to access a protected resource and is directed to log in. If SSO identity providers (IDP) have been configured by an admin, the user is presented with SSO log in. The user selects SSO, which invokes a call the InitiateSSO passing the URL of the protected resource that the user was originally trying access. Kolide server loads the IDP metadata and caches it along with the URL. We then build an auth request URL for the IDP which is returned to the front end. The IDP calls the server, invoking CallbackSSO with the auth response. We extract the original request id from the response and use it to fetch the cached metadata and the URL. We check the signature of the response, and validate the timestamps. If everything passes we get the user id from the IDP response and use it to create a login session. We then build a page which executes some javascript that will write the token to web local storage, and redirect to the original URL. I've created a test web page in tools/app/authtest.html that can be used to test and debug new IDP's which also illustrates how a front end would interact with the IDP and the server. This page can be loaded by starting Kolide with the environment variable KOLIDE_TEST_PAGE_PATH to the full path of the page and then accessed at https://localhost:8080/test --- cli/prepare.go | 2 +- cli/serve.go | 21 +- docs/third-party/licenses.md | 4 + glide.lock | 19 +- glide.yaml | 3 +- .../datastore_identity_providers_test.go | 81 ------ server/datastore/datastore_test.go | 1 - server/datastore/inmem/identity_providers.go | 25 -- server/datastore/mysql/app_configs.go | 97 ++++--- server/datastore/mysql/identity_providers.go | 91 ------ ...0411155225_CreateTableIdentityProviders.go | 34 --- .../20170502143928_AddIDPColsToAppConfig.go | 37 +++ server/kolide/app.go | 36 +++ server/kolide/datastore.go | 1 - server/kolide/identity_providers.go | 62 ---- server/kolide/sessions.go | 22 +- server/mock/datastore.go | 2 - server/mock/datastore_hosts.go | 20 +- server/mock/datastore_identity_providers.go | 59 ---- server/service/endpoint_appconfig.go | 21 ++ server/service/endpoint_appconfig_test.go | 19 ++ server/service/endpoint_decorators.go | 2 + server/service/endpoint_decorators_test.go | 1 - server/service/endpoint_sessions.go | 73 +++++ server/service/handler.go | 11 +- server/service/logging_sessions.go | 25 ++ server/service/metrics_sessions.go | 20 ++ server/service/service.go | 17 +- server/service/service_appconfig.go | 24 ++ server/service/service_invites_test.go | 2 +- server/service/service_sessions.go | 88 +++++- server/service/transport.go | 13 + server/service/transport_sessions.go | 24 ++ server/service/util_test.go | 4 +- server/service/validation_app_config.go | 50 ++++ server/service/validation_app_config_test.go | 47 +++ server/service/validation_sessions.go | 35 +++ server/service/validation_users.go | 5 - server/sso/authorization_request.go | 136 +++++++++ server/sso/authorization_request_test.go | 18 ++ server/sso/authorization_response.go | 132 +++++++++ server/sso/authorization_response_test.go | 21 ++ server/sso/session_store.go | 90 ++++++ server/sso/session_store_test.go | 55 ++++ server/sso/settings.go | 99 +++++++ server/sso/settings_test.go | 75 +++++ server/sso/types.go | 267 ++++++++++++++++++ server/sso/validate.go | 168 +++++++++++ server/sso/validate_test.go | 107 +++++++ tools/app/authtest.html | 108 +++++++ 50 files changed, 1946 insertions(+), 428 deletions(-) delete mode 100644 server/datastore/datastore_identity_providers_test.go delete mode 100644 server/datastore/inmem/identity_providers.go delete mode 100644 server/datastore/mysql/identity_providers.go delete mode 100644 server/datastore/mysql/migrations/tables/20170411155225_CreateTableIdentityProviders.go create mode 100644 server/datastore/mysql/migrations/tables/20170502143928_AddIDPColsToAppConfig.go delete mode 100644 server/kolide/identity_providers.go delete mode 100644 server/mock/datastore_identity_providers.go create mode 100644 server/service/validation_app_config.go create mode 100644 server/service/validation_app_config_test.go create mode 100644 server/service/validation_sessions.go create mode 100644 server/sso/authorization_request.go create mode 100644 server/sso/authorization_request_test.go create mode 100644 server/sso/authorization_response.go create mode 100644 server/sso/authorization_response_test.go create mode 100644 server/sso/session_store.go create mode 100644 server/sso/session_store_test.go create mode 100644 server/sso/settings.go create mode 100644 server/sso/settings_test.go create mode 100644 server/sso/types.go create mode 100644 server/sso/validate.go create mode 100644 server/sso/validate_test.go create mode 100644 tools/app/authtest.html 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

+
+ +