Skip to content

[Browser MFA] Add ValidateBrowserMFAChallenge gRPC#63873

Open
danielashare wants to merge 19 commits intodanielashare/browser-mfa-webauthn-validationfrom
danielashare/browser-mfa-validate-browser-mfa-chal
Open

[Browser MFA] Add ValidateBrowserMFAChallenge gRPC#63873
danielashare wants to merge 19 commits intodanielashare/browser-mfa-webauthn-validationfrom
danielashare/browser-mfa-validate-browser-mfa-chal

Conversation

@danielashare
Copy link
Contributor

@danielashare danielashare commented Feb 17, 2026

This PR adds the ValidateBrowserMFAChallenge gRPC endpoint. The RFD for this addition can be found here. Needs to be merged after #63978.

These changes address this part of the flow:

sequenceDiagram
    proxy->>auth: rpc ValidateBrowserMFAChallenge
    auth->>auth: ValidateMFAResponse()
    auth->>auth: Encrypt WebAuthn response<br/>with secret_key
    auth-->>proxy: Return http://127.0.0.1:port/callback?response={encrypted_webauthn}
    proxy-->>browser: HTTP 200 with redirect URL
Loading

I would like to draw particular attention to login.go. In order to validate the WebAuthn response from the browser without consuming it, I had to split the validation logic out of login.go's finish function in to its own function. I want to avoid consuming it because in the full Browser MFA flow, the WebAuthn response is returned to tsh where it will exchange the response for certificates etc. I wonder if it is worth validating the MFA response from the browser before sending it back to tsh where it will then send the MFA response back to the server where it will have to be validated again anyway?

Manual tests:

  • Login with OTP
  • Login with WebAuthn
  • Login with TouchID
  • Login with YubiKey
  • Passwordless login with TouchID
  • Passwordless login with YubiKey
  • Passwordless login with WebAuthn

@danielashare danielashare self-assigned this Feb 17, 2026
@danielashare danielashare added no-changelog Indicates that a PR does not require a changelog entry backport/branch/v18 labels Feb 17, 2026
@danielashare danielashare force-pushed the danielashare/browser-mfa-validate-browser-mfa-chal branch 2 times, most recently from d516d0c to 9075cce Compare February 18, 2026 12:20
Copy link
Contributor

@codingllama codingllama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed a good chunk of it, but not all.

As stated in the proto PR, I think the RPC could be in the specialized MFAService instead.

This could also, potentially, be split into a few distinct smaller PRs: lib/webauthn changes, browser MFA encrypt/decrypt, and the RPC itself. Once we add tests in various layers it'll likely grow.

// use.
ClientOptions ClientOptions `json:"client_options"`
// BrowserMFAWebauthnResponse is a webauthn response for the browser MFA flow
BrowserMFAWebauthnResponse *wantypes.CredentialAssertionResponse `json:"browser_mfa_webauthn_response,omitempty"`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused, why is this field in SSHLoginResponse?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The redirect handler in tsh expects a SSHLoginResponse containing an MFAToken for SSO MFA. I had a look at returning a plain string from encryptBrowserMFAResponse and modifying tsh's [callback](https://github.com/gravitational/teleport/blob/master/lib/client/sso/redirector.go#L422) handler to guess if the decrypted response was a SSHLoginResponse or a WebAuthn JSON, but it wasn't very confidence inspiring. Or it would require adding a new set of callback handlers or starting up two callback servers, which would be quite a large change.

I think it would be simpler to implement and maintain if we kept BrowserMFAWebauthnResponse as part of SSHLoginResponse, WDYT?

Copy link
Contributor

@codingllama codingllama Feb 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No wonder I'm confused, it's quite the indirect relationship.

I think a bit more context in the godoc is useful. Eg:

	// 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).

I would also consider a similar comment, or a type alias, for encryptBrowserMFAResponse:

// Payload required by lib/client/sso.Redirector.
type ssoRedirectorResponse = authclient.SSHLoginResponse

func encryptBrowserMFAResponse(redirectURL *url.URL, webauthnResponse *wantypes.CredentialAssertionResponse) (string, error) { 
	consoleResponse := ssoRedirectorResponse{ 
		BrowserMFAWebauthnResponse: webauthnResponse,
	}
	// (...)
}

The type alias could live inside lib/client/sso, but I'm not sure it's best touching that now (or in this PR).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added those for clarity

return protoResp, nil
}

func (g *GRPCServer) ValidateBrowserMFAChallenge(ctx context.Context, req *authpb.ValidateBrowserMFAChallengeRequest) (*authpb.ValidateBrowserMFAChallengeResponse, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's test this as well, as it should touch all layers.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to MFA Service and added tests

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see the move to MFAService, did you push?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not yet, sorry, still separating in to different PRs

return protoResp, nil
}

func (g *GRPCServer) ValidateBrowserMFAChallenge(ctx context.Context, req *authpb.ValidateBrowserMFAChallengeRequest) (*authpb.ValidateBrowserMFAChallengeResponse, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a godoc?


// ValidateBrowserMFAChallenge validates an MFA challenge response and returns the redirect URL with encrypted response.
func (a *ServerWithRoles) ValidateBrowserMFAChallenge(ctx context.Context, requestID string, webauthnResponse *wantypes.CredentialAssertionResponse) (string, error) {
if !authz.HasBuiltinRole(a.context, string(types.RoleProxy)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's include authz in the gRPC tests.

Comment on lines 6762 to 6763
req.BrowserMfaResponse.RequestId,
wantypes.CredentialAssertionResponseFromProto(req.BrowserMfaResponse.WebauthnResponse),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd be tempted to pass the request proto down all the way instead of unwrapping it here. If we change the request this will break.

Comment on lines +155 to +158
// Unlike Finish, this function:
// - Doesn't update the device counter
// - Doesn't persist any changes to the device
// - Doesn't delete the session data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(1) I wonder if we should let this all happen, but retain the data for exactly one following Finish call. (Maybe with caveats for reuse?)

Maybe:

a. Finish(retainData=true) - (update counter, update device, retain session data)
b. Finish() - (noop, update device, delete session data)


(2) If you take the suggestion from (1), let's first refactor Finish to it takes a struct as input. It'll be too many parameters and we'll have to touch all callers anyway.

Eg: Finish(context.Context, FinishParams) (*LoginData, error)

Let's do that in a separate PR, with only the refactor and nothing else.


(3) I was checking the RFD and found this:

After successful validation, the WebAuthn response is encrypted with the secret key from the client redirect URL and returned to be sent to tsh. The SSOMFASession is deleted to prevent reuse attacks.

https://github.com/gravitational/teleport/blob/master/rfd/0233-tsh-browser-mfa.md#rpc-validatebrowsermfachallenge

So it looks like we are deviating by retaining the session data?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be best to keep the two functions separate from a DX point of view. I think it's clearer what each function does.

Hmm, yeah, that part of the RFD needs updating because session data is retained if Reuse=true.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I keep a separate open branch where I collect RFD changes/corrections, so I can mail it the end. Maybe do something like that so you don't lose track of it?

@danielashare danielashare force-pushed the danielashare/browser-mfa-validate-browser-mfa-chal branch from 9075cce to 4ffb570 Compare February 19, 2026 07:38
@danielashare danielashare force-pushed the danielashare/browser-mfa-validate-browser-mfa-chal branch from 4ffb570 to f52aa5e Compare February 19, 2026 09:09
@danielashare danielashare force-pushed the danielashare/browser-mfa-validate-browser-mfa-chal branch from 2d299c0 to 5b450e8 Compare February 19, 2026 16:39
@danielashare danielashare changed the base branch from danielashare/browser-mfa-proto to danielashare/browser-mfa-webauthn-validation February 19, 2026 16:40
@danielashare
Copy link
Contributor Author

Apologies @codingllama, your comments have lost their context since I split out the webauthn changes

@codingllama
Copy link
Contributor

Apologies codingllama, your comments have lost their context since I split out the webauthn changes

No worries, give me a ping when this is good for another pass.

@danielashare danielashare mentioned this pull request Feb 19, 2026
5 tasks
@danielashare danielashare force-pushed the danielashare/browser-mfa-webauthn-validation branch from 9f7bc17 to dd9ef28 Compare February 20, 2026 07:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport/branch/v18 no-changelog Indicates that a PR does not require a changelog entry size/lg

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments