跳到正文
cccskills
免费在 GitHub 公开

mfa-flow-test-author

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.

中文简介正在准备中。当前显示原文说明。

testland/qa星标 13(GitHub 13 + cccskills 0)
集成工具Playwright

实测评分 未实测

查看安装方法

包含的文件(3)

  • SKILL.md17.0 KB
  • .tessl-plugin/plugin.json704 B
  • references/webauthn-virtual-authenticator.md2.4 KB

SKILL.md(原文)

安装前可以查看交给 AI 代理的指令内容。

mfa-flow-test-author

Overview

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.

Shared setup

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

Step 1 - TOTP (RFC 6238)

Per RFC 6238 §4.2, TOTP = HOTP(K, T) where T = floor((t - T0) / X), T0 = Unix epoch 0 and X = time step (default 30s). TOTP is testable because it is deterministic: a fixed secret + fixed for_time always yields the same code. Generate the expected code with pyotp.TOTP.at(for_time) (pyotp) and compare it against what the server accepts.

Interoperability test vectors are in RFC 6238 Appendix B: ASCII secret 12345678901234567890 at Unix time 59 produces 94287082 with SHA-1.

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.

Step 2 - HOTP (RFC 4226)

HOTP(K, C) is counter-based: K is the shared secret, C the counter. The client increments C on each use; the server increments only on successful validation and keeps a look-ahead window s (RFC 4226 §7.4) to tolerate drift.

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

Step 3 - SMS / email OTP

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:

  1. The code is accepted within its TTL.
  2. The code is rejected after expiry.
  3. The code cannot be used a second time (single-use).
  4. A wrong code is rejected.
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).

Step 4 - WebAuthn / passkey (virtual authenticator)

Registration calls navigator.credentials.create(); authentication calls navigator.credentials.get() and produces a signed assertion. For CI, bypass the physical device with the Chrome DevTools Protocol WebAuthn domain virtual authenticator (WebAuthn L2 §11), reached in Playwright via browserContext.newCDPSession(page) (pw-cdp).

# 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()

        cdp = context.new_cdp_session(page)
        cdp.send("WebAuthn.enable", {"enableUI": False})

        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")
    page.wait_for_selector("#passkey-registered-confirmation")
    assert page.is_visible("#passkey-registered-confirmation")

    creds = cdp.send("WebAuthn.getCredentials", {"authenticatorId": auth_id})
    assert len(creds["credentials"]) == 1

Full authentication round-trip, user-verification enforcement, and server-side verification with @simplewebauthn/server (persist newCounter to block signature-counter replay): references/webauthn-virtual-authenticator.md.

Step 5 - Recovery codes

Single-use backup tokens. Test:

  1. A valid unused code grants access.
  2. The code cannot be used a second time.
  3. An invalid code is rejected.
  4. Exhausting all codes forces re-enrollment (or locks the account per policy).
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]

Step 6 - MFA enrollment

Enrollment binds a second factor to an account. Test:

  1. Enrollment requires a valid first-factor session.
  2. Enrollment completes only after the user verifies the factor (confirm OTP or passkey ceremony completes).
  3. Enrollment cannot be completed with a wrong verification code.
  4. Concurrent enrollment requests do not create duplicate factors.
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 7 - Step-up authentication

Re-challenge a valid session for its second factor before a sensitive operation.

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

Step 8 - End-to-end MFA coverage checklist

For each second factor in scope:

  1. Happy path (valid code/credential accepted)
  2. Wrong code/credential rejected
  3. Expired code rejected (TOTP, SMS/email OTP)
  4. Replay rejected (HOTP, recovery codes, SMS/email OTP)
  5. Enrollment requires authenticated session
  6. Enrollment requires successful verification of the new factor
  7. Step-up re-challenges on privileged operations
  8. Recovery codes: single-use + invalid-code rejection
  9. WebAuthn: registration round-trip, authentication round-trip, UV enforcement, credential storage verified via CDP

Anti-patterns

Anti-patternWhy it failsFix
Call totp.now() without clock injectionTest is time-dependent; window drift makes it flakyUse totp.at(for_time=fixed_time) with clock injection
Use real SMS/email in testsNetwork dependency; non-deterministic; costs moneyReplace transport with a test-double stub
Use PKCE plain in the surrounding OAuth flowDefeats PKCE per RFC 7636 §4.2Always S256; see oauth-flow-test-author
Skip replay test for OTPsSingle-use property unverifiedStep 2 / Step 3 negative cases
Launch a real authenticator device for WebAuthnBlocks CI; hardware not available headlesslyCDP virtual authenticator (Step 4)
Test only the happy enrollment pathEnrollment with wrong code silently succeedsStep 6 confirm-with-wrong-code negative test
Trust signCount == 0 as a sign of no replay riskSome authenticators always report 0; server must track and reject decreasing countsPersist newCounter from verifyAuthenticationResponse

