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
How to use
Flow-selection decision surface
| Client type | Grant to test | Required tests | Recipe |
|---|---|---|---|
| Browser / SPA / native / mobile | Authorization Code + PKCE (S256) | Happy path + state CSRF + redirect-URI + (OIDC nonce if openid) | Worked example + references |
| Machine-to-machine (M2M) | Client Credentials | Happy path; assert no refresh_token | references |
| Any client holding a refresh token | Refresh Token | Rotation + reuse-detection | references |
| Any OIDC client | Authorization Code + ID Token | Nonce match + scope-downgrade | Worked 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:
PKCE per RFC 7636 §4.2 (rfc7636 (opens in new window)):
"Two methods are defined:
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:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Use PKCE plain method | Defeats PKCE; RFC 7636 §4.2 specifies S256 as recommended | Always S256 (Worked example) |
| Skip state validation in the callback handler | CSRF vulnerable | State CSRF negative test (references) |
| Hardcode redirect_uri prefix matching | Substring match accepts evil URIs | Strict equality (references) |
| Test only the happy path | Negative cases (mismatched state, invalid PKCE, expired token) untested | Negative tests (references) |
| Use Implicit or RO-Password grants in new code | Deprecated per RFC 9700 | Auth Code + PKCE |
Limitations
References
OAuth 2.0 / OIDC per-flow test recipes
View source (opens in a new tab) (opens in new window)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 bodyRefresh-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 == 400If 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"] == nonceScope-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 == 403Redirect-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]- Northwind turns off API keys on the 31st and our replacement is untested
- Three findings against the console callback, and the author says all three are wrong
- Token exchange fails three times in four and we cut over on Friday
- A contractor took the auth suite from red to green in an afternoon
- Security questionnaire on stay-signed-in, and the release branch has to stay green