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).
69
87%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
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.
Per RFC 6749 §4.1.1 (rfc6749):
"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).
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 bodyPer 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 == 400If reuse-detection isn't enabled, mark critical - old tokens remaining valid after rotation defeats the purpose.
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"] == nonceIf 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 == 403Per 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]