Testland
Browse all skills & agents

oauth-flow-test-author

Build-an-X for OAuth 2.0 / OIDC flow tests - authorization-code with PKCE per RFC 7636 (canonical for browser/native/mobile clients), client-credentials per RFC 6749 §1.3.4 (M2M), refresh-token rotation per RFC 9700 (token-binding + reuse-detection), state parameter for CSRF defense per RFC 6749 §10.12, nonce parameter for OIDC ID-token replay defense, scope-grant verification, redirect-URI strict matching. Use when authoring tests for any OAuth/OIDC client or resource server, regardless of the underlying IdP (Keycloak / Auth0 / Okta / mock).

Install with skills.sh (any agent)

npx skills add testland/qa --skill oauth-flow-test-author
View source

oauth-flow-test-author

Overview

OAuth 2.0 (RFC 6749) defines four grant types per datatracker.ietf.org/doc/html/rfc6749 (opens in new window):

"1. Authorization Code (§1.3.1): The authorization code is obtained by using an authorization server as an intermediary between the client and resource owner. 2. Implicit (§1.3.2): The implicit grant is a simplified authorization code flow optimized for clients implemented in a browser using a scripting language such as JavaScript. 3. Resource Owner Password Credentials (§1.3.3): The resource owner password credentials (i.e., username and password) can be used directly as an authorization grant to obtain an access token. 4. Client Credentials (§1.3.4): The client credentials can be used as an authorization grant when the authorization scope is limited to protected resources under the control of the client."

Per RFC 9700 (OAuth 2.0 Security Best Current Practice, March 2025):

This skill is the per-flow test recipe. The canonical authorization-code + PKCE flow is authored end to end in the Worked example below; the less-common grants and negative cases live in references/per-flow-test-recipes.md.

When to use

  • Authoring tests for an OAuth 2.0 / OIDC client (web, mobile, SPA, M2M).
  • Reviewing PRs that touch auth-flow code.
  • The team needs flow-coverage independent of the IdP choice (Keycloak / Auth0 / Okta / mock).

How to use

  1. Identify the grant(s) the client uses from the flow-selection table below.
  2. Author the authorization-code + PKCE happy path (the Worked example) - the canonical flow for browser / native / mobile clients.
  3. Add the negative and secondary-grant tests each client needs (state CSRF, client-credentials, refresh-token rotation, OIDC nonce, scope-downgrade, redirect-URI) from references/per-flow-test-recipes.md.
  4. Run the end-to-end coverage checklist for every OAuth/OIDC client in scope.

Flow-selection decision surface

Client typeGrant to testRequired testsRecipe
Browser / SPA / native / mobileAuthorization Code + PKCE (S256)Happy path + state CSRF + redirect-URI + (OIDC nonce if openid)Worked example + references
Machine-to-machine (M2M)Client CredentialsHappy path; assert no refresh_tokenreferences
Any client holding a refresh tokenRefresh TokenRotation + reuse-detectionreferences
Any OIDC clientAuthorization Code + ID TokenNonce match + scope-downgradeWorked example + references
Legacy Implicit / RO-Password(none - deprecated per RFC 9700)Migrate to Auth Code + PKCE-

Worked example - authorization-code + PKCE (canonical)

Per RFC 6749 §4.1 (per rfc6749 (opens in new window)) the flow:

  1. Client directs resource owner to authorization endpoint with client_id, redirect_uri, response_type=code, scope, state (RECOMMENDED for CSRF defense per §10.12), nonce (OIDC), and PKCE params (code_challenge + code_challenge_method).
  2. Authorization server authenticates the resource owner.
  3. Server redirects back to client with the authorization code + the original state.
  4. Client requests token endpoint with grant_type=authorization_code, code, redirect_uri, client_id, and PKCE code_verifier.
  5. Server returns access token (+ refresh token + ID token if OIDC).

PKCE per RFC 7636 §4.2 (rfc7636 (opens in new window)):

"Two methods are defined:

  • plain: code_challenge = code_verifier
  • S256: code_challenge = BASE64URL-ENCODE(SHA256(ASCII(code_verifier)))"

Always use S256; plain defeats the purpose of PKCE.

Test pattern (Python with requests + Playwright for browser flow):

import secrets, hashlib, base64, requests
from urllib.parse import urlparse, parse_qs

def make_pkce_pair():
    verifier = secrets.token_urlsafe(64)
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode("ascii")).digest()
    ).rstrip(b"=").decode("ascii")
    return verifier, challenge

def test_authz_code_pkce_flow(idp_url, client_id, redirect_uri, browser):
    verifier, challenge = make_pkce_pair()
    state = secrets.token_urlsafe(32)
    nonce = secrets.token_urlsafe(32)

    # Step 1: redirect to authorize endpoint
    authz_url = (
        f"{idp_url}/authorize?"
        f"client_id={client_id}&response_type=code&"
        f"redirect_uri={redirect_uri}&scope=openid+profile&"
        f"state={state}&nonce={nonce}&"
        f"code_challenge={challenge}&code_challenge_method=S256"
    )
    # Browser interaction (simulated via Playwright):
    page = browser.new_page()
    page.goto(authz_url)
    page.fill("#username", "alice")
    page.fill("#password", "alicepass")
    page.click("#submit")

    # Step 3: redirect lands on redirect_uri with code + state
    redirected_to = page.url
    parsed = urlparse(redirected_to)
    query = parse_qs(parsed.query)
    assert query["state"][0] == state    # RFC §10.12 CSRF defense
    code = query["code"][0]

    # Step 4: token endpoint
    token_response = requests.post(
        f"{idp_url}/token",
        data={
            "grant_type": "authorization_code",
            "code": code,
            "redirect_uri": redirect_uri,
            "client_id": client_id,
            "code_verifier": verifier,    # PKCE proof
        },
    )
    assert token_response.status_code == 200
    body = token_response.json()
    assert "access_token" in body
    assert body["token_type"] == "Bearer"

The happy path asserts the returned state matches; the negative state-mismatch test, plus the client-credentials, refresh-rotation, OIDC-nonce, scope-downgrade, and redirect-URI recipes, are in references/per-flow-test-recipes.md.

End-to-end coverage checklist

For each OAuth/OIDC client in scope:

  1. Auth-code + PKCE happy path (Worked example)
  2. State parameter validation (references)
  3. Client-credentials happy path (if M2M; references)
  4. Refresh-token rotation + reuse detection (references)
  5. OIDC nonce validation (if OIDC; references)
  6. Scope-downgrade handling (references)
  7. Redirect-URI strict matching (references)
  8. Token expiration handling (use-after-expiry returns 401)
  9. Token revocation (RFC 7009) if endpoint supports it

Anti-patterns

Anti-patternWhy it failsFix
Use PKCE plain methodDefeats PKCE; RFC 7636 §4.2 specifies S256 as recommendedAlways S256 (Worked example)
Skip state validation in the callback handlerCSRF vulnerableState CSRF negative test (references)
Hardcode redirect_uri prefix matchingSubstring match accepts evil URIsStrict equality (references)
Test only the happy pathNegative cases (mismatched state, invalid PKCE, expired token) untestedNegative tests (references)
Use Implicit or RO-Password grants in new codeDeprecated per RFC 9700Auth Code + PKCE

Limitations

  • This is a build-an-X workflow. Tests use the application's HTTP client + browser automation library; this skill is the per-flow checklist.
  • IdP-specific quirks (audience, custom auth servers, sign-in policies) layer on top - see keycloak-tests, auth0-tests, okta-tests for IdP-specific patterns.
  • RFC 9700 supersedes earlier security BCPs; pin to the latest version when authoring tests.
  • OAuth 2.1 (in-progress) consolidates the deprecations from RFC 9700; prefer 2.1 patterns when the IdP supports them.

References

  • rfc6749 (opens in new window) - RFC 6749 OAuth 2.0 Authorization Framework
  • rfc7636 (opens in new window) - RFC 7636 PKCE
  • IETF RFC 9700 - OAuth 2.0 Security Best Current Practice (March 2025)
  • IETF RFC 7009 - OAuth 2.0 Token Revocation
  • openid.net/specs/openid-connect-core-1_0.html - OIDC Core spec
  • references/per-flow-test-recipes.md - state CSRF, client-credentials, refresh-token rotation, OIDC nonce, scope-downgrade, and redirect-URI recipes.
  • keycloak-tests, auth0-tests, okta-tests - IdP-specific patterns
  • session-management-test-author - companion: post-auth session lifecycle

OAuth 2.0 / OIDC per-flow test recipes

View source (opens in new window)

OAuth 2.0 / OIDC per-flow test recipes

Deep reference for oauth-flow-test-author SKILL.md. Consult when the client uses a grant beyond the canonical authorization-code + PKCE flow shown in the SKILL's Worked example, or needs the negative-case recipes for state, refresh-token rotation, OIDC nonce, scope-downgrade, and redirect-URI matching.

State parameter CSRF defense

Per RFC 6749 §4.1.1 (rfc6749 (opens in new window)):

"An opaque value used by the client to maintain state between the request and callback...should be used for preventing cross-site request forgery as described in Section 10.12."

Test the negative case:

def test_state_mismatch_rejected(client):
    state = "expected-state"
    # ... initiate flow with state=expected-state ...
    # Simulate IdP redirect with WRONG state:
    callback_response = client.get(f"{redirect_uri}?code=valid-code&state=wrong-state")
    assert callback_response.status_code in [400, 403]

If the client accepts the redirect without state validation, mark critical finding (CSRF vulnerable).

Client-credentials grant (M2M)

Per RFC 6749 §4.4. Test pattern:

def test_client_credentials_grant(idp_url, client_id, client_secret, audience):
    response = requests.post(
        f"{idp_url}/token",
        auth=(client_id, client_secret),   # HTTP Basic per §2.3.1
        data={
            "grant_type": "client_credentials",
            "audience": audience,    # required by some IdPs (Auth0, Okta)
            "scope": "api:read",
        },
    )
    assert response.status_code == 200
    body = response.json()
    assert body["token_type"] == "Bearer"
    assert "access_token" in body
    # No refresh_token for client_credentials per §4.4.3
    assert "refresh_token" not in body

Refresh-token rotation

Per RFC 9700: refresh tokens for public clients (browser/native) should rotate on use - each refresh issues a new refresh token, invalidating the old.

Test pattern:

def test_refresh_token_rotates(idp_url, client_id, refresh_token):
    # First refresh
    r1 = requests.post(
        f"{idp_url}/token",
        data={
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
            "client_id": client_id,
        },
    )
    assert r1.status_code == 200
    new_refresh = r1.json()["refresh_token"]
    assert new_refresh != refresh_token   # rotated

    # Second refresh with NEW token works
    r2 = requests.post(
        f"{idp_url}/token",
        data={
            "grant_type": "refresh_token",
            "refresh_token": new_refresh,
            "client_id": client_id,
        },
    )
    assert r2.status_code == 200

    # Re-using the OLD refresh token fails (reuse detection)
    r3 = requests.post(
        f"{idp_url}/token",
        data={
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,    # the original (now invalid)
            "client_id": client_id,
        },
    )
    assert r3.status_code == 400

If reuse-detection isn't enabled, mark critical - old tokens remaining valid after rotation defeats the purpose.

OIDC nonce + ID-token validation

For OIDC (Authorization Code + ID Token), validate the nonce in the ID token matches what was sent in the authorize request. Defends against ID-token replay.

def test_id_token_nonce_matches(client, idp_url):
    nonce = secrets.token_urlsafe(32)
    # ... full code flow with nonce param ...
    id_token = token_response.json()["id_token"]
    decoded = jwt.decode(id_token, ...)   # verify signature too
    assert decoded["nonce"] == nonce

Scope-grant verification

If client requests openid profile email but user only consents to openid profile, the issued token must reflect the actual grant:

def test_scope_downgrade(client):
    # Request 3 scopes, user grants 2:
    response = ... # access_token with consented scopes only
    assert response.json()["scope"] == "openid profile"   # email omitted
    # Resource server should reject email-scoped requests:
    api_response = requests.get(
        f"{api_url}/me/email",
        headers={"Authorization": f"Bearer {access_token}"},
    )
    assert api_response.status_code == 403

Redirect-URI strict matching

Per RFC 9700: redirect URIs MUST match exactly, not by substring or regex. Tests:

def test_redirect_uri_mismatch_rejected(idp_url, client_id):
    response = requests.get(
        f"{idp_url}/authorize",
        params={
            "client_id": client_id,
            "redirect_uri": "https://evil.example.com/callback",  # NOT registered
            "response_type": "code",
        },
    )
    # IdP should reject; the response renders an error page, NOT a redirect:
    assert "evil.example.com" not in response.url
    assert response.status_code in [400, 403]

Related skills

auth0-tests

Authors tests against Auth0 - uses tenant isolation strategy (per-PR tenant or shared dev tenant with namespaced data); exercises Universal Login + auth-code-with-PKCE + client-credentials + RO-password (legacy) flows; tests Action scripts (Auth0's serverless extension hooks); tests Rules / Hooks (deprecated but still common); integrates with Auth0 Deploy CLI (`a0deploy`) for environment parity. Use when the user works with Auth0 SaaS and needs unit / integration tests for tenant config, auth flows, or Action scripts. Does not cover session lifecycle (refresh-token rotation, silent re-auth): use session-management-test-author for that. Differentiates from oauth-flow-test-author by Auth0-tenant specifics: Action scripts, Rules / Hooks, a0deploy config-drift, and Universal Login.

keycloak-tests

Authors and runs integration tests against Keycloak - uses Testcontainers Keycloak module to spin up an isolated server per test class, imports realm JSON for fixtures, exercises OIDC discovery / token endpoint / token introspection / admin REST API; tests password / authorization-code / client-credentials / token-exchange flows; covers UMA (User-Managed Access) permission tickets. Use when the user works with self-hosted Keycloak and needs unit / integration tests for realms, clients, users, or auth flows.

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.

okta-tests

Authors tests against Okta - uses org-isolation strategy (per-PR org via Okta Developer Edition vs shared org with namespaced data); tests sign-in policy + MFA enforcement; exercises Okta Identity Engine (OIE) workflows including factor enrollment, recovery flows, and SCIM provisioning; tests scoped API tokens for least-privilege automation. Use when the user works with Okta as IdP and needs unit / integration tests for org config, sign-in policies, or OIE workflows.

session-management-test-author

Build-an-X for session management tests per OWASP ASVS V3 - cookie attribute coverage (Secure / HttpOnly / SameSite=Strict|Lax), session-fixation defense (regenerate session ID on login), absolute + idle timeout, concurrent-session limits, logout invalidation across devices, CSRF token handling, session-binding to TLS / IP / device fingerprint. Use when authoring tests for any web app's session layer, regardless of framework (Express session, Django sessions, Spring Security, ASP.NET, Rails, etc.).