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,