diff --git a/lib/client/weblogin.go b/lib/client/weblogin.go index 31978248faf89..cd5d70d9251af 100644 --- a/lib/client/weblogin.go +++ b/lib/client/weblogin.go @@ -113,6 +113,8 @@ type MFAChallengeResponse struct { WebauthnResponse *wantypes.CredentialAssertionResponse `json:"webauthn_response,omitempty"` // SSOResponse is a response from an SSO MFA flow. SSOResponse *SSOResponse `json:"sso_response"` + // TODO(Joerger): DELETE IN v19.0.0, WebauthnResponse used instead. + WebauthnAssertionResponse *wantypes.CredentialAssertionResponse `json:"webauthnAssertionResponse"` } // SSOResponse is a json compatible [proto.SSOResponse]. @@ -128,15 +130,25 @@ func (r *MFAChallengeResponse) GetOptionalMFAResponseProtoReq() (*proto.MFAAuthe return nil, trace.BadParameter("only one MFA response field can be set") } - if r.TOTPCode != "" { + switch { + case r.WebauthnResponse != nil: + return &proto.MFAAuthenticateResponse{Response: &proto.MFAAuthenticateResponse_Webauthn{ + Webauthn: wantypes.CredentialAssertionResponseToProto(r.WebauthnResponse), + }}, nil + case r.SSOResponse != nil: + return &proto.MFAAuthenticateResponse{Response: &proto.MFAAuthenticateResponse_SSO{ + SSO: &proto.SSOResponse{ + RequestId: r.SSOResponse.RequestID, + Token: r.SSOResponse.Token, + }, + }}, nil + case r.TOTPCode != "": return &proto.MFAAuthenticateResponse{Response: &proto.MFAAuthenticateResponse_TOTP{ TOTP: &proto.TOTPResponse{Code: r.TOTPCode}, }}, nil - } - - if r.WebauthnResponse != nil { + case r.WebauthnAssertionResponse != nil: return &proto.MFAAuthenticateResponse{Response: &proto.MFAAuthenticateResponse_Webauthn{ - Webauthn: wantypes.CredentialAssertionResponseToProto(r.WebauthnResponse), + Webauthn: wantypes.CredentialAssertionResponseToProto(r.WebauthnAssertionResponse), }}, nil } @@ -152,6 +164,16 @@ func (r *MFAChallengeResponse) GetOptionalMFAResponseProtoReq() (*proto.MFAAuthe return nil, nil } +func ParseMFAChallengeResponse(mfaResponseJSON []byte) (*proto.MFAAuthenticateResponse, error) { + var resp MFAChallengeResponse + if err := json.Unmarshal(mfaResponseJSON, &resp); err != nil { + return nil, trace.Wrap(err) + } + + protoResp, err := resp.GetOptionalMFAResponseProtoReq() + return protoResp, trace.Wrap(err) +} + // CreateSSHCertReq is passed by tsh to authenticate a local user without MFA // and receive short-lived certificates. type CreateSSHCertReq struct { diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 6590c11d5fa91..506ab966d2588 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -4842,16 +4842,12 @@ func parseMFAResponseFromRequest(r *http.Request) error { // context and returned. func contextWithMFAResponseFromRequestHeader(ctx context.Context, requestHeader http.Header) (context.Context, error) { if mfaResponseJSON := requestHeader.Get("Teleport-MFA-Response"); mfaResponseJSON != "" { - var resp mfaResponse - if err := json.Unmarshal([]byte(mfaResponseJSON), &resp); err != nil { + mfaResp, err := client.ParseMFAChallengeResponse([]byte(mfaResponseJSON)) + if err != nil { return nil, trace.Wrap(err) } - return mfa.ContextWithMFAResponse(ctx, &proto.MFAAuthenticateResponse{ - Response: &proto.MFAAuthenticateResponse_Webauthn{ - Webauthn: wantypes.CredentialAssertionResponseToProto(resp.WebauthnAssertionResponse), - }, - }), nil + return mfa.ContextWithMFAResponse(ctx, mfaResp), nil } return ctx, nil diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index ff0f12fdc20cb..f2865dc20241d 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -5589,10 +5589,6 @@ func TestCreateAppSession_RequireSessionMFA(t *testing.T) { require.NoError(t, err) mfaResp, err := webauthnDev.SolveAuthn(chal) require.NoError(t, err) - mfaRespJSON, err := json.Marshal(mfaResponse{ - WebauthnAssertionResponse: wantypes.CredentialAssertionResponseFromProto(mfaResp.GetWebauthn()), - }) - require.NoError(t, err) // Extract the session ID and bearer token for the current session. rawCookie := *pack.cookies[0] @@ -5626,7 +5622,9 @@ func TestCreateAppSession_RequireSessionMFA(t *testing.T) { PublicAddr: "panel.example.com", ClusterName: "localhost", }, - MFAResponse: string(mfaRespJSON), + MFAResponse: client.MFAChallengeResponse{ + WebauthnAssertionResponse: wantypes.CredentialAssertionResponseFromProto(mfaResp.GetWebauthn()), + }, }, expectMFAVerified: true, }, diff --git a/lib/web/apps.go b/lib/web/apps.go index 5e809d2df29e1..7dc431ff22f77 100644 --- a/lib/web/apps.go +++ b/lib/web/apps.go @@ -22,7 +22,6 @@ package web import ( "context" - "encoding/json" "net/http" "sort" @@ -33,7 +32,7 @@ import ( "github.com/gravitational/teleport/api/client/proto" apidefaults "github.com/gravitational/teleport/api/defaults" "github.com/gravitational/teleport/api/types" - wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" + "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/httplib" "github.com/gravitational/teleport/lib/reversetunnelclient" "github.com/gravitational/teleport/lib/utils" @@ -191,7 +190,10 @@ type CreateAppSessionRequest struct { // AWSRole is the AWS role ARN when accessing AWS management console. AWSRole string `json:"arn,omitempty"` // MFAResponse is an optional MFA response used to create an MFA verified app session. - MFAResponse string `json:"mfa_response"` + MFAResponse client.MFAChallengeResponse `json:"mfaResponse"` + // TODO(Joerger): DELETE IN v19.0.0 + // Backwards compatible version of MFAResponse + MFAResponseJSON string `json:"mfa_response"` } // CreateAppSessionResponse is a response to POST /v1/webapi/sessions/app @@ -230,17 +232,16 @@ func (h *Handler) createAppSession(w http.ResponseWriter, r *http.Request, p htt } } - var mfaProtoResponse *proto.MFAAuthenticateResponse - if req.MFAResponse != "" { - var resp mfaResponse - if err := json.Unmarshal([]byte(req.MFAResponse), &resp); err != nil { - return nil, trace.Wrap(err) - } + mfaResponse, err := req.MFAResponse.GetOptionalMFAResponseProtoReq() + if err != nil { + return nil, trace.Wrap(err) + } - mfaProtoResponse = &proto.MFAAuthenticateResponse{ - Response: &proto.MFAAuthenticateResponse_Webauthn{ - Webauthn: wantypes.CredentialAssertionResponseToProto(resp.WebauthnAssertionResponse), - }, + // Fallback to backwards compatible mfa response. + if mfaResponse == nil && req.MFAResponseJSON != "" { + mfaResponse, err = client.ParseMFAChallengeResponse([]byte(req.MFAResponseJSON)) + if err != nil { + return nil, trace.Wrap(err) } } @@ -263,7 +264,7 @@ func (h *Handler) createAppSession(w http.ResponseWriter, r *http.Request, p htt PublicAddr: result.App.GetPublicAddr(), ClusterName: result.ClusterName, AWSRoleARN: req.AWSRole, - MFAResponse: mfaProtoResponse, + MFAResponse: mfaResponse, AppName: result.App.GetName(), URI: result.App.GetURI(), ClientAddr: r.RemoteAddr, diff --git a/lib/web/files.go b/lib/web/files.go index 53248258dd034..62b3913760313 100644 --- a/lib/web/files.go +++ b/lib/web/files.go @@ -20,7 +20,6 @@ package web import ( "context" - "encoding/json" "errors" "net/http" "time" @@ -35,7 +34,6 @@ import ( "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/auth/authclient" - wantypes "github.com/gravitational/teleport/lib/auth/webauthntypes" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/multiplexer" "github.com/gravitational/teleport/lib/reversetunnelclient" @@ -56,8 +54,8 @@ type fileTransferRequest struct { remoteLocation string // filename is a file name filename string - // webauthn is an optional parameter that contains a webauthn response string used to issue single use certs - webauthn string + // mfaResponse is an optional parameter that contains an mfa response string used to issue single use certs + mfaResponse string // fileTransferRequestID is used to find a FileTransferRequest on a session fileTransferRequestID string // moderatedSessonID is an ID of a moderated session that has completed a @@ -74,11 +72,22 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou remoteLocation: query.Get("location"), filename: query.Get("filename"), namespace: defaults.Namespace, - webauthn: query.Get("webauthn"), + mfaResponse: query.Get("mfaResponse"), fileTransferRequestID: query.Get("fileTransferRequestId"), moderatedSessionID: query.Get("moderatedSessionId"), } + // Check for old query parameter, uses the same data structure. + // TODO(Joerger): DELETE IN v19.0.0 + if req.mfaResponse == "" { + req.mfaResponse = query.Get("webauthn") + } + + mfaResponse, err := client.ParseMFAChallengeResponse([]byte(req.mfaResponse)) + if err != nil { + return nil, trace.Wrap(err) + } + // Send an error if only one of these params has been sent. Both should exist or not exist together if (req.fileTransferRequestID != "") != (req.moderatedSessionID != "") { return nil, trace.BadParameter("fileTransferRequestId and moderatedSessionId must both be included in the same request.") @@ -107,7 +116,7 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou return nil, trace.Wrap(err) } - if mfaReq.Required && query.Get("webauthn") == "" { + if mfaReq.Required && mfaResponse == nil { return nil, trace.AccessDenied("MFA required for file transfer") } @@ -135,8 +144,8 @@ func (h *Handler) transferFile(w http.ResponseWriter, r *http.Request, p httprou return nil, trace.Wrap(err) } - if req.webauthn != "" { - err = ft.issueSingleUseCert(req.webauthn, r, tc) + if req.mfaResponse != "" { + err = ft.issueSingleUseCert(mfaResponse, r, tc) if err != nil { return nil, trace.Wrap(err) } @@ -216,21 +225,10 @@ func (f *fileTransfer) createClient(req fileTransferRequest, httpReq *http.Reque return tc, nil } -type mfaResponse struct { - // WebauthnResponse is the response from authenticators. - WebauthnAssertionResponse *wantypes.CredentialAssertionResponse `json:"webauthnAssertionResponse"` -} - // issueSingleUseCert will take an assertion response sent from a solved challenge in the web UI // and use that to generate a cert. This cert is added to the Teleport Client as an authmethod that // can be used to connect to a node. -func (f *fileTransfer) issueSingleUseCert(webauthn string, httpReq *http.Request, tc *client.TeleportClient) error { - var mfaResp mfaResponse - err := json.Unmarshal([]byte(webauthn), &mfaResp) - if err != nil { - return trace.Wrap(err) - } - +func (f *fileTransfer) issueSingleUseCert(mfaResponse *proto.MFAAuthenticateResponse, httpReq *http.Request, tc *client.TeleportClient) error { pk, err := keys.ParsePrivateKey(f.sctx.cfg.Session.GetSSHPriv()) if err != nil { return trace.Wrap(err) @@ -241,11 +239,7 @@ func (f *fileTransfer) issueSingleUseCert(webauthn string, httpReq *http.Request SSHPublicKey: pk.MarshalSSHPublicKey(), Username: f.sctx.GetUser(), Expires: time.Now().Add(time.Minute).UTC(), - MFAResponse: &proto.MFAAuthenticateResponse{ - Response: &proto.MFAAuthenticateResponse_Webauthn{ - Webauthn: wantypes.CredentialAssertionResponseToProto(mfaResp.WebauthnAssertionResponse), - }, - }, + MFAResponse: mfaResponse, }) if err != nil { return trace.Wrap(err) diff --git a/lib/web/mfajson/mfajson.go b/lib/web/mfajson/mfajson.go index fcbd95bfa2096..2105b0178b3a9 100644 --- a/lib/web/mfajson/mfajson.go +++ b/lib/web/mfajson/mfajson.go @@ -28,7 +28,7 @@ import ( "github.com/gravitational/teleport/lib/client" ) -// TODO(Joerger): DELETE IN v18.0.0 and use client.MFAChallengeResponse instead. +// TODO(Joerger): DELETE IN v19.0.0 and use client.MFAChallengeResponse instead. // Before v17, the WebUI sends a flattened webauthn response instead of a full // MFA challenge response. Newer WebUI versions v17+ will send both for // backwards compatibility. @@ -45,14 +45,17 @@ func Decode(b []byte, typ string) (*authproto.MFAAuthenticateResponse, error) { return nil, trace.Wrap(err) } - // TODO(Joerger): DELETE in v18.0.0, client.MFAChallengeResponse is be used instead. - if resp.CredentialAssertionResponse != nil { - return &authproto.MFAAuthenticateResponse{ - Response: &authproto.MFAAuthenticateResponse_Webauthn{ - Webauthn: wantypes.CredentialAssertionResponseToProto(resp.CredentialAssertionResponse), - }, - }, nil + // Move flattened webauthn response into resp. + resp.MFAChallengeResponse.WebauthnAssertionResponse = resp.CredentialAssertionResponse + + protoResp, err := resp.GetOptionalMFAResponseProtoReq() + if err != nil { + return nil, trace.Wrap(err) + } + + if protoResp == nil { + return nil, trace.BadParameter("invalid MFA response from web") } - return resp.GetOptionalMFAResponseProtoReq() + return protoResp, trace.Wrap(err) } diff --git a/web/packages/teleport/src/Account/ManageDevices/wizards/DeleteAuthDeviceWizard.test.tsx b/web/packages/teleport/src/Account/ManageDevices/wizards/DeleteAuthDeviceWizard.test.tsx index 252376e2b6eab..b4cf9942ed16b 100644 --- a/web/packages/teleport/src/Account/ManageDevices/wizards/DeleteAuthDeviceWizard.test.tsx +++ b/web/packages/teleport/src/Account/ManageDevices/wizards/DeleteAuthDeviceWizard.test.tsx @@ -29,7 +29,7 @@ import auth from 'teleport/services/auth'; import { DeleteAuthDeviceWizardStepProps } from './DeleteAuthDeviceWizard'; -import { dummyPasskey, dummyHardwareDevice, deviceCases } from './deviceCases'; +import { dummyPasskey, dummyHardwareDevice } from './deviceCases'; import { DeleteAuthDeviceWizard } from '.'; diff --git a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts index 478ccbcc5fa59..ac12807524d9c 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts +++ b/web/packages/teleport/src/Console/DocumentSsh/useGetScpUrl.ts @@ -39,17 +39,14 @@ export default function useGetScpUrl(addMfaToScpUrls: boolean) { scope: MfaChallengeScope.USER_SESSION, }); - const response = await auth.getMfaChallengeResponse( - challenge, - 'webauthn' - ); + const mfaResponse = await auth.getMfaChallengeResponse(challenge); setAttempt({ status: 'success', statusText: '', }); return cfg.getScpUrl({ - webauthn: response.webauthn_response, + mfaResponse, ...params, }); } catch (error) { diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 2f1478017633d..7c6b4a238e0b2 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -34,7 +34,10 @@ import type { import type { SortType } from 'teleport/services/agents'; import type { RecordingType } from 'teleport/services/recordings'; -import type { WebauthnAssertionResponse } from './services/mfa'; +import type { + MfaChallengeResponse, + WebauthnAssertionResponse, +} from './services/mfa'; import type { PluginKind, Regions, @@ -871,20 +874,25 @@ const cfg = { }); }, - getScpUrl({ webauthn, ...params }: UrlScpParams) { + getScpUrl({ mfaResponse, ...params }: UrlScpParams) { let path = generatePath(cfg.api.scp, { ...params, }); - if (!webauthn) { + if (!mfaResponse) { return path; } // non-required MFA will mean this param is undefined and generatePath doesn't like undefined // or optional params. So we append it ourselves here. Its ok to be undefined when sent to the server // as the existence of this param is what will issue certs - return `${path}&webauthn=${JSON.stringify({ - webauthnAssertionResponse: webauthn, + + // TODO(Joerger): DELETE IN v19.0.0 + // We include webauthn for backwards compatibility. + path = `${path}&webauthn=${JSON.stringify({ + webauthnAssertionResponse: mfaResponse.webauthn_response, })}`; + + return `${path}&mfaResponse=${JSON.stringify(mfaResponse)}`; }, getRenewTokenUrl() { @@ -1232,7 +1240,7 @@ export interface UrlScpParams { filename: string; moderatedSessionId?: string; fileTransferRequestId?: string; - webauthn?: WebauthnAssertionResponse; + mfaResponse?: MfaChallengeResponse; } export interface UrlSshParams { diff --git a/web/packages/teleport/src/lib/EventEmitterMfaSender.ts b/web/packages/teleport/src/lib/EventEmitterMfaSender.ts index da30f1201e0c9..656ac29dc77fe 100644 --- a/web/packages/teleport/src/lib/EventEmitterMfaSender.ts +++ b/web/packages/teleport/src/lib/EventEmitterMfaSender.ts @@ -32,15 +32,6 @@ class EventEmitterMfaSender extends EventEmitter { sendChallengeResponse(data: MfaChallengeResponse) { throw new Error('Not implemented'); } - - // TODO (avatus) DELETE IN 18 - /** - * @deprecated Use sendChallengeResponse instead. - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - sendWebAuthn(data: WebauthnAssertionResponse) { - throw new Error('Not implemented'); - } } export { EventEmitterMfaSender }; diff --git a/web/packages/teleport/src/lib/tdp/client.ts b/web/packages/teleport/src/lib/tdp/client.ts index ca18c58744124..ef44f0cce0f3a 100644 --- a/web/packages/teleport/src/lib/tdp/client.ts +++ b/web/packages/teleport/src/lib/tdp/client.ts @@ -624,14 +624,6 @@ export default class Client extends EventEmitterMfaSender { this.send(this.codec.encodeClipboardData(clipboardData)); } - sendWebAuthn(data: WebauthnAssertionResponse) { - const msg = this.codec.encodeMfaJson({ - mfaType: 'n', - jsonString: JSON.stringify(data), - }); - this.send(msg); - } - addSharedDirectory(sharedDirectory: FileSystemDirectoryHandle) { try { this.sdManager.add(sharedDirectory); diff --git a/web/packages/teleport/src/lib/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index 6b28a592e659f..bb6558e187f2a 100644 --- a/web/packages/teleport/src/lib/term/tty.ts +++ b/web/packages/teleport/src/lib/term/tty.ts @@ -88,7 +88,7 @@ class Tty extends EventEmitterMfaSender { // but to be backward compatible, we need to still spread the existing webauthn only fields // as "top level" fields so old proxies can still respond to webauthn challenges. // in 19, we can just pass "data" without this extra step - // TODO (avatus): DELETE IN 18 + // TODO (avatus): DELETE IN 19.0.0 const backwardCompatibleData = { ...data.webauthn_response, ...data, @@ -100,16 +100,6 @@ class Tty extends EventEmitterMfaSender { this.socket.send(bytearray); } - // TODO (avatus) DELETE IN 18 - /** - * @deprecated Use sendChallengeResponse instead. - */ - sendWebAuthn(data: WebauthnAssertionResponse) { - const encoded = this._proto.encodeChallengeResponse(JSON.stringify(data)); - const bytearray = new Uint8Array(encoded); - this.socket.send(bytearray); - } - sendKubeExecData(data: KubeExecData) { const encoded = this._proto.encodeKubeExecData(JSON.stringify(data)); const bytearray = new Uint8Array(encoded); diff --git a/web/packages/teleport/src/lib/useMfa.ts b/web/packages/teleport/src/lib/useMfa.ts index 29ab598d5af39..e7970d00f34d7 100644 --- a/web/packages/teleport/src/lib/useMfa.ts +++ b/web/packages/teleport/src/lib/useMfa.ts @@ -86,7 +86,7 @@ export function useMfa(emitterSender: EventEmitterMfaSender): MfaState { errorText: '', webauthnPublicKey: null, })); - emitterSender.sendWebAuthn(res.webauthn_response); + emitterSender.sendChallengeResponse(res); }) .catch((err: Error) => { setErrorText(err.message); diff --git a/web/packages/teleport/src/services/api/api.test.ts b/web/packages/teleport/src/services/api/api.test.ts index b9689eeb4210b..af362e602bc4e 100644 --- a/web/packages/teleport/src/services/api/api.test.ts +++ b/web/packages/teleport/src/services/api/api.test.ts @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import { MfaChallengeResponse } from '../mfa'; - import api, { MFA_HEADER, defaultRequestOptions, @@ -28,7 +26,7 @@ import api, { describe('api.fetch', () => { const mockedFetch = jest.spyOn(global, 'fetch').mockResolvedValue({} as any); // we don't care about response - const mfaResp: MfaChallengeResponse = { + const mfaResp = { webauthn_response: { id: 'some-id', type: 'some-type', @@ -104,6 +102,7 @@ describe('api.fetch', () => { ...defaultRequestOptions.headers, ...getAuthHeaders(), [MFA_HEADER]: JSON.stringify({ + ...mfaResp, webauthnAssertionResponse: mfaResp.webauthn_response, }), }, @@ -124,6 +123,7 @@ describe('api.fetch', () => { ...customOpts.headers, ...getAuthHeaders(), [MFA_HEADER]: JSON.stringify({ + ...mfaResp, webauthnAssertionResponse: mfaResp.webauthn_response, }), }, diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index 1048c3333e11c..02f1c4ffbb21c 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -237,8 +237,8 @@ const api = { * If customOptions field is not provided, only fields defined in * `defaultRequestOptions` will be used. * - * @param webauthnResponse if defined (eg: `fetchJsonWithMfaAuthnRetry`) - * will add a custom MFA header field that will hold the webauthn response. + * @param mfaResponse if defined (eg: `fetchJsonWithMfaAuthnRetry`) + * will add a custom MFA header field that will hold the mfaResponse. */ fetch( url: string, @@ -258,7 +258,9 @@ const api = { if (mfaResponse) { options.headers[MFA_HEADER] = JSON.stringify({ - // TODO(Joerger): Handle non-webauthn response. + ...mfaResponse, + // TODO(Joerger): DELETE IN v19.0.0. + // We include webauthnAssertionResponse for backwards compatibility. webauthnAssertionResponse: mfaResponse.webauthn_response, }); } diff --git a/web/packages/teleport/src/services/apps/apps.ts b/web/packages/teleport/src/services/apps/apps.ts index d64f37414a872..a8ec75c1e2eb3 100644 --- a/web/packages/teleport/src/services/apps/apps.ts +++ b/web/packages/teleport/src/services/apps/apps.ts @@ -57,15 +57,17 @@ const service = { }, }); - const resp = await auth.getMfaChallengeResponse(challenge); + const mfaResponse = await auth.getMfaChallengeResponse(challenge); const createAppSession = { ...resolveApp, arn: params.arn, - // TODO(Joerger): Handle non-webauthn response. - mfa_response: resp + mfaResponse, + // TODO(Joerger): DELETE IN v19.0.0. + // We include a string version of the MFA response for backwards compatibility. + mfa_response: mfaResponse ? JSON.stringify({ - webauthnAssertionResponse: resp.webauthn_response, + webauthnAssertionResponse: mfaResponse.webauthn_response, }) : null, };