Skip to content

[Browser MFA] Split validation out of Finish function#63978

Open
danielashare wants to merge 1 commit intomasterfrom
danielashare/browser-mfa-webauthn-validation
Open

[Browser MFA] Split validation out of Finish function#63978
danielashare wants to merge 1 commit intomasterfrom
danielashare/browser-mfa-webauthn-validation

Conversation

@danielashare
Copy link
Contributor

@danielashare danielashare commented Feb 19, 2026

This PR extracts the validation logic out of the WebAuthn finish function. This allows the Browser MFA flow to validate a WebAuthn response it gets from the browser before sending it to tsh to be consumed. The RFD for this addition can be found here.

Manual tests:

  • Login with Passkey
  • Login with TouchID
  • Login with YubiKey
  • Passwordless with Passkey
  • Passwordless with TouchID
  • Passwordless with YubiKey

@danielashare danielashare self-assigned this Feb 19, 2026
@danielashare danielashare added the no-changelog Indicates that a PR does not require a changelog entry label Feb 19, 2026
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.

I appreciate the small, focused PR. Thanks!

This doesn't need to depend on danielashare/browser-mfa-proto - if you drop those commits it can land on its own.

Comment on lines +525 to +526
// validateOnly validates an MFA response without consuming it
func (f *loginFlow) validateOnly(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) error {
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.

Suggested change
// validateOnly validates an MFA response without consuming it
func (f *loginFlow) validateOnly(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) error {
func (f *loginFlow) validate(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) error {

So it matches the public method? A godoc becomes less pressing in the private method when there is parity too, IMO.

Or, better yet, remove it completely - see #63978 (comment).

}, nil
}

func (f *loginFlow) finish(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) (*LoginData, error) {
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.

I'm not convinced all of validateMFAResponseInternal/finish/validateOnly need to exist at a private API level, nor about mfaValidationResult existing and holding so much "private" state of the finish logic.

We could add a "validateOnly bool" to the private finish func and just use that accordingly via public APIs.

// finish implements both LoginFlow.Finish and LoginFlow.Validate.
// If validateOnly is set then updates to the device and session data are
// skipped.
func (f *loginFlow) finish(
	ctx context.Context,
	user string,
	resp *wantypes.CredentialAssertionResponse,
	requiredExtensions *mfav1.ChallengeExtensions,
	validateOnly bool,
) (*LoginData, error) {
 // (...)
}

Comment on lines +1236 to +1303
func TestLoginFlow_Validate(t *testing.T) {
// Simulate a previously registered U2F device.
u2fKey, err := mocku2f.Create()
require.NoError(t, err)
u2fKey.SetCounter(10)
devAddedAt := time.Now().Add(-5 * time.Minute)
u2fDev, err := keyToMFADevice(u2fKey, devAddedAt, devAddedAt)
require.NoError(t, err)

// U2F user has a legacy device and no webID.
const u2fUser = "alpaca"
u2fIdentity := newFakeIdentity(u2fUser, u2fDev)

// webUser gets a newly registered device and a webID.
const webUser = "alice"
webIdentity := newFakeIdentity(webUser)

u2fConfig := &types.U2F{AppID: "https://example.com:3080"}
webConfig := &types.Webauthn{RPID: "example.com"}

const u2fOrigin = "https://example.com:3080"
const webOrigin = "https://example.com"
ctx := context.Background()

// Register a Webauthn device.
webKey, err := mocku2f.Create()
require.NoError(t, err)
webKey.PreferRPID = true
webKey.SetCounter(20)
webRegistration := &wanlib.RegistrationFlow{
Webauthn: webConfig,
Identity: webIdentity,
}
cc, err := webRegistration.Begin(ctx, webUser, false)
require.NoError(t, err)
ccr, err := webKey.SignCredentialCreation(webOrigin, cc)
require.NoError(t, err)
_, err = webRegistration.Finish(ctx, wanlib.RegisterResponse{
User: webUser,
DeviceName: "webauthn1",
CreationResponse: ccr,
})
require.NoError(t, err)

tests := []struct {
name string
identity *fakeIdentity
user, origin string
key *mocku2f.Key
}{
{
name: "U2F device validation",
identity: u2fIdentity,
user: u2fUser,
origin: u2fOrigin,
key: u2fKey,
},
{
name: "Webauthn device validation",
identity: webIdentity,
user: webUser,
origin: webOrigin,
key: webKey,
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is the same as TestLoginFlow_BeginFinish, correct? Could we reuse the setup somehow?

A way we could do it is to incorporate Validate into TestLoginFlow_BeginFinish itself, for example by having t.Run variations that call/don't call Validate in the flow.

Eg:

func TestLoginFlow_BeginFinish(t *testing.T) {
	// (...)
	for _, test := range tests {
		runTest := func(t *testing.T, validate bool) {
			t.Parallel()

			identity := test.identity.clone()
			// (continue until Finish)

			if validate {
				// Test for validate here, or pass on to a "testLoginFlowValidate" function.
				// Avoid Fatal-ing the test in simple assertions so it continues to Finish.
			}

			// (continue onto Finish)
		}

		t.Run(test.name+"/Finish", func(t *testing.T) { runTest(t, false) })
		t.Run(test.name+"/ValidateThenFinish", func(t *testing.T) { runTest(t, false) })
	}
}


for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := webLogin.Validate(ctx, test.user, test.createResp(), test.exts)
Copy link
Contributor

Choose a reason for hiding this comment

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

Similarly, should we piggy back this onto TestLoginFlow_Finish_errors? That's even simpler with "createResp" being a func.

Scope: mfav1.ChallengeScope_CHALLENGE_SCOPE_ADMIN_ACTION,
AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES,
})
require.NoError(t, err)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: loop:

	for range 2 {
		// Validate won't consume the session.
		err = webLogin.Validate(ctx, user, assertionResp, &mfav1.ChallengeExtensions{
			Scope:      mfav1.ChallengeScope_CHALLENGE_SCOPE_ADMIN_ACTION,
			AllowReuse: mfav1.ChallengeAllowReuse_CHALLENGE_ALLOW_REUSE_YES,
		})
		require.NoError(t, err)
	}
	// Session data still exists.
	require.NotEmpty(t, webIdentity.SessionData, "Session should not be consumed by Validate")

Same for Finish.

Comment on lines +1564 to +1565
// Validate should succeed and not consume the session.
err = webLogin.Validate(ctx, user, assertionResp, &mfav1.ChallengeExtensions{
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this test covering a meaningful scenario?

Is a reusable session any different from a "normal" one? Do we expect multiple Validate calls for a reusable session?

@danielashare danielashare mentioned this pull request Feb 19, 2026
5 tasks
@danielashare danielashare changed the base branch from danielashare/browser-mfa-proto to master February 20, 2026 07:32
@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/md

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments