[Browser MFA] Split validation out of Finish function#63978
[Browser MFA] Split validation out of Finish function#63978danielashare wants to merge 1 commit intomasterfrom
Conversation
codingllama
left a comment
There was a problem hiding this comment.
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.
| // validateOnly validates an MFA response without consuming it | ||
| func (f *loginFlow) validateOnly(ctx context.Context, user string, resp *wantypes.CredentialAssertionResponse, requiredExtensions *mfav1.ChallengeExtensions) error { |
There was a problem hiding this comment.
| // 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) { |
There was a problem hiding this comment.
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) {
// (...)
}| 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) { |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
| // Validate should succeed and not consume the session. | ||
| err = webLogin.Validate(ctx, user, assertionResp, &mfav1.ChallengeExtensions{ |
There was a problem hiding this comment.
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?
9f7bc17 to
dd9ef28
Compare
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
tshto be consumed. The RFD for this addition can be found here.Manual tests: