diff --git a/e b/e index 6915e4210d56b..69b08e7d42731 160000 --- a/e +++ b/e @@ -1 +1 @@ -Subproject commit 6915e4210d56bd788a405b330da7016a9a752e0d +Subproject commit 69b08e7d42731388572287d35a53aa895b0d78e1 diff --git a/fuzz/oss-fuzz-build.sh b/fuzz/oss-fuzz-build.sh index 355fd81eb25fe..01124f85aa60d 100755 --- a/fuzz/oss-fuzz-build.sh +++ b/fuzz/oss-fuzz-build.sh @@ -26,9 +26,6 @@ build_teleport_fuzzers() { compile_native_go_fuzzer $TELEPORT_PREFIX/lib/services \ FuzzParserEvalBoolPredicate fuzz_parser_eval_bool_predicate - compile_native_go_fuzzer $TELEPORT_PREFIX/lib/auth \ - FuzzParseSAMLInResponseTo fuzz_parse_saml_in_response_to - compile_native_go_fuzzer $TELEPORT_PREFIX/lib/restrictedsession \ FuzzParseIPSpec fuzz_parse_ip_spec diff --git a/lib/auth/apiserver.go b/lib/auth/apiserver.go index af75674e08fb1..0d00f16687d01 100644 --- a/lib/auth/apiserver.go +++ b/lib/auth/apiserver.go @@ -171,8 +171,6 @@ func NewAPIServer(config *APIConfig) (http.Handler, error) { srv.POST("/:version/configuration/static_tokens", srv.WithAuth(srv.setStaticTokens)) // SSO validation handlers - srv.POST("/:version/oidc/requests/validate", srv.WithAuth(srv.validateOIDCAuthCallback)) - srv.POST("/:version/saml/requests/validate", srv.WithAuth(srv.validateSAMLResponse)) srv.POST("/:version/github/requests/validate", srv.WithAuth(srv.validateGithubAuthCallback)) // Audit logs AKA events @@ -868,74 +866,6 @@ func (s *APIServer) getSession(auth ClientI, w http.ResponseWriter, r *http.Requ return se, nil } -func (s *APIServer) validateOIDCAuthCallback(auth ClientI, w http.ResponseWriter, r *http.Request, p httprouter.Params, version string) (interface{}, error) { - var req *ValidateOIDCAuthCallbackReq - if err := httplib.ReadJSON(r, &req); err != nil { - return nil, trace.Wrap(err) - } - response, err := auth.ValidateOIDCAuthCallback(r.Context(), req.Query) - if err != nil { - return nil, trace.Wrap(err) - } - raw := OIDCAuthRawResponse{ - Username: response.Username, - Identity: response.Identity, - Cert: response.Cert, - TLSCert: response.TLSCert, - Req: response.Req, - } - if response.Session != nil { - rawSession, err := services.MarshalWebSession(response.Session, services.WithVersion(version)) - if err != nil { - return nil, trace.Wrap(err) - } - raw.Session = rawSession - } - raw.HostSigners = make([]json.RawMessage, len(response.HostSigners)) - for i, ca := range response.HostSigners { - data, err := services.MarshalCertAuthority(ca, services.WithVersion(version)) - if err != nil { - return nil, trace.Wrap(err) - } - raw.HostSigners[i] = data - } - return &raw, nil -} - -func (s *APIServer) validateSAMLResponse(auth ClientI, w http.ResponseWriter, r *http.Request, p httprouter.Params, version string) (interface{}, error) { - var req *ValidateSAMLResponseReq - if err := httplib.ReadJSON(r, &req); err != nil { - return nil, trace.Wrap(err) - } - response, err := auth.ValidateSAMLResponse(r.Context(), req.Response, req.ConnectorID) - if err != nil { - return nil, trace.Wrap(err) - } - raw := SAMLAuthRawResponse{ - Username: response.Username, - Identity: response.Identity, - Cert: response.Cert, - Req: response.Req, - TLSCert: response.TLSCert, - } - if response.Session != nil { - rawSession, err := services.MarshalWebSession(response.Session, services.WithVersion(version)) - if err != nil { - return nil, trace.Wrap(err) - } - raw.Session = rawSession - } - raw.HostSigners = make([]json.RawMessage, len(response.HostSigners)) - for i, ca := range response.HostSigners { - data, err := services.MarshalCertAuthority(ca, services.WithVersion(version)) - if err != nil { - return nil, trace.Wrap(err) - } - raw.HostSigners[i] = data - } - return &raw, nil -} - // validateGithubAuthCallbackReq is a request to validate Github OAuth2 callback type validateGithubAuthCallbackReq struct { // Query is the callback query string diff --git a/lib/auth/auth.go b/lib/auth/auth.go index e0e3440bc08de..5333856f285b1 100644 --- a/lib/auth/auth.go +++ b/lib/auth/auth.go @@ -292,22 +292,6 @@ func NewServer(cfg *InitConfig, opts ...ServerOption) (*Server, error) { ) } } - // Plug in SAML auth service - sas := NewSAMLAuthService(&SAMLAuthServiceConfig{ - Auth: &as, - Emitter: as.emitter, - AssertionReplayService: as.Unstable.AssertionReplayService, - }) - as.SetSAMLService(sas) - - oas, err := NewOIDCAuthService(&OIDCAuthServiceConfig{ - Auth: &as, - Emitter: as.emitter, - }) - if err != nil { - return nil, trace.Wrap(err) - } - as.SetOIDCService(oas) return &as, nil } diff --git a/lib/auth/auth_with_roles_test.go b/lib/auth/auth_with_roles_test.go index 8a52e18534d44..f9f5d4e882153 100644 --- a/lib/auth/auth_with_roles_test.go +++ b/lib/auth/auth_with_roles_test.go @@ -44,7 +44,6 @@ import ( "github.com/gravitational/teleport/lib/auth/testauthority" libdefaults "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" - "github.com/gravitational/teleport/lib/fixtures" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/session" "github.com/gravitational/teleport/lib/tlsca" @@ -156,181 +155,6 @@ func TestSSOUserCanReissueCert(t *testing.T) { require.NoError(t, err) } -func TestSAMLAuthRequest(t *testing.T) { - ctx := context.Background() - srv := newTestTLSServer(t) - - emptyRole, err := CreateRole(ctx, srv.Auth(), "test-empty", types.RoleSpecV5{}) - require.NoError(t, err) - - _, err = CreateRole(ctx, srv.Auth(), "baz", types.RoleSpecV5{}) - require.NoError(t, err) - - access1Role, err := CreateRole(ctx, srv.Auth(), "test-access-1", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindSAMLRequest}, - Verbs: []string{types.VerbCreate}, - }, - }, - }, - }) - require.NoError(t, err) - - access2Role, err := CreateRole(ctx, srv.Auth(), "test-access-2", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindSAML}, - Verbs: []string{types.VerbCreate}, - }, - }, - }, - }) - require.NoError(t, err) - - access3Role, err := CreateRole(ctx, srv.Auth(), "test-access-3", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindSAML, types.KindSAMLRequest}, - Verbs: []string{types.VerbCreate}, - }, - }, - }, - }) - require.NoError(t, err) - - readerRole, err := CreateRole(ctx, srv.Auth(), "test-access-4", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindSAMLRequest}, - Verbs: []string{types.VerbRead}, - }, - }, - }, - }) - require.NoError(t, err) - - conn, err := types.NewSAMLConnector("foo", types.SAMLConnectorSpecV2{ - Issuer: "test", - SSO: "test", - Cert: fixtures.TLSCACertPEM, - AssertionConsumerService: "test", - AttributesToRoles: []types.AttributeMapping{{ - Name: "foo", - Value: "bar", - Roles: []string{"baz"}, - }}, - }) - require.NoError(t, err) - - err = srv.Auth().UpsertSAMLConnector(ctx, conn) - require.NoError(t, err) - - reqNormal := types.SAMLAuthRequest{ConnectorID: conn.GetName(), Type: constants.SAML} - reqTest := types.SAMLAuthRequest{ConnectorID: conn.GetName(), Type: constants.SAML, SSOTestFlow: true, ConnectorSpec: &types.SAMLConnectorSpecV2{ - Issuer: "test", - Audience: "test", - ServiceProviderIssuer: "test", - SSO: "test", - Cert: fixtures.TLSCACertPEM, - AssertionConsumerService: "test", - AttributesToRoles: []types.AttributeMapping{{ - Name: "foo", - Value: "bar", - Roles: []string{"baz"}, - }}, - }} - - tests := []struct { - desc string - roles []string - request types.SAMLAuthRequest - expectAccessDenied bool - }{ - { - desc: "empty role - no access", - roles: []string{emptyRole.GetName()}, - request: reqNormal, - expectAccessDenied: true, - }, - { - desc: "can create regular request with normal access", - roles: []string{access1Role.GetName()}, - request: reqNormal, - expectAccessDenied: false, - }, - { - desc: "cannot create sso test request with normal access", - roles: []string{access1Role.GetName()}, - request: reqTest, - expectAccessDenied: true, - }, - { - desc: "cannot create normal request with connector access", - roles: []string{access2Role.GetName()}, - request: reqNormal, - expectAccessDenied: true, - }, - { - desc: "cannot create sso test request with connector access", - roles: []string{access2Role.GetName()}, - request: reqTest, - expectAccessDenied: true, - }, - { - desc: "can create regular request with combined access", - roles: []string{access3Role.GetName()}, - request: reqNormal, - expectAccessDenied: false, - }, - { - desc: "can create sso test request with combined access", - roles: []string{access3Role.GetName()}, - request: reqTest, - expectAccessDenied: false, - }, - } - - user, err := CreateUser(srv.Auth(), "dummy") - require.NoError(t, err) - - userReader, err := CreateUser(srv.Auth(), "dummy-reader", readerRole) - require.NoError(t, err) - - clientReader, err := srv.NewClient(TestUser(userReader.GetName())) - require.NoError(t, err) - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - user.SetRoles(tt.roles) - err = srv.Auth().UpsertUser(user) - require.NoError(t, err) - - client, err := srv.NewClient(TestUser(user.GetName())) - require.NoError(t, err) - - request, err := client.CreateSAMLAuthRequest(ctx, tt.request) - if tt.expectAccessDenied { - require.Error(t, err) - require.True(t, trace.IsAccessDenied(err), "expected access denied, got: %v", err) - return - } - - require.NoError(t, err) - require.NotEmpty(t, request.ID) - require.Equal(t, tt.request.ConnectorID, request.ConnectorID) - - requestCopy, err := clientReader.GetSAMLAuthRequest(ctx, request.ID) - require.NoError(t, err) - require.Equal(t, request, requestCopy) - }) - } -} - func TestInstaller(t *testing.T) { ctx := context.Background() srv := newTestTLSServer(t) @@ -423,191 +247,6 @@ func TestInstaller(t *testing.T) { } } -func TestOIDCAuthRequest(t *testing.T) { - ctx := context.Background() - srv := newTestTLSServer(t) - - idp := newFakeIDP(t, false /* tls */) - - emptyRole, err := CreateRole(ctx, srv.Auth(), "test-empty", types.RoleSpecV5{}) - require.NoError(t, err) - - access1Role, err := CreateRole(ctx, srv.Auth(), "test-access-1", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindOIDCRequest}, - Verbs: []string{types.VerbCreate}, - }, - }, - }, - }) - require.NoError(t, err) - - access2Role, err := CreateRole(ctx, srv.Auth(), "test-access-2", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindOIDC}, - Verbs: []string{types.VerbCreate}, - }, - }, - }, - }) - require.NoError(t, err) - - access3Role, err := CreateRole(ctx, srv.Auth(), "test-access-3", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindOIDC, types.KindOIDCRequest}, - Verbs: []string{types.VerbCreate}, - }, - }, - }, - }) - require.NoError(t, err) - - readerRole, err := CreateRole(ctx, srv.Auth(), "test-access-4", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - { - Resources: []string{types.KindOIDCRequest}, - Verbs: []string{types.VerbRead}, - }, - }, - }, - }) - require.NoError(t, err) - - conn, err := types.NewOIDCConnector("example", types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "example-client-id", - ClientSecret: "example-client-secret", - RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"}, - Display: "sign in with example.com", - Scope: []string{"foo", "bar"}, - ClaimsToRoles: []types.ClaimMapping{ - { - Claim: "groups", - Value: "idp-admin", - Roles: []string{"access"}, - }, - }, - }) - require.NoError(t, err) - - err = srv.Auth().UpsertOIDCConnector(context.Background(), conn) - require.NoError(t, err) - - reqNormal := types.OIDCAuthRequest{ConnectorID: conn.GetName(), Type: constants.OIDC} - reqTest := types.OIDCAuthRequest{ - ConnectorID: conn.GetName(), - Type: constants.OIDC, - SSOTestFlow: true, - ConnectorSpec: &types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "example-client-id", - ClientSecret: "example-client-secret", - RedirectURLs: []string{"https://localhost:3080/v1/webapi/oidc/callback"}, - Display: "sign in with example.com", - Scope: []string{"foo", "bar"}, - ClaimsToRoles: []types.ClaimMapping{ - { - Claim: "groups", - Value: "idp-admin", - Roles: []string{"access"}, - }, - }, - }, - } - - tests := []struct { - desc string - roles []string - request types.OIDCAuthRequest - expectAccessDenied bool - }{ - { - desc: "empty role - no access", - roles: []string{emptyRole.GetName()}, - request: reqNormal, - expectAccessDenied: true, - }, - { - desc: "can create regular request with normal access", - roles: []string{access1Role.GetName()}, - request: reqNormal, - expectAccessDenied: false, - }, - { - desc: "cannot create sso test request with normal access", - roles: []string{access1Role.GetName()}, - request: reqTest, - expectAccessDenied: true, - }, - { - desc: "cannot create normal request with connector access", - roles: []string{access2Role.GetName()}, - request: reqNormal, - expectAccessDenied: true, - }, - { - desc: "cannot create sso test request with connector access", - roles: []string{access2Role.GetName()}, - request: reqTest, - expectAccessDenied: true, - }, - { - desc: "can create regular request with combined access", - roles: []string{access3Role.GetName()}, - request: reqNormal, - expectAccessDenied: false, - }, - { - desc: "can create sso test request with combined access", - roles: []string{access3Role.GetName()}, - request: reqTest, - expectAccessDenied: false, - }, - } - - user, err := CreateUser(srv.Auth(), "dummy") - require.NoError(t, err) - - userReader, err := CreateUser(srv.Auth(), "dummy-reader", readerRole) - require.NoError(t, err) - - clientReader, err := srv.NewClient(TestUser(userReader.GetName())) - require.NoError(t, err) - - for _, tt := range tests { - t.Run(tt.desc, func(t *testing.T) { - user.SetRoles(tt.roles) - err = srv.Auth().UpsertUser(user) - require.NoError(t, err) - - client, err := srv.NewClient(TestUser(user.GetName())) - require.NoError(t, err) - - request, err := client.CreateOIDCAuthRequest(ctx, tt.request) - if tt.expectAccessDenied { - require.Error(t, err) - require.True(t, trace.IsAccessDenied(err), "expected access denied, got: %v", err) - return - } - - require.NoError(t, err) - require.NotEmpty(t, request.StateToken) - require.Equal(t, tt.request.ConnectorID, request.ConnectorID) - - requestCopy, err := clientReader.GetOIDCAuthRequest(ctx, request.StateToken) - require.NoError(t, err) - require.Equal(t, request, requestCopy) - }) - } -} - func TestGithubAuthRequest(t *testing.T) { ctx := context.Background() srv := newTestTLSServer(t) diff --git a/lib/auth/fuzz_test.go b/lib/auth/fuzz_test.go index 5948092de34b7..3c3fd8d758db3 100644 --- a/lib/auth/fuzz_test.go +++ b/lib/auth/fuzz_test.go @@ -17,24 +17,11 @@ limitations under the License. package auth import ( - "encoding/base64" "testing" - "github.com/sirupsen/logrus" "github.com/stretchr/testify/require" ) -func FuzzParseSAMLInResponseTo(f *testing.F) { - // Disable Go App Engine logging - logrus.SetLevel(logrus.PanicLevel) - - f.Fuzz(func(t *testing.T, response string) { - require.NotPanics(t, func() { - ParseSAMLInResponseTo(base64.StdEncoding.EncodeToString([]byte(response))) - }) - }) -} - func FuzzParseAndVerifyIID(f *testing.F) { f.Fuzz(func(t *testing.T, iidBytes []byte) { require.NotPanics(t, func() { diff --git a/lib/auth/oidc.go b/lib/auth/oidc.go index 00fb100cecbc2..a79897844b4f6 100644 --- a/lib/auth/oidc.go +++ b/lib/auth/oidc.go @@ -19,30 +19,13 @@ package auth import ( "context" "encoding/json" - "fmt" - "io" - "net/http" "net/url" - "sync" - "time" - "github.com/coreos/go-oidc/jose" - "github.com/coreos/go-oidc/oauth2" - "github.com/coreos/go-oidc/oidc" - "github.com/google/go-cmp/cmp" "github.com/gravitational/trace" - "github.com/gravitational/teleport" - "github.com/gravitational/teleport/api/constants" - apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" - apiutils "github.com/gravitational/teleport/api/utils" - "github.com/gravitational/teleport/api/utils/keys" - "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" - "github.com/gravitational/teleport/lib/services" - "github.com/gravitational/teleport/lib/utils" ) type OIDCService interface { @@ -52,6 +35,47 @@ type OIDCService interface { var errOIDCNotImplemented = trace.AccessDenied("OIDC is only available in enterprise subscriptions") +// UpsertOIDCConnector creates or updates an OIDC connector. +func (a *Server) UpsertOIDCConnector(ctx context.Context, connector types.OIDCConnector) error { + if err := a.Services.UpsertOIDCConnector(ctx, connector); err != nil { + return trace.Wrap(err) + } + if err := a.emitter.EmitAuditEvent(ctx, &apievents.OIDCConnectorCreate{ + Metadata: apievents.Metadata{ + Type: events.OIDCConnectorCreatedEvent, + Code: events.OIDCConnectorCreatedCode, + }, + UserMetadata: ClientUserMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: connector.GetName(), + }, + }); err != nil { + log.WithError(err).Warn("Failed to emit OIDC connector create event.") + } + + return nil +} + +// DeleteOIDCConnector deletes an OIDC connector by name. +func (a *Server) DeleteOIDCConnector(ctx context.Context, connectorName string) error { + if err := a.Services.DeleteOIDCConnector(ctx, connectorName); err != nil { + return trace.Wrap(err) + } + if err := a.emitter.EmitAuditEvent(ctx, &apievents.OIDCConnectorDelete{ + Metadata: apievents.Metadata{ + Type: events.OIDCConnectorDeletedEvent, + Code: events.OIDCConnectorDeletedCode, + }, + UserMetadata: ClientUserMetadata(ctx), + ResourceMetadata: apievents.ResourceMetadata{ + Name: connectorName, + }, + }); err != nil { + log.WithError(err).Warn("Failed to emit OIDC connector delete event.") + } + return nil +} + func (a *Server) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) { if a.oidcAuthService == nil { return nil, errOIDCNotImplemented @@ -133,1012 +157,3 @@ type OIDCAuthRawResponse struct { // trusted by proxy, used in console login HostSigners []json.RawMessage `json:"host_signers"` } - -type OIDCAuthService struct { - auth *Server - emitter apievents.Emitter - clients map[string]*oidcClient - lock sync.Mutex - getClaimsFun func(ctx context.Context, oidcClient *oidc.Client, connector types.OIDCConnector, code string) (jose.Claims, error) -} - -type OIDCAuthServiceConfig struct { - Auth *Server - Emitter apievents.Emitter -} - -func (cfg *OIDCAuthServiceConfig) CheckAndSetDefaults() error { - if cfg.Auth == nil { - return trace.BadParameter("auth.Server not provided") - } - if cfg.Emitter == nil { - cfg.Emitter = events.NewDiscardEmitter() - } - return nil -} - -func NewOIDCAuthService(cfg *OIDCAuthServiceConfig) (*OIDCAuthService, error) { - if err := cfg.CheckAndSetDefaults(); err != nil { - return nil, err - } - - return &OIDCAuthService{ - auth: cfg.Auth, - emitter: cfg.Emitter, - clients: make(map[string]*oidcClient), - getClaimsFun: getClaims, - }, nil -} - -// oidcClient is internal structure that stores OIDC client and its config -type oidcClient struct { - client *oidc.Client - connector types.OIDCConnector - // syncCtx controls the provider sync goroutine. - syncCtx context.Context - syncCancel context.CancelFunc - // firstSync will be closed once the first provider sync succeeds - firstSync chan struct{} -} - -// ErrOIDCNoRoles results from not mapping any roles from OIDC claims. -var ErrOIDCNoRoles = trace.AccessDenied("No roles mapped from claims. The mappings may contain typos.") - -// getOIDCConnectorAndClient returns the associated oidc connector -// and client for the given oidc auth request. -func (oas *OIDCAuthService) getOIDCConnectorAndClient(ctx context.Context, request types.OIDCAuthRequest) (types.OIDCConnector, *oidc.Client, error) { - // stateless test flow - if request.SSOTestFlow { - if request.ConnectorSpec == nil { - return nil, nil, trace.BadParameter("ConnectorSpec cannot be nil when SSOTestFlow is true") - } - - if request.ConnectorID == "" { - return nil, nil, trace.BadParameter("ConnectorID cannot be empty") - } - - connector, err := types.NewOIDCConnector(request.ConnectorID, *request.ConnectorSpec) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - // we don't want to cache the client. construct it directly. - client, err := newOIDCClient(ctx, connector, request.ProxyAddress) - if err != nil { - return nil, nil, trace.Wrap(err) - } - if err := client.waitFirstSync(defaults.WebHeadersTimeout); err != nil { - return nil, nil, trace.Wrap(err) - } - - // close this request-scoped oidc client after 10 minutes - go func() { - ticker := oas.auth.GetClock().NewTicker(defaults.OIDCAuthRequestTTL) - defer ticker.Stop() - select { - case <-ticker.Chan(): - client.syncCancel() - case <-client.syncCtx.Done(): - } - }() - - return connector, client.client, nil - } - - // regular execution flow - connector, err := oas.auth.GetOIDCConnector(ctx, request.ConnectorID, true) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - client, err := oas.getCachedOIDCClient(ctx, connector, request.ProxyAddress) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - // Wait for the client to successfully sync after getting it from the cache. - // We do this after caching the client to prevent locking the server during - // the initial sync period. - if err := client.waitFirstSync(defaults.WebHeadersTimeout); err != nil { - return nil, nil, trace.Wrap(err) - } - return connector, client.client, nil -} - -// getCachedOIDCClient gets a cached oidc client for -// the given OIDC connector and redirectURL preference. -func (oas *OIDCAuthService) getCachedOIDCClient(ctx context.Context, conn types.OIDCConnector, proxyAddr string) (*oidcClient, error) { - oas.lock.Lock() - defer oas.lock.Unlock() - - // Each connector and proxy combination has a distinct client, - // so we use a composite key to capture all combinations. - clientMapKey := conn.GetName() + "_" + proxyAddr - - cachedClient, ok := oas.clients[clientMapKey] - if ok { - if !cachedClient.needsRefresh(conn) && cachedClient.syncCtx.Err() == nil { - return cachedClient, nil - } - // Cached client needs to be refreshed or is no longer syncing. - cachedClient.syncCancel() - delete(oas.clients, clientMapKey) - } - - // Create a new oidc client and add it to the cache. - client, err := newOIDCClient(ctx, conn, proxyAddr) - if err != nil { - return nil, trace.Wrap(err) - } - - oas.clients[clientMapKey] = client - return client, nil -} - -func newOIDCClient(ctx context.Context, conn types.OIDCConnector, proxyAddr string) (*oidcClient, error) { - redirectURL, err := services.GetRedirectURL(conn, proxyAddr) - if err != nil { - return nil, trace.Wrap(err) - } - - config := oidcConfig(conn, redirectURL) - client, err := oidc.NewClient(config) - if err != nil { - return nil, trace.Wrap(err) - } - - oidcClient := &oidcClient{client: client, connector: conn, firstSync: make(chan struct{})} - oidcClient.startSync(ctx) - return oidcClient, nil -} - -func oidcConfig(conn types.OIDCConnector, redirectURL string) oidc.ClientConfig { - return oidc.ClientConfig{ - RedirectURL: redirectURL, - Credentials: oidc.ClientCredentials{ - ID: conn.GetClientID(), - Secret: conn.GetClientSecret(), - }, - // open id notifies provider that we are using OIDC scopes - Scope: apiutils.Deduplicate(append([]string{"openid", "email"}, conn.GetScope()...)), - } -} - -// needsRefresh returns whether the client's connector and the -// given connector have the same values for fields relevant to -// generating and syncing an oidc.Client. -func (c *oidcClient) needsRefresh(conn types.OIDCConnector) bool { - return !cmp.Equal(conn.GetRedirectURLs(), c.connector.GetRedirectURLs()) || - conn.GetClientID() != c.connector.GetClientID() || - conn.GetClientSecret() != c.connector.GetClientSecret() || - !cmp.Equal(conn.GetScope(), c.connector.GetScope()) || - conn.GetIssuerURL() != c.connector.GetIssuerURL() -} - -// startSync starts a goroutine to sync the client with its provider -// config until the given ctx is closed or the sync is canceled. -func (c *oidcClient) startSync(ctx context.Context) { - // SyncProviderConfig doesn't take a context for cancellation, instead it - // returns a channel that has to be closed to stop the sync. To ensure that the - // sync is eventually stopped, we "wrap" the stop channel with a cancel context. - c.syncCtx, c.syncCancel = context.WithCancel(ctx) - go func() { - stop := c.client.SyncProviderConfig(c.connector.GetIssuerURL()) - close(c.firstSync) - <-c.syncCtx.Done() - close(stop) - }() -} - -// waitFirstSync waits for the client to start syncing successfully, or -// returns an error if syncing fails or fails to succeed within 10 seconds. -// This prevents waiting on clients with faulty provider configurations. -func (c *oidcClient) waitFirstSync(timeout time.Duration) error { - timeoutTimer := time.NewTimer(timeout) - - select { - case <-c.firstSync: - case <-c.syncCtx.Done(): - case <-timeoutTimer.C: - // cancel sync so that it gets removed from the cache - c.syncCancel() - return trace.ConnectionProblem(nil, "timed out syncing oidc connector %v, ensure URL %q is valid and accessible and check configuration", - c.connector.GetName(), c.connector.GetIssuerURL()) - } - - // stop and flush timer - if !timeoutTimer.Stop() { - <-timeoutTimer.C - } - - // return the syncing error if there is one - return trace.Wrap(c.syncCtx.Err()) -} - -// UpsertOIDCConnector creates or updates an OIDC connector. -func (a *Server) UpsertOIDCConnector(ctx context.Context, connector types.OIDCConnector) error { - if err := a.Services.UpsertOIDCConnector(ctx, connector); err != nil { - return trace.Wrap(err) - } - if err := a.emitter.EmitAuditEvent(ctx, &apievents.OIDCConnectorCreate{ - Metadata: apievents.Metadata{ - Type: events.OIDCConnectorCreatedEvent, - Code: events.OIDCConnectorCreatedCode, - }, - UserMetadata: ClientUserMetadata(ctx), - ResourceMetadata: apievents.ResourceMetadata{ - Name: connector.GetName(), - }, - }); err != nil { - log.WithError(err).Warn("Failed to emit OIDC connector create event.") - } - - return nil -} - -// DeleteOIDCConnector deletes an OIDC connector by name. -func (a *Server) DeleteOIDCConnector(ctx context.Context, connectorName string) error { - if err := a.Services.DeleteOIDCConnector(ctx, connectorName); err != nil { - return trace.Wrap(err) - } - if err := a.emitter.EmitAuditEvent(ctx, &apievents.OIDCConnectorDelete{ - Metadata: apievents.Metadata{ - Type: events.OIDCConnectorDeletedEvent, - Code: events.OIDCConnectorDeletedCode, - }, - UserMetadata: ClientUserMetadata(ctx), - ResourceMetadata: apievents.ResourceMetadata{ - Name: connectorName, - }, - }); err != nil { - log.WithError(err).Warn("Failed to emit OIDC connector delete event.") - } - return nil -} - -func (oas *OIDCAuthService) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) { - // ensure prompt removal of OIDC client in test flows. does nothing in regular flows. - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - connector, client, err := oas.getOIDCConnectorAndClient(ctx, req) - if err != nil { - return nil, trace.Wrap(err) - } - oauthClient, err := client.OAuthClient() - if err != nil { - return nil, trace.Wrap(err) - } - - stateToken, err := utils.CryptoRandomHex(TokenLenBytes) - if err != nil { - return nil, trace.Wrap(err) - } - - req.StateToken = stateToken - - // online indicates that this login should only work online - req.RedirectURL = oauthClient.AuthCodeURL(req.StateToken, teleport.OIDCAccessTypeOnline, connector.GetPrompt()) - - // if the connector has an Authentication Context Class Reference (ACR) value set, - // update redirect url and add it as a query value. - acrValue := connector.GetACR() - if acrValue != "" { - u, err := url.Parse(req.RedirectURL) - if err != nil { - return nil, trace.Wrap(err) - } - q := u.Query() - q.Set("acr_values", acrValue) - u.RawQuery = q.Encode() - req.RedirectURL = u.String() - } - - log.Debugf("OIDC redirect URL: %v.", req.RedirectURL) - - err = oas.auth.Services.CreateOIDCAuthRequest(ctx, req, defaults.OIDCAuthRequestTTL) - if err != nil { - return nil, trace.Wrap(err) - } - return &req, nil -} - -// ValidateOIDCAuthCallback is called by the proxy to check OIDC query parameters -// returned by OIDC Provider, if everything checks out, auth server -// will respond with OIDCAuthResponse, otherwise it will return error -func (oas *OIDCAuthService) ValidateOIDCAuthCallback(ctx context.Context, q url.Values) (*OIDCAuthResponse, error) { - event := &apievents.UserLogin{ - Metadata: apievents.Metadata{ - Type: events.UserLoginEvent, - }, - Method: events.LoginMethodOIDC, - } - - diagCtx := NewSSODiagContext(types.KindOIDC, oas.auth) - - auth, err := oas.validateOIDCAuthCallback(ctx, diagCtx, q) - diagCtx.Info.Error = trace.UserMessage(err) - - diagCtx.WriteToBackend(ctx) - - claims := diagCtx.Info.OIDCClaims - if claims != nil { - attributes, err := apievents.EncodeMap(claims) - if err != nil { - event.Status.UserMessage = fmt.Sprintf("Failed to encode identity attributes: %v", err.Error()) - log.WithError(err).Debug("Failed to encode identity attributes.") - } else { - event.IdentityAttributes = attributes - } - } - - if err != nil { - event.Code = events.UserSSOLoginFailureCode - if diagCtx.Info.TestFlow { - event.Code = events.UserSSOTestFlowLoginFailureCode - } - event.Status.Success = false - event.Status.Error = trace.Unwrap(err).Error() - event.Status.UserMessage = err.Error() - - if err := oas.emitter.EmitAuditEvent(ctx, event); err != nil { - log.WithError(err).Warn("Failed to emit OIDC login failed event.") - } - - return nil, trace.Wrap(err) - } - - event.Code = events.UserSSOLoginCode - if diagCtx.Info.TestFlow { - event.Code = events.UserSSOTestFlowLoginCode - } - event.User = auth.Username - event.Status.Success = true - - if err := oas.emitter.EmitAuditEvent(ctx, event); err != nil { - log.WithError(err).Warn("Failed to emit OIDC login event.") - } - - return auth, nil -} - -func checkEmailVerifiedClaim(claims jose.Claims) error { - claimName := "email_verified" - unverifiedErr := trace.AccessDenied("email not verified by OIDC provider") - - emailVerified, hasEmailVerifiedClaim, _ := claims.StringClaim(claimName) - if hasEmailVerifiedClaim { - if emailVerified == "false" { - return unverifiedErr - } - if emailVerified == "true" { - return nil - } - - return trace.BadParameter("unable to parse oidc claim: %q, must be either 'true' or 'false', got '%s'", claimName, emailVerified) - } - - data, ok := claims[claimName] - if !ok { - return nil - } - - emailVerifiedBool, ok := data.(bool) - if !ok { - return trace.BadParameter("unable to parse oidc claim: %q, must be a string or bool", claimName) - } - - if !emailVerifiedBool { - return unverifiedErr - } - - return nil -} - -func (oas *OIDCAuthService) validateOIDCAuthCallback(ctx context.Context, diagCtx *SSODiagContext, q url.Values) (*OIDCAuthResponse, error) { - if errParam := q.Get("error"); errParam != "" { - // try to find request so the error gets logged against it. - state := q.Get("state") - if state != "" { - diagCtx.RequestID = state - req, err := oas.auth.GetOIDCAuthRequest(ctx, state) - if err == nil { - diagCtx.Info.TestFlow = req.SSOTestFlow - } - } - - // optional parameter: error_description - errDesc := q.Get("error_description") - oidcErr := trace.OAuth2(oauth2.ErrorInvalidRequest, errParam, q) - return nil, trace.WithUserMessage(oidcErr, "OIDC provider returned error: %v [%v]", errDesc, errParam) - } - - code := q.Get("code") - if code == "" { - oidcErr := trace.OAuth2(oauth2.ErrorInvalidRequest, "code query param must be set", q) - return nil, trace.WithUserMessage(oidcErr, "Invalid parameters received from OIDC provider.") - } - - stateToken := q.Get("state") - if stateToken == "" { - oidcErr := trace.OAuth2(oauth2.ErrorInvalidRequest, "missing state query param", q) - return nil, trace.WithUserMessage(oidcErr, "Invalid parameters received from OIDC provider.") - } - diagCtx.RequestID = stateToken - - req, err := oas.auth.GetOIDCAuthRequest(ctx, stateToken) - if err != nil { - return nil, trace.Wrap(err, "Failed to get OIDC Auth Request.") - } - diagCtx.Info.TestFlow = req.SSOTestFlow - - // ensure prompt removal of OIDC client in test flows. does nothing in regular flows. - ctxC, cancel := context.WithCancel(ctx) - defer cancel() - - connector, client, err := oas.getOIDCConnectorAndClient(ctxC, *req) - if err != nil { - return nil, trace.Wrap(err, "Failed to get OIDC connector and client.") - } - - // extract claims from both the id token and the userinfo endpoint and merge them - claims, err := oas.getClaims(ctx, client, connector, code) - if err != nil { - // different error message for Google Workspace as likely cause is different. - if isGoogleWorkspaceConnector(connector) { - return nil, trace.Wrap(err, "Failed to extract OIDC claims. Check your Google Workspace plan and enabled APIs. See: https://goteleport.com/docs/enterprise/sso/google-workspace/#ensure-your-google-workspace-plan-is-correct") - } - - return nil, trace.Wrap(err, "Failed to extract OIDC claims. This may indicate need to set 'provider' flag in connector definition. See: https://goteleport.com/docs/enterprise/sso/#provider-specific-workarounds") - } - diagCtx.Info.OIDCClaims = types.OIDCClaims(claims) - - log.Debugf("OIDC claims: %v.", claims) - if !connector.GetAllowUnverifiedEmail() { - if err := checkEmailVerifiedClaim(claims); err != nil { - return nil, trace.Wrap(err, "OIDC provider did not verify email.") - } - } - - // if we are sending acr values, make sure we also validate them - acrValue := connector.GetACR() - if acrValue != "" { - err := validateACRValues(acrValue, connector.GetProvider(), claims) - if err != nil { - return nil, trace.Wrap(err, "OIDC ACR validation failure.") - } - log.Debugf("OIDC ACR values %q successfully validated.", acrValue) - } - - ident, err := oidc.IdentityFromClaims(claims) - if err != nil { - return nil, trace.OAuth2( - oauth2.ErrorUnsupportedResponseType, "unable to convert claims to identity", q) - } - diagCtx.Info.OIDCIdentity = &types.OIDCIdentity{ - ID: ident.ID, - Name: ident.Name, - Email: ident.Email, - ExpiresAt: ident.ExpiresAt, - } - log.Debugf("OIDC user %q expires at: %v.", ident.Email, ident.ExpiresAt) - - if len(connector.GetClaimsToRoles()) == 0 { - oidcErr := trace.BadParameter("no claims to roles mapping, check connector documentation") - return nil, trace.WithUserMessage(oidcErr, "Claims-to-roles mapping is empty, SSO user will never have any roles.") - } - log.Debugf("Applying %v OIDC claims to roles mappings.", len(connector.GetClaimsToRoles())) - diagCtx.Info.OIDCClaimsToRoles = connector.GetClaimsToRoles() - - // Calculate (figure out name, roles, traits, session TTL) of user and - // create the user in the backend. - params, err := oas.calculateOIDCUser(diagCtx, connector, claims, ident, req) - if err != nil { - return nil, trace.Wrap(err, "Failed to calculate user attributes.") - } - - diagCtx.Info.CreateUserParams = &types.CreateUserParams{ - ConnectorName: params.ConnectorName, - Username: params.Username, - KubeGroups: params.KubeGroups, - KubeUsers: params.KubeUsers, - Roles: params.Roles, - Traits: params.Traits, - SessionTTL: types.Duration(params.SessionTTL), - } - - user, err := oas.createOIDCUser(params, req.SSOTestFlow) - if err != nil { - return nil, trace.Wrap(err, "Failed to create user from provided parameters.") - } - - // Auth was successful, return session, certificate, etc. to caller. - resp := &OIDCAuthResponse{ - Req: OIDCAuthRequestFromProto(req), - Identity: types.ExternalIdentity{ - ConnectorID: params.ConnectorName, - Username: params.Username, - }, - Username: user.GetName(), - } - - // In test flow skip signing and creating web sessions. - if req.SSOTestFlow { - diagCtx.Info.Success = true - return resp, nil - } - - if !req.CheckUser { - return resp, nil - } - - // If the request is coming from a browser, create a web session. - if req.CreateWebSession { - session, err := oas.auth.CreateWebSessionFromReq(ctx, types.NewWebSessionRequest{ - User: user.GetName(), - Roles: user.GetRoles(), - Traits: user.GetTraits(), - SessionTTL: params.SessionTTL, - LoginTime: oas.auth.GetClock().Now().UTC(), - }) - if err != nil { - return nil, trace.Wrap(err, "Failed to create web session.") - } - resp.Session = session - } - - // If a public key was provided, sign it and return a certificate. - if len(req.PublicKey) != 0 { - sshCert, tlsCert, err := oas.auth.CreateSessionCert(user, params.SessionTTL, req.PublicKey, req.Compatibility, req.RouteToCluster, - req.KubernetesCluster, keys.AttestationStatementFromProto(req.AttestationStatement)) - if err != nil { - return nil, trace.Wrap(err, "Failed to create session certificate.") - } - - clusterName, err := oas.auth.GetClusterName() - if err != nil { - return nil, trace.Wrap(err, "Failed to obtain cluster name.") - } - resp.Cert = sshCert - resp.TLSCert = tlsCert - - // Return the host CA for this cluster only. - authority, err := oas.auth.GetCertAuthority(ctx, types.CertAuthID{ - Type: types.HostCA, - DomainName: clusterName.GetClusterName(), - }, false) - if err != nil { - return nil, trace.Wrap(err, "Failed to obtain cluster's host CA.") - } - resp.HostSigners = append(resp.HostSigners, authority) - } - - return resp, nil -} - -// OIDCAuthRequestFromProto converts the types.OIDCAuthRequest to OIDCAuthRequest. -func OIDCAuthRequestFromProto(req *types.OIDCAuthRequest) OIDCAuthRequest { - return OIDCAuthRequest{ - ConnectorID: req.ConnectorID, - PublicKey: req.PublicKey, - CSRFToken: req.CSRFToken, - CreateWebSession: req.CreateWebSession, - ClientRedirectURL: req.ClientRedirectURL, - } -} - -func (oas *OIDCAuthService) calculateOIDCUser(diagCtx *SSODiagContext, connector types.OIDCConnector, claims jose.Claims, ident *oidc.Identity, request *types.OIDCAuthRequest) (*CreateUserParams, error) { - var err error - - username, err := usernameFromClaims(connector, claims, ident) - if err != nil { - return nil, err - } - - p := CreateUserParams{ - ConnectorName: connector.GetName(), - Username: username, - } - - p.Traits = services.OIDCClaimsToTraits(claims) - - diagCtx.Info.OIDCTraitsFromClaims = p.Traits - diagCtx.Info.OIDCConnectorTraitMapping = connector.GetTraitMappings() - - var warnings []string - warnings, p.Roles = services.TraitsToRoles(connector.GetTraitMappings(), p.Traits) - if len(p.Roles) == 0 { - if len(warnings) != 0 { - log.WithField("connector", connector).Warnf("No roles mapped from claims. Warnings: %q", warnings) - diagCtx.Info.OIDCClaimsToRolesWarnings = &types.SSOWarnings{ - Message: "No roles mapped for the user", - Warnings: warnings, - } - } else { - log.WithField("connector", connector).Warnf("No roles mapped from claims.") - diagCtx.Info.OIDCClaimsToRolesWarnings = &types.SSOWarnings{ - Message: "No roles mapped for the user. The mappings may contain typos.", - } - } - return nil, trace.Wrap(ErrOIDCNoRoles) - } - - // Pick smaller for role: session TTL from role or requested TTL. - roles, err := services.FetchRoles(p.Roles, oas.auth, p.Traits) - if err != nil { - return nil, trace.Wrap(err) - } - roleTTL := roles.AdjustSessionTTL(apidefaults.MaxCertDuration) - p.SessionTTL = utils.MinTTL(roleTTL, request.CertTTL) - - return &p, nil -} - -func (oas *OIDCAuthService) createOIDCUser(p *CreateUserParams, dryRun bool) (types.User, error) { - expires := oas.auth.GetClock().Now().UTC().Add(p.SessionTTL) - - log.Debugf("Generating dynamic OIDC identity %v/%v with roles: %v. Dry run: %v.", p.ConnectorName, p.Username, p.Roles, dryRun) - user := &types.UserV2{ - Kind: types.KindUser, - Version: types.V2, - Metadata: types.Metadata{ - Name: p.Username, - Namespace: apidefaults.Namespace, - Expires: &expires, - }, - Spec: types.UserSpecV2{ - Roles: p.Roles, - Traits: p.Traits, - OIDCIdentities: []types.ExternalIdentity{ - { - ConnectorID: p.ConnectorName, - Username: p.Username, - }, - }, - CreatedBy: types.CreatedBy{ - User: types.UserRef{Name: teleport.UserSystem}, - Time: oas.auth.GetClock().Now().UTC(), - Connector: &types.ConnectorRef{ - Type: constants.OIDC, - ID: p.ConnectorName, - Identity: p.Username, - }, - }, - }, - } - - if dryRun { - return user, nil - } - - // Get the user to check if it already exists or not. - existingUser, err := oas.auth.Services.GetUser(p.Username, false) - if err != nil && !trace.IsNotFound(err) { - return nil, trace.Wrap(err) - } - - ctx := context.TODO() - - // Overwrite exisiting user if it was created from an external identity provider. - if existingUser != nil { - connectorRef := existingUser.GetCreatedBy().Connector - - // If the exisiting user is a local user, fail and advise how to fix the problem. - if connectorRef == nil { - return nil, trace.AlreadyExists("local user with name %q already exists. Either change "+ - "email in OIDC identity or remove local user and try again.", existingUser.GetName()) - } - - log.Debugf("Overwriting existing user %q created with %v connector %v.", - existingUser.GetName(), connectorRef.Type, connectorRef.ID) - - if err := oas.auth.UpdateUser(ctx, user); err != nil { - return nil, trace.Wrap(err) - } - } else { - if err := oas.auth.CreateUser(ctx, user); err != nil { - return nil, trace.Wrap(err) - } - } - - return user, nil -} - -// usernameFromClaims gets the username of the OIDC user based on the claims received. The `username_claim` field in the OIDC -// config specifies which claim from the OIDC provider to use as the user's username. If it isn't specified, the email will be used -// as the username. If it is specified but specifies a claim that doesn't exist, an error is returned. -func usernameFromClaims(connector types.OIDCConnector, claims jose.Claims, ident *oidc.Identity) (string, error) { - usernameClaim := connector.GetUsernameClaim() - if usernameClaim == "" { - return ident.Email, nil - } - - username, ok, err := claims.StringClaim(usernameClaim) - if err != nil { - return "", err - } else if !ok { - return "", trace.BadParameter("The configured username_claim of %q was not received from the IdP. Please update the username_claim in connector %q.", usernameClaim, connector.GetName()) - } - - return username, nil -} - -// claimsFromIDToken extracts claims from the ID token. -func claimsFromIDToken(oidcClient *oidc.Client, idToken string) (jose.Claims, error) { - jwt, err := jose.ParseJWT(idToken) - if err != nil { - return nil, trace.Wrap(err) - } - - err = oidcClient.VerifyJWT(jwt) - if err != nil { - return nil, trace.Wrap(err) - } - - log.Debugf("Extracting OIDC claims from ID token.") - - claims, err := jwt.Claims() - if err != nil { - return nil, trace.Wrap(err) - } - - return claims, nil -} - -// claimsFromUserInfo finds the UserInfo endpoint from the provider config and then extracts claims from it. -// -// Note: We don't request signed JWT responses for UserInfo, instead we force the provider config and -// the issuer to be HTTPS and leave integrity and confidentiality to TLS. Authenticity is taken care of -// during the token exchange. -func claimsFromUserInfo(oidcClient *oidc.Client, issuerURL string, accessToken string) (jose.Claims, error) { - // If the issuer URL is not HTTPS, return the error as trace.NotFound to - // allow the caller to treat this condition gracefully and extract claims - // just from the token. - err := isHTTPS(issuerURL) - if err != nil { - return nil, trace.NotFound(err.Error()) - } - - oac, err := oidcClient.OAuthClient() - if err != nil { - return nil, trace.Wrap(err) - } - hc := oac.HttpClient() - - // go get the provider config so we can find out where the UserInfo endpoint - // is. if the provider doesn't offer a UserInfo endpoint return not found. - pc, err := oidc.FetchProviderConfig(oac.HttpClient(), issuerURL) - if err != nil { - return nil, trace.Wrap(err) - } - if pc.UserInfoEndpoint == nil { - return nil, trace.NotFound("UserInfo endpoint not found") - } - - endpoint := pc.UserInfoEndpoint.String() - - // If the userinfo endpoint is not HTTPS, return the error as trace.NotFound to - // allow the caller to treat this condition gracefully and extract claims - // just from the token. - err = isHTTPS(endpoint) - if err != nil { - return nil, trace.NotFound(err.Error()) - } - log.Debugf("Fetching OIDC claims from UserInfo endpoint: %q.", endpoint) - - req, err := http.NewRequest("GET", endpoint, nil) - if err != nil { - return nil, trace.Wrap(err) - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken)) - - resp, err := hc.Do(req) - if err != nil { - return nil, trace.Wrap(err) - } - defer resp.Body.Close() - - code := resp.StatusCode - if code < 200 || code > 299 { - // These are expected userinfo failures. - if code == http.StatusBadRequest || code == http.StatusUnauthorized || - code == http.StatusForbidden || code == http.StatusMethodNotAllowed { - return nil, trace.AccessDenied("bad status code: %v", code) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, trace.Wrap(err) - } - return nil, trace.ReadError(code, body) - } - - var claims jose.Claims - err = json.NewDecoder(resp.Body).Decode(&claims) - if err != nil { - return nil, trace.Wrap(err) - } - - return claims, nil -} - -// mergeClaims merges b into a. -func mergeClaims(a jose.Claims, b jose.Claims) (jose.Claims, error) { - for k, v := range b { - _, ok := a[k] - if !ok { - a[k] = v - } - } - - return a, nil -} - -// getClaims gets claims from ID token and UserInfo and returns UserInfo claims merged into ID token claims. -func (oas *OIDCAuthService) getClaims(ctx context.Context, oidcClient *oidc.Client, connector types.OIDCConnector, code string) (jose.Claims, error) { - return oas.getClaimsFun(ctx, oidcClient, connector, code) -} - -// getClaims implements OIDCAuthService.getClaims, but allows that code path to be overridden for testing. -func getClaims(ctx context.Context, oidcClient *oidc.Client, connector types.OIDCConnector, code string) (jose.Claims, error) { - oac, err := getOAuthClient(oidcClient, connector) - if err != nil { - return nil, trace.Wrap(err) - } - - t, err := oac.RequestToken(oauth2.GrantTypeAuthCode, code) - if err != nil { - if e, ok := err.(*oauth2.Error); ok { - if e.Type == oauth2.ErrorAccessDenied { - return nil, trace.Wrap(err, "the client_id and/or client_secret may be incorrect") - } - } - return nil, trace.Wrap(err) - } - - idTokenClaims, err := claimsFromIDToken(oidcClient, t.IDToken) - if err != nil { - log.Debugf("Unable to fetch OIDC ID token claims: %v.", err) - return nil, trace.Wrap(err, "unable to fetch OIDC ID token claims") - } - log.Debugf("OIDC ID Token claims: %v.", idTokenClaims) - - userInfoClaims, err := claimsFromUserInfo(oidcClient, connector.GetIssuerURL(), t.AccessToken) - if err != nil { - if trace.IsNotFound(err) { - log.Debugf("OIDC provider doesn't offer valid UserInfo endpoint. Returning token claims: %v.", idTokenClaims) - return idTokenClaims, nil - } - // This captures 400, 401, 403, and 405. - if trace.IsAccessDenied(err) { - log.Debugf("UserInfo endpoint returned an error: %v. Returning token claims: %v.", err, idTokenClaims) - return idTokenClaims, nil - } - log.Debugf("Unable to fetch UserInfo claims: %v.", err) - return nil, trace.Wrap(err, "unable to fetch UserInfo claims") - } - log.Debugf("UserInfo claims: %v.", userInfoClaims) - - // make sure that the subject in the userinfo claim matches the subject in - // the id token otherwise there is the possibility of a token substitution attack. - // see section 16.11 of the oidc spec for more details. - var idsub string - var uisub string - var exists bool - if idsub, exists, err = idTokenClaims.StringClaim("sub"); err != nil || !exists { - log.Debugf("Unable to extract OIDC sub claim from ID token.") - return nil, trace.Wrap(err, "unable to extract OIDC sub claim from ID token") - } - if uisub, exists, err = userInfoClaims.StringClaim("sub"); err != nil || !exists { - log.Debugf("Unable to extract OIDC sub claim from UserInfo.") - return nil, trace.Wrap(err, "unable to extract OIDC sub claim from UserInfo") - } - if idsub != uisub { - log.Debugf("OIDC claim subjects don't match '%v' != '%v'.", idsub, uisub) - return nil, trace.BadParameter("OIDC claim subjects in UserInfo does not match") - } - - claims, err := mergeClaims(idTokenClaims, userInfoClaims) - if err != nil { - log.Debugf("Unable to merge OIDC claims: %v.", err) - return nil, trace.Wrap(err, "unable to merge OIDC claims") - } - - if isGoogleWorkspaceConnector(connector) { - claims, err = addGoogleWorkspaceClaims(ctx, connector, claims) - if err != nil { - return nil, trace.Wrap(err) - } - } - - return claims, nil -} - -// getOAuthClient returns a Oauth2 client from the oidc.Client. If the connector is set as a Ping provider sets the Client Secret Post auth method -func getOAuthClient(oidcClient *oidc.Client, connector types.OIDCConnector) (*oauth2.Client, error) { - oac, err := oidcClient.OAuthClient() - if err != nil { - return nil, trace.Wrap(err) - } - - // For OIDC, Ping and Okta will throw an error when the - // default client secret basic method is used. - // See: https://github.com/gravitational/teleport/issues/8374 - switch connector.GetProvider() { - case teleport.Ping, teleport.Okta: - oac.SetAuthMethod(oauth2.AuthMethodClientSecretPost) - } - - return oac, err -} - -// validateACRValues validates that we get an appropriate response for acr values. By default -// we expect the same value we send, but this function also handles Identity Provider specific -// forms of validation. -func validateACRValues(acrValue string, identityProvider string, claims jose.Claims) error { - switch identityProvider { - case teleport.NetIQ: - log.Debugf("Validating OIDC ACR values with '%v' rules.", identityProvider) - - tokenAcr, ok := claims["acr"] - if !ok { - return trace.BadParameter("acr not found in claims") - } - tokenAcrMap, ok := tokenAcr.(map[string]interface{}) - if !ok { - return trace.BadParameter("acr unexpected type: %T", tokenAcr) - } - tokenAcrValues, ok := tokenAcrMap["values"] - if !ok { - return trace.BadParameter("acr.values not found in claims") - } - tokenAcrValuesSlice, ok := tokenAcrValues.([]interface{}) - if !ok { - return trace.BadParameter("acr.values unexpected type: %T", tokenAcr) - } - - acrValueMatched := false - for _, v := range tokenAcrValuesSlice { - vv, ok := v.(string) - if !ok { - continue - } - if acrValue == vv { - acrValueMatched = true - break - } - } - if !acrValueMatched { - log.Debugf("No OIDC ACR match found for '%v' in '%v'.", acrValue, tokenAcrValues) - return trace.BadParameter("acr claim does not match") - } - default: - log.Debugf("Validating OIDC ACR values with default rules.") - - claimValue, exists, err := claims.StringClaim("acr") - if !exists { - return trace.BadParameter("acr claim does not exist") - } - if err != nil { - return trace.Wrap(err) - } - if claimValue != acrValue { - log.Debugf("No OIDC ACR match found '%v' != '%v'.", acrValue, claimValue) - return trace.BadParameter("acr claim does not match") - } - } - - return nil -} - -// isHTTPS checks if the scheme for a URL is https or not. -func isHTTPS(u string) error { - earl, err := url.Parse(u) - if err != nil { - return trace.Wrap(err) - } - if earl.Scheme != "https" { - return trace.BadParameter("expected scheme https, got %q", earl.Scheme) - } - - return nil -} diff --git a/lib/auth/oidc_google.go b/lib/auth/oidc_google.go deleted file mode 100644 index fd2595890a23c..0000000000000 --- a/lib/auth/oidc_google.go +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright 2022 Gravitational, Inc -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package auth - -import ( - "context" - "fmt" - "os" - - "github.com/coreos/go-oidc/jose" - "github.com/gravitational/trace" - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" - directory "google.golang.org/api/admin/directory/v1" - "google.golang.org/api/cloudidentity/v1" - "google.golang.org/api/option" - - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/utils" -) - -const ( - // googleWorkspaceIssuerURL is the issuer URL for Google Workspace accounts. - googleWorkspaceIssuerURL = "https://accounts.google.com" - - // googleGroupsClaim is the OIDC claim that we inject into the claims - // returned for Google Workspace users, containing the email addresses of - // the Google Groups that the user belongs to. - googleGroupsClaim = "groups" -) - -// isGoogleWorkspaceConnector returns true if the connector is a OIDC connector -// for Google Workspace, configured to fetch extra claims. -func isGoogleWorkspaceConnector(connector types.OIDCConnector) bool { - // If google_service_account_uri and google_service_account are not set, we - // assume that this is a non-Google Workspace OIDC provider using the same - // issuer URL as Google Workspace (e.g. - // https://developers.google.com/identity/protocols/oauth2/openid-connect). - return connector.GetIssuerURL() == googleWorkspaceIssuerURL && - (connector.GetGoogleServiceAccountURI() != "" || connector.GetGoogleServiceAccount() != "") -} - -// addGoogleWorkspaceClaims will fetch extra data from proprietary Google APIs -// and it will add claims based on the fetched data. The current implementation -// adds a "groups" claim containing the Google Groups that the user is a member -// of. -func addGoogleWorkspaceClaims(ctx context.Context, connector types.OIDCConnector, claims jose.Claims) (jose.Claims, error) { - email, exists, err := claims.StringClaim("email") - if err != nil || !exists { - return nil, trace.BadParameter("no `email` in oauth claims for Google Workspace account") - } - - var googleGroups []string - switch connector.GetVersion() { - // for the V3 connector we first check to see if the service account can use - // the Cloud Identity API (fetching direct and indirect groups for all - // domains) and if that's not the case we fall back to the Admin SDK - // Directory API (fetching direct groups for all domains) - case types.V3: - credentials, err := getGoogleWorkspaceCredentials(ctx, connector, cloudidentity.CloudIdentityGroupsReadonlyScope) - if err != nil { - return nil, trace.Wrap(err) - } - - if credentials != nil { - log.Debugf("fetching transitive Google groups for %v", email) - googleGroups, err = groupsFromGoogleCloudIdentity(ctx, email, option.WithTokenSource(credentials)) - if err != nil { - return nil, trace.Wrap(err) - } - } else { - credentials, err := getGoogleWorkspaceCredentials(ctx, connector, directory.AdminDirectoryGroupReadonlyScope) - if err != nil { - return nil, trace.Wrap(err) - } - - if credentials == nil { - return nil, trace.BadParameter("invalid Google Workspace credentials for scopes %v or %v", - cloudidentity.CloudIdentityGroupsReadonlyScope, directory.AdminDirectoryGroupReadonlyScope) - } - - log.Debugf("fetching direct Google groups with no domain filtering for %v", email) - googleGroups, err = groupsFromGoogleDirectory(ctx, email, "", option.WithTokenSource(credentials)) - if err != nil { - return nil, trace.Wrap(err) - } - } - - // for the V2 connector we always try to use the Admin SDK Directory API to - // fetch direct groups filtered by domain, for backwards compatibility - case types.V2: - hostedDomain, exists, err := claims.StringClaim("hd") - if err != nil || !exists { - return nil, trace.BadParameter("no `hd` in oauth claims for Google Workspace account") - } - - credentials, err := getGoogleWorkspaceCredentials(ctx, connector, directory.AdminDirectoryGroupReadonlyScope) - if err != nil { - return nil, trace.Wrap(err) - } - - if credentials == nil { - return nil, trace.BadParameter("invalid Google Workspace credentials for scope %v", directory.AdminDirectoryGroupReadonlyScope) - } - - log.Debugf("fetching direct Google groups for %v, filtering by domain %v", email, hostedDomain) - googleGroups, err = groupsFromGoogleDirectory(ctx, email, hostedDomain, option.WithTokenSource(credentials)) - if err != nil { - return nil, trace.Wrap(err) - } - default: - return nil, trace.BadParameter("OIDC connector resource version %v is not supported", connector.GetVersion()) - } - - if len(googleGroups) > 0 { - googleClaims := jose.Claims{googleGroupsClaim: googleGroups} - log.Debugf("Claims from Google Workspace: %v.", googleClaims) - claims, err = mergeClaims(claims, googleClaims) - if err != nil { - return nil, trace.Wrap(err) - } - } else { - log.Debugf("No Google Workspace claims.") - } - - return claims, nil -} - -func getGoogleWorkspaceCredentials(ctx context.Context, connector types.OIDCConnector, scopes ...string) (oauth2.TokenSource, error) { - var jsonCredentials []byte - var credentialLoadingMethod string - if connector.GetGoogleServiceAccountURI() != "" { - // load the google service account from URI - credentialLoadingMethod = "google_service_account_uri" - - uri, err := utils.ParseSessionsURI(connector.GetGoogleServiceAccountURI()) - if err != nil { - return nil, trace.BadParameter("failed to parse google_service_account_uri: %v", err) - } - jsonCredentials, err = os.ReadFile(uri.Path) - if err != nil { - return nil, trace.Wrap(err) - } - } else if connector.GetGoogleServiceAccount() != "" { - // load the google service account from string - credentialLoadingMethod = "google_service_account" - jsonCredentials = []byte(connector.GetGoogleServiceAccount()) - } - - // we only support service_account credentials (the only ones that allow - // specifying an arbitrary Subject) - jwtConfig, err := google.JWTConfigFromJSON(jsonCredentials, scopes...) - if err != nil { - return nil, trace.BadParameter("unable to parse google service account from %v: %v", credentialLoadingMethod, err) - } - // The "Admin SDK Directory API" needs admin delegation (see - // https://developers.google.com/admin-sdk/directory/v1/guides/delegation - // and - // https://developers.google.com/identity/protocols/oauth2/service-account#delegatingauthority ) - // and the "Cloud Identity API" needs an account with View permission on - // all groups to work reliably. - jwtConfig.Subject = connector.GetGoogleAdminEmail() - - tokenSource := jwtConfig.TokenSource(ctx) - token, err := tokenSource.Token() - if err != nil || !token.Valid() { - log.WithError(err).Debugf("failed to obtain valid Google Workspace credentials for scopes %v", scopes) - return nil, nil - } - - return tokenSource, nil -} - -func groupsFromGoogleDirectory(ctx context.Context, email, filterDomain string, clientOptions ...option.ClientOption) ([]string, error) { - service, err := directory.NewService(ctx, clientOptions...) - if err != nil { - return nil, trace.Wrap(err) - } - - call := service.Groups.List().UserKey(email) - if filterDomain != "" { - call = call.Domain(filterDomain) - } - - var groups []string - err = call.Pages(ctx, func(resp *directory.Groups) error { - if resp == nil { - return nil - } - for _, g := range resp.Groups { - if g != nil && g.Email != "" { - groups = append(groups, g.Email) - } - } - return nil - }) - if err != nil { - return nil, trace.Wrap(err) - } - return groups, nil -} - -func groupsFromGoogleCloudIdentity(ctx context.Context, email string, clientOptions ...option.ClientOption) ([]string, error) { - service, err := cloudidentity.NewService(ctx, clientOptions...) - if err != nil { - return nil, trace.Wrap(err) - } - - // SearchTransitiveGroups takes a fixed parameter as part of the URL - // ("Format: `groups/{group}`, where `group` is always '-'") and a query - // parameter that the google API docs claim to be a CEL expression - // (https://opensource.google/projects/cel) that filters the results based - // on `member_key_id`, optionally `member_key_namespace`, and `labels`. The - // query parameter doesn't seem to actually be a CEL expression, and even - // changing the single quotes into double quotes (which is fine according to - // the CEL grammar) makes every API call fail with an "Unauthorized" error - // message. - // - // The query string was lifted directly from - // https://cloud.google.com/identity/docs/how-to/query-memberships#searching_for_all_group_memberships_of_a_member - // and some more informations on group labels can be found at - // https://cloud.google.com/identity/docs/groups#group_properties . - // The actual docs for the API call are at - // https://cloud.google.com/identity/docs/reference/rest/v1/groups.memberships/searchTransitiveGroups . - call := service.Groups.Memberships.SearchTransitiveGroups("groups/-"). - Query(fmt.Sprintf("member_key_id == '%s' && 'cloudidentity.googleapis.com/groups.discussion_forum' in labels", email)) - - var groups []string - err = call.Pages(ctx, func(resp *cloudidentity.SearchTransitiveGroupsResponse) error { - if resp == nil { - return nil - } - for _, g := range resp.Memberships { - if g != nil && g.GroupKey != nil && g.GroupKey.Id != "" { - groups = append(groups, g.GroupKey.Id) - } - } - return nil - }) - if err != nil { - return nil, trace.Wrap(err) - } - return groups, nil -} diff --git a/lib/auth/oidc_test.go b/lib/auth/oidc_test.go deleted file mode 100644 index 78a96be4e4e70..0000000000000 --- a/lib/auth/oidc_test.go +++ /dev/null @@ -1,1001 +0,0 @@ -/* -Copyright 2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package auth - -import ( - "context" - "crypto/tls" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/coreos/go-oidc/jose" - "github.com/coreos/go-oidc/oauth2" - "github.com/coreos/go-oidc/oidc" - "github.com/gravitational/trace" - "github.com/jonboulle/clockwork" - "github.com/stretchr/testify/require" - directory "google.golang.org/api/admin/directory/v1" - "google.golang.org/api/cloudidentity/v1" - "google.golang.org/api/option" - - "github.com/gravitational/teleport" - "github.com/gravitational/teleport/api/constants" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/auth/keystore" - authority "github.com/gravitational/teleport/lib/auth/testauthority" - "github.com/gravitational/teleport/lib/backend" - "github.com/gravitational/teleport/lib/backend/memory" - "github.com/gravitational/teleport/lib/defaults" - "github.com/gravitational/teleport/lib/fixtures" - "github.com/gravitational/teleport/lib/services" -) - -type OIDCSuite struct { - a *Server - b backend.Backend - c clockwork.FakeClock - oas *OIDCAuthService -} - -func setUpSuite(t *testing.T) *OIDCSuite { - s := OIDCSuite{} - - ctx := context.Background() - s.c = clockwork.NewFakeClockAt(time.Now()) - - var err error - s.b, err = memory.New(memory.Config{ - Context: ctx, - Clock: s.c, - }) - require.NoError(t, err) - - clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{ - ClusterName: "me.localhost", - }) - require.NoError(t, err) - - authConfig := &InitConfig{ - ClusterName: clusterName, - Backend: s.b, - Authority: authority.New(), - SkipPeriodicOperations: true, - KeyStoreConfig: keystore.Config{ - Software: keystore.SoftwareConfig{ - RSAKeyPairSource: authority.New().GenerateKeyPair, - }, - }, - } - s.a, err = NewServer(authConfig) - require.NoError(t, err) - - var ok bool - s.oas, ok = s.a.oidcAuthService.(*OIDCAuthService) - require.True(t, ok, "Server.oidcAuthService is not type *OIDCAuthService") - - return &s -} - -// createInsecureOIDCClient creates an insecure client for testing. -func createInsecureOIDCClient(t *testing.T, connector types.OIDCConnector) *oidc.Client { - conf := oidcConfig(connector, "") - conf.HTTPClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: true, - }, - }, - } - client, err := oidc.NewClient(conf) - require.NoError(t, err) - client.SyncProviderConfig(connector.GetIssuerURL()) - return client -} - -func TestCreateOIDCUser(t *testing.T) { - t.Parallel() - - s := setUpSuite(t) - - // Dry-run creation of OIDC user. - user, err := s.oas.createOIDCUser(&CreateUserParams{ - ConnectorName: "oidcService", - Username: "foo@example.com", - Roles: []string{"admin"}, - SessionTTL: 1 * time.Minute, - }, true) - require.NoError(t, err) - require.Equal(t, "foo@example.com", user.GetName()) - - // Dry-run must not create a user. - _, err = s.a.GetUser("foo@example.com", false) - require.Error(t, err) - - // Create OIDC user with 1 minute expiry. - _, err = s.oas.createOIDCUser(&CreateUserParams{ - ConnectorName: "oidcService", - Username: "foo@example.com", - Roles: []string{"admin"}, - SessionTTL: 1 * time.Minute, - }, false) - require.NoError(t, err) - - // Within that 1 minute period the user should still exist. - _, err = s.a.GetUser("foo@example.com", false) - require.NoError(t, err) - - // Advance time 2 minutes, the user should be gone. - s.c.Advance(2 * time.Minute) - _, err = s.a.GetUser("foo@example.com", false) - require.Error(t, err) -} - -// TestUserInfoBlockHTTP ensures that an insecure userinfo endpoint returns -// trace.NotFound similar to an invalid userinfo endpoint. For these users, -// all claim information is already within the token and additional claim -// information does not need to be fetched. -func TestUserInfoBlockHTTP(t *testing.T) { - t.Parallel() - - ctx := context.Background() - s := setUpSuite(t) - - // Create configurable IdP to use in tests. - idp := newFakeIDP(t, false /* tls */) - - // Create OIDC connector and client. - connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - }) - require.NoError(t, err) - - oidcClient, err := s.oas.getCachedOIDCClient(ctx, connector, "") - require.NoError(t, err) - - // Verify HTTP endpoints return trace.NotFound. - _, err = claimsFromUserInfo(oidcClient.client, idp.s.URL, "") - fixtures.AssertNotFound(t, err) -} - -// TestUserInfoBadStatus asserts that a 4xx response from userinfo results -// in AccessDenied. -func TestUserInfoBadStatus(t *testing.T) { - t.Parallel() - - // Create configurable IdP to use in tests. - idp := newFakeIDP(t, true /* tls */) - - // Create OIDC connector and client. - connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - }) - require.NoError(t, err) - oidcClient := createInsecureOIDCClient(t, connector) - - // Verify HTTP endpoints return trace.AccessDenied. - _, err = claimsFromUserInfo(oidcClient, idp.s.URL, "") - fixtures.AssertAccessDenied(t, err) -} - -func TestSSODiagnostic(t *testing.T) { - t.Parallel() - tests := []struct { - name string - claimsToRoles []types.ClaimMapping - wantValidateErr error - }{ - { - name: "success", - claimsToRoles: []types.ClaimMapping{ - { - Claim: "groups", - Value: "idp-admin", - Roles: []string{"access"}, - }, - }, - }, - { - name: "fail to map claims to roles", - claimsToRoles: []types.ClaimMapping{ - { - Claim: "groups", - Value: "nonexistant", - Roles: []string{"access"}, - }, - }, - wantValidateErr: ErrOIDCNoRoles, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - s := setUpSuite(t) - - // Create configurable IdP to use in tests. - idp := newFakeIDP(t, false /* tls */) - - // create role referenced in request. - role, err := types.NewRole("access", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Logins: []string{"dummy"}, - }, - }) - require.NoError(t, err) - err = s.a.CreateRole(ctx, role) - require.NoError(t, err) - - // connector spec - spec := types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - Display: "Test", - Scope: []string{"groups"}, - ClaimsToRoles: tc.claimsToRoles, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - } - - oidcRequest := types.OIDCAuthRequest{ - ConnectorID: "-sso-test-okta", - Type: constants.OIDC, - CertTTL: defaults.OIDCAuthRequestTTL, - SSOTestFlow: true, - ConnectorSpec: &spec, - } - - request, err := s.a.CreateOIDCAuthRequest(ctx, oidcRequest) - require.NoError(t, err) - require.NotNil(t, request) - - values := url.Values{ - "code": []string{"XXX-code"}, - "state": []string{request.StateToken}, - } - - // override getClaimsFun. - s.oas.getClaimsFun = func(closeCtx context.Context, oidcClient *oidc.Client, connector types.OIDCConnector, code string) (jose.Claims, error) { - cc := map[string]interface{}{ - "email_verified": true, - "groups": []string{"everyone", "idp-admin", "idp-dev"}, - "email": "superuser@example.com", - "sub": "00001234abcd", - "exp": 1652091713.0, - } - return cc, nil - } - - resp, err := s.oas.ValidateOIDCAuthCallback(ctx, values) - if tc.wantValidateErr != nil { - require.ErrorIs(t, err, tc.wantValidateErr) - return - } - - require.NoError(t, err) - require.NotNil(t, resp) - require.Equal(t, &OIDCAuthResponse{ - Username: "superuser@example.com", - Identity: types.ExternalIdentity{ - ConnectorID: "-sso-test-okta", - Username: "superuser@example.com", - }, - Req: OIDCAuthRequestFromProto(request), - }, resp) - - diagCtx := SSODiagContext{} - - resp, err = s.oas.validateOIDCAuthCallback(ctx, &diagCtx, values) - require.NoError(t, err) - require.NotNil(t, resp) - require.Equal(t, &OIDCAuthResponse{ - Username: "superuser@example.com", - Identity: types.ExternalIdentity{ - ConnectorID: "-sso-test-okta", - Username: "superuser@example.com", - }, - Req: OIDCAuthRequestFromProto(request), - }, resp) - require.Equal(t, types.SSODiagnosticInfo{ - TestFlow: true, - Success: true, - CreateUserParams: &types.CreateUserParams{ - ConnectorName: "-sso-test-okta", - Username: "superuser@example.com", - Logins: nil, - KubeGroups: nil, - KubeUsers: nil, - Roles: []string{"access"}, - Traits: map[string][]string{ - "email": {"superuser@example.com"}, - "groups": {"everyone", "idp-admin", "idp-dev"}, - "sub": {"00001234abcd"}, - }, - SessionTTL: 600000000000, - }, - OIDCClaimsToRoles: []types.ClaimMapping{ - { - Claim: "groups", - Value: "idp-admin", - Roles: []string{"access"}, - }, - }, - OIDCClaimsToRolesWarnings: nil, - OIDCClaims: map[string]interface{}{ - "email_verified": true, - "groups": []string{"everyone", "idp-admin", "idp-dev"}, - "email": "superuser@example.com", - "sub": "00001234abcd", - "exp": 1652091713.0, - }, - OIDCIdentity: &types.OIDCIdentity{ - ID: "00001234abcd", - Name: "", - Email: "superuser@example.com", - ExpiresAt: diagCtx.Info.OIDCIdentity.ExpiresAt, - }, - OIDCTraitsFromClaims: map[string][]string{ - "email": {"superuser@example.com"}, - "groups": {"everyone", "idp-admin", "idp-dev"}, - "sub": {"00001234abcd"}, - }, - OIDCConnectorTraitMapping: []types.TraitMapping{ - { - Trait: "groups", - Value: "idp-admin", - Roles: []string{"access"}, - }, - }, - }, diagCtx.Info) - }) - } -} - -// TestPingProvider confirms that the client_secret_post auth -// method was set for a oauthclient. -func TestPingProvider(t *testing.T) { - t.Parallel() - - ctx := context.Background() - s := setUpSuite(t) - - // Create configurable IdP to use in tests. - idp := newFakeIDP(t, false /* tls */) - - // Create and upsert oidc connector into identity - connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - Provider: teleport.Ping, - ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - }) - require.NoError(t, err) - err = s.a.UpsertOIDCConnector(ctx, connector) - require.NoError(t, err) - - for _, req := range []types.OIDCAuthRequest{ - { - ConnectorID: "test-connector", - }, { - SSOTestFlow: true, - ConnectorID: "test-connector", - ConnectorSpec: &types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - Provider: teleport.Ping, - ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - }, - }, - } { - t.Run(fmt.Sprintf("Test SSOFlow: %v", req.SSOTestFlow), func(t *testing.T) { - oidcConnector, oidcClient, err := s.oas.getOIDCConnectorAndClient(ctx, req) - require.NoError(t, err) - - oac, err := getOAuthClient(oidcClient, oidcConnector) - require.NoError(t, err) - - // authMethod should be client secret post now - require.Equal(t, oauth2.AuthMethodClientSecretPost, oac.GetAuthMethod()) - }) - } -} - -func TestOIDCClientProviderSync(t *testing.T) { - t.Parallel() - - ctx := context.Background() - // Create configurable IdP to use in tests. - idp := newFakeIDP(t, false /* tls */) - - // Create OIDC connector and client. - connector, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - Provider: teleport.Ping, - ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - }) - require.NoError(t, err) - - client, err := newOIDCClient(ctx, connector, "proxy.example.com") - require.NoError(t, err) - - // first sync should complete successfully - require.NoError(t, client.waitFirstSync(100*time.Millisecond)) - require.NoError(t, client.syncCtx.Err()) - - // Create OIDC client with a canceled ctx - canceledCtx, cancel := context.WithCancel(ctx) - cancel() - - client, err = newOIDCClient(canceledCtx, connector, "proxy.example.com") - require.NoError(t, err) - - // provider sync goroutine should end and first sync should fail - require.ErrorIs(t, client.syncCtx.Err(), context.Canceled) - err = client.waitFirstSync(100 * time.Millisecond) - require.Error(t, err) - require.ErrorIs(t, err, context.Canceled) - - // Create OIDC connector and client without an issuer URL for provider syncing - connectorNoIssuer, err := types.NewOIDCConnector("test-connector", types.OIDCConnectorSpecV3{ - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - Provider: teleport.Ping, - ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - }) - require.NoError(t, err) - - timeoutCtx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - client, err = newOIDCClient(timeoutCtx, connectorNoIssuer, "proxy.example.com") - require.NoError(t, err) - - // first sync should fail after the given timeout and cancel the sync goroutine. - err = client.waitFirstSync(100 * time.Millisecond) - require.Error(t, err) - require.True(t, trace.IsConnectionProblem(err)) - require.ErrorIs(t, client.syncCtx.Err(), context.Canceled) -} - -func TestOIDCClientCache(t *testing.T) { - t.Parallel() - - ctx := context.Background() - s := setUpSuite(t) - - // Create configurable IdP to use in tests. - idp := newFakeIDP(t, false /* tls */) - connectorSpec := types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "00000000000000000000000000000000", - ClientSecret: "0000000000000000000000000000000000000000000000000000000000000000", - Provider: teleport.Ping, - ClaimsToRoles: []types.ClaimMapping{{Claim: "roles", Value: "teleport-user", Roles: []string{"dictator"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - } - connector, err := types.NewOIDCConnector("test-connector", connectorSpec) - require.NoError(t, err) - - // Create and cache a new oidc client - client, err := s.oas.getCachedOIDCClient(ctx, connector, "proxy.example.com") - require.NoError(t, err) - - // The next call should return the same client (compare memory address) - cachedClient, err := s.oas.getCachedOIDCClient(ctx, connector, "proxy.example.com") - require.NoError(t, err) - require.True(t, client == cachedClient) - - // Canceling provider sync on a cached client should cause it to be replaced - client.syncCancel() - cachedClient, err = s.oas.getCachedOIDCClient(ctx, connector, "proxy.example.com") - require.NoError(t, err) - require.False(t, client == cachedClient) - - // Certain changes to the connector should cause the cached client to be refreshed - originalClient := cachedClient - for _, tc := range []struct { - desc string - mutateConnector func(types.OIDCConnector) - expectNoRefresh bool - }{ - { - desc: "IssuerURL", - mutateConnector: func(conn types.OIDCConnector) { - conn.SetIssuerURL(newFakeIDP(t, false /* tls */).s.URL) - }, - }, { - desc: "ClientID", - mutateConnector: func(conn types.OIDCConnector) { - conn.SetClientID("11111111111111111111111111111111") - }, - }, { - desc: "ClientSecret", - mutateConnector: func(conn types.OIDCConnector) { - conn.SetClientSecret("1111111111111111111111111111111111111111111111111111111111111111") - }, - }, { - desc: "RedirectURLs", - mutateConnector: func(conn types.OIDCConnector) { - conn.SetRedirectURLs([]string{"https://other.example.com/v1/webapi/oidc/callback"}) - }, - }, { - desc: "Scope", - mutateConnector: func(conn types.OIDCConnector) { - conn.SetScope([]string{"groups"}) - }, - }, { - desc: "Prompt - no refresh", - mutateConnector: func(conn types.OIDCConnector) { - conn.SetPrompt("none") - }, - expectNoRefresh: true, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - newConnector, err := types.NewOIDCConnector("test-connector", connectorSpec) - require.NoError(t, err) - tc.mutateConnector(newConnector) - - client, err = s.oas.getCachedOIDCClient(ctx, newConnector, "proxy.example.com") - require.NoError(t, err) - require.True(t, (client == originalClient) == tc.expectNoRefresh) - - // reset cached client to the original client for remaining tests - originalClient, err = s.oas.getCachedOIDCClient(ctx, connector, "proxy.example.com") - require.NoError(t, err) - }) - } -} - -// fakeIDP is a configurable OIDC IdP that can be used to mock responses in -// tests. At the moment it creates an HTTP server and only responds to the -// "/.well-known/openid-configuration" endpoint. -type fakeIDP struct { - s *httptest.Server -} - -// newFakeIDP creates a new instance of a configurable IdP. -func newFakeIDP(t *testing.T, tls bool) *fakeIDP { - var s fakeIDP - - mux := http.NewServeMux() - mux.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - }) - mux.HandleFunc("/", s.configurationHandler) - - if tls { - s.s = httptest.NewTLSServer(mux) - } else { - s.s = httptest.NewServer(mux) - } - - t.Cleanup(s.s.Close) - return &s -} - -// configurationHandler returns OpenID configuration. -func (s *fakeIDP) configurationHandler(w http.ResponseWriter, r *http.Request) { - resp := fmt.Sprintf(` -{ - "issuer": "%v", - "authorization_endpoint": "%v", - "token_endpoint": "%v", - "jwks_uri": "%v", - "userinfo_endpoint": "%v/userinfo", - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["HS256", "RS256"] -}`, s.s.URL, s.s.URL, s.s.URL, s.s.URL, s.s.URL) - - w.Header().Set("Content-Type", "application/json") - fmt.Fprintln(w, resp) -} - -func TestOIDCGoogle(t *testing.T) { - t.Parallel() - - directGroups := map[string][]string{ - "alice@foo.example": {"group1@foo.example", "group2@sub.foo.example", "group3@bar.example"}, - "bob@foo.example": {"group1@foo.example"}, - "carlos@bar.example": {"group1@foo.example", "group2@sub.foo.example", "group3@bar.example"}, - } - - // group2@sub.foo.example is in group3@bar.example and group3@bar.example is in group4@bar.example - strictDirectGroups := map[string][]string{ - "alice@foo.example": {"group1@foo.example", "group2@sub.foo.example"}, - "bob@foo.example": {"group1@foo.example"}, - "carlos@bar.example": {"group1@foo.example", "group2@sub.foo.example"}, - } - directIndirectGroups := map[string][]string{ - "alice@foo.example": {"group3@bar.example"}, - "bob@foo.example": {}, - "carlos@bar.example": {"group3@bar.example"}, - } - indirectGroups := map[string][]string{ - "alice@foo.example": {"group4@bar.example"}, - "bob@foo.example": {}, - "carlos@bar.example": {"group4@bar.example"}, - } - - mux := http.NewServeMux() - mux.HandleFunc("/admin/directory/v1/groups", func(rw http.ResponseWriter, r *http.Request) { - require.Equal(t, "GET", r.Method) - - email := r.URL.Query().Get("userKey") - require.NotEmpty(t, email) - require.Contains(t, directGroups, email) - - domain := r.URL.Query().Get("domain") - - resp := &directory.Groups{} - for _, groupEmail := range directGroups[email] { - if domain == "" || strings.HasSuffix(groupEmail, "@"+domain) { - resp.Groups = append(resp.Groups, &directory.Group{Email: groupEmail}) - } - } - - require.NoError(t, json.NewEncoder(rw).Encode(resp)) - }) - mux.HandleFunc("/v1/groups/-/memberships:searchTransitiveGroups", func(rw http.ResponseWriter, r *http.Request) { - require.Equal(t, "GET", r.Method) - q := r.URL.Query().Get("query") - - // hacky solution but the query parameter of searchTransitiveGroups is also pretty hacky - prefix := "member_key_id == '" - suffix := "' && 'cloudidentity.googleapis.com/groups.discussion_forum' in labels" - require.True(t, strings.HasPrefix(q, prefix)) - require.True(t, strings.HasSuffix(q, suffix)) - email := strings.TrimSuffix(strings.TrimPrefix(q, prefix), suffix) - require.NotEmpty(t, email) - require.Contains(t, directGroups, email) - - resp := &cloudidentity.SearchTransitiveGroupsResponse{} - - for relationType, groupEmails := range map[string][]string{ - "DIRECT": strictDirectGroups[email], - "DIRECT_AND_INDIRECT": directIndirectGroups[email], - "INDIRECT": indirectGroups[email], - } { - for _, groupEmail := range groupEmails { - resp.Memberships = append(resp.Memberships, &cloudidentity.GroupRelation{ - GroupKey: &cloudidentity.EntityKey{ - Id: groupEmail, - }, - Labels: map[string]string{ - "cloudidentity.googleapis.com/groups.discussion_forum": "", - }, - RelationType: relationType, - }) - } - } - - require.NoError(t, json.NewEncoder(rw).Encode(resp)) - }) - - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - testOptions := []option.ClientOption{option.WithEndpoint(ts.URL), option.WithoutAuthentication()} - - ctx := context.Background() - - for _, testCase := range []struct { - email, domain string - transitive, direct, filtered []string - }{ - { - "alice@foo.example", "foo.example", - []string{"group1@foo.example", "group2@sub.foo.example", "group3@bar.example", "group4@bar.example"}, - []string{"group1@foo.example", "group2@sub.foo.example", "group3@bar.example"}, - []string{"group1@foo.example"}, - }, - { - "bob@foo.example", "foo.example", - []string{"group1@foo.example"}, - []string{"group1@foo.example"}, - []string{"group1@foo.example"}, - }, - { - "carlos@bar.example", "bar.example", - []string{"group1@foo.example", "group2@sub.foo.example", "group3@bar.example", "group4@bar.example"}, - []string{"group1@foo.example", "group2@sub.foo.example", "group3@bar.example"}, - []string{"group3@bar.example"}, - }, - } { - // transitive groups - groups, err := groupsFromGoogleCloudIdentity(ctx, testCase.email, testOptions...) - require.NoError(t, err) - require.ElementsMatch(t, testCase.transitive, groups) - - // direct groups, unfiltered - groups, err = groupsFromGoogleDirectory(ctx, testCase.email, "", testOptions...) - require.NoError(t, err) - require.ElementsMatch(t, testCase.direct, groups) - - // direct groups, filtered by domain - groups, err = groupsFromGoogleDirectory(ctx, testCase.email, testCase.domain, testOptions...) - require.NoError(t, err) - require.ElementsMatch(t, testCase.filtered, groups) - } -} - -func TestEmailVerifiedClaim(t *testing.T) { - tests := []struct { - claims map[string]interface{} - expectedError string - }{ - { - claims: map[string]interface{}{ - "email_verified": "true", - }, - expectedError: "", - }, - { - claims: map[string]interface{}{ - "email_verified": "false", - }, - expectedError: "email not verified by OIDC provider", - }, - { - claims: map[string]interface{}{ - "email_verified": false, - }, - expectedError: "email not verified by OIDC provider", - }, - { - claims: map[string]interface{}{ - "email_verified": true, - }, - expectedError: "", - }, - { - claims: map[string]interface{}{ - "email_verified": "random_value", - }, - expectedError: "unable to parse oidc claim: \"email_verified\", must be either 'true' or 'false', got 'random_value'", - }, - } - - for _, test := range tests { - err := checkEmailVerifiedClaim(test.claims) - if test.expectedError == "" { - require.NoError(t, err) - } else { - require.ErrorContains(t, err, test.expectedError) - } - } -} - -// TestUsernameClaim ensures that the `username_claim` field in an OIDC config is handled correctly. -func TestUsernameClaim(t *testing.T) { - ctx := context.Background() - s := setUpSuite(t) - idp := newFakeIDP(t, false) - - diagCtx := SSODiagContext{} - - // Create role that will be mapped to the user. - role, err := types.NewRole("access", types.RoleSpecV5{ - Allow: types.RoleConditions{}, - }) - require.NoError(t, err) - err = s.a.CreateRole(ctx, role) - require.NoError(t, err) - - // Create claims with "preferred_username" field. - claims := map[string]interface{}{ - "email_verified": true, - "groups": []string{"everyone"}, - "email": "test-user@example.com", - "sub": "00001234abcd", - "exp": 1652091713.0, - "preferred_username": "Teleport_TestUser", - } - - // Create identity from the claims. - ident, err := oidc.IdentityFromClaims(claims) - require.NoError(t, err) - - tests := []struct { - desc string - spec types.OIDCConnectorSpecV3 - expectedUsername string - expectedError string - }{ - { - desc: "username_claim specified with correct claim", - spec: types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "000", - ClientSecret: "0000", - ClaimsToRoles: []types.ClaimMapping{{Claim: "groups", Value: "everyone", Roles: []string{"access"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - UsernameClaim: "preferred_username", - }, - expectedUsername: "Teleport_TestUser", - }, - { - desc: "username_claim specified with incorrect claim", - spec: types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "000", - ClientSecret: "0000", - ClaimsToRoles: []types.ClaimMapping{{Claim: "groups", Value: "everyone", Roles: []string{"access"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - UsernameClaim: "prefred_usrnam", - }, - expectedError: "The configured username_claim of \"prefred_usrnam\" was not received from the IdP. Please update the username_claim in connector \"okta-oidc\".", - }, - { - desc: "no username_claim specified, default to using email", - spec: types.OIDCConnectorSpecV3{ - IssuerURL: idp.s.URL, - ClientID: "000", - ClientSecret: "0000", - ClaimsToRoles: []types.ClaimMapping{{Claim: "groups", Value: "everyone", Roles: []string{"access"}}}, - RedirectURLs: []string{"https://proxy.example.com/v1/webapi/oidc/callback"}, - }, - expectedUsername: "test-user@example.com", - }, - } - - for _, tc := range tests { - t.Run(tc.desc, func(t *testing.T) { - // Create OIDC connector with UsernameClaim specified. - connector, err := types.NewOIDCConnector("okta-oidc", tc.spec) - require.NoError(t, err) - - // Create OIDC request. - oidcRequest := types.OIDCAuthRequest{ - ConnectorID: "okta-oidc", - Type: constants.OIDC, - CertTTL: defaults.OIDCAuthRequestTTL, - SSOTestFlow: true, - ConnectorSpec: &tc.spec, - } - request, err := s.a.CreateOIDCAuthRequest(ctx, oidcRequest) - require.NoError(t, err) - - // Generate the userCreateParams for the OIDC user. - createUserParams, err := s.oas.calculateOIDCUser(&diagCtx, connector, claims, ident, request) - if tc.expectedError != "" { - require.ErrorContains(t, err, tc.expectedError) - } else { - require.NoError(t, err) - require.Equal(t, tc.expectedUsername, createUserParams.Username) - } - }) - } -} - -func TestValidateACRValues(t *testing.T) { - tests := []struct { - comment string - inIDToken string - inACRValue string - inACRProvider string - outIsValid require.ErrorAssertionFunc - }{ - { - "0 - default, acr values match", - ` -{ - "acr": "foo", - "aud": "00000000-0000-0000-0000-000000000000", - "exp": 1111111111 -} - `, - "foo", - "", - require.NoError, - }, - { - "1 - default, acr values do not match", - ` -{ - "acr": "foo", - "aud": "00000000-0000-0000-0000-000000000000", - "exp": 1111111111 -} - `, - "bar", - "", - require.Error, - }, - { - "2 - netiq, acr values match", - ` -{ - "acr": { - "values": [ - "foo/bar/baz" - ] - }, - "aud": "00000000-0000-0000-0000-000000000000", - "exp": 1111111111 -} - `, - "foo/bar/baz", - "netiq", - require.NoError, - }, - { - "3 - netiq, invalid format", - ` -{ - "acr": { - "values": "foo/bar/baz" - }, - "aud": "00000000-0000-0000-0000-000000000000", - "exp": 1111111111 -} - `, - "foo/bar/baz", - "netiq", - require.Error, - }, - { - "4 - netiq, invalid value", - ` -{ - "acr": { - "values": [ - "foo/bar/baz/qux" - ] - }, - "aud": "00000000-0000-0000-0000-000000000000", - "exp": 1111111111 -} - `, - "foo/bar/baz", - "netiq", - require.Error, - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.comment, func(t *testing.T) { - t.Parallel() - var claims jose.Claims - err := json.Unmarshal([]byte(tt.inIDToken), &claims) - require.NoError(t, err) - - err = validateACRValues(tt.inACRValue, tt.inACRProvider, claims) - tt.outIsValid(t, err) - }) - } -} diff --git a/lib/auth/saml.go b/lib/auth/saml.go index a8bfbf2b3f4b7..3cf652a3e24a3 100644 --- a/lib/auth/saml.go +++ b/lib/auth/saml.go @@ -17,31 +17,16 @@ limitations under the License. package auth import ( - "bytes" - "compress/flate" "context" - "encoding/base64" "encoding/json" "fmt" - "io" - "sync" - "github.com/beevik/etree" - "github.com/google/go-cmp/cmp" "github.com/gravitational/trace" - saml2 "github.com/russellhaering/gosaml2" - "github.com/gravitational/teleport" - "github.com/gravitational/teleport/api/constants" - apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" - "github.com/gravitational/teleport/api/utils/keys" - "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/services" - "github.com/gravitational/teleport/lib/services/local" - "github.com/gravitational/teleport/lib/utils" ) // ErrSAMLRequiresEnterprise is the error returned by the SAML methods when not @@ -129,307 +114,6 @@ func (a *Server) ValidateSAMLResponse(ctx context.Context, re string, connectorI return resp, trace.Wrap(err) } -// SAMLAuthService implements the logic of the SAML connector, allowing SSO -// logins using the SAML protocol. -// -// SAMLAuthService implements the SAMLService interface. -type SAMLAuthService struct { - auth *Server - emitter apievents.Emitter - assertionReplayService *local.AssertionReplayService - samlProviders map[string]*samlProvider - lock sync.Mutex -} - -type SAMLAuthServiceConfig struct { - Auth *Server - Emitter apievents.Emitter - AssertionReplayService *local.AssertionReplayService -} - -// NewSAMLAuthService returns a SAMLAuthService configured to use the -// services given in the config. -func NewSAMLAuthService(cfg *SAMLAuthServiceConfig) *SAMLAuthService { - return &SAMLAuthService{ - auth: cfg.Auth, - emitter: cfg.Emitter, - assertionReplayService: cfg.AssertionReplayService, - - samlProviders: make(map[string]*samlProvider), - } -} - -// samlProvider is internal structure that stores SAML client and its config -type samlProvider struct { - provider *saml2.SAMLServiceProvider - connector types.SAMLConnector -} - -// ErrSAMLNoRoles results from not mapping any roles from SAML claims. -var ErrSAMLNoRoles = trace.AccessDenied("No roles mapped from claims. The mappings may contain typos.") - -func (sas *SAMLAuthService) CreateSAMLAuthRequest(ctx context.Context, req types.SAMLAuthRequest) (*types.SAMLAuthRequest, error) { - connector, provider, err := sas.getSAMLConnectorAndProvider(ctx, req) - if err != nil { - return nil, trace.Wrap(err) - } - - doc, err := provider.BuildAuthRequestDocument() - if err != nil { - return nil, trace.Wrap(err) - } - - attr := doc.Root().SelectAttr("ID") - if attr == nil || attr.Value == "" { - return nil, trace.BadParameter("missing auth request ID") - } - - req.ID = attr.Value - - // Workaround for Ping: Ping expects `SigAlg` and `Signature` query - // parameters when "Enforce Signed Authn Request" is enabled, but gosaml2 - // only provides these parameters when binding == BindingHttpRedirect. - // Luckily, BuildAuthURLRedirect sets this and is otherwise identical to - // the standard BuildAuthURLFromDocument. - if connector.GetProvider() == teleport.Ping { - req.RedirectURL, err = provider.BuildAuthURLRedirect("", doc) - } else { - req.RedirectURL, err = provider.BuildAuthURLFromDocument("", doc) - } - - if err != nil { - return nil, trace.Wrap(err) - } - - err = sas.auth.Services.CreateSAMLAuthRequest(ctx, req, defaults.SAMLAuthRequestTTL) - if err != nil { - return nil, trace.Wrap(err) - } - return &req, nil -} - -func (sas *SAMLAuthService) getSAMLConnectorAndProviderByID(ctx context.Context, connectorID string) (types.SAMLConnector, *saml2.SAMLServiceProvider, error) { - connector, err := sas.auth.Identity.GetSAMLConnector(ctx, connectorID, true) - if err != nil { - return nil, nil, trace.Wrap(err) - } - provider, err := sas.getSAMLProvider(connector) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - return connector, provider, nil -} - -func (sas *SAMLAuthService) getSAMLConnectorAndProvider(ctx context.Context, req types.SAMLAuthRequest) (types.SAMLConnector, *saml2.SAMLServiceProvider, error) { - if req.SSOTestFlow { - if req.ConnectorSpec == nil { - return nil, nil, trace.BadParameter("ConnectorSpec cannot be nil when SSOTestFlow is true") - } - - if req.ConnectorID == "" { - return nil, nil, trace.BadParameter("ConnectorID cannot be empty") - } - - // stateless test flow - connector, err := types.NewSAMLConnector(req.ConnectorID, *req.ConnectorSpec) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - // validate, set defaults for connector - err = services.ValidateSAMLConnector(connector, sas.auth) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - // we don't want to cache the provider. construct it directly instead of using sas.getSAMLProvider() - provider, err := services.GetSAMLServiceProvider(connector, sas.auth.GetClock()) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - return connector, provider, nil - } - - // regular execution flow - return sas.getSAMLConnectorAndProviderByID(ctx, req.ConnectorID) -} - -func (sas *SAMLAuthService) getSAMLProvider(conn types.SAMLConnector) (*saml2.SAMLServiceProvider, error) { - sas.lock.Lock() - defer sas.lock.Unlock() - - providerPack, ok := sas.samlProviders[conn.GetName()] - if ok && cmp.Equal(providerPack.connector, conn) { - return providerPack.provider, nil - } - delete(sas.samlProviders, conn.GetName()) - - serviceProvider, err := services.GetSAMLServiceProvider(conn, sas.auth.GetClock()) - if err != nil { - return nil, trace.Wrap(err) - } - - sas.samlProviders[conn.GetName()] = &samlProvider{connector: conn, provider: serviceProvider} - - return serviceProvider, nil -} - -func (sas *SAMLAuthService) calculateSAMLUser(diagCtx *SSODiagContext, connector types.SAMLConnector, assertionInfo saml2.AssertionInfo, request *types.SAMLAuthRequest) (*CreateUserParams, error) { - p := CreateUserParams{ - ConnectorName: connector.GetName(), - Username: assertionInfo.NameID, - } - - p.Traits = services.SAMLAssertionsToTraits(assertionInfo) - - diagCtx.Info.SAMLTraitsFromAssertions = p.Traits - diagCtx.Info.SAMLConnectorTraitMapping = connector.GetTraitMappings() - - var warnings []string - warnings, p.Roles = services.TraitsToRoles(connector.GetTraitMappings(), p.Traits) - if len(p.Roles) == 0 { - if len(warnings) != 0 { - log.WithField("connector", connector).Warnf("No roles mapped from claims. Warnings: %q", warnings) - diagCtx.Info.SAMLAttributesToRolesWarnings = &types.SSOWarnings{ - Message: "No roles mapped for the user", - Warnings: warnings, - } - } else { - log.WithField("connector", connector).Warnf("No roles mapped from claims.") - diagCtx.Info.SAMLAttributesToRolesWarnings = &types.SSOWarnings{ - Message: "No roles mapped for the user. The mappings may contain typos.", - } - } - return nil, trace.Wrap(ErrSAMLNoRoles) - } - - // Pick smaller for role: session TTL from role or requested TTL. - roles, err := services.FetchRoles(p.Roles, sas.auth, p.Traits) - if err != nil { - return nil, trace.Wrap(err) - } - roleTTL := roles.AdjustSessionTTL(apidefaults.MaxCertDuration) - - if request != nil { - p.SessionTTL = utils.MinTTL(roleTTL, request.CertTTL) - } else { - p.SessionTTL = roleTTL - } - - return &p, nil -} - -func (sas *SAMLAuthService) createSAMLUser(p *CreateUserParams, dryRun bool) (types.User, error) { - expires := sas.auth.GetClock().Now().UTC().Add(p.SessionTTL) - - log.Debugf("Generating dynamic SAML identity %v/%v with roles: %v. Dry run: %v.", p.ConnectorName, p.Username, p.Roles, dryRun) - - user := &types.UserV2{ - Kind: types.KindUser, - Version: types.V2, - Metadata: types.Metadata{ - Name: p.Username, - Namespace: apidefaults.Namespace, - Expires: &expires, - }, - Spec: types.UserSpecV2{ - Roles: p.Roles, - Traits: p.Traits, - SAMLIdentities: []types.ExternalIdentity{ - { - ConnectorID: p.ConnectorName, - Username: p.Username, - }, - }, - CreatedBy: types.CreatedBy{ - User: types.UserRef{ - Name: teleport.UserSystem, - }, - Time: sas.auth.GetClock().Now().UTC(), - Connector: &types.ConnectorRef{ - Type: constants.SAML, - ID: p.ConnectorName, - Identity: p.Username, - }, - }, - }, - } - - if dryRun { - return user, nil - } - - // Get the user to check if it already exists or not. - existingUser, err := sas.auth.Services.GetUser(p.Username, false) - if err != nil && !trace.IsNotFound(err) { - return nil, trace.Wrap(err) - } - - ctx := context.TODO() - - // Overwrite exisiting user if it was created from an external identity provider. - if existingUser != nil { - connectorRef := existingUser.GetCreatedBy().Connector - - // If the exisiting user is a local user, fail and advise how to fix the problem. - if connectorRef == nil { - return nil, trace.AlreadyExists("local user with name %q already exists. Either change "+ - "NameID in assertion or remove local user and try again.", existingUser.GetName()) - } - - log.Debugf("Overwriting existing user %q created with %v connector %v.", - existingUser.GetName(), connectorRef.Type, connectorRef.ID) - - if err := sas.auth.UpdateUser(ctx, user); err != nil { - return nil, trace.Wrap(err) - } - } else { - if err := sas.auth.CreateUser(ctx, user); err != nil { - return nil, trace.Wrap(err) - } - } - - return user, nil -} - -func ParseSAMLInResponseTo(response string) (string, error) { - raw, _ := base64.StdEncoding.DecodeString(response) - - doc := etree.NewDocument() - err := doc.ReadFromBytes(raw) - if err != nil { - // Attempt to inflate the response in case it happens to be compressed (as with one case at saml.oktadev.com) - buf, err := io.ReadAll(flate.NewReader(bytes.NewReader(raw))) - if err != nil { - return "", trace.Wrap(err) - } - - doc = etree.NewDocument() - err = doc.ReadFromBytes(buf) - if err != nil { - return "", trace.Wrap(err) - } - } - - if doc.Root() == nil { - return "", trace.BadParameter("unable to parse response") - } - - // Try to find the InResponseTo attribute in the SAML response. If we can't find this, return - // a predictable error message so the caller may choose interpret it as an IdP-initiated payload. - el := doc.Root() - responseTo := el.SelectAttr("InResponseTo") - if responseTo == nil { - return "", trace.NotFound("missing InResponseTo attribute") - } - if responseTo.Value == "" { - return "", trace.BadParameter("InResponseTo can not be empty") - } - return responseTo.Value, nil -} - // SAMLAuthResponse is returned when auth server validated callback parameters // returned from SAML identity provider type SAMLAuthResponse struct { @@ -494,260 +178,3 @@ type SAMLAuthRawResponse struct { // TLSCert is TLS certificate authority certificate TLSCert []byte `json:"tls_cert,omitempty"` } - -// SAMLAuthRequestFromProto converts the types.SAMLAuthRequest to SAMLAuthRequestData. -func SAMLAuthRequestFromProto(req *types.SAMLAuthRequest) SAMLAuthRequest { - return SAMLAuthRequest{ - ID: req.ID, - PublicKey: req.PublicKey, - CSRFToken: req.CSRFToken, - CreateWebSession: req.CreateWebSession, - ClientRedirectURL: req.ClientRedirectURL, - } -} - -// ValidateSAMLResponse consumes attribute statements from SAML identity provider -func (sas *SAMLAuthService) ValidateSAMLResponse(ctx context.Context, samlResponse string, connectorID string) (*SAMLAuthResponse, error) { - event := &apievents.UserLogin{ - Metadata: apievents.Metadata{ - Type: events.UserLoginEvent, - }, - Method: events.LoginMethodSAML, - } - - diagCtx := NewSSODiagContext(types.KindSAML, sas.auth) - - auth, err := sas.validateSAMLResponse(ctx, diagCtx, samlResponse, connectorID) - diagCtx.Info.Error = trace.UserMessage(err) - - diagCtx.WriteToBackend(ctx) - - attributeStatements := diagCtx.Info.SAMLAttributeStatements - if attributeStatements != nil { - attributes, err := apievents.EncodeMapStrings(attributeStatements) - if err != nil { - event.Status.UserMessage = fmt.Sprintf("Failed to encode identity attributes: %v", err.Error()) - log.WithError(err).Debug("Failed to encode identity attributes.") - } else { - event.IdentityAttributes = attributes - } - } - - if err != nil { - event.Code = events.UserSSOLoginFailureCode - if diagCtx.Info.TestFlow { - event.Code = events.UserSSOTestFlowLoginFailureCode - } - event.Status.Success = false - event.Status.Error = trace.Unwrap(err).Error() - event.Status.UserMessage = err.Error() - if err := sas.emitter.EmitAuditEvent(ctx, event); err != nil { - log.WithError(err).Warn("Failed to emit SAML login failed event.") - } - return nil, trace.Wrap(err) - } - - event.Status.Success = true - event.User = auth.Username - event.Code = events.UserSSOLoginCode - if diagCtx.Info.TestFlow { - event.Code = events.UserSSOTestFlowLoginCode - } - - if err := sas.emitter.EmitAuditEvent(ctx, event); err != nil { - log.WithError(err).Warn("Failed to emit SAML login event.") - } - - return auth, nil -} - -func (sas *SAMLAuthService) checkIDPInitiatedSAML(ctx context.Context, connector types.SAMLConnector, assertion *saml2.AssertionInfo) error { - if !connector.GetAllowIDPInitiated() { - return trace.AccessDenied("IdP initiated SAML is not allowed by the connector configuration") - } - - // Not all IdP's provide these variables, replay mitigation is best effort. - if assertion.SessionIndex != "" || assertion.SessionNotOnOrAfter == nil { - return nil - } - - err := sas.assertionReplayService.RecognizeSSOAssertion(ctx, connector.GetName(), assertion.SessionIndex, assertion.NameID, *assertion.SessionNotOnOrAfter) - return trace.Wrap(err) -} - -func (sas *SAMLAuthService) validateSAMLResponse(ctx context.Context, diagCtx *SSODiagContext, samlResponse string, connectorID string) (*SAMLAuthResponse, error) { - idpInitiated := false - var connector types.SAMLConnector - var provider *saml2.SAMLServiceProvider - var request *types.SAMLAuthRequest - requestID, err := ParseSAMLInResponseTo(samlResponse) - switch { - case trace.IsNotFound(err): - if connectorID == "" { - return nil, trace.BadParameter("ACS URI did not include a valid SAML connector ID parameter") - } - - idpInitiated = true - connector, provider, err = sas.getSAMLConnectorAndProviderByID(ctx, connectorID) - if err != nil { - return nil, trace.Wrap(err, "Failed to get SAML connector and provider") - } - case err != nil: - return nil, trace.Wrap(err) - default: - diagCtx.RequestID = requestID - request, err = sas.auth.Identity.GetSAMLAuthRequest(ctx, requestID) - if err != nil { - return nil, trace.Wrap(err, "Failed to get SAML Auth Request") - } - - diagCtx.Info.TestFlow = request.SSOTestFlow - connector, provider, err = sas.getSAMLConnectorAndProvider(ctx, *request) - if err != nil { - return nil, trace.Wrap(err, "Failed to get SAML connector and provider") - } - } - - assertionInfo, err := provider.RetrieveAssertionInfo(samlResponse) - if err != nil { - samlErr := trace.AccessDenied("received response with incorrect or missing attribute statements, please check the identity provider configuration to make sure that mappings for claims/attribute statements are set up correctly. , failed to retrieve SAML assertion info from response: %v.", err) - return nil, trace.WithUserMessage(samlErr, "Failed to retrieve assertion info. This may indicate IdP configuration error.") - } - - if assertionInfo != nil { - diagCtx.Info.SAMLAssertionInfo = (*types.AssertionInfo)(assertionInfo) - } - - if idpInitiated { - if err := sas.checkIDPInitiatedSAML(ctx, connector, assertionInfo); err != nil { - if trace.IsAccessDenied(err) { - log.Warnf("Failed to process IdP-initiated login request. IdP-initiated login is disabled for this connector: %v.", err) - } - - return nil, trace.Wrap(err) - } - } - - if assertionInfo.WarningInfo.InvalidTime { - samlErr := trace.AccessDenied("invalid time in SAML assertion info") - return nil, trace.WithUserMessage(samlErr, "SAML assertion info contained warning: invalid time.") - } - - if assertionInfo.WarningInfo.NotInAudience { - samlErr := trace.AccessDenied("no audience in SAML assertion info") - return nil, trace.WithUserMessage(samlErr, "SAML: not in expected audience. Check auth connector audience field and IdP configuration for typos and other errors.") - } - - log.Debugf("Obtained SAML assertions for %q.", assertionInfo.NameID) - log.Debugf("SAML assertion warnings: %+v.", assertionInfo.WarningInfo) - - attributeStatements := map[string][]string{} - - for key, val := range assertionInfo.Values { - var vals []string - for _, vv := range val.Values { - vals = append(vals, vv.Value) - } - log.Debugf("SAML assertion: %q: %q.", key, vals) - attributeStatements[key] = vals - } - - diagCtx.Info.SAMLAttributeStatements = attributeStatements - diagCtx.Info.SAMLAttributesToRoles = connector.GetAttributesToRoles() - - if len(connector.GetAttributesToRoles()) == 0 { - samlErr := trace.BadParameter("no attributes to roles mapping, check connector documentation") - return nil, trace.WithUserMessage(samlErr, "Attributes-to-roles mapping is empty, SSO user will never have any roles.") - } - - log.Debugf("Applying %v SAML attribute to roles mappings.", len(connector.GetAttributesToRoles())) - - // Calculate (figure out name, roles, traits, session TTL) of user and - // create the user in the backend. - params, err := sas.calculateSAMLUser(diagCtx, connector, *assertionInfo, request) - if err != nil { - return nil, trace.Wrap(err, "Failed to calculate user attributes.") - } - - diagCtx.Info.CreateUserParams = &types.CreateUserParams{ - ConnectorName: params.ConnectorName, - Username: params.Username, - KubeGroups: params.KubeGroups, - KubeUsers: params.KubeUsers, - Roles: params.Roles, - Traits: params.Traits, - SessionTTL: types.Duration(params.SessionTTL), - } - - user, err := sas.createSAMLUser(params, request != nil && request.SSOTestFlow) - if err != nil { - return nil, trace.Wrap(err, "Failed to create user from provided parameters.") - } - - // Auth was successful, return session, certificate, etc. to caller. - auth := &SAMLAuthResponse{ - Identity: types.ExternalIdentity{ - ConnectorID: params.ConnectorName, - Username: params.Username, - }, - Username: user.GetName(), - } - - if request != nil { - auth.Req = SAMLAuthRequestFromProto(request) - } else { - auth.Req = SAMLAuthRequest{ - CreateWebSession: true, - } - } - - // In test flow skip signing and creating web sessions. - if request != nil && request.SSOTestFlow { - diagCtx.Info.Success = true - return auth, nil - } - - // If the request is coming from a browser, create a web session. - if request == nil || request.CreateWebSession { - session, err := sas.auth.CreateWebSessionFromReq(ctx, types.NewWebSessionRequest{ - User: user.GetName(), - Roles: user.GetRoles(), - Traits: user.GetTraits(), - SessionTTL: params.SessionTTL, - LoginTime: sas.auth.GetClock().Now().UTC(), - }) - if err != nil { - return nil, trace.Wrap(err, "Failed to create web session.") - } - - auth.Session = session - } - - // If a public key was provided, sign it and return a certificate. - if request != nil && len(request.PublicKey) != 0 { - sshCert, tlsCert, err := sas.auth.CreateSessionCert(user, params.SessionTTL, request.PublicKey, request.Compatibility, request.RouteToCluster, - request.KubernetesCluster, keys.AttestationStatementFromProto(request.AttestationStatement)) - if err != nil { - return nil, trace.Wrap(err, "Failed to create session certificate.") - } - clusterName, err := sas.auth.GetClusterName() - if err != nil { - return nil, trace.Wrap(err, "Failed to obtain cluster name.") - } - auth.Cert = sshCert - auth.TLSCert = tlsCert - - // Return the host CA for this cluster only. - authority, err := sas.auth.GetCertAuthority(ctx, types.CertAuthID{ - Type: types.HostCA, - DomainName: clusterName.GetClusterName(), - }, false) - if err != nil { - return nil, trace.Wrap(err, "Failed to obtain cluster's host CA.") - } - auth.HostSigners = append(auth.HostSigners, authority) - } - - diagCtx.Info.Success = true - return auth, nil -} diff --git a/lib/auth/saml_test.go b/lib/auth/saml_test.go deleted file mode 100644 index 6f384de385fb4..0000000000000 --- a/lib/auth/saml_test.go +++ /dev/null @@ -1,677 +0,0 @@ -/* -Copyright 2019-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package auth - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509/pkix" - "encoding/base64" - "encoding/xml" - "net/url" - "testing" - "time" - - "github.com/jonboulle/clockwork" - saml2 "github.com/russellhaering/gosaml2" - samltypes "github.com/russellhaering/gosaml2/types" - "github.com/stretchr/testify/require" - - apidefaults "github.com/gravitational/teleport/api/defaults" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/auth/keystore" - authority "github.com/gravitational/teleport/lib/auth/testauthority" - "github.com/gravitational/teleport/lib/backend/memory" - "github.com/gravitational/teleport/lib/defaults" - "github.com/gravitational/teleport/lib/fixtures" - "github.com/gravitational/teleport/lib/services" - "github.com/gravitational/teleport/lib/tlsca" - "github.com/gravitational/teleport/lib/utils" -) - -func TestCreateSAMLUser(t *testing.T) { - t.Parallel() - - ctx := context.Background() - clock := clockwork.NewFakeClockAt(time.Now()) - - b, err := memory.New(memory.Config{ - Context: ctx, - Clock: clock, - }) - require.NoError(t, err) - - clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{ - ClusterName: "me.localhost", - }) - require.NoError(t, err) - - authConfig := &InitConfig{ - ClusterName: clusterName, - Backend: b, - Authority: authority.New(), - SkipPeriodicOperations: true, - KeyStoreConfig: keystore.Config{ - Software: keystore.SoftwareConfig{ - RSAKeyPairSource: authority.New().GenerateKeyPair, - }, - }, - } - - a, err := NewServer(authConfig) - require.NoError(t, err) - - sas, ok := a.samlAuthService.(*SAMLAuthService) - require.True(t, ok, "Server.samlAuthServer is not type *samlAuthServer") - - // Dry-run creation of SAML user. - user, err := sas.createSAMLUser(&CreateUserParams{ - ConnectorName: "samlService", - Username: "foo@example.com", - Roles: []string{"admin"}, - SessionTTL: 1 * time.Minute, - }, true) - require.NoError(t, err) - require.Equal(t, "foo@example.com", user.GetName()) - - // Dry-run must not create a user. - _, err = a.GetUser("foo@example.com", false) - require.Error(t, err) - - // Create SAML user with 1 minute expiry. - _, err = sas.createSAMLUser(&CreateUserParams{ - ConnectorName: "samlService", - Username: "foo@example.com", - Roles: []string{"admin"}, - SessionTTL: 1 * time.Minute, - }, false) - require.NoError(t, err) - - // Within that 1 minute period the user should still exist. - _, err = a.GetUser("foo@example.com", false) - require.NoError(t, err) - - // Advance time 2 minutes, the user should be gone. - clock.Advance(2 * time.Minute) - _, err = a.GetUser("foo@example.com", false) - require.Error(t, err) -} - -func TestEncryptedSAML(t *testing.T) { - t.Parallel() - - // This Base64 encoded XML blob is a signed SAML response with an encrypted assertion for testing decryption and parsing. - const EncryptedResponse = `PD94bWwgdmVyc2lvbj0iMS4wIj8+DQo8c2FtbHA6UmVzcG9uc2UgeG1sbnM6c2FtbHA9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpwcm90b2NvbCIgeG1sbnM6c2FtbD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiIgSUQ9InBmeDBmNTBiYTg0LWVmNjctNTQyZi1kZDgyLTI4NTU0MzVlMGM4MCIgVmVyc2lvbj0iMi4wIiBJc3N1ZUluc3RhbnQ9IjIwMTQtMDctMTdUMDE6MDE6NDhaIiBEZXN0aW5hdGlvbj0iaHR0cDovL3NwLmV4YW1wbGUuY29tL2RlbW8xL2luZGV4LnBocD9hY3MiIEluUmVzcG9uc2VUbz0iT05FTE9HSU5fNGZlZTNiMDQ2Mzk1YzRlNzUxMDExZTk3Zjg5MDBiNTI3M2Q1NjY4NSI+DQogIDxzYW1sOklzc3Vlcj5odHRwOi8vaWRwLmV4YW1wbGUuY29tL21ldGFkYXRhLnBocDwvc2FtbDpJc3N1ZXI+PGRzOlNpZ25hdHVyZSB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+DQogIDxkczpTaWduZWRJbmZvPjxkczpDYW5vbmljYWxpemF0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8xMC94bWwtZXhjLWMxNG4jIi8+DQogICAgPGRzOlNpZ25hdHVyZU1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNyc2Etc2hhMSIvPg0KICA8ZHM6UmVmZXJlbmNlIFVSST0iI3BmeDBmNTBiYTg0LWVmNjctNTQyZi1kZDgyLTI4NTU0MzVlMGM4MCI+PGRzOlRyYW5zZm9ybXM+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyNlbnZlbG9wZWQtc2lnbmF0dXJlIi8+PGRzOlRyYW5zZm9ybSBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMTAveG1sLWV4Yy1jMTRuIyIvPjwvZHM6VHJhbnNmb3Jtcz48ZHM6RGlnZXN0TWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnI3NoYTEiLz48ZHM6RGlnZXN0VmFsdWU+TUxic3U4WFFOcW4xWE8walUzeHZIL0pPalZnPTwvZHM6RGlnZXN0VmFsdWU+PC9kczpSZWZlcmVuY2U+PC9kczpTaWduZWRJbmZvPjxkczpTaWduYXR1cmVWYWx1ZT5yVTVDUzhWQnZGVjl3RkUvOEY1NHROQTd3UVFWbG9UZkRsL0h1amJwRzJBWTNZcExtdWxzU2pOdngvc0F4a3luZ0lLTVE2dHphZkN3KzZjaGNldzh4bUNOcWdSNWNiQ09DbzB2UUJXaXhINm9jU2FKWDRTU21WeEhhU2p1clRNRkZnamFFYktiM2duV21haGpDb093TU9MZHJtWlprYkp2OWQrWTVUR0VYL2hhUmMvbXU2b05WT3dCL0xMdURDdzk3RTkxdVNUVUpvL1RPS0tVRjJYenZhVEEwMXZobzM5OTYvalpFWkRYR1ZyTGlkOTg5NDJXWWVjT3F6ZnZTWWtLemNaRGd2ZE1udlR1M20yVHpXQ1RqaEVzVDN1cjQ2OThIUmlyZUZTbnFINldhYUVMYjFFeGFiemdxNGFsRG9ma1J3ZU14YWJleVV6aUVBSWhKOGYrNVE9PTwvZHM6U2lnbmF0dXJlVmFsdWU+DQo8ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE+PGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlES2pDQ0FoS2dBd0lCQWdJUUp0SkRKWlpCa2cvYWZNOGQyWkpDVGpBTkJna3Foa2lHOXcwQkFRc0ZBREJBTVJVd0V3WURWUVFLRXd4VVpXeGxjRzl5ZENCUFUxTXhKekFsQmdOVkJBTVRIblJsYkdWd2IzSjBMbXh2WTJGc2FHOXpkQzVzYjJOaGJHUnZiV0ZwYmpBZUZ3MHhOekExTURreE9UUXdNelphRncweU56QTFNRGN4T1RRd016WmFNRUF4RlRBVEJnTlZCQW9UREZSbGJHVndiM0owSUU5VFV6RW5NQ1VHQTFVRUF4TWVkR1ZzWlhCdmNuUXViRzlqWVd4b2IzTjBMbXh2WTJGc1pHOXRZV2x1TUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF1S0ZMYWYyaUlJL3hEUittMllqNlBuVUVhK3F6cXd4c2RMVWpudW5GWmFBWEcraFptNE1sODBTQ2lCZ0lnVEhRbEp5TElrVHR1Um9INWFlTXl6MUVSVUN0aWk0WnNUcURyampVeWJ4UDRyKzRIVlg2bTM0czZod0VyOEZpZnRzOXBNcDRpUzN0UWd1UmMyOGdQZERvL1Q2VnJKVFZZVWZVVXNORFJ0SXJsQjVPOWlncXFMbnVhWTllcUdpNFBVeDBHMHdSWUpwUnl3b2o4RzBJa3BmUVRpWCtDQUM3ZHQ1d3M3WnJuR3FDTkJMR2k1YkdzYU1tcHRWYnNTRXAxVGVubnRGNTRWMWlSNDlJVjVKcURobTFTMEhta2xlb0p6S2RjKzZzUC94TmVwejlQSnp1RjlkOU51YlRMV2dCc0syOFlJdGNtV0hkSFhEL09EeFZhZWhSandJREFRQUJveUF3SGpBT0JnTlZIUThCQWY4RUJBTUNCNEF3REFZRFZSMFRBUUgvQkFJd0FEQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFBVlU2c05CZGo3NnNhSHdPeEdTZG5FcVFvMnRNdVIzbXNTTTRGNndGSzJVa0tlcHNEN0NZSWYvUHpOU05VcUE1SklFVVZlTXFHeWlIdUFiVTRDNjU1blQxSXlKWDFELytyNzNzU3A1amJJcFFtMnhvUUdabmo2Zy9LbHR3OE9TT0F3K0RzTUYvUExWcW9XSnAwN3U2ZXcvbU54V3NKS2NaNWsrcTRlTXhjaTltS1JISHFzcXVXS1h6UWxVUk1ORkkrbUdhRndyS000ZG16YVIwQkVjK2lsU3hRcVV2UTc0c21zTEsremhOaWttZ2psR0M1b2I5ZzhYa2hWQWtKTUFoMnJiOW9uRE5pUmw2OGlBZ2N6UDg4bVh1dk4vbzk4ZHlwenNQeFhtdzZ0a0RxSVJQVUFVYmg0NjVybFk1c0tNbVJnWGkyclVmbC9RVjVuYm96VW8vSFE9PTwvZHM6WDUwOUNlcnRpZmljYXRlPjwvZHM6WDUwOURhdGE+PC9kczpLZXlJbmZvPjwvZHM6U2lnbmF0dXJlPg0KICA8c2FtbHA6U3RhdHVzPg0KICAgIDxzYW1scDpTdGF0dXNDb2RlIFZhbHVlPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6c3RhdHVzOlN1Y2Nlc3MiLz4NCiAgPC9zYW1scDpTdGF0dXM+DQogIA0KPHNhbWw6RW5jcnlwdGVkQXNzZXJ0aW9uPjx4ZW5jOkVuY3J5cHRlZERhdGEgeG1sbnM6eGVuYz0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjIiB4bWxuczpkc2lnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjIiBUeXBlPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNFbGVtZW50Ij48eGVuYzpFbmNyeXB0aW9uTWV0aG9kIEFsZ29yaXRobT0iaHR0cDovL3d3dy53My5vcmcvMjAwMS8wNC94bWxlbmMjYWVzMTI4LWNiYyIvPjxkc2lnOktleUluZm8geG1sbnM6ZHNpZz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI+PHhlbmM6RW5jcnlwdGVkS2V5Pjx4ZW5jOkVuY3J5cHRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNyc2Etb2FlcC1tZ2YxcCIvPjx4ZW5jOkNpcGhlckRhdGE+PHhlbmM6Q2lwaGVyVmFsdWU+TjlLaHFKeWtJdGk1eVZETzdzT0VpT2lMb1VWL2p5aEdITU0wZmFTUzZnWVJ5RUZqaFR2RzNCUEpsd1RTTXpzTTFuY1pwTGVBd0FKaFVzci9mT0pCVGtQbjA3UzZqZGsxYTBMaU9EbjkrcDlCVXlidjRXYWsyWGduMnhXNytDNVQ5bGhvQ0dHRThrdHh6Q0tXL1FhWUNWV3RsMEp1TGdNYWIyWHUzL1dJdVlSWDhKbVZ2ckdPWTlOd0hpeFhFT09PbDFSUUNnbXpNaCtxUno5eFhwWGU0Sk1XRGNqQ0g2blk5V2Fxem4yQTNJVnJzS1V3bUZuTWFaM1lOM04ybmNROWo2QXRYSThoWEErMjBvRlhBQWx2c3JVK0xOek9hTFRzb1QySFZVUER0YldhQm9tS1cxdW9lc1hPZG1KNnVDUVc4Zk9BL3p0QStjL1JERTdyaTZiSFNERCs3YW5uQlNaRzVzL1lrdm5PT2wxRjRFYndLdVpMd3RWdjQza1B1dnRVeUVxTE1HRFlhNmtXczlLRjRsR0dqa0YxMXpqTmVnSTRFd09EVXhZSm14QldRNXhvVm1sOGVvK0VNdGt5NzFGeUttMFhpSGo0cWw5Qnl4dUtaVHJrTXdjQjlPSkNFKzcwM1dpdW5uZy9OSGkrV1IrTmhORlZqUm40SWQ3UTVFTEZZNFRNMklNQld6Y3R2cEd6TGVHdjF2L2RXanVodU1aVjJpeG5nMzRxNXg3VVVnODAwNVlRTnNwOVcwYVZKdDRnY0tUeFAyRnJTVGVjM0MxRktVT1JXSCtVc2ZpZ21GY09YdHFvQXgyK3dDZGs3MkVTS3Z2WFYvWEVFSmd0aTRiVldUK0dzSmc2eGRhTjBPNnlpWTg4eE5BcWZMUEcwajJTb0k1bUcxYzM0YVE9PC94ZW5jOkNpcGhlclZhbHVlPjwveGVuYzpDaXBoZXJEYXRhPjwveGVuYzpFbmNyeXB0ZWRLZXk+PC9kc2lnOktleUluZm8+DQogICA8eGVuYzpDaXBoZXJEYXRhPg0KICAgICAgPHhlbmM6Q2lwaGVyVmFsdWU+a1dWbkpzTkZZZ1JTNlMvTlFERVlsN3RTTjVsTzR6YWtqdXdxTDROMlFHbW9rdnBWRlJreFp3bjVhOXkrVDhLUGZpOWt2bEhlNktzL1I1UTJ2NkdVd0Z4V1dPYVZrOFJDMUh1blN1ZnZ5Tks1Y21hRmJSM0t3OFZZZnNPRVV2T0d6Y2pqTFFjSFFFUEZUMXJsMW0vazc3dExzbFlxV1B6WkRhUFpkU1lkd0kvdlo0OThIeXF3b0Y4U2tCcFVQcW02bnYwVGFVdU5pOWMzbkdDUTBWejE3UGFnLzN3SERWTnA3RlNsSFJlSStySkZsS0RXalF4MStDZjF6U3pjTG5Ecll1aEt3MWY3WVVTYUh0enlBL2l5MkhRMDdSZjRyNUpRaUorR2FOSUkwYk44RTZ0QXJqbjFZSGZWMDRQdWNwa0xDTEJTRndiOERXZE9wMnFEN3pvcEJvc2g2YVF6Vjh3QjhsUEFqUUd2aFhlQVdwRmsxWkVaUG5SRkkydkdkaGFHL2o5TkQvM0EvbzlvZFZpd1ZBdFd4SG9WVzZWcWtLOG5GQlVyL25IVDZTYmdsOFlUd2s4N0hRaldHUEdSaksyNEp3YXc0VjV6djN4bG9zSlFpdnc2dGwyVXk5YWROdUlyOUFCRGJuc3RmdXlQNEZaenR2Q0RTUHFPZlVVQlhya0thczNJRE1iNm9KREQ3a003QVdHcEU5K0lJT0NoVzdiRk5hb3RndHN1OTVqd1ZNQ3BVL0NkNkhPbURrMWpDYVc5RzhlQWJVdjhaUEVmN1Q5c0Z1VnZOaVBVbkFyMWV1VEl6WFNPekFtZk5jdXZpZ1BnOUlCYzAvT0pYWjBBVTgvQWxyRTZRSldwL2pDYThUNTF0YTFjbVN6SGQ4SG0zTEt5aDhsYjljUG5RR3RCeW9LcFUzZEMwQWk3OU1Lc1NhekRTalByY2l6ZUdhS0Vzd1NCTWtWRGtDWFMybGJicXBrckxvN2tMdy9TNG1OMWZCVjU5a2txd1ZlL3pKQXJNNllIckNJUURwRkRCSWtHeXE1WVl1VkQyeXk4YnJmeFNBemMyK2ZpeWQ5OVJndEtTeGZhVkROV3VJZzJnVTRQME40TnVTOCtrb0MzclF6eWovOGdWSlRpSGg5UTFEOVRJbjhjZGllTE12ZlZLT25oMHZBTnR0MDN2YnRHVkJxTTQwa3VLeUxKRWE5TWY0N2xvWk5qamUxd2VvWW1wblZScGVxVGxzUDRzVmwzd3QwYWlDZG5mdHVaVlVWb0M3Um51VDRidVRWZVgybUdNMElxR242czJwZ0xsSU5xVEFTY0F0K3FlVEhycmVTcUhFSmovZnhyM3NySEpyMHZjQ0w2enZZMWtOUi9taGcyL25Bc0hwTkc3c21ITS9oQ3gzOS9HZ01FeFpXbU5lVXV3amhhOHpWc3FRdnZ6cDM3Nk1OWC9xeERuU0JxTEorTERlRXg0SnYweGJxd0tvVE01L3BSQThDOVA4NWRqZXYwT1RKWVBEOGZzNHhabmV6NHRQTlZhcEc1RzEwWG1Wem9TYm1MNXdYamV0bXJJY0NMZ0laeEZnZ1NFSmsxUGFtdThzU2VraHBNOE1EbVZXejZrRDZxdUxyRXNqc0h2K0Rxc1REMkJIRjJMOVVuMjlaL0NvbFBNaDRuT0NRZHpjcjNUT0dEalJaMk1lanJHb2VnblVORW5MaGhwK2luWTB5L2lsODZYV2FweERoUEFLclIxS3pTRUJ5Yk5UUE81S0o4M0pBMGx0dE5JVk9YZ3FkZkQxSy9KYnM4ZHdYY3pRenZCRXBMOXlEZVN4d0wzVXR2VG80NEJLVkYxUVdwUDVvdHU5ZndpZXNkZlJLZm1zM3pteFZiQTZ4dkprK0M2MkE2YXBDcjhqL3FaUldNTUczNytnMUI4bU13eGdabkxyOE9ic2JMQjBVV3JOYWdMbldpc29ZQTVmclcxN21wbW9tc3V1cmhQQ2IrbGczbmFkK1BCRXpXaUZISnJvVkdQMFRwZ1NWZlZvSU9wYkZCS2JpWjVoUnd5YURuaFMrL1UreXNUZ1hXdE1qUk9QYjRROVZiUW8vSXd0NEpVZjZNUlVlN0FoaTNUbTUyOUR5Q1ppOFNydTBtRDF4ZHArdjlkSXF2ZEoxZXROYXZFMTlCSmRDSzN0VHhIZHk2ODh6bEo4NFJOUTlxYzI1R3pudXVFNFg3ZjBkUkQrdEoyNkJNVmh5L3RLeDRzdUpIVURzS05yUmZ4aGM4czNwU2FhUTFZS0E4eVpnQnBIN2tDY0FJZ2g5NlpEU3NuUngzdU5Zc0txakFmd0x3TkNQam02bHY1YVV1azBtYklMelBwMDhFRWFzRWNhWE1CcEZraERKTGhIS1l2WUhPdjlQYTZvWEFOVzZSOC9rSUxoT2ppbnl3RmZUZ2FDci96R20zcitjc2VoM0RxTGNjZjRteXY3Tmp5eERUSWtVeUVDdDJLYnI3S2ZPckh3T3I0L3M0ODVhVVVJVDRxYml5Lzd4U0E1NXlDaFUzV0RkaGpDQ3l2WXNSa3cvUjhVQUMyRExmZWxDVlducWdoTnE3blhNdU9zM3h4L3hpLzE5eEVYK3RxTWNqUHVsc3FQejk4WDF1UE5iZDluYXpSUnFEaXQ2R0Y2NHNlY1ZKVmVjZlpHNTJpTHpRbzZLNzVtdnU0M00rNlI5MkhDdkVWd09sRDhCRWoxYncrUWFaL3FDNVF4UjNtMkR5VHlmd20wVVVUM25yOG0yTDdaR3ozTUU2RXViTzZFWFBVR0JpRnlUeTNNUzRxVGUrbTJoWHdwd0xhTXM3SXFjRVBvMW5KeUJmN1plWm1kZVpsLzJHZ1pVNk94YmxiTmtUZUJORlRWVjhzK0Y2K2h3ZVArMzdBbEVsVWNGNTZaUk9NU0Nmemw0OFRtR1BZNHdpRmlMWGErMXZqTFN6NGdlYzhlVGZJcXczdEdaNjAvUTJGRGV6TUV2MEd0cSsxbTU3VXpqWGtLSVoyNzJBZ3VUNzZsbWUyd1B3dUVtOER2ZHE0Z21ZZlE5MVZ5M3J3aElsVVpvakRxU2R0UHJiTU4zK0JvR1Y0SVFTdUExNlN0UUtJWkg3VVNkckZBZ1dhLzVjQzUwVjIvMUVMMWZRWjJFa01LbktFczRnc2hZNU5YRTYzTmJTSjZiMnpKQll6MDdRcXVTemhycGxUeTR3dVpqcytRd082bUhDZkdEaFY4R2I1RG1HcnExMVVJNVllNVp4RXNLS0FvSlRYdWFOZlRpamFKb2l5L2lvcmFZZk00eUNHUVIyU04wdnc4Zk9LdXA3SlIrejk5TXdmSEJwV21LaHhFM3dITWpZOWpJa3Nyc3JLWnk3MnFRaGROMDZCdEo2SFdpN25PNWJUdHVQZlpmUFdPOFVEZUpKZFdLdUVUNUdpeWFxWGhOM0VwQUQvcDA5RjZkOEgrb1gyRVAzQjRZWXE2cTlSOHpJZG9kZ3cxbncxZjM3MERpL0pSSG1PbnQ5VEord05FS0x5eW42dFVodXRpOGZMY2VKYkk2dHJFb1I2S3NZWGJUeXprVXg5TGtiVkNFelVicVV1YmtrdG5WVm5rcHFJSmNFTGdpZWl0TFRTYW80ZkdOaFROVDVCWDEySWtoQWsrSEhSZlZtWjdhY0lBNDlLYkhlRElpUGxOR3A1WGxMQ1luZGxlOFZlWDF4bEw4Y0duZlB5QWtiSTRLN3RQR2h4S0YzSWFoazFtTVFGWFRhQ3FvdS9pYm9ZQ0F0R3pod3pOaUtLcXhRT2FSWFpQWkY3TlVKbE13NlR1VFhhaU9pM0dLMDZ4eXY2ZEcxTk9ya2daRmhNSS9CZHdjWVcxaHdCaElXWHBVTnpNUyt6cDVTS1BOQW5BSHE4aW16OU1JcGNQajFkYXo0cUNQd3N4dXMwYXBnMkdZQ0xYTW8zdnlTRXd3WTAwQjZzUUlrRHhLTUc4TWtvQjFpMU5vVGMwNnJxTkFOL2szZ3h4UnpBNUFpNHU4NGhpd3dxOXlMWC9oNjFGRnpySklEY3gydHl4MHdUTFd2SzNBWCtOZStQMDZvWWEyVHN4R0RkV0RQR1BFc2U0aFZlTUl6PC94ZW5jOkNpcGhlclZhbHVlPg0KICAgPC94ZW5jOkNpcGhlckRhdGE+DQo8L3hlbmM6RW5jcnlwdGVkRGF0YT48L3NhbWw6RW5jcnlwdGVkQXNzZXJ0aW9uPjwvc2FtbHA6UmVzcG9uc2U+` - - // This XML blob is a sample EntityDescriptor made to satisfy the connector validator for testing. - const EntityDescriptor = ` - - - - - - MIIFazCCA1OgAwIBAgIUDpXWZ8npv3sWeCQbB1WCwMoDe9QwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTAyMTgyMTUyNTVaFw0yMjAyMTgyMTUyNTVaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDiEvFfAwgR8rfFPXVkJiWQGisFQNpQ5oq4ng5sD/3phPBBzwx0TTn+V+XG5pBTlyVe0h9kLqZ3Dnavdk9VDC1DIrc0CSKUhP01JdV9TlC/tCek9a2IQEjEZ0pZPbU/gtXxEGyrs9JVFf0K8saMH6xB8jJwB4Eq9jB8rsWZJh4HeyX1VEdruPdwRkFjuNhBnIax//DQSZepAhtM+mtxP+cHtRzXPlXHTpYvxcP2LoXjSdCh/XEu8Ai33O4Ek14HIFmNQ63pmzmxhpcPm8ejDFchOEU67zeOz2RQNAefeHRgG1gvFIcgmVXcLM+VmC0JlzNuyMFY1XUygm1PYcFz93p4OGJBkYgKifNHPcMzTLQtPoY397WREd/kkMtvgxSDs6GQr2VwByHoo5IoQJ/OpridaDduL9NSc6YHEEXxSceMSdI+txuZvOAJJuLR1DQ5S5xjdHBj8uDsAnmX7oORVadEJ38Aj1UlM+Lk6qnmoBEGAXEfa3Fxyz0qgN9MrtutJO0S4BLqqmXgM9Kulp0B7e7gkRaAyNt/Y0+dAuzYva+uTd7Qm96EEYCTwd9LM4OghTLpDCXFm5EQI+D0zEyOGhDqwQDdx3MHJoPd6xg72ZkoiADY235D/av/ZisF7acPucLvQ41gbWphQgsRTN81lRll/Wgd4EknznXq060RQBkNbwIDAQABo1MwUTAdBgNVHQ4EFgQUzpwOh72T7DyvsvkVV9Cu4YRKBTYwHwYDVR0jBBgwFoAUzpwOh72T7DyvsvkVV9Cu4YRKBTYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEADSc0AEFgMcwArn9zvppOdMlF4GqyJa7mzeVAKHRyXiLm4TSUk8oBk8GgO9f32B5sEUVBnL5FnzEUm7hMAG5DUcMXANkHguIwoISpAZdFh1VhH+13HIOmxre/UN9a1l829g1dANvYWcoGJc4uUtj3HF5UKcfEmrUwISimW0Mpuin+jDlRiLvpvImqxWUyFazucpE8Kj4jqmFNnoOLAQbEerR61W1wC3fpifM9cW5mKLsSpk9uG5PUTWKA1W7u+8AgLxvfdbFA9HnDc93JKWeWyBLX6GSeVL6y9pOY9MRBHqnpPVEPcjbZ3ZpX1EPWbniF+WRCIpjcye0obTTjipWJli5HqwGGauyXPGmevCkG96jiy8nf18HrQ3459SuRSZ1lQD5EoF+1QBL/O1Y6P7PVuOSQev376RD56tOLu1EWxZAmfDNNmlZSmZSn+h5JRcjSh1NFfktIVkHtNPKw8FXDp8098oqrJ3MoNTQgE0vpXiho1QIxWhfaEU5y/WynZFk1PssjBULWNxbeIpOFYk3paNyEpb9cOkOE8ZHOdi7WWJSwHaDmx6qizOQXO75QMLIMxkCdENFx6wWbNMvKCxOlPfgkNcBaAsybM+K0AHwwvyzlcpVfEdaCexGtecBoGkjFRCG+f9InppaaSzmgbIJvkSOMUWEDO/JlFizzWAG8koM= - - - - - - - MIIFazCCA1OgAwIBAgIUDpXWZ8npv3sWeCQbB1WCwMoDe9QwDQYJKoZIhvcNAQELBQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMTAyMTgyMTUyNTVaFw0yMjAyMTgyMTUyNTVaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDiEvFfAwgR8rfFPXVkJiWQGisFQNpQ5oq4ng5sD/3phPBBzwx0TTn+V+XG5pBTlyVe0h9kLqZ3Dnavdk9VDC1DIrc0CSKUhP01JdV9TlC/tCek9a2IQEjEZ0pZPbU/gtXxEGyrs9JVFf0K8saMH6xB8jJwB4Eq9jB8rsWZJh4HeyX1VEdruPdwRkFjuNhBnIax//DQSZepAhtM+mtxP+cHtRzXPlXHTpYvxcP2LoXjSdCh/XEu8Ai33O4Ek14HIFmNQ63pmzmxhpcPm8ejDFchOEU67zeOz2RQNAefeHRgG1gvFIcgmVXcLM+VmC0JlzNuyMFY1XUygm1PYcFz93p4OGJBkYgKifNHPcMzTLQtPoY397WREd/kkMtvgxSDs6GQr2VwByHoo5IoQJ/OpridaDduL9NSc6YHEEXxSceMSdI+txuZvOAJJuLR1DQ5S5xjdHBj8uDsAnmX7oORVadEJ38Aj1UlM+Lk6qnmoBEGAXEfa3Fxyz0qgN9MrtutJO0S4BLqqmXgM9Kulp0B7e7gkRaAyNt/Y0+dAuzYva+uTd7Qm96EEYCTwd9LM4OghTLpDCXFm5EQI+D0zEyOGhDqwQDdx3MHJoPd6xg72ZkoiADY235D/av/ZisF7acPucLvQ41gbWphQgsRTN81lRll/Wgd4EknznXq060RQBkNbwIDAQABo1MwUTAdBgNVHQ4EFgQUzpwOh72T7DyvsvkVV9Cu4YRKBTYwHwYDVR0jBBgwFoAUzpwOh72T7DyvsvkVV9Cu4YRKBTYwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEADSc0AEFgMcwArn9zvppOdMlF4GqyJa7mzeVAKHRyXiLm4TSUk8oBk8GgO9f32B5sEUVBnL5FnzEUm7hMAG5DUcMXANkHguIwoISpAZdFh1VhH+13HIOmxre/UN9a1l829g1dANvYWcoGJc4uUtj3HF5UKcfEmrUwISimW0Mpuin+jDlRiLvpvImqxWUyFazucpE8Kj4jqmFNnoOLAQbEerR61W1wC3fpifM9cW5mKLsSpk9uG5PUTWKA1W7u+8AgLxvfdbFA9HnDc93JKWeWyBLX6GSeVL6y9pOY9MRBHqnpPVEPcjbZ3ZpX1EPWbniF+WRCIpjcye0obTTjipWJli5HqwGGauyXPGmevCkG96jiy8nf18HrQ3459SuRSZ1lQD5EoF+1QBL/O1Y6P7PVuOSQev376RD56tOLu1EWxZAmfDNNmlZSmZSn+h5JRcjSh1NFfktIVkHtNPKw8FXDp8098oqrJ3MoNTQgE0vpXiho1QIxWhfaEU5y/WynZFk1PssjBULWNxbeIpOFYk3paNyEpb9cOkOE8ZHOdi7WWJSwHaDmx6qizOQXO75QMLIMxkCdENFx6wWbNMvKCxOlPfgkNcBaAsybM+K0AHwwvyzlcpVfEdaCexGtecBoGkjFRCG+f9InppaaSzmgbIJvkSOMUWEDO/JlFizzWAG8koM= - - - - urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified - - - ` - - signingKeypair := &types.AsymmetricKeyPair{ - Cert: fixtures.TLSCACertPEM, - PrivateKey: fixtures.TLSCAKeyPEM, - } - - encryptionKeypair := &types.AsymmetricKeyPair{ - Cert: fixtures.EncryptionCertPEM, - PrivateKey: fixtures.EncryptionKeyPEM, - } - - connector, err := types.NewSAMLConnector("spongebob", types.SAMLConnectorSpecV2{ - Cert: signingKeypair.Cert, - Issuer: "http://idp.example.com/metadata.php", - SSO: "nil", - AssertionConsumerService: "http://sp.example.com/demo1/index.php?acs", - EntityDescriptor: EntityDescriptor, - }) - require.NoError(t, err) - - connector.SetSigningKeyPair(signingKeypair) - connector.SetEncryptionKeyPair(encryptionKeypair) - - clock := clockwork.NewFakeClockAt(time.Date(2021, time.April, 4, 0, 0, 0, 0, time.UTC)) - provider, err := services.GetSAMLServiceProvider(connector, clock) - require.NoError(t, err) - assertionInfo, err := provider.RetrieveAssertionInfo(EncryptedResponse) - require.NoError(t, err) - require.NotEmpty(t, assertionInfo.Assertions) -} - -// TestPingSAMLWorkaround ensures we provide required additional authn query -// parameters for Ping backends (PingOne, PingFederate, etc) when -// `provider: ping` is set. -func TestPingSAMLWorkaround(t *testing.T) { - t.Parallel() - - ctx := context.Background() - clock := clockwork.NewFakeClockAt(time.Now()) - - // Create a Server instance for testing. - b, err := memory.New(memory.Config{ - Context: ctx, - Clock: clock, - }) - require.NoError(t, err) - - clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{ - ClusterName: "me.localhost", - }) - require.NoError(t, err) - - authConfig := &InitConfig{ - ClusterName: clusterName, - Backend: b, - Authority: authority.New(), - SkipPeriodicOperations: true, - KeyStoreConfig: keystore.Config{ - Software: keystore.SoftwareConfig{ - RSAKeyPairSource: authority.New().GenerateKeyPair, - }, - }, - } - - a, err := NewServer(authConfig) - require.NoError(t, err) - - // Create a new SAML connector for Ping. - const entityDescriptor = ` - - - - - MIIDejCCAmKgAwIBAgIGAXnsYbiQMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRYwFAYDVQQKDA1QaW5nIElkZW50aXR5MRYwFAYDVQQLDA1QaW5nIElkZW50aXR5MT8wPQYDVQQDDDZQaW5nT25lIFNTTyBDZXJ0aWZpY2F0ZSBmb3IgQWRtaW5pc3RyYXRvcnMgZW52aXJvbm1lbnQwHhcNMjEwNjA4MTYwODE3WhcNMjIwNjA4MTYwODE3WjB+MQswCQYDVQQGEwJVUzEWMBQGA1UECgwNUGluZyBJZGVudGl0eTEWMBQGA1UECwwNUGluZyBJZGVudGl0eTE/MD0GA1UEAww2UGluZ09uZSBTU08gQ2VydGlmaWNhdGUgZm9yIEFkbWluaXN0cmF0b3JzIGVudmlyb25tZW50MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArqJP+9QA8rzt9lLrKQigkT1HxCP5qIQH9vKgIhCDx5q7eSHOlxQ7MMa+1v1WQq1y5mgNG1zxe+cEaJ646JHQLoa0yj+rXsfCsUsKG7qceHzMR8p4y74x77PHTBJEviS9g/+fMGq7eaSK/F8ksPBfBjHnWv+lvnzrAGhxEuBXfFPf5Gb2Vr5LYurZEu9lIdFtSnFCVjzUIC1SMyovl92K4WdJpZ60N8FUSR6Jb7b8gWjnNHNc1iwr5C2b8+HUuWhqCIc0TQygEilZAdJhpYkeCQMiSqySsV+cmJ1vdjsV0HXX0YREDq6koklnw1hyTe1AckcH6qfWyBcoG2VYORjZPQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQA0eVvkB+/RSIEs7CXje7KKFGO99X7nIBNcpztp6kevxTDFHKsVlGFfl/mkksw9SjzdWSMDgGxxy6riYnScQD0FdyxaKzM0CRFfqdHf2+qVnK4GbiodqLOVp1dDE6CSQuPp7inQr+JDO/xD1WUAyMSC+ouFRdHq2O7MCYolEcyWiZoTTcch8RhLo5nqueKQfP0vaJwzAPgpXxAuabVuyrtN0BZHixO/sjjg9yup8/esCMBB/RR90PxzbI+8ZX5g1MxZZwSaXauQFyOjm5/t+JEisZf8rzrrhDd2GzWrYngB8DJLxCUK1JTM5SO/k3TqeDHLHi202P7AN2S/1CqzCaGb - - - - - - - - - ` - - signingKeypair := &types.AsymmetricKeyPair{ - Cert: fixtures.TLSCACertPEM, - PrivateKey: fixtures.TLSCAKeyPEM, - } - - encryptionKeypair := &types.AsymmetricKeyPair{ - Cert: fixtures.EncryptionCertPEM, - PrivateKey: fixtures.EncryptionKeyPEM, - } - - // SAML connector validation requires the roles in mappings exist. - role, err := types.NewRole("admin", types.RoleSpecV5{}) - require.NoError(t, err) - err = a.CreateRole(ctx, role) - require.NoError(t, err) - - connector, err := types.NewSAMLConnector("ping", types.SAMLConnectorSpecV2{ - AssertionConsumerService: "https://proxy.example.com:3080/v1/webapi/saml/acs", - Provider: "ping", - Display: "Ping", - AttributesToRoles: []types.AttributeMapping{ - {Name: "groups", Value: "ping-admin", Roles: []string{role.GetName()}}, - }, - EntityDescriptor: entityDescriptor, - SigningKeyPair: signingKeypair, - EncryptionKeyPair: encryptionKeypair, - }) - require.NoError(t, err) - - err = a.UpsertSAMLConnector(ctx, connector) - require.NoError(t, err) - - // Create an auth request that we can inspect. - req, err := a.CreateSAMLAuthRequest(ctx, types.SAMLAuthRequest{ - ConnectorID: "ping", - }) - require.NoError(t, err) - - // Parse the generated redirection URL. - parsed, err := url.Parse(req.RedirectURL) - require.NoError(t, err) - - require.Equal(t, "auth.pingone.com", parsed.Host) - require.Equal(t, "/8be7412d-7d2f-4392-90a4-07458d3dee78/saml20/idp/sso", parsed.Path) - - // SigAlg and Signature must be added when `provider: ping`. - require.NotEmpty(t, parsed.Query().Get("SigAlg"), "SigAlg is required for provider: ping") - require.NotEmpty(t, parsed.Query().Get("Signature"), "Signature is required for provider: ping") -} - -func TestServer_getConnectorAndProvider(t *testing.T) { - t.Parallel() - - ctx := context.Background() - clock := clockwork.NewFakeClockAt(time.Now()) - - // Create a Server instance for testing. - b, err := memory.New(memory.Config{ - Context: ctx, - Clock: clock, - }) - require.NoError(t, err) - - clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{ - ClusterName: "me.localhost", - }) - require.NoError(t, err) - - authConfig := &InitConfig{ - ClusterName: clusterName, - Backend: b, - Authority: authority.New(), - SkipPeriodicOperations: true, - KeyStoreConfig: keystore.Config{ - Software: keystore.SoftwareConfig{ - RSAKeyPairSource: authority.New().GenerateKeyPair, - }, - }, - } - - a, err := NewServer(authConfig) - require.NoError(t, err) - - sas, ok := a.samlAuthService.(*SAMLAuthService) - require.True(t, ok, "Server.samlAuthServer is not type *samlAuthServer") - - _, err = CreateRole(ctx, a, "baz", types.RoleSpecV5{}) - require.NoError(t, err) - - caKey, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - tlsCert, err := tlsca.GenerateSelfSignedCAWithSigner( - caKey, - pkix.Name{ - CommonName: "server1", - Organization: []string{"server1"}, - }, nil, defaults.CATTL) - require.NoError(t, err) - require.NotNil(t, tlsCert) - - keyPEM, certPEM, err := utils.GenerateSelfSignedSigningCert(pkix.Name{ - Organization: []string{"Teleport OSS"}, - CommonName: "teleport.localhost.localdomain", - }, nil, 10*365*24*time.Hour) - require.NoError(t, err) - - request := types.SAMLAuthRequest{ - ID: "ABC", - ConnectorID: "zzz", - CheckUser: false, - PublicKey: nil, - CertTTL: 0, - CreateWebSession: false, - SSOTestFlow: true, - ConnectorSpec: &types.SAMLConnectorSpecV2{ - Issuer: "test", - Audience: "test", - ServiceProviderIssuer: "test", - SSO: "test", - Cert: string(tlsCert), - AssertionConsumerService: "test", - AttributesToRoles: []types.AttributeMapping{{ - Name: "foo", - Value: "bar", - Roles: []string{"baz"}, - }}, - SigningKeyPair: &types.AsymmetricKeyPair{ - PrivateKey: string(keyPEM), - Cert: string(certPEM), - }, - }, - } - - connector, provider, err := sas.getSAMLConnectorAndProvider(context.Background(), request) - require.NoError(t, err) - require.NotNil(t, connector) - require.NotNil(t, provider) - - expectedConnector := &types.SAMLConnectorV2{Kind: "saml", Version: "v2", Metadata: types.Metadata{Name: "zzz", Namespace: apidefaults.Namespace}, Spec: *request.ConnectorSpec} - require.Equal(t, expectedConnector, connector) - - require.Equal(t, "test", provider.IdentityProviderSSOURL) - require.Equal(t, "test", provider.IdentityProviderIssuer) - require.Equal(t, "test", provider.AssertionConsumerServiceURL) - require.Equal(t, "test", provider.ServiceProviderIssuer) - - conn, err := types.NewSAMLConnector("foo", types.SAMLConnectorSpecV2{ - Issuer: "test", - SSO: "test", - Cert: string(tlsCert), - AssertionConsumerService: "test", - AttributesToRoles: []types.AttributeMapping{{ - Name: "foo", - Value: "bar", - Roles: []string{"baz"}, - }}, - }) - require.NoError(t, err) - - err = a.UpsertSAMLConnector(ctx, conn) - require.NoError(t, err) - - request2 := types.SAMLAuthRequest{ - ID: "ABC", - ConnectorID: "foo", - SSOTestFlow: false, - } - - connector, provider, err = sas.getSAMLConnectorAndProvider(context.Background(), request2) - require.NoError(t, err) - require.NotNil(t, connector) - require.NotNil(t, provider) -} - -func TestServer_ValidateSAMLResponse(t *testing.T) { - t.Parallel() - - ctx := context.Background() - clock := clockwork.NewFakeClockAt(time.Date(2022, 4, 25, 9, 0, 0, 0, time.UTC)) - - // Create a Server instance for testing. - b, err := memory.New(memory.Config{ - Context: ctx, - Clock: clock, - }) - require.NoError(t, err) - - clusterName, err := services.NewClusterNameWithRandomID(types.ClusterNameSpecV2{ - ClusterName: "me.localhost", - }) - require.NoError(t, err) - - authConfig := &InitConfig{ - ClusterName: clusterName, - Backend: b, - Authority: authority.New(), - SkipPeriodicOperations: true, - KeyStoreConfig: keystore.Config{ - Software: keystore.SoftwareConfig{ - RSAKeyPairSource: authority.New().GenerateKeyPair, - }, - }, - } - - a, err := NewServer(authConfig, WithClock(clock)) - require.NoError(t, err) - - sas, ok := a.samlAuthService.(*SAMLAuthService) - require.True(t, ok, "Server.samlAuthServer is not type *samlAuthServer") - - // empty response gives error. - response, err := a.ValidateSAMLResponse(context.Background(), "", "") - require.Nil(t, response) - require.Error(t, err) - - // create role referenced in request. - role, err := types.NewRole("access", types.RoleSpecV5{ - Allow: types.RoleConditions{ - Logins: []string{"dummy"}, - }, - }) - require.NoError(t, err) - err = a.CreateRole(ctx, role) - require.NoError(t, err) - - // real response from Okta - respOkta := `http://www.okta.com/exk14fxcpjuKMcor30h8uBRfvYvl5C/LPCh36uAmRLHW76+aDP3ngChtIwP3/Fc=M1VfkOOBH6r7niHhfGvf4OJ1HH5QJl83aD/b+mTDUUnXzHXgXlkb0BGQkSFn6ixojwCoXchpxCNzVLPN/tvfyY1dxP4MO8b+/07bGuVD2yTNlhN43/FFcDpmZ1ZDW8w2nPF1E5gy1lR8Wx2NgT3kQ2Ui1vRNX/KeX/P9NnABj4AjcshyHK2e49WLM/D4U84XOl7ODtzS7PTvtB0SGIwRE25G//8AsAv81eBfHL54Nz1HAqinMhxQtz32ZDXpKaAV6GypyBTvk6vo7Pkk4OiL6G9VIGC8Bd/gnavsc+Ickfuo7KTq8NDKTLB5WG34XKJqq6dGopSMrxr67oYjCEDZfw==MIIDpDCCAoygAwIBAgIGAX4zyofpMA0GCSqGSIb3DQEBCwUAMIGSMQswCQYDVQQGEwJVUzETMBEG -A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU -MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi04MTMzNTQxHDAaBgkqhkiG9w0BCQEW -DWluZm9Ab2t0YS5jb20wHhcNMjIwMTA3MDkwNTU4WhcNMzIwMTA3MDkwNjU4WjCBkjELMAkGA1UE -BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV -BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtODEzMzU0MRwwGgYJ -KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -xQz+tLD5cNlOBfdohHvqNWIfC13OCSnUAe20qA0K8y+jtZrpwjtjjLX8iRuCx8dYc/nd6zYOhhSq -2sLmrRa09wUXXTgnLGcj50gePTaroYLyF4FNgQWLvPHJk0FGcx6JvD6L+V5RzYwH87Fhg8niP4LZ -EBw3iZnsIJN9KOuLuQeXTW0PIlMFzpCwT9aUCHCoLepe5Ou8oi8XcOCmsOESHPchV2RC/xQDIqRP -Lp1Sf7NNJ6mTmP2gOoLwsz95beOLrEI+PI/GgZBqM3OutWA0L9mAbJK9T5dPAvhnwCV+SK2HvicJ -T8c6uJxuKmoWv1t3SyaN0cIbmw6vj9CIf4DTwQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCWGgLL -f3tgUZRGjmR5iiKeOeaEWG/eaF1nfenVfSaWT9ckimcXyLCY/P7CXEBiioVrxjky07iceJpi4rVE -RcVZ8SGXCa0NroESmIFlIHez6vRTrqUsfDmidxsSCwY02eaBq+9gK5iXV5WeXMKbn0yeGwF+3PkU -RAH1HuypwMH0FJRLIdW36pw7FCrGrXpk3UC6mEumXC9FptjSK1FlW+ZckgDprePOoUpypEygr2UC -XXOsqT0dwBUUttdOQMZHqIiXS5VPJ8zhYPHBGYI8WGk5FWVuXIXhgRm7LN/EyXIvCOFmDH0tVnQL -V115UGOwvjOOxmOFbYBn865SHgMndFtrhttp://www.okta.com/exk14fxcpjuKMcor30h8XwJSotSzU2qLdzu/WDk8dpQ/Cy1Id88932S/95+N+Ds=qyIvGi1+w93AdGUj0+T5RYAq+CAjLSScMTMc7dLTEze6qr3mP51W/bCoZz8E47lpsbLeh0EiATa6h2Uaj6/34rILfCt3aQRNjNicu0gBKhePyNraapdnoyeqJEV8UrAOOKFiH30e5AvQ1nRZqfgY7KMt6cZH5/eXjUS63lPJJn4yr9vLw9loCdHCoHlaseh2IHi7CickyyxSMTX+Y58zpBy2g/KwN3K4oZM4a10ZYWkZpzkZJXDRSUkEc/wTTO7IPPY7Zv7R7UC+zjf5Px1sYeKTkkIxlZViZmtqjYuhibnTmhroJx7wX/LtOPxCkwLHlQRDACBNbP/UtrudU1ZMxA==MIIDpDCCAoygAwIBAgIGAX4zyofpMA0GCSqGSIb3DQEBCwUAMIGSMQswCQYDVQQGEwJVUzETMBEG -A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU -MBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi04MTMzNTQxHDAaBgkqhkiG9w0BCQEW -DWluZm9Ab2t0YS5jb20wHhcNMjIwMTA3MDkwNTU4WhcNMzIwMTA3MDkwNjU4WjCBkjELMAkGA1UE -BhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV -BAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtODEzMzU0MRwwGgYJ -KoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA -xQz+tLD5cNlOBfdohHvqNWIfC13OCSnUAe20qA0K8y+jtZrpwjtjjLX8iRuCx8dYc/nd6zYOhhSq -2sLmrRa09wUXXTgnLGcj50gePTaroYLyF4FNgQWLvPHJk0FGcx6JvD6L+V5RzYwH87Fhg8niP4LZ -EBw3iZnsIJN9KOuLuQeXTW0PIlMFzpCwT9aUCHCoLepe5Ou8oi8XcOCmsOESHPchV2RC/xQDIqRP -Lp1Sf7NNJ6mTmP2gOoLwsz95beOLrEI+PI/GgZBqM3OutWA0L9mAbJK9T5dPAvhnwCV+SK2HvicJ -T8c6uJxuKmoWv1t3SyaN0cIbmw6vj9CIf4DTwQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCWGgLL -f3tgUZRGjmR5iiKeOeaEWG/eaF1nfenVfSaWT9ckimcXyLCY/P7CXEBiioVrxjky07iceJpi4rVE -RcVZ8SGXCa0NroESmIFlIHez6vRTrqUsfDmidxsSCwY02eaBq+9gK5iXV5WeXMKbn0yeGwF+3PkU -RAH1HuypwMH0FJRLIdW36pw7FCrGrXpk3UC6mEumXC9FptjSK1FlW+ZckgDprePOoUpypEygr2UC -XXOsqT0dwBUUttdOQMZHqIiXS5VPJ8zhYPHBGYI8WGk5FWVuXIXhgRm7LN/EyXIvCOFmDH0tVnQL -V115UGOwvjOOxmOFbYBn865SHgMndFtrops@gravitational.iohttps://boson.tener.io:3080/v1/webapi/saml/acsurn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransportops@gravitational.ioEveryoneokta-adminokta-dev` - - caKey, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) - - tlsCert, err := tlsca.GenerateSelfSignedCAWithSigner( - caKey, - pkix.Name{ - CommonName: "server1", - Organization: []string{"server1"}, - }, nil, defaults.CATTL) - require.NoError(t, err) - require.NotNil(t, tlsCert) - - keyPEM, certPEM, err := utils.GenerateSelfSignedSigningCert(pkix.Name{ - Organization: []string{"Teleport OSS"}, - CommonName: "teleport.localhost.localdomain", - }, nil, 10*365*24*time.Hour) - require.NoError(t, err) - - // SAML connector validation requires the roles in mappings exist. - connectorRole, err := types.NewRole("baz", types.RoleSpecV5{}) - require.NoError(t, err) - err = a.CreateRole(ctx, connectorRole) - require.NoError(t, err) - - conn, err := types.NewSAMLConnector("saml-test-conn", types.SAMLConnectorSpecV2{ - Issuer: "test", - SSO: "test", - Cert: string(tlsCert), - AssertionConsumerService: "test", - AttributesToRoles: []types.AttributeMapping{{ - Name: "foo", - Value: "bar", - Roles: []string{connectorRole.GetName()}, - }}, - SigningKeyPair: &types.AsymmetricKeyPair{ - PrivateKey: string(keyPEM), - Cert: string(certPEM), - }, - }) - require.NoError(t, err) - - err = a.UpsertSAMLConnector(ctx, conn) - require.NoError(t, err) - - err = a.Services.CreateSAMLAuthRequest(ctx, types.SAMLAuthRequest{ - ID: "_4f256462-6c2d-466d-afc0-6ee36602b6f2", - ConnectorID: "saml-test-conn", - SSOTestFlow: true, - RedirectURL: "https://dev-813354.oktapreview.com/app/dev-813354_krzysztofssodev_1/exk14fxcpjuKMcor30h8/sso/saml?SAMLRequest=lFZZk6JKE%2F0rHc6jYbOIiMbtiSgWEREEQVBfbrAUUMiiFFLgr%2F9i7Jm%2BPXf75j5mUifz5MmMJH%2FDQVlcl%2BDeZtUe3u4Qty99WVR4%2BfzwNro31bIOMMLLKighXrbR0gHGdsm%2B0strU7d1VBejT5B%2FRwQYw6ZFdTV60eS30e9cws54jmcnfMTGE47n40mQRPSEh3DK8zQb8gk7evFgg1FdvY3YV3r0Yn3PKqIqRlX67wnD90d4uXZda2LtHHf0An6QkOoK30vYOLDpUAQP%2B%2B3bKGvbK15SVFjjunptYQWbV1Qvp7RAUx1DERgGV0R9q5QKIjx60TC%2BQ63CbVC1byOWZtkJzU3YmUsLy9lsyQjn0YsMcYuqoH3W8CNBDLuJwEynM%2B61vrTBtYEdguQ1qksquF4%2Fff790jwG%2FGjrBOM6ht3vDAX7C8MlfXTN77oR1c2UzgQK4%2FrJa%2FT12dTlk1nz9b8V9Bv1GftbjJcOSqugvTfwe5Nj%2FF7DkqIIIa9k%2Blo3KcXSNE3RC6ovixij9MvoAwtjrUrqpykFVV2hKCjQ4ymGAdusjl9AkdYNarPyHwLzFMN%2BCzyJGK5imBH1M69fjMJQNPeD3qSsG%2FilwcEEZwE747%2BH3MMENrCK4Mthr72NvvzaeD6hbhNUOKmbEv9s%2Fl9aP6kGqw4W9RXGE%2Fyjuu%2FUfj3g36hF%2FZWgjFKI2%2F8oHayiLz8J9h7FC4o7%2FGogv2z54Iyj8bXj7FAoDc5Dwf3O0atwvsq3Ju%2FKBm0Bcnh7MvoMfjo%2B5H83%2FzQ8H%2F1%2BR2wJqAs5PUmLBA%2B5pPtrwRn4rhbER6QzrNHr9h1nztqm1S0kJT1Oo0NfxHRdnvx%2B27Lurbdl8FgdEZvM3FBqnV1DeHisSX7ezTHT24DMvdJBpb%2FlrVj0UmQk%2BVHhB1MyU65CSl2t1cHyBANokpgwPbrm86ne%2BBssK0UEVoAqlKFREkvel%2BWM7Qkv7MVOsUg0X%2FM3Yc1x2prVjXw4CUVxccn6UO53BsObdG4MpcBNGcYaWyp9NBQXcRTKtqSdVpyo7zM7PA6OQ53nJ0%2BMTGtqHpFmVLfxdnaKUpMa32Wt2N%2Bsy0NYr8aC7gxb5KPAnR%2Flta%2B0VaWa8I5XdyHSZ3XZiMM4Ho9tKaHA29uH9J%2B0%2Fia%2FDoePVhxn9EIO2uDDkL7t0wRFQQu%2FGpom6w9JAtkuBUQTQartwVbT871EMqWEfmR2ZGASUToQ2T5t9PqsZV1kAlvZijYgtqx4hmiogDkoUmYYnurh81HsospOXcZ0DSciG%2Fske7YtK%2F2MPvt9EamLIZZmOGTNLFSzLpra91Bd5Ce%2Fv4QskwU%2BR9ZZZBq5RkxZYww5ZYyHwvnffI%2Bnb%2Fjw5SIw9geikGcOXSH94Y8conVgjH7zAIWYmp4IDHdd7YtQ9Ug43dDbsu9O7AoH6uLxB599F%2Fqra5hLEnC0P9csijaQ01SxgCxJwK6lNFVEYAZRd7v7M%2BFkb1nIzriDojbZoOe3vb2z8xUppklq%2BWaQ9tNq84j53vOPQqEG7KbjFFo93MIT2GoBVXgNOCVuy9oqdDirWoiHq6pTV9NKdySK52MZjvO5hjMLye6miZrHoqR7nN96vqwyTKsb%2FZTz7W63urT12CWsdlmV05vNRw6rcpcdjw26i3w%2FgNhShGyfn%2BVNfM%2Bgf7xk1IWiu16cx7QVxoXZFJej1yyctR%2FHM51Hjqe4xgy4sraoSXSSbuJj8O%2F0GMw4xO38u5s6F3Z6m7GPqddGEb3SRKII6%2FMBGHGHUkd30Km51Xi%2FSFhm3%2FN%2BRBToqKs5ZeJzCQKj7u1wk%2FYL9mblYsBeBAkQBYDASA2RI3J6kr09bQF7TYnAlkEKgQHIt7mLFaKIFLElA4C%2Fm1H52SP56MXrtT1di%2FxxbqOHV9mUlKYPZacgQ2KkAveXIzxXIrPm3etKKRd5787nrSeqIt0vYjAopNyRE0iT8WrmbrSCL2%2FJoIVHfXxc9bcgquX1tWmt9lbe1ky7Z2l1EVlC2SdhOQ5v3LzZ%2BSbTpqDUir4P9vyZVV2Ffehk1g15fMYOiqX%2BEixOsS%2BAVImuldfCW583mHN2UYjijYbHC7PqW3DdWIa%2B2l3CS5dv%2BtkCXxIJm1VzEeSpBJGM3Dm1NuStwUXCVHFVmpMo52E6nVpcE54uXS2%2FDI8hBnqnIR2hBWZLRkfuDqu6d5GVZmyFRl1zA3%2Fci3Tr1WUs2f1DOm9lzCPSIVU9wGZOtPcF9Oel8uF8XzvU54X008L6%2Bv2gNYMSarJVFygaXkBR1ERqYNDCt1Hb3OHoZVU3ZdD%2B8%2B3IvDJPD4onyfPp8l7hK4xQgmD8%2FKf%2B9XD%2B%2Br8AAAD%2F%2Fw%3D%3D", - ClientRedirectURL: "http://127.0.0.1:57293/callback?secret_key=70e98f8871530e66e6a136ae71fe002454dc1c76d754f090f895508a2226c36b", - ConnectorSpec: &types.SAMLConnectorSpecV2{ - Issuer: "http://www.okta.com/exk14fxcpjuKMcor30h8", - SSO: "https://dev-813354.oktapreview.com/app/dev-813354_krzysztofssodev_1/exk14fxcpjuKMcor30h8/sso/saml", - Cert: "", - Display: "Okta", - AssertionConsumerService: "https://boson.tener.io:3080/v1/webapi/saml/acs", - Audience: "https://boson.tener.io:3080/v1/webapi/saml/acs", - ServiceProviderIssuer: "https://boson.tener.io:3080/v1/webapi/saml/acs", - EntityDescriptor: "\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\u003cmd:EntityDescriptor entityID=\"http://www.okta.com/exk14fxcpjuKMcor30h8\" xmlns:md=\"urn:oasis:names:tc:SAML:2.0:metadata\"\u003e\u003cmd:IDPSSODescriptor WantAuthnRequestsSigned=\"false\" protocolSupportEnumeration=\"urn:oasis:names:tc:SAML:2.0:protocol\"\u003e\u003cmd:KeyDescriptor use=\"signing\"\u003e\u003cds:KeyInfo xmlns:ds=\"http://www.w3.org/2000/09/xmldsig#\"\u003e\u003cds:X509Data\u003e\u003cds:X509Certificate\u003eMIIDpDCCAoygAwIBAgIGAX4zyofpMA0GCSqGSIb3DQEBCwUAMIGSMQswCQYDVQQGEwJVUzETMBEG\nA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU\nMBIGA1UECwwLU1NPUHJvdmlkZXIxEzARBgNVBAMMCmRldi04MTMzNTQxHDAaBgkqhkiG9w0BCQEW\nDWluZm9Ab2t0YS5jb20wHhcNMjIwMTA3MDkwNTU4WhcNMzIwMTA3MDkwNjU4WjCBkjELMAkGA1UE\nBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTALBgNV\nBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRMwEQYDVQQDDApkZXYtODEzMzU0MRwwGgYJ\nKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\nxQz+tLD5cNlOBfdohHvqNWIfC13OCSnUAe20qA0K8y+jtZrpwjtjjLX8iRuCx8dYc/nd6zYOhhSq\n2sLmrRa09wUXXTgnLGcj50gePTaroYLyF4FNgQWLvPHJk0FGcx6JvD6L+V5RzYwH87Fhg8niP4LZ\nEBw3iZnsIJN9KOuLuQeXTW0PIlMFzpCwT9aUCHCoLepe5Ou8oi8XcOCmsOESHPchV2RC/xQDIqRP\nLp1Sf7NNJ6mTmP2gOoLwsz95beOLrEI+PI/GgZBqM3OutWA0L9mAbJK9T5dPAvhnwCV+SK2HvicJ\nT8c6uJxuKmoWv1t3SyaN0cIbmw6vj9CIf4DTwQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCWGgLL\nf3tgUZRGjmR5iiKeOeaEWG/eaF1nfenVfSaWT9ckimcXyLCY/P7CXEBiioVrxjky07iceJpi4rVE\nRcVZ8SGXCa0NroESmIFlIHez6vRTrqUsfDmidxsSCwY02eaBq+9gK5iXV5WeXMKbn0yeGwF+3PkU\nRAH1HuypwMH0FJRLIdW36pw7FCrGrXpk3UC6mEumXC9FptjSK1FlW+ZckgDprePOoUpypEygr2UC\nXXOsqT0dwBUUttdOQMZHqIiXS5VPJ8zhYPHBGYI8WGk5FWVuXIXhgRm7LN/EyXIvCOFmDH0tVnQL\nV115UGOwvjOOxmOFbYBn865SHgMndFtr\u003c/ds:X509Certificate\u003e\u003c/ds:X509Data\u003e\u003c/ds:KeyInfo\u003e\u003c/md:KeyDescriptor\u003e\u003cmd:NameIDFormat\u003eurn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress\u003c/md:NameIDFormat\u003e\u003cmd:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST\" Location=\"https://dev-813354.oktapreview.com/app/dev-813354_krzysztofssodev_1/exk14fxcpjuKMcor30h8/sso/saml\"/\u003e\u003cmd:SingleSignOnService Binding=\"urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect\" Location=\"https://dev-813354.oktapreview.com/app/dev-813354_krzysztofssodev_1/exk14fxcpjuKMcor30h8/sso/saml\"/\u003e\u003c/md:IDPSSODescriptor\u003e\u003c/md:EntityDescriptor\u003e", - EntityDescriptorURL: "", - AttributesToRoles: []types.AttributeMapping{ - { - Name: "groups", - Value: "okta-admin", - Roles: []string{"access"}, - }, - }, - SigningKeyPair: &types.AsymmetricKeyPair{ - PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1py+q5bnxhAvZ7bnhQQauHIqOpFA5CMXCXd+A9Y1qDHecnN3\nrFVZfyUZrYm/gTQZSptgAshr+VWsBh9O3ZAZ5Lg+f0FSkYr+k0+A7Bx3v4N76Psi\nyE+INMmtyvP2bTGyOrHqaeGzQYkpiPq044WS2j5PDYiQWbepDpxLYbiQ7qwzS9xZ\nZp6w8TyFGNkMl26F5ZeSH+T/S/EHt3Q9t2U2uWRdWv1IdZ13krqJJURMzkBMMj2j\nBxgKoHPJa7T4DniLg5a5OBKTbernbPdW1xzQUgHATwdlQAx2+KBIpKJiuqixH1/b\nVHHpZzAR5IYXv82xmYBoyjFBsmDH3ao+MFraTwIDAQABAoIBAQCEXZbINEHtiiwC\n1u/Cvb5RRrC/ALm6O95Ii3egnCzp+SAPDSKhmt6hKdvFifEgmmaC+oPkE4Ns/Ccm\ne4bj5q3hwLVjPYHUnJrZdq64cfJ1n338O3C/hTYoAL/9Li0uOfmIdBV1iqxJ3nRM\ntPx+W/MwQj/1w+XsP/e4ODPSKMjTOyZkLVhArLA2qBM3l1NBWQw4EV96m1Dvjq2o\nxFYhSODZOYDYXq82NZya3cBzj30kEB/6fNk6qPAsMa3Ck7F/9mx3MA2XM/S0aB/U\nq+5I1g+mTAMPKa2c2Tv2hwWuRU9ddKGiXuw/gHPoBwEU7AVNk+nNSVDhj5/pqcFB\nM/lPNg6xAoGBAPu/oICyXwlXYfsvkTx+HHpY7Imq8RtBUjVpD3z+LC6+QydlF2Z/\nNqLDxBPZAAdA7VGzw5QdNR8DKY9EgeRQAcci8FIqjueTPQDVKl9kwJzOxqg8DM8t\nR8YpIOtP22JvjHAkFafBq9cWYZNLQwVabIkCdc0jUruN53WKRkj+b0npAoGBANo8\nkX7ypsS+riLu6Ez89tXHV78CW8eMb/Wxsa2hgrzd7KmgjYdj2wlfRaEY6pSvBA1Y\nMvy/Emvm2KhwuGzWWPJvoaQq/Hr6Puns9DVP8U3TTJDC3R5dDZUQ0DiVUGyez9Qu\nXV5aNMnXFjGPdRQYjGM1zG6677dM0CVYu/6MVSd3AoGBAN5X3tALufgsHzOUTXfa\nAhjk1PS573yc8piNk8pXSnp2PCVdGY/DJ2QV9uV4sJe3dmLEnCYCrdoYFuqcHQSi\nzQ8uAobvY4uP9T75BhV+jMdxsO8BKmcInO2dgZ+SxjZoQucAV8f0O2saL0/CFw1x\nUY6oh5aIbheMOzMKzwzE+1GRAoGAYm5FFWPuUfjK49irj+XckulZKz6uFJ/D86YU\nxIJ/TB4wWwWeL/2a0mxVJGbvjuYtRrOMM7EeZup0t+w3UmePMLGmzzvQKstpyupj\n7xPCe16dPwGU59gCg0RVFeBKqOMsS8Apvp+jBZJsYSgaH1k/IJQoQ50u95a+nsmZ\n6SJ0WdsCgYBfTcIJ456LNPQ7sjDtcqIQcbBgXYIt/u4dIhz0zuLSfvTrXAtcy7nU\nEDU+N2Ay715GmS+/iwc592Itam93t3sl1ql/Y+SrrxGQ7zRd6MALKIxM0+4qptB5\nDFXT1B5qhKHdZFr71AIGPrUjfsRQV8tPKFjRkuQ0zqEPvi4g06RMRw==\n-----END RSA PRIVATE KEY-----\n", - Cert: "-----BEGIN CERTIFICATE-----\nMIIDKzCCAhOgAwIBAgIRALIKjRCwhEmeWcNvwy1fBCUwDQYJKoZIhvcNAQELBQAw\nQDEVMBMGA1UEChMMVGVsZXBvcnQgT1NTMScwJQYDVQQDEx50ZWxlcG9ydC5sb2Nh\nbGhvc3QubG9jYWxkb21haW4wHhcNMjIwNDI1MDg1MzE4WhcNMzIwNDIyMDg1MzE4\nWjBAMRUwEwYDVQQKEwxUZWxlcG9ydCBPU1MxJzAlBgNVBAMTHnRlbGVwb3J0Lmxv\nY2FsaG9zdC5sb2NhbGRvbWFpbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC\nggEBANacvquW58YQL2e254UEGrhyKjqRQOQjFwl3fgPWNagx3nJzd6xVWX8lGa2J\nv4E0GUqbYALIa/lVrAYfTt2QGeS4Pn9BUpGK/pNPgOwcd7+De+j7IshPiDTJrcrz\n9m0xsjqx6mnhs0GJKYj6tOOFkto+Tw2IkFm3qQ6cS2G4kO6sM0vcWWaesPE8hRjZ\nDJduheWXkh/k/0vxB7d0PbdlNrlkXVr9SHWdd5K6iSVETM5ATDI9owcYCqBzyWu0\n+A54i4OWuTgSk23q52z3Vtcc0FIBwE8HZUAMdvigSKSiYrqosR9f21Rx6WcwEeSG\nF7/NsZmAaMoxQbJgx92qPjBa2k8CAwEAAaMgMB4wDgYDVR0PAQH/BAQDAgeAMAwG\nA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggEBADXVdHHQ3HB6X7QizVnQ/Cgg\nzEOEiMC1ClsxkXeZnB1H6TpFEm9jxT77tVBGB0x9dAyEwmOwYAgf+F5TJIl6mqfy\nIbXK+XFxqacoDHprtPtqmqH1tR20G9cP8mxfbm+bq47rOWN1tgAmIlxxaR6Z2GTE\n2zKw5vyjdZsSidCxka9YdW8AgEcpnVteqxjrs4SOcbidJIs+9NnxtApJPMKFOkbk\nvjJx59skfCsNnrk8D3CeiDiT7/HMDLM4c83ETG04C/SzNSvGlpf60mTIjkyzydAK\nvIiKii9s2m1KiTOsGKVkDEr+PbMoo4y6XRB0tVomdCQxzCZLDs6iwviGGUer7wI=\n-----END CERTIFICATE-----\n", - }, - Provider: "", - EncryptionKeyPair: nil, - }, - }, defaults.SAMLAuthRequestTTL) - require.NoError(t, err) - - // check ValidateSAMLResponse - response, err = sas.ValidateSAMLResponse(context.Background(), base64.StdEncoding.EncodeToString([]byte(respOkta)), "") - require.NoError(t, err) - require.NotNil(t, response) - - // check internal method, validate diagnostic outputs. - diagCtx := NewSSODiagContext(types.KindSAML, a) - auth, err := sas.validateSAMLResponse(context.Background(), diagCtx, base64.StdEncoding.EncodeToString([]byte(respOkta)), "") - require.NoError(t, err) - - // ensure diag info got stored and is identical. - infoFromBackend, err := a.GetSSODiagnosticInfo(context.Background(), types.KindSAML, auth.Req.ID) - require.NoError(t, err) - require.Equal(t, &diagCtx.Info, infoFromBackend) - - // verify values - require.Equal(t, "ops@gravitational.io", auth.Username) - require.Equal(t, "ops@gravitational.io", auth.Identity.Username) - require.Equal(t, "saml-test-conn", auth.Identity.ConnectorID) - require.Equal(t, "_4f256462-6c2d-466d-afc0-6ee36602b6f2", auth.Req.ID) - require.Equal(t, 0, len(auth.HostSigners)) - - authnInstant := time.Date(2022, 4, 25, 8, 3, 11, 779000000, time.UTC) - - // ignore, this is boring and very complex. - require.NotNil(t, diagCtx.Info.SAMLAssertionInfo.Assertions) - diagCtx.Info.SAMLAssertionInfo.Assertions = nil - - require.Equal(t, types.SSODiagnosticInfo{ - TestFlow: true, - Error: "", - Success: true, - CreateUserParams: &types.CreateUserParams{ - ConnectorName: "saml-test-conn", - Username: "ops@gravitational.io", - Roles: []string{"access"}, - Traits: map[string][]string{ - "groups": {"Everyone", "okta-admin", "okta-dev"}, - "username": {"ops@gravitational.io"}, - }, - SessionTTL: 108000000000000, - }, - SAMLAttributesToRoles: []types.AttributeMapping{ - { - Name: "groups", - Value: "okta-admin", - Roles: []string{"access"}, - }, - }, - SAMLAttributesToRolesWarnings: nil, - SAMLAttributeStatements: map[string][]string{ - "groups": {"Everyone", "okta-admin", "okta-dev"}, - "username": {"ops@gravitational.io"}, - }, - SAMLAssertionInfo: &types.AssertionInfo{ - NameID: "ops@gravitational.io", - Values: map[string]samltypes.Attribute{ - "groups": { - XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Attribute"}, - Name: "groups", - NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified", - Values: []samltypes.AttributeValue{ - { - XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "AttributeValue"}, - Value: "Everyone", - }, - { - XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "AttributeValue"}, - Value: "okta-admin", - }, - { - XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "AttributeValue"}, - Value: "okta-dev", - }, - }, - }, - "username": { - XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "Attribute"}, - Name: "username", - NameFormat: "urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified", - Values: []samltypes.AttributeValue{ - { - XMLName: xml.Name{Space: "urn:oasis:names:tc:SAML:2.0:assertion", Local: "AttributeValue"}, - Value: "ops@gravitational.io", - }, - }, - }, - }, - WarningInfo: &saml2.WarningInfo{}, - SessionIndex: "_4f256462-6c2d-466d-afc0-6ee36602b6f2", - AuthnInstant: &authnInstant, - SessionNotOnOrAfter: nil, - Assertions: nil, - ResponseSignatureValidated: true, - }, - SAMLTraitsFromAssertions: map[string][]string{ - "groups": {"Everyone", "okta-admin", "okta-dev"}, - "username": {"ops@gravitational.io"}, - }, - SAMLConnectorTraitMapping: []types.TraitMapping{ - { - Trait: "groups", - Value: "okta-admin", - Roles: []string{"access"}, - }, - }, - }, diagCtx.Info) - - // make sure no users have been created. - users, err := a.GetUsers(false) - require.NoError(t, err) - require.Equal(t, 0, len(users)) -} diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index d2aafea862fa8..a355bf4760db2 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -609,17 +609,6 @@ func (h *Handler) bindDefaultEndpoints() { // Kube access handlers. h.GET("/webapi/sites/:site/kubernetes", h.WithClusterAuth(h.clusterKubesGet)) - // OIDC related callback handlers - h.GET("/webapi/oidc/login/web", h.WithRedirect(h.oidcLoginWeb)) - h.GET("/webapi/oidc/callback", h.WithMetaRedirect(h.oidcCallback)) - h.POST("/webapi/oidc/login/console", h.WithLimiter(h.oidcLoginConsole)) - - // SAML 2.0 handlers - h.POST("/webapi/saml/acs", h.WithMetaRedirect(h.samlACS)) - h.POST("/webapi/saml/acs/:connector", h.WithMetaRedirect(h.samlACS)) - h.GET("/webapi/saml/sso", h.WithMetaRedirect(h.samlSSO)) - h.POST("/webapi/saml/login/console", h.WithLimiter(h.samlSSOConsole)) - // Github connector handlers h.GET("/webapi/github/login/web", h.WithRedirect(h.githubLoginWeb)) h.GET("/webapi/github/callback", h.WithMetaRedirect(h.githubCallback)) @@ -1329,32 +1318,6 @@ func (h *Handler) motd(w http.ResponseWriter, r *http.Request, p httprouter.Para return webclient.MotD{Text: authPrefs.GetMessageOfTheDay()}, nil } -func (h *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) string { - logger := h.log.WithField("auth", "oidc") - logger.Debug("Web login start.") - - req, err := ParseSSORequestParams(r) - if err != nil { - logger.WithError(err).Error("Failed to extract SSO parameters from request.") - return client.LoginFailedRedirectURL - } - - response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest(r.Context(), types.OIDCAuthRequest{ - CSRFToken: req.CSRFToken, - ConnectorID: req.ConnectorID, - CreateWebSession: true, - ClientRedirectURL: req.ClientRedirectURL, - CheckUser: true, - ProxyAddress: r.Host, - }) - if err != nil { - logger.WithError(err).Error("Error creating auth request.") - return client.LoginFailedRedirectURL - } - - return response.RedirectURL -} - func (h *Handler) githubLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) string { logger := h.log.WithField("auth", "github") logger.Debug("Web login start.") @@ -1484,112 +1447,6 @@ func (h *Handler) githubCallback(w http.ResponseWriter, r *http.Request, p httpr return redirectURL.String() } -func (h *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { - logger := h.log.WithField("auth", "oidc") - logger.Debug("Console login start.") - - req := new(client.SSOLoginConsoleReq) - if err := httplib.ReadJSON(r, req); err != nil { - logger.WithError(err).Error("Error reading json.") - return nil, trace.AccessDenied(SSOLoginFailureMessage) - } - - if err := req.CheckAndSetDefaults(); err != nil { - logger.WithError(err).Error("Missing request parameters.") - return nil, trace.AccessDenied(SSOLoginFailureMessage) - } - - response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest(r.Context(), types.OIDCAuthRequest{ - ConnectorID: req.ConnectorID, - ClientRedirectURL: req.RedirectURL, - PublicKey: req.PublicKey, - CertTTL: req.CertTTL, - CheckUser: true, - Compatibility: req.Compatibility, - RouteToCluster: req.RouteToCluster, - KubernetesCluster: req.KubernetesCluster, - ProxyAddress: r.Host, - AttestationStatement: req.AttestationStatement.ToProto(), - }) - if err != nil { - logger.WithError(err).Error("Failed to create OIDC auth request.") - return nil, trace.AccessDenied(SSOLoginFailureMessage) - } - - return &client.SSOLoginConsoleResponse{ - RedirectURL: response.RedirectURL, - }, nil -} - -func (h *Handler) oidcCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) string { - logger := h.log.WithField("auth", "oidc") - logger.Debug("Callback start.") - - response, err := h.cfg.ProxyClient.ValidateOIDCAuthCallback(r.Context(), r.URL.Query()) - if err != nil { - logger.WithError(err).Error("Error while processing callback.") - - // try to find the auth request, which bears the original client redirect URL. - // if found, use it to terminate the flow. - // - // this improves the UX by terminating the failed SSO flow immediately, rather than hoping for a timeout. - if requestID := r.URL.Query().Get("state"); requestID != "" { - if request, errGet := h.cfg.ProxyClient.GetOIDCAuthRequest(r.Context(), requestID); errGet == nil && !request.CreateWebSession { - if redURL, errEnc := RedirectURLWithError(request.ClientRedirectURL, err); errEnc == nil { - return redURL.String() - } - } - } - - if errors.Is(err, auth.ErrOIDCNoRoles) { - return client.LoginFailedUnauthorizedRedirectURL - } - - return client.LoginFailedBadCallbackRedirectURL - } - - // if we created web session, set session cookie and redirect to original url - if response.Req.CreateWebSession { - logger.Info("Redirecting to web browser.") - - res := &SSOCallbackResponse{ - CSRFToken: response.Req.CSRFToken, - Username: response.Username, - SessionName: response.Session.GetName(), - ClientRedirectURL: response.Req.ClientRedirectURL, - } - - if err := SSOSetWebSessionAndRedirectURL(w, r, res, true); err != nil { - logger.WithError(err).Error("Error setting web session.") - return client.LoginFailedRedirectURL - } - - return res.ClientRedirectURL - } - - logger.Info("Callback redirecting to console login.") - if len(response.Req.PublicKey) == 0 { - logger.Error("Not a web or console login request.") - return client.LoginFailedRedirectURL - } - - redirectURL, err := ConstructSSHResponse(AuthParams{ - ClientRedirectURL: response.Req.ClientRedirectURL, - Username: response.Username, - Identity: response.Identity, - Session: response.Session, - Cert: response.Cert, - TLSCert: response.TLSCert, - HostSigners: response.HostSigners, - }) - if err != nil { - logger.WithError(err).Error("Error constructing ssh response") - return client.LoginFailedRedirectURL - } - - return redirectURL.String() -} - func (h *Handler) installer(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { httplib.SetScriptHeaders(w.Header()) diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 49ce5fd4c7f22..43e3e5b2b2f73 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -20,7 +20,6 @@ import ( "archive/tar" "bufio" "bytes" - "compress/flate" "compress/gzip" "context" "crypto" @@ -41,14 +40,12 @@ import ( "os" "os/user" "path/filepath" - "regexp" "sort" "strings" "sync" "testing" "time" - "github.com/beevik/etree" "github.com/gogo/protobuf/proto" "github.com/google/go-cmp/cmp" "github.com/google/uuid" @@ -75,7 +72,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/serializer" - kyaml "k8s.io/apimachinery/pkg/util/yaml" authztypes "k8s.io/client-go/kubernetes/typed/authorization/v1" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" @@ -96,13 +92,11 @@ import ( "github.com/gravitational/teleport/lib/auth/native" "github.com/gravitational/teleport/lib/auth/testauthority" wanlib "github.com/gravitational/teleport/lib/auth/webauthn" - "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/bpf" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/client/conntest" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" - "github.com/gravitational/teleport/lib/fixtures" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/httplib/csrf" kubeproxy "github.com/gravitational/teleport/lib/kube/proxy" @@ -734,137 +728,6 @@ func Test_clientMetaFromReq(t *testing.T) { }, got) } -func TestSAML(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - rawConnector string - validSession bool - expectedRedirectURL string - }{ - { - name: "success", - rawConnector: fixtures.SAMLOktaConnectorV2, - validSession: true, - expectedRedirectURL: "/after", - }, - { - name: "fail to map claims to roles", - rawConnector: strings.ReplaceAll(fixtures.SAMLOktaConnectorV2, "Everyone", "No-one"), - validSession: false, - expectedRedirectURL: client.LoginFailedUnauthorizedRedirectURL, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - ctx := context.Background() - s := newWebSuite(t) - input := tc.rawConnector - - decoder := kyaml.NewYAMLOrJSONDecoder(strings.NewReader(input), defaults.LookaheadBufSize) - var raw services.UnknownResource - err := decoder.Decode(&raw) - require.NoError(t, err) - - connector, err := services.UnmarshalSAMLConnector(raw.Raw) - require.NoError(t, err) - - role, err := types.NewRoleV3(connector.GetAttributesToRoles()[0].Roles[0], types.RoleSpecV5{ - Options: types.RoleOptions{ - MaxSessionTTL: types.NewDuration(apidefaults.MaxCertDuration), - }, - Allow: types.RoleConditions{ - NodeLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, - Namespaces: []string{apidefaults.Namespace}, - Rules: []types.Rule{ - types.NewRule(types.Wildcard, services.RW()), - }, - }, - }) - require.NoError(t, err) - role.SetLogins(types.Allow, []string{s.user}) - err = s.server.Auth().UpsertRole(s.ctx, role) - require.NoError(t, err) - - err = s.server.Auth().UpsertSAMLConnector(ctx, connector) - require.NoError(t, err) - s.server.Auth().SetClock(clockwork.NewFakeClockAt(time.Date(2017, 5, 10, 18, 53, 0, 0, time.UTC))) - clt := s.clientNoRedirects() - - csrfToken := "2ebcb768d0090ea4368e42880c970b61865c326172a4a2343b645cf5d7f20992" - - baseURL, err := url.Parse(clt.Endpoint("webapi", "saml", "sso") + `?connector_id=` + connector.GetName() + `&redirect_url=http://localhost/after`) - require.NoError(t, err) - req, err := http.NewRequest("GET", baseURL.String(), nil) - require.NoError(t, err) - addCSRFCookieToReq(req, csrfToken) - re, err := clt.Client.RoundTrip(func() (*http.Response, error) { - return clt.Client.HTTPClient().Do(req) - }) - require.NoError(t, err) - - // we got a redirect - urlPattern := regexp.MustCompile(`URL='([^']*)'`) - locationURL := urlPattern.FindStringSubmatch(string(re.Bytes()))[1] - u, err := url.Parse(locationURL) - require.NoError(t, err) - require.Equal(t, fixtures.SAMLOktaSSO, u.Scheme+"://"+u.Host+u.Path) - data, err := base64.StdEncoding.DecodeString(u.Query().Get("SAMLRequest")) - require.NoError(t, err) - buf, err := io.ReadAll(flate.NewReader(bytes.NewReader(data))) - require.NoError(t, err) - doc := etree.NewDocument() - err = doc.ReadFromBytes(buf) - require.NoError(t, err) - id := doc.Root().SelectAttr("ID") - require.NotNil(t, id) - - authRequest, err := s.server.Auth().GetSAMLAuthRequest(context.Background(), id.Value) - require.NoError(t, err) - - // now swap the request id to the hardcoded one in fixtures - authRequest.ID = fixtures.SAMLOktaAuthRequestID - authRequest.CSRFToken = csrfToken - err = s.server.Auth().Services.CreateSAMLAuthRequest(ctx, *authRequest, backend.Forever) - require.NoError(t, err) - - // now respond with pre-recorded request to the POST url - in := &bytes.Buffer{} - fw, err := flate.NewWriter(in, flate.DefaultCompression) - require.NoError(t, err) - - _, err = fw.Write([]byte(fixtures.SAMLOktaAuthnResponseXML)) - require.NoError(t, err) - err = fw.Close() - require.NoError(t, err) - encodedResponse := base64.StdEncoding.EncodeToString(in.Bytes()) - require.NotNil(t, encodedResponse) - - // now send the response to the server to exchange it for auth session - form := url.Values{} - form.Add("SAMLResponse", encodedResponse) - req, err = http.NewRequest("POST", clt.Endpoint("webapi", "saml", "acs"), strings.NewReader(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - addCSRFCookieToReq(req, csrfToken) - require.NoError(t, err) - authRe, err := clt.Client.RoundTrip(func() (*http.Response, error) { - return clt.Client.HTTPClient().Do(req) - }) - - require.NoError(t, err) - // This route uses a meta redirect, so expect redirect URL in body instead of location header. - require.Equal(t, http.StatusOK, authRe.Code(), "Response: %v", string(authRe.Bytes())) - if tc.validSession { - // we have got valid session - require.NotEmpty(t, authRe.Headers().Get("Set-Cookie")) - } - require.Contains(t, string(authRe.Bytes()), tc.expectedRedirectURL) - }) - } -} - func TestWebSessionsCRUD(t *testing.T) { t.Parallel() s := newWebSuite(t) @@ -6169,19 +6032,6 @@ func waitForOutput(stream *terminalStream, substr string) error { } } -func (s *WebSuite) clientNoRedirects(opts ...roundtrip.ClientParam) *client.WebClient { - hclient := client.NewInsecureWebClient() - hclient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - opts = append(opts, roundtrip.HTTPClient(hclient)) - wc, err := client.NewWebClient(s.url().String(), opts...) - if err != nil { - panic(err) - } - return wc -} - func (s *WebSuite) client(opts ...roundtrip.ClientParam) *client.WebClient { opts = append(opts, roundtrip.HTTPClient(client.NewInsecureWebClient())) wc, err := client.NewWebClient(s.url().String(), opts...) diff --git a/lib/web/saml.go b/lib/web/saml.go deleted file mode 100644 index c7eba7ee84028..0000000000000 --- a/lib/web/saml.go +++ /dev/null @@ -1,169 +0,0 @@ -/* -Copyright 2015-2019 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package web - -import ( - "errors" - "net/http" - - "github.com/gravitational/form" - "github.com/gravitational/trace" - "github.com/julienschmidt/httprouter" - - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/auth" - "github.com/gravitational/teleport/lib/client" - "github.com/gravitational/teleport/lib/httplib" -) - -func (h *Handler) samlSSO(w http.ResponseWriter, r *http.Request, p httprouter.Params) string { - logger := h.log.WithField("auth", "saml") - logger.Debug("Web login start.") - - req, err := ParseSSORequestParams(r) - if err != nil { - logger.WithError(err).Error("Failed to extract SSO parameters from request.") - return client.LoginFailedRedirectURL - } - - response, err := h.cfg.ProxyClient.CreateSAMLAuthRequest(r.Context(), types.SAMLAuthRequest{ - ConnectorID: req.ConnectorID, - CSRFToken: req.CSRFToken, - CreateWebSession: true, - ClientRedirectURL: req.ClientRedirectURL, - }) - if err != nil { - logger.WithError(err).Error("Error creating auth request.") - return client.LoginFailedRedirectURL - } - - return response.RedirectURL -} - -func (h *Handler) samlSSOConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { - logger := h.log.WithField("auth", "saml") - logger.Debug("Console login start.") - - req := new(client.SSOLoginConsoleReq) - if err := httplib.ReadJSON(r, req); err != nil { - logger.WithError(err).Error("Error reading json.") - return nil, trace.AccessDenied(SSOLoginFailureMessage) - } - - if err := req.CheckAndSetDefaults(); err != nil { - logger.WithError(err).Error("Missing request parameters.") - return nil, trace.AccessDenied(SSOLoginFailureMessage) - } - - response, err := h.cfg.ProxyClient.CreateSAMLAuthRequest(r.Context(), types.SAMLAuthRequest{ - ConnectorID: req.ConnectorID, - ClientRedirectURL: req.RedirectURL, - PublicKey: req.PublicKey, - CertTTL: req.CertTTL, - Compatibility: req.Compatibility, - RouteToCluster: req.RouteToCluster, - KubernetesCluster: req.KubernetesCluster, - AttestationStatement: req.AttestationStatement.ToProto(), - }) - if err != nil { - logger.WithError(err).Error("Failed to create SAML auth request.") - return nil, trace.AccessDenied(SSOLoginFailureMessage) - } - - return &client.SSOLoginConsoleResponse{RedirectURL: response.RedirectURL}, nil -} - -func (h *Handler) samlACS(w http.ResponseWriter, r *http.Request, p httprouter.Params) string { - logger := h.log.WithField("auth", "saml") - logger.Debug("Callback start.") - - var samlResponse string - if err := form.Parse(r, form.String("SAMLResponse", &samlResponse, form.Required())); err != nil { - logger.WithError(err).Error("Error parsing response.") - return client.LoginFailedRedirectURL - } - - response, err := h.cfg.ProxyClient.ValidateSAMLResponse(r.Context(), samlResponse, p.ByName("connector")) - - if err != nil { - logger.WithError(err).Error("Error while processing callback.") - - // try to find the auth request, which bears the original client redirect URL. - // if found, use it to terminate the flow. - // - // this improves the UX by terminating the failed SSO flow immediately, rather than hoping for a timeout. - if requestID, errParse := auth.ParseSAMLInResponseTo(samlResponse); errParse == nil { - if request, errGet := h.cfg.ProxyClient.GetSAMLAuthRequest(r.Context(), requestID); errGet == nil && !request.CreateWebSession { - if url, errEnc := RedirectURLWithError(request.ClientRedirectURL, err); errEnc == nil { - return url.String() - } - } - } - - if errors.Is(err, auth.ErrSAMLNoRoles) { - return client.LoginFailedUnauthorizedRedirectURL - } - - return client.LoginFailedBadCallbackRedirectURL - } - - // if we created web session, set session cookie and redirect to original url - if response.Req.CreateWebSession { - logger.Debug("Redirecting to web browser.") - - redirect := response.Req.ClientRedirectURL - if redirect == "" { - redirect = "/web/" - } - - res := &SSOCallbackResponse{ - CSRFToken: response.Req.CSRFToken, - Username: response.Username, - SessionName: response.Session.GetName(), - ClientRedirectURL: redirect, - } - - if err := SSOSetWebSessionAndRedirectURL(w, r, res, response.Req.CSRFToken != ""); err != nil { - logger.WithError(err).Error("Error setting web session.") - return client.LoginFailedRedirectURL - } - - return res.ClientRedirectURL - } - - logger.Debug("Callback redirecting to console login.") - if len(response.Req.PublicKey) == 0 { - logger.Error("Not a web or console login request.") - return client.LoginFailedRedirectURL - } - - redirectURL, err := ConstructSSHResponse(AuthParams{ - ClientRedirectURL: response.Req.ClientRedirectURL, - Username: response.Username, - Identity: response.Identity, - Session: response.Session, - Cert: response.Cert, - TLSCert: response.TLSCert, - HostSigners: response.HostSigners, - }) - if err != nil { - logger.WithError(err).Error("Error constructing ssh response.") - return client.LoginFailedRedirectURL - } - - return redirectURL.String() -}