diff --git a/lib/auth/grpcserver.go b/lib/auth/grpcserver.go index 7d3ef4d24a261..e67b1b4de9030 100644 --- a/lib/auth/grpcserver.go +++ b/lib/auth/grpcserver.go @@ -2565,6 +2565,10 @@ func isDBLocalProxyTunnelCertReq(req *proto.UserCertsRequest) bool { req.RequesterName == proto.UserCertsRequest_TSH_DB_LOCAL_PROXY_TUNNEL } +// ErrNoMFADevices is returned when an MFA ceremony is performed without possible devices to +// complete the challenge with. +var ErrNoMFADevices = trace.AccessDenied("MFA is required to access this resource but user has no MFA devices; use 'tsh mfa add' to register MFA devices") + func userSingleUseCertsAuthChallenge(gctx *grpcContext, stream proto.AuthService_GenerateUserSingleUseCertsServer, mfaRequired proto.MFARequired) (*types.MFADevice, error) { ctx := stream.Context() auth := gctx.authServer @@ -2576,7 +2580,7 @@ func userSingleUseCertsAuthChallenge(gctx *grpcContext, stream proto.AuthService return nil, trace.Wrap(err) } if challenge.TOTP == nil && challenge.WebauthnChallenge == nil { - return nil, trace.AccessDenied("MFA is required to access this resource but user has no MFA devices; use 'tsh mfa add' to register MFA devices") + return nil, ErrNoMFADevices } challenge.MFARequired = mfaRequired diff --git a/lib/client/client.go b/lib/client/client.go index 4ad94f72ab886..3c00d0d6c06d6 100644 --- a/lib/client/client.go +++ b/lib/client/client.go @@ -21,6 +21,7 @@ import ( "context" "crypto/tls" "encoding/json" + "errors" "fmt" "io" "net" @@ -30,6 +31,7 @@ import ( "time" "github.com/gravitational/trace" + "github.com/gravitational/trace/trail" "github.com/moby/term" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/propagation" @@ -570,6 +572,16 @@ func (proxy *ProxyClient) IssueUserCertsWithMFA(ctx context.Context, params Reis resp, err := stream.Recv() if err != nil { + // Older versions will NOT reply with a MFARequired response in the + // challenge and will terminate the stream with an auth.ErrNoMFADevices error. + // In this case for all protocols other than SSH fall back to reissuing + // certs without MFA. + if errors.Is(trail.FromGRPC(err), auth.ErrNoMFADevices) { + if params.usage() != proto.UserCertsRequest_SSH { + return proxy.reissueUserCerts(ctx, CertCacheKeep, params) + } + } + return nil, trace.Wrap(err) } mfaChal := resp.GetMFAChallenge()