verifyAuthWebAuthnBrowserRuntimeConformance function
Verifies a successful browser-shaped passkey ceremony through one host.
transportOrigin is the URL of the host adapter under test. The WebAuthn
payload retains the fixture's secure browser RP origin so an ephemeral
plain-HTTP IO listener can carry the same deterministic browser JSON.
Implementation
Future<void> verifyAuthWebAuthnBrowserRuntimeConformance({
required Uri transportOrigin,
required AuthRuntimeConformanceSend send,
required String sessionCookie,
required String sessionCookieName,
required String csrfToken,
required String expectedUserEmail,
}) async {
final registrationOptions = await send(
AuthRuntimeConformanceRequest(
method: 'POST',
path: '/auth/webauthn/register/options',
headers: _jsonHeaders(
cookie: sessionCookie,
origin: transportOrigin.toString(),
),
body: jsonEncode(<String, Object?>{'_csrf': csrfToken}),
),
);
_expectStatus(registrationOptions, 200, 'webauthn.registration-options');
final registrationBody = _jsonObject(
registrationOptions,
'webauthn.registration-options',
);
final relyingParty = registrationBody['rp'];
final registrationUser = registrationBody['user'];
_check(
registrationBody['challenge'] is String &&
(registrationBody['challenge']! as String).isNotEmpty &&
relyingParty is Map<String, Object?> &&
relyingParty['id'] == _relyingPartyId &&
registrationUser is Map<String, Object?> &&
registrationUser['id'] is String,
'webauthn.registration-options',
'Registration options omitted browser ceremony fields.',
);
final registrationUserMap = switch (registrationUser) {
final Map<String, Object?> value => value,
_ => throw const AuthRuntimeConformanceFailure(
caseId: 'webauthn.registration-options',
message: 'Registration options omitted browser ceremony fields.',
),
};
final registrationUserId = switch (registrationUserMap['id']) {
final String value => value,
_ => throw const AuthRuntimeConformanceFailure(
caseId: 'webauthn.registration-options',
message: 'Registration options omitted browser ceremony fields.',
),
};
final fixture = AuthWebAuthnCeremonyFixture(
relyingPartyId: _relyingPartyId,
origin: Uri.parse(_browserOrigin),
);
final registered = await send(
AuthRuntimeConformanceRequest(
method: 'POST',
path: '/auth/webauthn/register/verify',
headers: _jsonHeaders(
cookie: sessionCookie,
origin: transportOrigin.toString(),
),
body: jsonEncode(<String, Object?>{
'credential': fixture.registrationCredential(
challenge: registrationBody['challenge']! as String,
),
'_csrf': csrfToken,
}),
),
);
_expectStatus(registered, 200, 'webauthn.registration-verify');
final registeredCredential = _jsonObject(
registered,
'webauthn.registration-verify',
)['credential'];
_check(
registeredCredential is Map<String, Object?> &&
registeredCredential['credential_id'] == fixture.credentialId,
'webauthn.registration-verify',
'Registration did not persist the browser credential.',
);
final authenticationOptions = await send(
AuthRuntimeConformanceRequest(
method: 'POST',
path: '/auth/webauthn/authenticate/options',
headers: _jsonHeaders(),
body: '{}',
),
);
_expectStatus(authenticationOptions, 200, 'webauthn.authentication-options');
final authenticationBody = _jsonObject(
authenticationOptions,
'webauthn.authentication-options',
);
_check(
authenticationBody['challenge'] is String &&
(authenticationBody['challenge']! as String).isNotEmpty,
'webauthn.authentication-options',
'Authentication options omitted the browser challenge.',
);
final assertion = fixture.assertionCredential(
challenge: authenticationBody['challenge']! as String,
counter: 1,
userHandle: registrationUserId,
);
_check(
fixture.hasDerEs256Signature(assertion),
'webauthn.authentication-assertion',
'Assertion was not encoded as a browser ASN.1 DER signature.',
);
final authenticated = await send(
AuthRuntimeConformanceRequest(
method: 'POST',
path: '/auth/webauthn/authenticate/verify',
headers: _jsonHeaders(),
body: jsonEncode(<String, Object?>{'credential': assertion}),
),
);
_expectStatus(authenticated, 200, 'webauthn.authentication-verify');
final authenticatedBody = _jsonObject(
authenticated,
'webauthn.authentication-verify',
);
_check(
_userField(authenticatedBody, 'email') == expectedUserEmail &&
(authenticatedBody['credential'] as Map?)?['counter'] == 1,
'webauthn.authentication-verify',
'Authentication did not resolve the user and advance the counter.',
);
_requireSessionCookie(
authenticated,
caseId: 'webauthn.authentication-verify',
cookieName: sessionCookieName,
);
final replayedChallenge = await send(
AuthRuntimeConformanceRequest(
method: 'POST',
path: '/auth/webauthn/authenticate/verify',
headers: _jsonHeaders(),
body: jsonEncode(<String, Object?>{'credential': assertion}),
),
);
_expectSanitizedWebAuthnError(
replayedChallenge,
'webauthn.challenge-replay',
expectedError: 'webauthn_challenge_invalid',
sensitiveValue: fixture.credentialId,
);
final counterOptions = await send(
AuthRuntimeConformanceRequest(
method: 'POST',
path: '/auth/webauthn/authenticate/options',
headers: _jsonHeaders(),
body: '{}',
),
);
_expectStatus(counterOptions, 200, 'webauthn.counter-options');
final staleCounterAssertion = fixture.assertionCredential(
challenge:
_jsonObject(counterOptions, 'webauthn.counter-options')['challenge']!
as String,
counter: 1,
userHandle: registrationUserId,
);
final replayedCounter = await send(
AuthRuntimeConformanceRequest(
method: 'POST',
path: '/auth/webauthn/authenticate/verify',
headers: _jsonHeaders(),
body: jsonEncode(<String, Object?>{'credential': staleCounterAssertion}),
),
);
_expectSanitizedWebAuthnError(
replayedCounter,
'webauthn.counter-replay',
expectedError: 'webauthn_counter_replay',
sensitiveValue: fixture.credentialId,
);
}