From qa-auth-flows
Build-an-X workflow for authoring automated tests covering multi-factor authentication flows: TOTP (RFC 6238, deterministic codes from a known secret + fixed time), HOTP (RFC 4226, counter-based), SMS/email OTP, WebAuthn/passkey registration and authentication via Chrome DevTools Protocol virtual authenticator (WebAuthn L2 §11), recovery codes, MFA enrollment, and step-up authentication challenges. Use when the team needs end-to-end MFA test coverage beyond what oauth-flow-test-author covers, or when introducing a new second factor to an existing auth surface.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-auth-flows:mfa-flow-test-authorThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill is the per-flow test recipe for multi-factor authentication.
This skill is the per-flow test recipe for multi-factor authentication.
It covers the six second-factor mechanisms most commonly found in production
systems. Neighbour skills handle the first-factor and session layers:
oauth-flow-test-author covers
the primary OAuth/OIDC token flow;
session-management-test-author
covers the post-authentication session lifecycle.
Per ISTQB Glossary v4: authentication is "the process of confirming that someone or something is who or what they claim to be." MFA adds a second or third factor (TOTP, OTP, biometric/hardware key) on top of the first factor (password, SSO).
All test patterns below require a known, fixed second-factor secret in the test environment. Never use production secrets in tests. Provision per-run secrets using the relevant library and store them in the test fixture.
# Common imports used across patterns in this skill
import base64, hashlib, hmac, struct, time
import pyotp # pyauth.github.io/pyotp
Per RFC 6238 §4.2, TOTP = HOTP(K, T) where T = floor((t - T0) / X): T0 is the Unix epoch (0) and X is the time step in seconds (default 30).
"The accuracy of the device... is important; however, in practice, a time step of 30 seconds provides adequate margin while protecting against brute-force attacks."
The deterministic property is what makes TOTP testable: given a fixed secret
and a fixed for_time, the code is always the same. Use pyotp.TOTP.at(for_time)
(pyauth.github.io/pyotp) to generate the expected code in the test and
compare it against what the server accepts.
Interoperability test vectors are in RFC 6238 Appendix B:
the ASCII secret 12345678901234567890 at Unix time 59 must produce 94287082
with SHA-1, confirming the implementation matches the standard.
import pyotp
def make_totp_fixture(digits=6, interval=30):
"""Return a base32 secret and the matching pyotp.TOTP instance."""
secret = pyotp.random_base32() # 32-char base32 per pyotp docs
totp = pyotp.TOTP(secret, digits=digits, interval=interval)
return secret, totp
def test_totp_happy_path(client, db):
secret, totp = make_totp_fixture()
# Seed the user's MFA secret in the test fixture (not production DB)
db.set_totp_secret(user_id="alice", secret=secret)
fixed_time = 1_234_567_890 # deterministic: RFC 6238 Appendix B
expected_code = totp.at(for_time=fixed_time) # "89005924" for SHA-1
response = client.post(
"/auth/mfa/verify",
json={"otp": expected_code},
headers={"X-Test-Time": str(fixed_time)}, # server must accept clock injection
)
assert response.status_code == 200
def test_totp_wrong_code_rejected(client, db):
secret, totp = make_totp_fixture()
db.set_totp_secret(user_id="alice", secret=secret)
wrong_code = "000000"
response = client.post("/auth/mfa/verify", json={"otp": wrong_code})
assert response.status_code in [400, 401]
assert "invalid" in response.json().get("error", "").lower()
def test_totp_expired_step_rejected(client, db):
"""A code more than valid_window steps old must be rejected."""
secret, totp = make_totp_fixture()
db.set_totp_secret(user_id="alice", secret=secret)
old_time = time.time() - 120 # 4 steps ago; outside the grace window
stale_code = totp.at(for_time=old_time)
response = client.post("/auth/mfa/verify", json={"otp": stale_code})
assert response.status_code in [400, 401]
Clock injection requirement: the server MUST accept a test-controlled
clock value (env var, header, or config) so the test can pass a fixed
for_time. If the server cannot accept clock injection, freeze the system
clock for the test process instead. Tests that rely on time.time() with
valid_window > 0 are timing-dependent and flaky.
Per RFC 4226 §5.2:
"HOTP(K,C) = Truncate(HMAC-SHA-1(K,C))"
K is the shared secret, C is the counter value. The counter increments on
the client side; the server increments on successful validation only. Per
RFC 4226 §7.4, the server maintains a look-ahead window s to
tolerate minor counter drift without a denial-of-service risk.
Test vectors (Appendix D of RFC 4226): secret
12345678901234567890, counter 0 -> 755224, counter 1 -> 287082.
def test_hotp_sequential_codes(client, db):
secret = pyotp.random_base32()
hotp = pyotp.HOTP(secret) # pyotp.HOTP(s, digits=6, initial_count=0)
db.set_hotp_secret(user_id="bob", secret=secret, counter=0)
# Each code is valid exactly once; server increments counter on success
for counter in range(3):
code = hotp.at(counter)
response = client.post("/auth/mfa/verify", json={"otp": code})
assert response.status_code == 200, f"counter {counter} rejected"
def test_hotp_replay_rejected(client, db):
"""Replaying a used counter code must be rejected (counter advanced)."""
secret = pyotp.random_base32()
hotp = pyotp.HOTP(secret)
db.set_hotp_secret(user_id="bob", secret=secret, counter=0)
code = hotp.at(0)
client.post("/auth/mfa/verify", json={"otp": code}) # consume counter 0
replay = client.post("/auth/mfa/verify", json={"otp": code})
assert replay.status_code in [400, 401]
def test_hotp_look_ahead_resync(client, db):
"""Server must accept a code within the resync window (RFC 4226 §7.4)."""
secret = pyotp.random_base32()
hotp = pyotp.HOTP(secret)
db.set_hotp_secret(user_id="bob", secret=secret, counter=0)
# Client is 2 steps ahead (simulates missed increments)
ahead_code = hotp.at(2)
response = client.post("/auth/mfa/verify", json={"otp": ahead_code})
assert response.status_code == 200
SMS and email OTPs are not standardized in a single RFC. Test them by controlling the delivery channel: inject a fixed code via a stub (test-double transport), then verify that:
def test_sms_otp_happy_path(client, sms_stub):
"""sms_stub is a test double that captures outbound OTPs."""
client.post("/auth/mfa/send-otp", json={"channel": "sms"})
captured_otp = sms_stub.last_otp() # read from the stub
response = client.post("/auth/mfa/verify", json={"otp": captured_otp})
assert response.status_code == 200
def test_sms_otp_single_use(client, sms_stub):
client.post("/auth/mfa/send-otp", json={"channel": "sms"})
captured_otp = sms_stub.last_otp()
client.post("/auth/mfa/verify", json={"otp": captured_otp}) # consume
replay = client.post("/auth/mfa/verify", json={"otp": captured_otp})
assert replay.status_code in [400, 401]
def test_sms_otp_expired(client, sms_stub, freeze_clock):
client.post("/auth/mfa/send-otp", json={"channel": "sms"})
captured_otp = sms_stub.last_otp()
freeze_clock.advance(seconds=600) # advance past TTL
response = client.post("/auth/mfa/verify", json={"otp": captured_otp})
assert response.status_code in [400, 401]
Email OTP tests follow the same pattern; replace sms_stub with an
email delivery stub (e.g., a mock SMTP sink such as Mailpit or MailHog).
WebAuthn Level 2 §7.1 defines the registration ceremony as
a sequence where navigator.credentials.create() is called with
PublicKeyCredentialCreationOptions, the authenticator creates an asymmetric
key pair, and the server verifies the attestation. The authentication ceremony
(§7.2) uses navigator.credentials.get() and produces a signed assertion.
For automated testing, bypass the physical authenticator using the Chrome
DevTools Protocol (CDP) WebAuthn domain, which provides a
virtual authenticator per WebAuthn L2 §11 ("User Agent Automation"). CDP
is available in Playwright via browserContext.newCDPSession(page)
(playwright.dev/docs/api/class-cdpsession).
# Playwright + CDP virtual authenticator pattern (Python)
import pytest
from playwright.sync_api import sync_playwright
@pytest.fixture
def virtual_auth_page():
with sync_playwright() as p:
browser = p.chromium.launch() # CDP only on Chromium
context = browser.new_context()
page = context.new_page()
# Open CDP session and enable the WebAuthn domain
cdp = context.new_cdp_session(page)
cdp.send("WebAuthn.enable", {"enableUI": False})
# Add a virtual authenticator (CTAP2, internal transport, UV=true)
result = cdp.send("WebAuthn.addVirtualAuthenticator", {
"options": {
"protocol": "ctap2",
"transport": "internal",
"hasResidentKey": True,
"hasUserVerification": True,
"isUserVerified": True,
"automaticPresenceSimulation": True, # auto-approve gestures
}
})
authenticator_id = result["authenticatorId"]
yield page, cdp, authenticator_id
cdp.send("WebAuthn.removeVirtualAuthenticator",
{"authenticatorId": authenticator_id})
browser.close()
def test_webauthn_registration(virtual_auth_page, app_url):
"""Happy path: register a passkey with the virtual authenticator."""
page, cdp, auth_id = virtual_auth_page
page.goto(f"{app_url}/settings/passkeys")
page.click("#register-passkey")
# navigator.credentials.create() resolves automatically via virtual auth
page.wait_for_selector("#passkey-registered-confirmation")
assert page.is_visible("#passkey-registered-confirmation")
# Verify a credential was stored on the virtual authenticator
creds = cdp.send("WebAuthn.getCredentials", {"authenticatorId": auth_id})
assert len(creds["credentials"]) == 1
def test_webauthn_authentication(virtual_auth_page, app_url):
"""Full round-trip: register then authenticate with the same passkey."""
page, cdp, auth_id = virtual_auth_page
# Register first
page.goto(f"{app_url}/settings/passkeys")
page.click("#register-passkey")
page.wait_for_selector("#passkey-registered-confirmation")
# Now authenticate
page.goto(f"{app_url}/login")
page.click("#passkey-login")
# navigator.credentials.get() resolves automatically
page.wait_for_url(f"{app_url}/dashboard")
assert "/dashboard" in page.url
def test_webauthn_user_verification_required(virtual_auth_page, app_url, cdp):
"""When UV is disabled mid-session, server must reject the assertion."""
page, cdp, auth_id = virtual_auth_page
# Register with UV enabled, then disable UV on the virtual authenticator
# and attempt login; the RP requires UV=true, so it must reject
cdp.send("WebAuthn.setUserVerified",
{"authenticatorId": auth_id, "isUserVerified": False})
page.goto(f"{app_url}/login")
page.click("#passkey-login")
page.wait_for_selector("#login-error")
assert page.is_visible("#login-error")
Server-side verification uses @simplewebauthn/server
(simplewebauthn.dev/docs/packages/server).
verifyRegistrationResponse({ response, expectedChallenge, expectedOrigin, expectedRPID })
returns { verified, registrationInfo }. After authentication,
verifyAuthenticationResponse({ response, expectedChallenge, expectedOrigin, expectedRPID, credential })
returns { verified, authenticationInfo: { newCounter } } - the
newCounter must be persisted to prevent signature-counter replay.
Recovery codes are single-use backup tokens. Test:
def test_recovery_code_single_use(client, db):
codes = db.generate_recovery_codes(user_id="alice", count=8)
first_code = codes[0]
r1 = client.post("/auth/mfa/verify", json={"recovery_code": first_code})
assert r1.status_code == 200
r2 = client.post("/auth/mfa/verify", json={"recovery_code": first_code})
assert r2.status_code in [400, 401] # consumed
def test_invalid_recovery_code_rejected(client, db):
db.generate_recovery_codes(user_id="alice", count=8)
r = client.post("/auth/mfa/verify", json={"recovery_code": "XXXX-XXXX"})
assert r.status_code in [400, 401]
Enrollment is the registration flow that binds a second factor to an account. Test:
def test_totp_enrollment_requires_valid_session(client):
# Unauthenticated request to the enroll endpoint must be rejected
r = client.post("/auth/mfa/enroll/totp")
assert r.status_code in [401, 403]
def test_totp_enrollment_verify_confirms_factor(client, db, authenticated_session):
r = client.post("/auth/mfa/enroll/totp",
headers=authenticated_session.headers)
assert r.status_code == 200
provisioned_secret = r.json()["secret"] # server-generated, base32
totp = pyotp.TOTP(provisioned_secret)
confirm_code = totp.now()
r2 = client.post("/auth/mfa/enroll/totp/confirm",
json={"otp": confirm_code},
headers=authenticated_session.headers)
assert r2.status_code == 200
assert db.user_has_totp(user_id=authenticated_session.user_id)
Step-up authentication re-challenges an already-authenticated user when a sensitive operation is requested. The user holds a valid session token but must prove their second factor again before the resource is granted.
def test_step_up_triggers_mfa_challenge(client, authenticated_session_no_mfa):
"""Accessing a privileged endpoint without MFA should return 403/step-up."""
r = client.delete("/account/delete",
headers=authenticated_session_no_mfa.headers)
assert r.status_code in [403, 401]
data = r.json()
assert data.get("mfa_required") is True
def test_step_up_grants_access_after_mfa(client, db, authenticated_session_no_mfa):
"""After completing MFA the privileged operation is permitted."""
secret, totp = make_totp_fixture()
db.set_totp_secret(user_id=authenticated_session_no_mfa.user_id,
secret=secret)
fixed_time = 1_234_567_890
code = totp.at(for_time=fixed_time)
# Step-up: verify MFA
step_up = client.post(
"/auth/step-up",
json={"otp": code},
headers={**authenticated_session_no_mfa.headers,
"X-Test-Time": str(fixed_time)},
)
assert step_up.status_code == 200
elevated_token = step_up.json()["elevated_token"]
# Use elevated token on the privileged endpoint
r = client.delete("/account/delete",
headers={"Authorization": f"Bearer {elevated_token}"})
assert r.status_code == 200
For each second factor in scope:
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Call totp.now() without clock injection | Test is time-dependent; window drift makes it flaky | Use totp.at(for_time=fixed_time) with clock injection |
| Use real SMS/email in tests | Network dependency; non-deterministic; costs money | Replace transport with a test-double stub |
Use PKCE plain in the surrounding OAuth flow | Defeats PKCE per RFC 7636 §4.2 | Always S256; see oauth-flow-test-author |
| Skip replay test for OTPs | Single-use property unverified | Step 2 / Step 3 negative cases |
| Launch a real authenticator device for WebAuthn | Blocks CI; hardware not available headlessly | CDP virtual authenticator (Step 4) |
| Test only the happy enrollment path | Enrollment with wrong code silently succeeds | Step 6 confirm-with-wrong-code negative test |
Trust signCount == 0 as a sign of no replay risk | Some authenticators always report 0; server must track and reject decreasing counts | Persist newCounter from verifyAuthenticationResponse |
pytest.mark.skipif) for non-Chromium
browser fixtures.automaticPresenceSimulation: True suppresses the user-gesture requirement;
tests do not cover the UI/UX of the authenticator prompt itself. Cover
prompt rendering with a separate visual-regression test.keycloak-tests,
auth0-tests,
okta-tests.pyotp requires a base32-encoded secret per pyauth.github.io/pyotp;
the raw ASCII secrets used in RFC 6238 Appendix B test vectors must be
base32-encoded before passing to pyotp.TOTP.oauth-flow-test-author - companion: first-factor OAuth/OIDC flowssession-management-test-author - companion: post-auth session lifecyclekeycloak-tests, auth0-tests, okta-tests - IdP-specific enrollment patternsnpx claudepluginhub testland/qa --plugin qa-auth-flowsProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.