Limitations

  • CDP virtual authenticator is Chromium-only; it is not available on Firefox or WebKit. Add a skip guard (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.
  • This skill is the per-flow recipe. IdP-specific enrollment UIs (Keycloak OTP policies, Auth0 Actions, Okta Factors API) are in 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.

References

评价

还没有评价。欢迎分享你的使用感受。

评论

无需登录即可发表。欢迎分享使用感受、提问,或说明在哪些环境下无法运行。

还没有评论。来写第一条吧。

提交后会立即公开。请勿填写邮箱、电话等个人信息。URL 最多 2 个。

0 / 1000 字

此表单受 reCAPTCHA 保护,适用 Google 的隐私权政策和服务条款。

同一仓库的技能

Builds a CI gate that fails the build on **new** WCAG / a11y violations introduced by a PR while grandfathering pre-existing violations on a per-rule / per-page baseline. Aggregates verdicts from axe-core / pa11y / Lighthouse a11y / WAVE / IBM Equal Access scans. Use when a project has accumulated a11y debt and a strict "zero violations" gate would block every PR - the ratchet pattern lets the team ship while preventing regressions.

中文简介正在准备中。当前显示原文说明。

testland/qa132026年10月20日 更新

Workflow skill that builds an A/B-test validity checklist from an experiment proposal, walking the canonical design-correctness gates - pre-registered OEC/power/guardrails, randomization unit + SRM check, assignment integrity, telemetry, peeking discipline, novelty/primacy, post-experiment SRM re-check - into a per-experiment checklist + sign-off form. Use when launching, auditing, or governing an experiment. For pitfall mechanics (guardrails, peeking) see experiment-results-interpreter's references; to read an already-valid result use experiment-results-interpreter; for per-SDK harness tests use experiment-sdk-testing - this gates DESIGN, not SDK code.

中文简介正在准备中。当前显示原文说明。

testland/qa132026年10月20日 更新

The single install-flow skill: the reference contract for the PWA install flow (installability gate fields, `beforeinstallprompt` handshake, per-platform paths - Android WebAPK / iOS Share menu / Firefox no-op - and the `display-mode` post-install signal, in references/install-flow-reference.md) plus the build-an-X workflow that emits the Add-to-Home-Screen suite. Walks the four-stage timeline, emitting one test per gate cell per [web.dev/articles/install-criteria][install-criteria], the deferred-prompt → `prompt()` → `userChoice` chain per [web.dev/articles/customize-install][customize-install], the iOS Safari manual-metadata branch (`apple-touch-icon`) per [web.dev/learn/pwa/installation][learn-pwa], and the post-install `(display-mode: standalone)` MQ assertion. Output: a Playwright spec with per-stage cells plus a coverage matrix. Use when a PWA's manifest, icons, or install handler change, when install conversion drops at an unknown stage, or when triaging a flaky install assertion.

中文简介正在准备中。当前显示原文说明。

testland/qa132026年10月20日 更新

Build-an-X workflow that uses an LLM to map existing tests to spec sections - given a spec doc + the test suite, the LLM identifies which tests cover which sections, surfaces uncovered sections (gap), and recommends specific tests to add. Output is a coverage matrix per spec ID. Scope is mapping tests that already exist and naming the gaps, not authoring tests for new acceptance criteria. Use when a spec doc and a test suite both exist but nobody can say which requirements are actually covered - before a release sign-off, an audit, or a decision about where to spend the next round of test effort.

中文简介正在准备中。当前显示原文说明。

testland/qa132026年10月20日 更新

Generates tests from natural-language specs (acceptance criteria, user stories, requirements) using an LLM, with confidence scoring per test case (LLM self-assessment plus heuristics: assertion quality, naming, completeness), batching uncertain cases for human review, and integration with the team's existing test framework. Use when the user asks to generate unit tests from acceptance criteria, convert user stories to test cases, automate test creation from requirements, or augment a spec-driven test suite with AI-generated stubs that are then curated before merge.

中文简介正在准备中。当前显示原文说明。

testland/qa132026年10月20日 更新

Configures Allure Report (test-runner adapter install, `allure-results` directory wiring, `categories.json` for failure classification, `history-trend.json` retention via the copy-history-between-runs pattern), runs the Allure CLI to convert `allure-results` to a static HTML site, and uploads the report as a CI artifact. Use when the team needs richer test reporting than JUnit XML - step-level attachments, per-test history, retry tracking, and severity / epic / feature labeling across framework-agnostic adapters (pytest, Jest, JUnit, TestNG, NUnit, Mocha). As a rich static HTML report generator, it is the open-source alternative to the sunset ExtentReports (JVM/.NET per-test HTML narrative); for hosted cross-run flakiness analytics rather than a static per-run report use currents-integration.

中文简介正在准备中。当前显示原文说明。

testland/qa132026年10月20日 更新

查看 testland 的全部技能

报告此技能的问题