diff --git a/lib/auth/auth_login_test.go b/lib/auth/auth_login_test.go index 4e013a58b60ab..ba7e42f2ade4e 100644 --- a/lib/auth/auth_login_test.go +++ b/lib/auth/auth_login_test.go @@ -1727,7 +1727,7 @@ func TestSSOPasswordBypass(t *testing.T) { // proxyClient.AuthenticateSSHUser to something else (eg, // proxyClient.AuthenticateWebUser). // Optional. - authenticateOverride func(context.Context, authclient.AuthenticateSSHRequest) (*authclient.SSHLoginResponse, error) + authenticateOverride func(context.Context, authclient.AuthenticateSSHRequest) (*authclient.CLILoginResponse, error) }{ { name: "OTP", @@ -1749,7 +1749,7 @@ func TestSSOPasswordBypass(t *testing.T) { { name: "AuthenticateWeb", setSecondFactor: solveWebauthn, - authenticateOverride: func(ctx context.Context, req authclient.AuthenticateSSHRequest) (*authclient.SSHLoginResponse, error) { + authenticateOverride: func(ctx context.Context, req authclient.AuthenticateSSHRequest) (*authclient.CLILoginResponse, error) { // We only care about the error here, it's OK to swallow the session. _, err := proxyClient.AuthenticateWebUser(ctx, req.AuthenticateUserRequest) return nil, err diff --git a/lib/auth/auth_test.go b/lib/auth/auth_test.go index dd500317cf6fc..f61e3c519f578 100644 --- a/lib/auth/auth_test.go +++ b/lib/auth/auth_test.go @@ -974,7 +974,7 @@ func TestAuthenticateUser_mfaDeviceLocked(t *testing.T) { proxyClient, err := testServer.NewClient(authtest.TestBuiltin(types.RoleProxy)) require.NoError(t, err, "NewClient") - authenticateSSH := func(dev *authtest.Device) (*authclient.SSHLoginResponse, error) { + authenticateSSH := func(dev *authtest.Device) (*authclient.CLILoginResponse, error) { chal, err := proxyClient.CreateAuthenticateChallenge(ctx, &proto.CreateAuthenticateChallengeRequest{ Request: &proto.CreateAuthenticateChallengeRequest_UserCredentials{ UserCredentials: &proto.UserCredentials{ diff --git a/lib/auth/auth_with_roles.go b/lib/auth/auth_with_roles.go index d40212335581e..12170563fd550 100644 --- a/lib/auth/auth_with_roles.go +++ b/lib/auth/auth_with_roles.go @@ -589,7 +589,7 @@ func (a *ServerWithRoles) AuthenticateWebUser(ctx context.Context, req authclien // AuthenticateSSHUser authenticates SSH console user, creates and returns a pair of signed TLS and SSH // short lived certificates as a result -func (a *ServerWithRoles) AuthenticateSSHUser(ctx context.Context, req authclient.AuthenticateSSHRequest) (*authclient.SSHLoginResponse, error) { +func (a *ServerWithRoles) AuthenticateSSHUser(ctx context.Context, req authclient.AuthenticateSSHRequest) (*authclient.CLILoginResponse, error) { // authentication request has its own authentication, however this limits the requests // types to proxies to make it harder to break if !a.hasBuiltinRole(types.RoleProxy) { diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index b0090cadea4e1..51f6715879808 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -1515,9 +1515,9 @@ func (a *AuthenticateSSHRequest) CheckAndSetDefaults() error { return nil } -// SSHLoginResponse is a response returned by web proxy, it preserves backwards compatibility +// CLILoginResponse is a response returned by web proxy, it preserves backwards compatibility // on the wire, which is the primary reason for non-matching json tags -type SSHLoginResponse struct { +type CLILoginResponse struct { // User contains a logged-in user information Username string `json:"username"` // Cert is a PEM encoded signed certificate @@ -1533,8 +1533,15 @@ type SSHLoginResponse struct { // ClientOptions contains some options that the cluster wants the client to // use. ClientOptions ClientOptions `json:"client_options"` + // BrowserMFAWebauthnResponse is a webauthn response for the browser MFA flow + // Exists in SSHLoginResponse as this is the payload used by the SSO redirector + // (lib/client/sso/redirector.go). + BrowserMFAWebauthnResponse *wantypes.CredentialAssertionResponse `json:"browser_mfa_webauthn_response,omitempty"` } +// TODO(danielashare): Remove this alias once e no longer references it +type SSHLoginResponse = CLILoginResponse + // ClientOptions contains options passed from the control plane to the client at // login time. type ClientOptions struct { @@ -1694,7 +1701,7 @@ type ClientI interface { AuthenticateWebUser(ctx context.Context, req AuthenticateUserRequest) (types.WebSession, error) // AuthenticateSSHUser authenticates SSH console user, creates and returns a pair of signed TLS and SSH // short-lived certificates as a result - AuthenticateSSHUser(ctx context.Context, req AuthenticateSSHRequest) (*SSHLoginResponse, error) + AuthenticateSSHUser(ctx context.Context, req AuthenticateSSHRequest) (*CLILoginResponse, error) // Ping gets basic info about the auth server. Ping(ctx context.Context) (proto.PingResponse, error) diff --git a/lib/auth/authclient/http_client.go b/lib/auth/authclient/http_client.go index e74c755f1b37e..8c0f5721ad9c6 100644 --- a/lib/auth/authclient/http_client.go +++ b/lib/auth/authclient/http_client.go @@ -498,7 +498,7 @@ func (c *HTTPClient) AuthenticateWebUser(ctx context.Context, req AuthenticateUs // AuthenticateSSHUser authenticates SSH console user, creates and returns a pair of signed TLS and SSH // short lived certificates as a result -func (c *HTTPClient) AuthenticateSSHUser(ctx context.Context, req AuthenticateSSHRequest) (*SSHLoginResponse, error) { +func (c *HTTPClient) AuthenticateSSHUser(ctx context.Context, req AuthenticateSSHRequest) (*CLILoginResponse, error) { out, err := c.PostJSON( ctx, c.Endpoint("users", url.PathEscape(req.Username), "ssh", "authenticate"), @@ -507,7 +507,7 @@ func (c *HTTPClient) AuthenticateSSHUser(ctx context.Context, req AuthenticateSS if err != nil { return nil, trace.Wrap(err) } - var re SSHLoginResponse + var re CLILoginResponse if err := json.Unmarshal(out.Bytes(), &re); err != nil { return nil, trace.Wrap(err) } diff --git a/lib/auth/browser_mfa.go b/lib/auth/browser_mfa.go new file mode 100644 index 0000000000000..740efc941341a --- /dev/null +++ b/lib/auth/browser_mfa.go @@ -0,0 +1,64 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth + +import ( + "context" + "net/url" + + "github.com/gravitational/trace" + + webauthnpb "github.com/gravitational/teleport/api/types/webauthn" + "github.com/gravitational/teleport/lib/auth/internal/browsermfa" + wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" + "github.com/gravitational/teleport/lib/authz" +) + +// CompleteBrowserMFAChallenge completes an MFA challenge response by returning the redirect URL with encrypted response. +func (a *Server) CompleteBrowserMFAChallenge(ctx context.Context, requestID string, webauthnResponse *webauthnpb.CredentialAssertionResponse) (string, error) { + const notFoundErrMsg = "mfa session data not found" + // Retrieve the MFA session + mfaSession, err := a.GetSSOMFASession(ctx, requestID) + if trace.IsNotFound(err) { + return "", trace.AccessDenied("%s", notFoundErrMsg) + } else if err != nil { + return "", trace.Wrap(err) + } + + user, err := authz.UserFromContext(ctx) + if err != nil { + return "", trace.Wrap(err) + } + + if mfaSession.Username != user.GetIdentity().Username { + return "", trace.AccessDenied("%s", notFoundErrMsg) + } + + // Valid WebAuthn response, encrypt and return it + u, err := url.Parse(mfaSession.ClientRedirectURL) + if err != nil { + return "", trace.Wrap(err) + } + + wr := wantypes.CredentialAssertionResponseFromProto(webauthnResponse) + clientRedirectURL, err := browsermfa.EncryptBrowserMFAResponse(u, wr) + if err != nil { + return "", trace.Wrap(err) + } + + return clientRedirectURL, nil +} diff --git a/lib/auth/browser_mfa_test.go b/lib/auth/browser_mfa_test.go new file mode 100644 index 0000000000000..4896335004cfe --- /dev/null +++ b/lib/auth/browser_mfa_test.go @@ -0,0 +1,316 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package auth_test + +import ( + "encoding/base64" + "encoding/json" + "net/url" + "testing" + + "github.com/google/uuid" + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/auth/authtest" + "github.com/gravitational/teleport/lib/auth/internal/browsermfa" + "github.com/gravitational/teleport/lib/auth/mfatypes" + wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" + "github.com/gravitational/teleport/lib/authz" + "github.com/gravitational/teleport/lib/secret" + "github.com/gravitational/teleport/lib/services" +) + +func TestEncryptBrowserMFAResponse(t *testing.T) { + t.Parallel() + + secretKey, err := secret.NewKey() + require.NoError(t, err) + + webauthnResponse := &wantypes.CredentialAssertionResponse{ + PublicKeyCredential: wantypes.PublicKeyCredential{ + Credential: wantypes.Credential{ + ID: "test-credential-id", + Type: "public-key", + }, + RawID: []byte("test-raw-id"), + }, + AssertionResponse: wantypes.AuthenticatorAssertionResponse{ + AuthenticatorResponse: wantypes.AuthenticatorResponse{ + ClientDataJSON: []byte(`{"type":"webauthn.get","challenge":"test-challenge"}`), + }, + AuthenticatorData: []byte("test-authenticator-data"), + Signature: []byte("test-signature"), + }, + } + + tests := []struct { + name string + redirectURL string + webauthnResponse *wantypes.CredentialAssertionResponse + assertError func(t *testing.T, err error) + assertRedirectURL func(t *testing.T, redirectURL string) + }{ + { + name: "OK valid inputs", + redirectURL: "http://127.0.0.1:62972/callback?secret_key=" + secretKey.String(), + webauthnResponse: webauthnResponse, + assertError: func(t *testing.T, err error) { + require.NoError(t, err) + }, + assertRedirectURL: func(t *testing.T, redirectURL string) { + // Parse the returned URL + u, err := url.Parse(redirectURL) + require.NoError(t, err) + + // Verify the response parameter exists + response := u.Query().Get("response") + require.NotEmpty(t, response, "response parameter should be present") + + // Verify we can decrypt the response + plaintext, err := secretKey.Open([]byte(response)) + require.NoError(t, err) + + // Verify the decrypted content + var loginResponse authclient.CLILoginResponse + err = json.Unmarshal(plaintext, &loginResponse) + require.NoError(t, err) + require.NotNil(t, loginResponse.BrowserMFAWebauthnResponse) + assert.Equal(t, webauthnResponse.ID, loginResponse.BrowserMFAWebauthnResponse.ID) + }, + }, + { + name: "NOK missing secret_key", + redirectURL: "http://127.0.0.1:62972/callback", + webauthnResponse: webauthnResponse, + assertError: func(t *testing.T, err error) { + require.Error(t, err) + assert.True(t, trace.IsBadParameter(err), "expected bad parameter error but got %v", err) + assert.Contains(t, err.Error(), "missing secret_key") + }, + assertRedirectURL: func(t *testing.T, redirectURL string) { + require.Fail(t, "should not reach here, expected an error") + }, + }, + { + name: "NOK invalid secret_key format", + redirectURL: "http://127.0.0.1:62972/callback?secret_key=invalid-not-hex", + webauthnResponse: webauthnResponse, + assertError: func(t *testing.T, err error) { + require.Error(t, err) + assert.Contains(t, err.Error(), "encoding/hex") + }, + assertRedirectURL: func(t *testing.T, redirectURL string) { + require.Fail(t, "should not reach here, expected an error") + }, + }, + { + name: "NOK invalid URL", + redirectURL: "://invalid-url", + webauthnResponse: webauthnResponse, + assertError: func(t *testing.T, err error) { + // This will fail during url.Parse + require.Error(t, err) + }, + assertRedirectURL: func(t *testing.T, redirectURL string) { + require.Fail(t, "should not reach here, expected an error") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + redirectURL, err := url.Parse(tt.redirectURL) + if err != nil { + tt.assertError(t, err) + return + } + + result, err := browsermfa.EncryptBrowserMFAResponse(redirectURL, tt.webauthnResponse) + tt.assertError(t, err) + if err == nil { + tt.assertRedirectURL(t, result) + } + }) + } +} + +func TestCompleteBrowserMFAChallenge(t *testing.T) { + t.Parallel() + ctx := t.Context() + + testAuthServer, err := authtest.NewAuthServer(authtest.AuthServerConfig{ + Dir: t.TempDir(), + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testAuthServer.Close()) }) + + testServer, err := testAuthServer.NewTestTLSServer() + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, testServer.Close()) }) + + a := testServer.Auth() + + username := "test-user" + _, _, err = authtest.CreateUserAndRole(a, username, []string{"role"}, nil) + require.NoError(t, err) + + secretKey, err := secret.NewKey() + require.NoError(t, err) + + rawID := []byte("test-raw-id") + webauthnResponse := &wantypes.CredentialAssertionResponse{ + PublicKeyCredential: wantypes.PublicKeyCredential{ + Credential: wantypes.Credential{ + ID: base64.RawURLEncoding.EncodeToString(rawID), + Type: "public-key", + }, + RawID: rawID, + }, + AssertionResponse: wantypes.AuthenticatorAssertionResponse{ + AuthenticatorResponse: wantypes.AuthenticatorResponse{ + ClientDataJSON: []byte(`{"type":"webauthn.get","challenge":"test-challenge"}`), + }, + AuthenticatorData: []byte("test-authenticator-data"), + Signature: []byte("test-signature"), + }, + } + + tests := []struct { + name string + setupSession func(t *testing.T) string + assertError require.ErrorAssertionFunc + assertResult func(t *testing.T, result string) + }{ + { + name: "NOK missing MFA session", + setupSession: func(t *testing.T) string { + return "non-existent-request-id" + }, + assertError: func(t require.TestingT, err error, i ...interface{}) { + require.Error(t, err) + assert.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err) + }, + }, + { + name: "NOK username mismatch", + setupSession: func(t *testing.T) string { + requestID := uuid.NewString() + redirectURL := "http://127.0.0.1:62972/callback?secret_key=" + secretKey.String() + session := &services.SSOMFASessionData{ + RequestID: requestID, + Username: "other-user", // mismatch username + ClientRedirectURL: redirectURL, + ConnectorID: "test-connector", + ConnectorType: "test", + ChallengeExtensions: &mfatypes.ChallengeExtensions{ + Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, + }, + } + err := a.UpsertSSOMFASessionData(ctx, session) + require.NoError(t, err) + return requestID + }, + assertError: func(t require.TestingT, err error, i ...interface{}) { + require.Error(t, err) + assert.True(t, trace.IsAccessDenied(err), "expected access denied error but got %v", err) + }, + }, + { + name: "NOK invalid redirect URL in session", + setupSession: func(t *testing.T) string { + requestID := uuid.NewString() + session := &services.SSOMFASessionData{ + RequestID: requestID, + Username: username, + ClientRedirectURL: "://invalid-url", + ConnectorID: "test-connector", + ConnectorType: "test", + ChallengeExtensions: &mfatypes.ChallengeExtensions{ + Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, + }, + } + err := a.UpsertSSOMFASessionData(ctx, session) + require.NoError(t, err) + return requestID + }, + assertError: func(t require.TestingT, err error, i ...interface{}) { + require.Error(t, err) + }, + }, + { + name: "OK valid response", + setupSession: func(t *testing.T) string { + requestID := uuid.NewString() + redirectURL := "http://127.0.0.1:62972/callback?secret_key=" + secretKey.String() + session := &services.SSOMFASessionData{ + RequestID: requestID, + Username: username, + ClientRedirectURL: redirectURL, + ConnectorID: "test-connector", + ConnectorType: "test", + ChallengeExtensions: &mfatypes.ChallengeExtensions{ + Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_LOGIN, + }, + } + err := a.UpsertSSOMFASessionData(ctx, session) + require.NoError(t, err) + return requestID + }, + assertError: func(t require.TestingT, err error, i ...interface{}) { + require.NoError(t, err) + }, + assertResult: func(t *testing.T, result string) { + u, err := url.Parse(result) + require.NoError(t, err) + assert.Equal(t, "127.0.0.1:62972", u.Host) + assert.Equal(t, "/callback", u.Path) + + response := u.Query().Get("response") + require.NotEmpty(t, response, "response parameter should be present") + + plaintext, err := secretKey.Open([]byte(response)) + require.NoError(t, err) + + var loginResponse authclient.CLILoginResponse + err = json.Unmarshal(plaintext, &loginResponse) + require.NoError(t, err) + require.NotNil(t, loginResponse.BrowserMFAWebauthnResponse) + assert.Equal(t, webauthnResponse.ID, loginResponse.BrowserMFAWebauthnResponse.ID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requestID := tt.setupSession(t) + userCtx := authz.ContextWithUser(ctx, authtest.TestUserWithRoles(username, []string{"role"}).I) + result, err := a.CompleteBrowserMFAChallenge( + userCtx, + requestID, + wantypes.CredentialAssertionResponseToProto(webauthnResponse), + ) + tt.assertError(t, err) + if tt.assertResult != nil { + tt.assertResult(t, result) + } + }) + } +} diff --git a/lib/auth/internal/browsermfa/browser_mfa.go b/lib/auth/internal/browsermfa/browser_mfa.go new file mode 100644 index 0000000000000..ee6c052690e58 --- /dev/null +++ b/lib/auth/internal/browsermfa/browser_mfa.go @@ -0,0 +1,66 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package browsermfa + +import ( + "encoding/json" + "net/url" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/auth/authclient" + wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" + "github.com/gravitational/teleport/lib/secret" +) + +// Payload type required by (lib/client/sso/redirector.go). +type ssoRedirectorResponse = authclient.CLILoginResponse + +// EncryptBrowserMFAResponse encrypts a browser MFA webauthn response and returns the redirect URL with the encrypted response. +func EncryptBrowserMFAResponse(redirectURL *url.URL, webauthnResponse *wantypes.CredentialAssertionResponse) (string, error) { + // Extract secret out of the redirect URL. + secretKey := redirectURL.Query().Get("secret_key") + if secretKey == "" { + return "", trace.BadParameter("missing secret_key") + } + + // AES-GCM based symmetric cipher. + key, err := secret.ParseKey([]byte(secretKey)) + if err != nil { + return "", trace.Wrap(err, "parse secret key") + } + + // Build response payload. + consoleResponse := ssoRedirectorResponse{ + BrowserMFAWebauthnResponse: webauthnResponse, + } + out, err := json.Marshal(consoleResponse) + if err != nil { + return "", trace.Wrap(err, "marshaling response payload") + } + + // Base64 and encrypt the response payload. + ciphertext, err := key.Seal(out) + if err != nil { + return "", trace.Wrap(err, "seal response with secret key") + } + + // Place ciphertext into the redirect URL. + redirectURL.RawQuery = url.Values{"response": {string(ciphertext)}}.Encode() + + return redirectURL.String(), nil +} diff --git a/lib/auth/methods.go b/lib/auth/methods.go index 04c112e3de4bd..0d55dc253df70 100644 --- a/lib/auth/methods.go +++ b/lib/auth/methods.go @@ -741,7 +741,7 @@ func (a *Server) AuthenticateWebUser(ctx context.Context, req authclient.Authent // AuthenticateSSHUser authenticates an SSH user and returns SSH and TLS // certificates for the public key in req. -func (a *Server) AuthenticateSSHUser(ctx context.Context, req authclient.AuthenticateSSHRequest) (*authclient.SSHLoginResponse, error) { +func (a *Server) AuthenticateSSHUser(ctx context.Context, req authclient.AuthenticateSSHRequest) (*authclient.CLILoginResponse, error) { if err := req.CheckAndSetDefaults(); err != nil { return nil, trace.Wrap(err) } @@ -851,7 +851,7 @@ func (a *Server) AuthenticateSSHUser(ctx context.Context, req authclient.Authent a.logger.WarnContext(ctx, "Failed to calculate client options for local login", "username", user.GetName(), "error", err) } - return &authclient.SSHLoginResponse{ + return &authclient.CLILoginResponse{ Username: user.GetName(), Cert: certs.SSH, TLSCert: certs.TLS, diff --git a/lib/auth/mfa/mfav1/mocks_test.go b/lib/auth/mfa/mfav1/mocks_test.go index 0efb13bf83c09..0c55293255e79 100644 --- a/lib/auth/mfa/mfav1/mocks_test.go +++ b/lib/auth/mfa/mfav1/mocks_test.go @@ -28,6 +28,7 @@ import ( "github.com/gravitational/teleport/api/client/proto" mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1" "github.com/gravitational/teleport/api/types" + webauthnpb "github.com/gravitational/teleport/api/types/webauthn" "github.com/gravitational/teleport/lib/auth/authtest" "github.com/gravitational/teleport/lib/auth/mfatypes" "github.com/gravitational/teleport/lib/authz" @@ -118,6 +119,21 @@ func (m *mockAuthServer) VerifySSOMFASession( }, nil } +// CompleteBrowserMFAChallenge mocks the completion of a browser MFA challenge. +func (m *mockAuthServer) CompleteBrowserMFAChallenge( + ctx context.Context, + requestID string, + webauthnResponse *webauthnpb.CredentialAssertionResponse, +) (string, error) { + _, ok := m.requestIDs.Load(requestID) + if !ok { + return "", trace.NotFound("invalid browser MFA challenge request ID %q", requestID) + } + + // Return a mock redirect URL for testing + return "http://127.0.0.1:62972/callback?response=mock-encrypted-response", nil +} + type mockAuthServerIdentity struct { services.Identity diff --git a/lib/auth/mfa/mfav1/service.go b/lib/auth/mfa/mfav1/service.go index 7876baae138b4..87ce9e9acbdc5 100644 --- a/lib/auth/mfa/mfav1/service.go +++ b/lib/auth/mfa/mfav1/service.go @@ -31,6 +31,7 @@ import ( mfav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/mfa/v1" "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" + webauthnpb "github.com/gravitational/teleport/api/types/webauthn" "github.com/gravitational/teleport/api/utils/retryutils" "github.com/gravitational/teleport/lib/auth/mfatypes" wanlib "github.com/gravitational/teleport/lib/auth/webauthn" @@ -55,6 +56,12 @@ type AuthServer interface { token string, ext *mfav1.ChallengeExtensions, ) (*authz.MFAAuthData, error) + + CompleteBrowserMFAChallenge( + ctx context.Context, + requestID string, + webauthnResponse *webauthnpb.CredentialAssertionResponse, + ) (string, error) } // Cache defines the subset of cache methods used by the MFA service. @@ -897,3 +904,42 @@ func isRemoteProxy(authContext authz.Context) bool { return true } + +// CompleteBrowserMFAChallenge takes a MFA response from the browser and returns +// it via an encrypted response parameter in a callback URL for the browser to +// return to tsh. +func (s *Service) CompleteBrowserMFAChallenge(ctx context.Context, req *mfav1.CompleteBrowserMFAChallengeRequest) (*mfav1.CompleteBrowserMFAChallengeResponse, error) { + authCtx, err := s.authorizer.Authorize(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + if !authz.IsLocalOrRemoteUser(*authCtx) { + return nil, trace.AccessDenied("only local or remote users can complete a browser MFA challenge") + } + + if req.BrowserMfaResponse == nil { + return nil, trace.BadParameter("missing browser_mfa_response in request") + } + + if req.BrowserMfaResponse.RequestId == "" { + return nil, trace.BadParameter("missing request_id in browser_mfa_response") + } + + if req.BrowserMfaResponse.WebauthnResponse == nil { + return nil, trace.BadParameter("missing webauthn_response in browser_mfa_response") + } + + tshRedirectURL, err := s.authServer.CompleteBrowserMFAChallenge( + ctx, + req.BrowserMfaResponse.RequestId, + req.BrowserMfaResponse.WebauthnResponse, + ) + if err != nil { + return nil, trace.Wrap(err) + } + + return &mfav1.CompleteBrowserMFAChallengeResponse{ + TshRedirectUrl: tshRedirectURL, + }, nil +} diff --git a/lib/auth/mfa/mfav1/service_test.go b/lib/auth/mfa/mfav1/service_test.go index 1d4ee8d12a432..826925902bf80 100644 --- a/lib/auth/mfa/mfav1/service_test.go +++ b/lib/auth/mfa/mfav1/service_test.go @@ -1297,6 +1297,128 @@ func TestVerifyValidatedMFAChallenge_NotFound(t *testing.T) { require.Nil(t, resp) } +func TestCompleteBrowserMFAChallenge_Success(t *testing.T) { + t.Parallel() + + authServer, service, _, user := setupAuthServer(t, nil) + + requestID := "test-request-id" + authServer.requestIDs.Store(requestID, struct{}{}) + + ctx := authz.ContextWithUser(t.Context(), authtest.TestUserWithRoles(user.GetName(), user.GetRoles()).I) + + resp, err := service.CompleteBrowserMFAChallenge( + ctx, + &mfav1.CompleteBrowserMFAChallengeRequest{ + BrowserMfaResponse: &mfav1.BrowserMFAResponse{ + RequestId: requestID, + WebauthnResponse: &webauthnpb.CredentialAssertionResponse{ + Type: "public-key", + }, + }, + }, + ) + require.NoError(t, err) + require.NotNil(t, resp) + require.NotEmpty(t, resp.TshRedirectUrl) + require.Contains(t, resp.TshRedirectUrl, "127.0.0.1") +} + +func TestCompleteBrowserMFAChallenge_NonUserDenied(t *testing.T) { + t.Parallel() + + _, service, _, _ := setupAuthServer(t, nil) + + // Use a context with a non-user role (proxy). + ctx := authz.ContextWithUser(t.Context(), authtest.TestBuiltin(types.RoleProxy).I) + + resp, err := service.CompleteBrowserMFAChallenge( + ctx, + &mfav1.CompleteBrowserMFAChallengeRequest{ + BrowserMfaResponse: &mfav1.BrowserMFAResponse{ + RequestId: "test-request-id", + WebauthnResponse: &webauthnpb.CredentialAssertionResponse{ + Type: "public-key", + }, + }, + }, + ) + require.Error(t, err) + require.True(t, trace.IsAccessDenied(err)) + require.ErrorContains(t, err, "only local or remote users can complete a browser MFA challenge") + require.Nil(t, resp) +} + +func TestCompleteBrowserMFAChallenge_InvalidRequest(t *testing.T) { + t.Parallel() + + authServer, service, _, user := setupAuthServer(t, nil) + + ctx := authz.ContextWithUser(t.Context(), authtest.TestUserWithRoles(user.GetName(), user.GetRoles()).I) + + for _, testCase := range []struct { + name string + req *mfav1.CompleteBrowserMFAChallengeRequest + expectedError string + }{ + { + name: "missing BrowserMfaResponse", + req: &mfav1.CompleteBrowserMFAChallengeRequest{ + BrowserMfaResponse: nil, + }, + expectedError: "missing browser_mfa_response in request", + }, + { + name: "missing RequestId", + req: &mfav1.CompleteBrowserMFAChallengeRequest{ + BrowserMfaResponse: &mfav1.BrowserMFAResponse{ + RequestId: "", + WebauthnResponse: &webauthnpb.CredentialAssertionResponse{ + Type: "public-key", + }, + }, + }, + expectedError: "missing request_id in browser_mfa_response", + }, + { + name: "missing WebauthnResponse", + req: &mfav1.CompleteBrowserMFAChallengeRequest{ + BrowserMfaResponse: &mfav1.BrowserMFAResponse{ + RequestId: "test-request-id", + WebauthnResponse: nil, + }, + }, + expectedError: "missing webauthn_response in browser_mfa_response", + }, + { + name: "non-existent RequestId", + req: func() *mfav1.CompleteBrowserMFAChallengeRequest { + return &mfav1.CompleteBrowserMFAChallengeRequest{ + BrowserMfaResponse: &mfav1.BrowserMFAResponse{ + RequestId: "non-existent-request-id", + WebauthnResponse: &webauthnpb.CredentialAssertionResponse{ + Type: "public-key", + }, + }, + } + }(), + expectedError: "invalid browser MFA challenge request ID", + }, + } { + t.Run(testCase.name, func(t *testing.T) { + // For the "invalid RequestId" test, ensure we have at least one valid ID stored. + if testCase.name == "invalid RequestId" { + authServer.requestIDs.Store("valid-id", struct{}{}) + } + + resp, err := service.CompleteBrowserMFAChallenge(ctx, testCase.req) + require.Error(t, err) + require.ErrorContains(t, err, testCase.expectedError) + require.Nil(t, resp) + }) + } +} + func setupAuthServer(t *testing.T, devices []*types.MFADevice) (*mockAuthServer, *mfav1impl.Service, *eventstest.MockRecorderEmitter, types.User) { t.Helper() diff --git a/lib/client/api.go b/lib/client/api.go index 6758f7947d51a..c57f4ac47d3a4 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -3770,7 +3770,7 @@ func (tc *TeleportClient) getSSHLoginFunc(pr *webclient.PingResponse) (SSHLoginF return nil, trace.BadParameter("headless disallowed by cluster settings") } if tc.AllowHeadless { - return func(ctx context.Context, keyRing *KeyRing) (*authclient.SSHLoginResponse, error) { + return func(ctx context.Context, keyRing *KeyRing) (*authclient.CLILoginResponse, error) { return tc.headlessLogin(ctx, keyRing) }, nil } @@ -3786,7 +3786,7 @@ func (tc *TeleportClient) getSSHLoginFunc(pr *webclient.PingResponse) (SSHLoginF return tc.pwdlessLogin, nil } - return func(ctx context.Context, keyRing *KeyRing) (*authclient.SSHLoginResponse, error) { + return func(ctx context.Context, keyRing *KeyRing) (*authclient.CLILoginResponse, error) { return tc.localLogin(ctx, keyRing, pr.Auth.SecondFactor) }, nil default: @@ -3964,7 +3964,7 @@ func (tc *TeleportClient) canDefaultToPasswordless(pr *webclient.PingResponse) b } // SSHLoginFunc is a function which carries out authn with an auth server and returns an auth response. -type SSHLoginFunc func(context.Context, *KeyRing) (*authclient.SSHLoginResponse, error) +type SSHLoginFunc func(context.Context, *KeyRing) (*authclient.CLILoginResponse, error) // SSHLogin uses the given login function to login the client. This function handles // private key logic and parsing the resulting auth response. @@ -3976,7 +3976,7 @@ func (tc *TeleportClient) SSHLogin(ctx context.Context, sshLoginFunc SSHLoginFun ) defer span.End() - var response *authclient.SSHLoginResponse + var response *authclient.CLILoginResponse keyRing, err := tc.loginWithHardwareKeyRetry(ctx, func(ctx context.Context, keyRing *KeyRing) error { var err error response, err = sshLoginFunc(ctx, keyRing) @@ -4169,7 +4169,7 @@ func (tc *TeleportClient) NewSSHLogin(keyRing *KeyRing) (SSHLogin, error) { }, nil } -func (tc *TeleportClient) pwdlessLogin(ctx context.Context, keyRing *KeyRing) (*authclient.SSHLoginResponse, error) { +func (tc *TeleportClient) pwdlessLogin(ctx context.Context, keyRing *KeyRing) (*authclient.CLILoginResponse, error) { ctx, span := tc.Tracer.Start( ctx, "teleportClient/pwdlessLogin", @@ -4201,7 +4201,7 @@ func (tc *TeleportClient) pwdlessLogin(ctx context.Context, keyRing *KeyRing) (* } // localLogin asks for a password and performs an MFA ceremony. -func (tc *TeleportClient) localLogin(ctx context.Context, keyRing *KeyRing, _ constants.SecondFactorType) (*authclient.SSHLoginResponse, error) { +func (tc *TeleportClient) localLogin(ctx context.Context, keyRing *KeyRing, _ constants.SecondFactorType) (*authclient.CLILoginResponse, error) { ctx, span := tc.Tracer.Start( ctx, "teleportClient/localLogin", @@ -4228,7 +4228,7 @@ func (tc *TeleportClient) localLogin(ctx context.Context, keyRing *KeyRing, _ co return response, trace.Wrap(err) } -func (tc *TeleportClient) headlessLogin(ctx context.Context, keyRing *KeyRing) (*authclient.SSHLoginResponse, error) { +func (tc *TeleportClient) headlessLogin(ctx context.Context, keyRing *KeyRing) (*authclient.CLILoginResponse, error) { if tc.MockHeadlessLogin != nil { return tc.MockHeadlessLogin(ctx, keyRing) } @@ -4269,13 +4269,13 @@ func (tc *TeleportClient) headlessLogin(ctx context.Context, keyRing *KeyRing) ( } // SSOLoginFunc is a function used in tests to mock SSO logins. -type SSOLoginFunc func(ctx context.Context, connectorID string, keyRing *KeyRing, protocol string) (*authclient.SSHLoginResponse, error) +type SSOLoginFunc func(ctx context.Context, connectorID string, keyRing *KeyRing, protocol string) (*authclient.CLILoginResponse, error) // SSOLoginFn returns a function that will carry out SSO login. A browser window will be opened // for the user to authenticate through SSO. On completion they will be redirected to a success // page and the resulting login session will be captured and returned. func (tc *TeleportClient) SSOLoginFn(connectorID, connectorName, connectorType string) SSHLoginFunc { - return func(ctx context.Context, keyRing *KeyRing) (*authclient.SSHLoginResponse, error) { + return func(ctx context.Context, keyRing *KeyRing) (*authclient.CLILoginResponse, error) { if tc.MockSSOLogin != nil { // sso login response is being mocked for testing purposes return tc.MockSSOLogin(ctx, connectorID, keyRing, connectorType) diff --git a/lib/client/sso/ceremony.go b/lib/client/sso/ceremony.go index ddeebd021d217..76e678ea9bacc 100644 --- a/lib/client/sso/ceremony.go +++ b/lib/client/sso/ceremony.go @@ -33,14 +33,14 @@ type Ceremony struct { clientCallbackURL string Init CeremonyInit HandleRedirect func(ctx context.Context, redirectURL string) error - GetCallbackResponse func(ctx context.Context) (*authclient.SSHLoginResponse, error) + GetCallbackResponse func(ctx context.Context) (*authclient.CLILoginResponse, error) } // CeremonyInit initializes an SSO login ceremony. type CeremonyInit func(ctx context.Context, clientCallbackURL string) (redirectURL string, err error) // Run the SSO ceremony. -func (c *Ceremony) Run(ctx context.Context) (*authclient.SSHLoginResponse, error) { +func (c *Ceremony) Run(ctx context.Context) (*authclient.CLILoginResponse, error) { redirectURL, err := c.Init(ctx, c.clientCallbackURL) if err != nil { return nil, trace.Wrap(err) @@ -69,14 +69,14 @@ type SAMLCeremony struct { clientCallbackURL string Init SAMLCeremonyInit HandleRequest func(ctx context.Context, redirectURL, postformData string) error - GetCallbackResponse func(ctx context.Context) (*authclient.SSHLoginResponse, error) + GetCallbackResponse func(ctx context.Context) (*authclient.CLILoginResponse, error) } // SAMLCeremonyInit initializes an SAML based SSO login ceremony. type SAMLCeremonyInit func(ctx context.Context, clientCallbackURL string) (redirectURL, postformData string, err error) // Run the SAML SSO ceremony. -func (c *SAMLCeremony) Run(ctx context.Context) (*authclient.SSHLoginResponse, error) { +func (c *SAMLCeremony) Run(ctx context.Context) (*authclient.CLILoginResponse, error) { redirectURL, postformData, err := c.Init(ctx, c.clientCallbackURL) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/client/sso/redirector.go b/lib/client/sso/redirector.go index f8484ef1e1699..ad745454c7470 100644 --- a/lib/client/sso/redirector.go +++ b/lib/client/sso/redirector.go @@ -139,7 +139,7 @@ type Redirector struct { // the data key secret.Key // responseC is a channel to receive responses - responseC chan *authclient.SSHLoginResponse + responseC chan *authclient.CLILoginResponse // errorC will contain errors errorC chan error // doneC will be closed when the redirector is closed. @@ -196,7 +196,7 @@ func NewRedirector(config RedirectorConfig) (*Redirector, error) { proxyURL: proxyURL, mux: http.NewServeMux(), key: key, - responseC: make(chan *authclient.SSHLoginResponse, 1), + responseC: make(chan *authclient.CLILoginResponse, 1), errorC: make(chan error, 1), doneC: make(chan struct{}), } @@ -380,7 +380,7 @@ func OpenURLInBrowser(browser string, URL string) error { } // WaitForResponse waits for a response from the callback handler. -func (rd *Redirector) WaitForResponse(ctx context.Context) (*authclient.SSHLoginResponse, error) { +func (rd *Redirector) WaitForResponse(ctx context.Context) (*authclient.CLILoginResponse, error) { slog.InfoContext(ctx, "Waiting for response", "callback_url", rd.server.URL) select { case err := <-rd.ErrorC(): @@ -408,7 +408,7 @@ func (rd *Redirector) Done() <-chan struct{} { } // ResponseC returns a channel with response -func (rd *Redirector) ResponseC() <-chan *authclient.SSHLoginResponse { +func (rd *Redirector) ResponseC() <-chan *authclient.CLILoginResponse { return rd.responseC } @@ -419,7 +419,7 @@ func (rd *Redirector) ErrorC() <-chan error { // callback is used by Teleport proxy to send back credentials // issued by Teleport proxy -func (rd *Redirector) callback(w http.ResponseWriter, r *http.Request) (*authclient.SSHLoginResponse, error) { +func (rd *Redirector) callback(w http.ResponseWriter, r *http.Request) (*authclient.CLILoginResponse, error) { if r.URL.Path != "/callback" { return nil, trace.NotFound("path not found") } @@ -436,7 +436,7 @@ func (rd *Redirector) callback(w http.ResponseWriter, r *http.Request) (*authcli return nil, trace.BadParameter("failed to decrypt response: in %v, err: %v", r.URL.String(), err) } - var re authclient.SSHLoginResponse + var re authclient.CLILoginResponse err = json.Unmarshal(plaintext, &re) if err != nil { return nil, trace.BadParameter("failed to decrypt response: in %v, err: %v", r.URL.String(), err) @@ -453,7 +453,7 @@ func (rd *Redirector) Close() { // wrapCallback is a helper wrapper method that wraps callback HTTP handler // and sends a result to the channel and redirect users to error page -func (rd *Redirector) wrapCallback(fn func(http.ResponseWriter, *http.Request) (*authclient.SSHLoginResponse, error)) http.Handler { +func (rd *Redirector) wrapCallback(fn func(http.ResponseWriter, *http.Request) (*authclient.CLILoginResponse, error)) http.Handler { // Generate possible redirect URLs from the proxy URL. clone := *rd.proxyURL clone.Path = LoginFailedRedirectURL diff --git a/lib/client/weblogin.go b/lib/client/weblogin.go index d462ea1f50fc5..979290521a501 100644 --- a/lib/client/weblogin.go +++ b/lib/client/weblogin.go @@ -506,7 +506,7 @@ func initClient(proxyAddr string, insecure bool, pool *x509.CertPool, extraHeade } // SSHAgentHeadlessLogin begins the headless login ceremony, returning new user certificates if successful. -func SSHAgentHeadlessLogin(ctx context.Context, login SSHLoginHeadless) (*authclient.SSHLoginResponse, error) { +func SSHAgentHeadlessLogin(ctx context.Context, login SSHLoginHeadless) (*authclient.CLILoginResponse, error) { clt, _, err := initClient(login.ProxyAddr, login.Insecure, login.Pool, login.ExtraHeaders) if err != nil { return nil, trace.Wrap(err) @@ -536,7 +536,7 @@ func SSHAgentHeadlessLogin(ctx context.Context, login SSHLoginHeadless) (*authcl return nil, trace.Wrap(err) } - var out authclient.SSHLoginResponse + var out authclient.CLILoginResponse err = json.Unmarshal(re.Bytes(), &out) if err != nil { return nil, trace.Wrap(err) @@ -550,7 +550,7 @@ func SSHAgentHeadlessLogin(ctx context.Context, login SSHLoginHeadless) (*authcl // end user. // // Returns the SSH certificate if authn is successful or an error. -func SSHAgentPasswordlessLogin(ctx context.Context, login SSHLoginPasswordless) (*authclient.SSHLoginResponse, error) { +func SSHAgentPasswordlessLogin(ctx context.Context, login SSHLoginPasswordless) (*authclient.CLILoginResponse, error) { webClient, webURL, err := initClient(login.ProxyAddr, login.Insecure, login.Pool, login.ExtraHeaders) if err != nil { return nil, trace.Wrap(err) @@ -620,7 +620,7 @@ func SSHAgentPasswordlessLogin(ctx context.Context, login SSHLoginPasswordless) return nil, trace.Wrap(err) } - loginResp := &authclient.SSHLoginResponse{} + loginResp := &authclient.CLILoginResponse{} if err := json.Unmarshal(loginRespJSON.Bytes(), loginResp); err != nil { return nil, trace.Wrap(err) } @@ -631,7 +631,7 @@ func SSHAgentPasswordlessLogin(ctx context.Context, login SSHLoginPasswordless) // If the credentials are valid, the proxy will return a challenge. We then // prompt the user to provide 2nd factor and pass the response to the proxy. // If the authentication succeeds, we will get a temporary certificate back. -func SSHAgentMFALogin(ctx context.Context, login SSHLoginMFA) (*authclient.SSHLoginResponse, error) { +func SSHAgentMFALogin(ctx context.Context, login SSHLoginMFA) (*authclient.CLILoginResponse, error) { clt, _, err := initClient(login.ProxyAddr, login.Insecure, login.Pool, login.ExtraHeaders) if err != nil { return nil, trace.Wrap(err) @@ -673,7 +673,7 @@ func SSHAgentMFALogin(ctx context.Context, login SSHLoginMFA) (*authclient.SSHLo return nil, trace.Wrap(err) } - loginResp := &authclient.SSHLoginResponse{} + loginResp := &authclient.CLILoginResponse{} return loginResp, trace.Wrap(json.Unmarshal(loginRespJSON.Bytes(), loginResp)) } diff --git a/lib/services/sso_mfa.go b/lib/services/sso_mfa.go index f1a7afa723eac..b197150cbd39c 100644 --- a/lib/services/sso_mfa.go +++ b/lib/services/sso_mfa.go @@ -41,4 +41,6 @@ type SSOMFASessionData struct { SourceCluster string `json:"source_cluster,omitempty"` // TargetCluster is the optional cluster where the authentication is targeted. TargetCluster string `json:"target_cluster,omitempty"` + // ClientRedirectURL is the redirect URL for the client + ClientRedirectURL string `json:"client_redirect_url,omitempty"` } diff --git a/lib/teleterm/clusters/cluster_auth.go b/lib/teleterm/clusters/cluster_auth.go index 9f576e31b3bb8..0b3473f7bb99c 100644 --- a/lib/teleterm/clusters/cluster_auth.go +++ b/lib/teleterm/clusters/cluster_auth.go @@ -142,7 +142,7 @@ func (c *Cluster) updateClientFromPingResponse(ctx context.Context) (*webclient. return pingResp, nil } -type SSHLoginFunc func(context.Context, *keys.PrivateKey) (*authclient.SSHLoginResponse, error) +type SSHLoginFunc func(context.Context, *keys.PrivateKey) (*authclient.CLILoginResponse, error) func (c *Cluster) login(ctx context.Context, sshLoginFunc client.SSHLoginFunc) error { // TODO(alex-kovoy): SiteName needs to be reset if trying to login to a cluster with @@ -187,7 +187,7 @@ func (c *Cluster) login(ctx context.Context, sshLoginFunc client.SSHLoginFunc) e } func (c *Cluster) localMFALogin(user, password string) client.SSHLoginFunc { - return func(ctx context.Context, keyRing *client.KeyRing) (*authclient.SSHLoginResponse, error) { + return func(ctx context.Context, keyRing *client.KeyRing) (*authclient.CLILoginResponse, error) { sshLogin, err := c.clusterClient.NewSSHLogin(keyRing) if err != nil { return nil, trace.Wrap(err) @@ -211,7 +211,7 @@ func (c *Cluster) ssoLogin(providerType, providerName string) client.SSHLoginFun } func (c *Cluster) passwordlessLogin(stream api.TerminalService_LoginPasswordlessServer) client.SSHLoginFunc { - return func(ctx context.Context, keyRing *client.KeyRing) (*authclient.SSHLoginResponse, error) { + return func(ctx context.Context, keyRing *client.KeyRing) (*authclient.CLILoginResponse, error) { sshLogin, err := c.clusterClient.NewSSHLogin(keyRing) if err != nil { return nil, trace.Wrap(err) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 9d27d06aebfc9..084335403b03b 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -2617,7 +2617,7 @@ func ConstructSSHResponse(response AuthParams) (*url.URL, error) { if err != nil { return nil, trace.Wrap(err) } - consoleResponse := authclient.SSHLoginResponse{ + consoleResponse := authclient.CLILoginResponse{ Username: response.Username, Cert: response.Cert, TLSCert: response.TLSCert, diff --git a/lib/web/apiserver_login_test.go b/lib/web/apiserver_login_test.go index 5e637f78bc8e2..014f51a4e947a 100644 --- a/lib/web/apiserver_login_test.go +++ b/lib/web/apiserver_login_test.go @@ -636,10 +636,10 @@ func configureClusterForMFA(t *testing.T, env *webPack, spec *types.AuthPreferen } } -func validateSSHLoginResponse(t *testing.T, resp []byte, expectedSubjectSSHPub ssh.PublicKey, expectedSubjectTLSPub crypto.PublicKey) *authclient.SSHLoginResponse { +func validateSSHLoginResponse(t *testing.T, resp []byte, expectedSubjectSSHPub ssh.PublicKey, expectedSubjectTLSPub crypto.PublicKey) *authclient.CLILoginResponse { t.Helper() - var loginResp authclient.SSHLoginResponse + var loginResp authclient.CLILoginResponse require.NoError(t, json.Unmarshal(resp, &loginResp)) assert.NotEmpty(t, loginResp.Username) assert.NotEmpty(t, loginResp.HostSigners) diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 7b7d9153688a0..0e0af5a06e041 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -3500,7 +3500,7 @@ func TestConstructSSHResponse(t *testing.T) { plaintext, err := key.Open([]byte(rawresp.Query().Get("response"))) require.NoError(t, err) - var resp *authclient.SSHLoginResponse + var resp *authclient.CLILoginResponse err = json.Unmarshal(plaintext, &resp) require.NoError(t, err) require.Equal(t, "foo", resp.Username) @@ -10999,7 +10999,7 @@ func stateTokenFromConsoleLoginResponse(t *testing.T, responseBody []byte) strin // - in a query param // - in a redirect URL // - in an HTML document -func sshLoginResponseFromCallbackResponse(t *testing.T, responseBody io.Reader, secretKey secret.Key) *authclient.SSHLoginResponse { +func sshLoginResponseFromCallbackResponse(t *testing.T, responseBody io.Reader, secretKey secret.Key) *authclient.CLILoginResponse { // First pull the URL from the HTML meta redirect. redirectURL, err := app.GetURLFromMetaRedirect(responseBody) require.NoError(t, err) @@ -11015,7 +11015,7 @@ func sshLoginResponseFromCallbackResponse(t *testing.T, responseBody io.Reader, require.NoError(t, err, "unencrypting github callback response") // Then unmarshal the JSON. - var sshLoginResponse authclient.SSHLoginResponse + var sshLoginResponse authclient.CLILoginResponse require.NoError(t, json.Unmarshal(callbackPlaintext, &sshLoginResponse)) return &sshLoginResponse } diff --git a/lib/web/sessions.go b/lib/web/sessions.go index 082ab810b53ef..63a232f1263a4 100644 --- a/lib/web/sessions.go +++ b/lib/web/sessions.go @@ -957,7 +957,7 @@ func (s *sessionCache) AuthenticateWebUser( func (s *sessionCache) AuthenticateSSHUser( ctx context.Context, c client.AuthenticateSSHUserRequest, clientMeta *authclient.ForwardedClientMetadata, -) (*authclient.SSHLoginResponse, error) { +) (*authclient.CLILoginResponse, error) { authReq := authclient.AuthenticateUserRequest{ Username: c.User, Scope: c.Scope, diff --git a/tool/tctl/sso/tester/command.go b/tool/tctl/sso/tester/command.go index 6ee92757c77e3..46a74b8bce3bf 100644 --- a/tool/tctl/sso/tester/command.go +++ b/tool/tctl/sso/tester/command.go @@ -186,7 +186,7 @@ type AuthRequestInfo struct { // SSOLoginConsoleRequestFn allows customizing issuance of SSOLoginConsoleReq. Optional. type SSOLoginConsoleRequestFn func(req client.SSOLoginConsoleReq) (*client.SSOLoginConsoleResponse, error) -func (cmd *SSOTestCommand) runSSOLoginFlow(ctx context.Context, connectorType string, c *authclient.Client, initiateSSOLoginFn SSOLoginConsoleRequestFn) (*authclient.SSHLoginResponse, error) { +func (cmd *SSOTestCommand) runSSOLoginFlow(ctx context.Context, connectorType string, c *authclient.Client, initiateSSOLoginFn SSOLoginConsoleRequestFn) (*authclient.CLILoginResponse, error) { proxies, err := clientutils.CollectWithFallback(ctx, c.ListProxyServers, func(context.Context) ([]types.Server, error) { //nolint:staticcheck // TODO(kiosion) DELETE IN 21.0.0 return c.GetProxies() @@ -254,7 +254,7 @@ func GetDiagMessage(present bool, show bool, msg string) string { return "" } -func (cmd *SSOTestCommand) reportLoginResult(authKind string, diag *types.SSODiagnosticInfo, infoErr error, loginResponse *authclient.SSHLoginResponse, loginErr error) (errResult error) { +func (cmd *SSOTestCommand) reportLoginResult(authKind string, diag *types.SSODiagnosticInfo, infoErr error, loginResponse *authclient.CLILoginResponse, loginErr error) (errResult error) { success := diag != nil && diag.Success // check for errors diff --git a/tool/tsh/common/tsh_test.go b/tool/tsh/common/tsh_test.go index 23f5f185e0de9..abeacdae591df 100644 --- a/tool/tsh/common/tsh_test.go +++ b/tool/tsh/common/tsh_test.go @@ -169,7 +169,7 @@ func handleReexec() { // is re-executed. if addr := os.Getenv(tshBinMockHeadlessAddrEnv); addr != "" { runOpts = append(runOpts, func(c *CLIConf) error { - c.MockHeadlessLogin = func(ctx context.Context, keyRing *client.KeyRing) (*authclient.SSHLoginResponse, error) { + c.MockHeadlessLogin = func(ctx context.Context, keyRing *client.KeyRing) (*authclient.CLILoginResponse, error) { conn, err := net.Dial("tcp", addr) if err != nil { return nil, trace.Wrap(err, "dialing mock headless server") @@ -203,7 +203,7 @@ func handleReexec() { if err != nil { return nil, trace.Wrap(err, "reading reply from mock headless server") } - var loginResp authclient.SSHLoginResponse + var loginResp authclient.CLILoginResponse if err := json.Unmarshal(reply, &loginResp); err != nil { return nil, trace.Wrap(err, "decoding reply from mock headless server") } @@ -4389,7 +4389,7 @@ func mockConnector(t *testing.T) types.OIDCConnector { } func mockSSOLogin(authServer *auth.Server, user types.User) client.SSOLoginFunc { - return func(ctx context.Context, connectorID string, keyRing *client.KeyRing, protocol string) (*authclient.SSHLoginResponse, error) { + return func(ctx context.Context, connectorID string, keyRing *client.KeyRing, protocol string) (*authclient.CLILoginResponse, error) { // generate certificates for our user clusterName, err := authServer.GetClusterName(ctx) if err != nil { @@ -4424,7 +4424,7 @@ func mockSSOLogin(authServer *auth.Server, user types.User) client.SSOLoginFunc } // build login response - return &authclient.SSHLoginResponse{ + return &authclient.CLILoginResponse{ Username: user.GetName(), Cert: sshCert, TLSCert: tlsCert, @@ -4434,7 +4434,7 @@ func mockSSOLogin(authServer *auth.Server, user types.User) client.SSOLoginFunc } func mockHeadlessLogin(t *testing.T, authServer *auth.Server, user types.User) client.SSHLoginFunc { - return func(ctx context.Context, keyRing *client.KeyRing) (*authclient.SSHLoginResponse, error) { + return func(ctx context.Context, keyRing *client.KeyRing) (*authclient.CLILoginResponse, error) { // generate certificates for our user clusterName, err := authServer.GetClusterName(ctx) require.NoError(t, err) @@ -4459,7 +4459,7 @@ func mockHeadlessLogin(t *testing.T, authServer *auth.Server, user types.User) c require.NoError(t, err) // build login response - return &authclient.SSHLoginResponse{ + return &authclient.CLILoginResponse{ Username: user.GetName(), Cert: sshCert, TLSCert: tlsCert,