Run a complete OAuth test environment on your own machine
OAuth failures often look like provider defects even when the real problem is redirect URI construction, callback correlation, PKCE verification, authorization-code handling, or the token exchange. This tutorial builds a runnable Python and Flask reference lab with separate client and mock authorization-server listeners. You will install it, start it, complete a browser flow, run automated positive and negative tests, and optionally publish only the client callback through a Localtonet HTTP tunnel. The broader test matrix remains applicable when you replace the reference client or mock server with your own application.
๐ What's in this guide
What the runnable OAuth and PKCE lab contains
A useful OAuth test lab must do more than return a successful token response. It needs to reproduce the security relationships of an authorization code flow and let us violate each relationship independently. The application creates a transaction, sends the browser to an authorization endpoint, receives a callback, validates that callback, and exchanges the code at a token endpoint. The authorization server binds the code to the client, redirect URI, PKCE challenge, lifetime, and one-time-use status.
The reference project in this guide uses Python 3 and Flask. It runs two independent HTTP listeners from one source file:
- Client application on port 5000: provides
/login,/callback,/success, and/healthz. - Mock authorization server on port 9000: provides
/authorize,/token, and/healthz.
Keeping the listeners separate matters when Localtonet is added. The HTTP tunnel can target the client on port 5000 without also publishing the mock authorization server and token endpoint on port 9000.
How PKCE binds the code exchange
PKCE, or Proof Key for Code Exchange, adds a per-transaction secret called the code_verifier. The client derives a code_challenge from that verifier and places the challenge in the authorization request. During the token exchange, the client sends the original verifier. The authorization server derives the challenge again and rejects the exchange if it does not match the challenge associated with the code.
For the S256 method, the challenge is the unpadded base64url encoding of the SHA-256 digest of the verifier's ASCII bytes. RFC 7636 defines the verifier syntax, the 43 to 128 character length range, and the S256 transformation in RFC 7636 Sections 4.1 through 4.6. The reference implementation generates 32 cryptographically secure random bytes and base64url-encodes them without padding, producing a 43-character verifier.
Callback CSRF protection: state and PKCE
OAuth clients must prevent callback cross-site request forgery. A random state value associated with a browser transaction is a common and valid defense. However, the categorical statement that PKCE can never replace state is too broad. RFC 9700 explains that PKCE can provide protection against authorization-response injection and callback CSRF when the authorization server supports PKCE, the client uses it correctly, the verifier is securely bound to the initiating transaction, and the client can determine that PKCE protection was applied. See the callback and PKCE guidance in
RFC 9700 Section 4.7.
This lab deliberately uses both controls. State is the transaction lookup key and provides explicit callback correlation. PKCE binds the resulting code to the verifier stored in that transaction. Retaining state is useful when the application already uses it for transaction correlation, concurrent login attempts, post-login return paths, or compatibility with deployments where PKCE alone is not the selected CSRF defense.
Preventing callback CSRF is the security requirement. Using both state and PKCE is the policy chosen by this lab. An application may rely on PKCE for that defense only when the RFC 9700 conditions are satisfied. Do not remove state from an existing integration merely because the authorization server accepts a code challenge.
OAuth is not automatically OpenID Connect
OAuth grants access to protected resources. OpenID Connect adds an identity layer, including an ID token, issuer metadata, signing keys, nonce processing, and identity claims. An OAuth token response does not have to include an id_token. The distinction is defined by the
OpenID Connect Core specification.
This reference lab returns an opaque synthetic access token and does not implement OpenID Connect. If the application under test requires provider discovery, an ID token, nonce validation, a JSON Web Key Set, issuer checks, audience checks, or signature validation, add a separate OpenID Connect test layer rather than treating those fields as mandatory OAuth behavior.
Prerequisites and safe lab boundaries
Use this project only with synthetic clients, users, authorization codes, and tokens. It is an intentionally small protocol test harness, not a production authorization server. Its in-memory records disappear at shutdown, it has no user authentication system, and its approval page represents a deterministic test decision rather than real consent.
Required software
- Python 3.11 or newer: includes the standard-library cryptography primitives, URL handling, and HTTP client features used by the project.
- pip and the Python virtual-environment module: used to isolate Flask and pytest.
- Flask 3.x: supplies the two local web applications.
- pytest 8.x: runs the positive and negative integration tests.
- A current browser: used for the interactive redirect and approval path.
- Optional Localtonet client: install it on the device that can reach
127.0.0.1:5000when a public HTTPS callback is needed.
Confirm the Python runtime before continuing:
python --version
python -m pip --version
On systems where Python 3 is invoked as python3, use that command consistently in the remaining examples.
Synthetic client configuration
| Setting | Reference value | Purpose |
|---|---|---|
| Client identifier | lab-public-client |
A non-secret identifier shared by the client and mock authorization server. |
| Client type | Public | No client secret is created or sent. |
| Local redirect URI | http://127.0.0.1:5000/callback |
Exact callback used by the local browser flow. |
| Authorization endpoint | http://127.0.0.1:9000/authorize |
Browser-facing mock endpoint. |
| Token endpoint | http://127.0.0.1:9000/token |
Direct code exchange performed by the client application. |
| Scope | read:profile |
A synthetic OAuth scope with no identity-token meaning. |
A public client cannot safely hold a client secret. The reference client therefore sends its client identifier and PKCE verifier but no secret. A confidential server-side client can authenticate to the token endpoint using its registered method, but PKCE does not cancel that separate authentication requirement.
Keep production client secrets, real user accounts, reusable refresh tokens, and customer data out of the project. Do not expose development exception pages, database consoles, unrelated administrative routes, or a real provider's credentials through the public callback listener.
Build the runnable Python reference project
Create a new directory containing one implementation file, one dependency file, and one test file:
oauth-pkce-lab/
โโโ lab.py
โโโ requirements.txt
โโโ test_lab.py
1. Create and activate a virtual environment
On macOS or Linux:
mkdir oauth-pkce-lab
cd oauth-pkce-lab
python3 -m venv .venv
source .venv/bin/activate
On Windows PowerShell:
mkdir oauth-pkce-lab
cd oauth-pkce-lab
py -3.11 -m venv .venv
.venv\Scripts\Activate.ps1
2. Add the dependencies
Create requirements.txt:
Flask>=3.0,<4
pytest>=8.0,<9
Install the packages:
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
3. Implement the client and mock authorization server
Create lab.py with the complete implementation below. The configuration can be overridden with environment variables, but the local defaults run without editing the source.
import base64
import hashlib
import html
import json
import os
import secrets
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from flask import Flask, jsonify, redirect, request
from werkzeug.serving import make_server
CLIENT_ID = "lab-public-client"
CLIENT_BASE_URL = os.environ.get(
"CLIENT_BASE_URL", "http://127.0.0.1:5000"
).rstrip("/")
AUTH_BASE_URL = os.environ.get(
"AUTH_BASE_URL", "http://127.0.0.1:9000"
).rstrip("/")
REDIRECT_URI = os.environ.get(
"REDIRECT_URI", f"{CLIENT_BASE_URL}/callback"
)
SCOPE = "read:profile"
TRANSACTION_TTL = 300
CODE_TTL = 120
client_app = Flask("oauth-client")
auth_app = Flask("mock-authorization-server")
transactions = {}
authorization_codes = {}
def base64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def new_random_value():
return base64url(secrets.token_bytes(32))
def pkce_challenge(verifier):
digest = hashlib.sha256(verifier.encode("ascii")).digest()
return base64url(digest)
def now():
return int(time.time())
def exact_registered_redirect(candidate):
return candidate == REDIRECT_URI
def oauth_error(error, description, status=400):
return jsonify({
"error": error,
"error_description": description
}), status
@client_app.get("/healthz")
def client_health():
return jsonify({"service": "client", "status": "ok"})
@client_app.get("/")
def client_home():
safe_redirect = html.escape(REDIRECT_URI)
return (
"<h2>OAuth Authorization Code and PKCE Lab</h2>"
"<p>Configured callback: <code>"
f"{safe_redirect}"
"</code></p>"
'<p><a href="/login">Start test login</a></p>'
)
@client_app.get("/login")
def login():
state = new_random_value()
verifier = new_random_value()
transactions[state] = {
"verifier": verifier,
"redirect_uri": REDIRECT_URI,
"created_at": now(),
"consumed": False,
}
query = urllib.parse.urlencode({
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": SCOPE,
"state": state,
"code_challenge": pkce_challenge(verifier),
"code_challenge_method": "S256",
})
return redirect(f"{AUTH_BASE_URL}/authorize?{query}", code=302)
@client_app.get("/callback")
def callback():
state = request.args.get("state", "")
transaction = transactions.get(state)
if not transaction:
return "Unknown or missing OAuth transaction.", 400
if transaction["consumed"]:
return "OAuth transaction has already been consumed.", 400
if now() - transaction["created_at"] > TRANSACTION_TTL:
transactions.pop(state, None)
return "OAuth transaction expired.", 400
if request.args.get("error"):
transaction["consumed"] = True
return "Authorization was denied by the test user.", 400
code = request.args.get("code", "")
if not code:
return "Authorization code is missing.", 400
form = urllib.parse.urlencode({
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"redirect_uri": transaction["redirect_uri"],
"code_verifier": transaction["verifier"],
}).encode("ascii")
token_request = urllib.request.Request(
f"{AUTH_BASE_URL}/token",
data=form,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
try:
with urllib.request.urlopen(token_request, timeout=5) as response:
token_response = json.loads(response.read().decode("utf-8"))
except urllib.error.HTTPError as error:
safe_status = error.code
return f"Token exchange failed with HTTP {safe_status}.", 502
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
return "Token exchange failed safely.", 502
if token_response.get("token_type") != "Bearer":
return "Unexpected token response.", 502
if not token_response.get("access_token"):
return "Access token is missing.", 502
transaction["consumed"] = True
transaction["verifier"] = None
return redirect("/success", code=302)
@client_app.get("/success")
def success():
return (
"<h2>OAuth lab success</h2>"
"<p>The callback, state correlation, PKCE proof, "
"and token exchange succeeded.</p>"
)
@auth_app.get("/healthz")
def auth_health():
return jsonify({
"service": "mock-authorization-server",
"status": "ok"
})
@auth_app.get("/authorize")
def authorize():
response_type = request.args.get("response_type", "")
client_id = request.args.get("client_id", "")
redirect_uri = request.args.get("redirect_uri", "")
state = request.args.get("state", "")
challenge = request.args.get("code_challenge", "")
method = request.args.get("code_challenge_method", "")
scope = request.args.get("scope", "")
decision = request.args.get("decision", "")
if client_id != CLIENT_ID:
return "Unknown client.", 400
if not exact_registered_redirect(redirect_uri):
return "Unregistered redirect URI.", 400
if response_type != "code":
return "Unsupported response type.", 400
if method != "S256":
return "S256 PKCE is required.", 400
if not challenge or len(challenge) != 43:
return "Invalid PKCE challenge.", 400
if not state:
return "State is required by this lab policy.", 400
if decision == "deny":
query = urllib.parse.urlencode({
"error": "access_denied",
"state": state,
})
return redirect(f"{redirect_uri}?{query}", code=302)
if decision == "approve":
code = new_random_value()
authorization_codes[code] = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"challenge": challenge,
"scope": scope,
"created_at": now(),
"consumed": False,
}
query = urllib.parse.urlencode({
"code": code,
"state": state,
})
return redirect(f"{redirect_uri}?{query}", code=302)
approve_query = request.args.to_dict(flat=True)
approve_query["decision"] = "approve"
deny_query = request.args.to_dict(flat=True)
deny_query["decision"] = "deny"
approve_url = "/authorize?" + urllib.parse.urlencode(approve_query)
deny_url = "/authorize?" + urllib.parse.urlencode(deny_query)
return (
"<h2>Synthetic authorization decision</h2>"
"<p>Client: <code>"
f"{html.escape(client_id)}"
"</code></p>"
"<p>Scope: <code>"
f"{html.escape(scope)}"
"</code></p>"
f'<p><a href="{html.escape(approve_url)}">Approve</a></p>'
f'<p><a href="{html.escape(deny_url)}">Deny</a></p>'
)
@auth_app.post("/token")
def token():
grant_type = request.form.get("grant_type", "")
client_id = request.form.get("client_id", "")
code = request.form.get("code", "")
redirect_uri = request.form.get("redirect_uri", "")
verifier = request.form.get("code_verifier", "")
if grant_type != "authorization_code":
return oauth_error(
"unsupported_grant_type",
"authorization_code is required"
)
record = authorization_codes.get(code)
if not record:
return oauth_error("invalid_grant", "unknown authorization code")
if record["consumed"]:
return oauth_error("invalid_grant", "authorization code was used")
if now() - record["created_at"] > CODE_TTL:
return oauth_error("invalid_grant", "authorization code expired")
if client_id != record["client_id"]:
return oauth_error("invalid_grant", "client binding failed")
if redirect_uri != record["redirect_uri"]:
return oauth_error("invalid_grant", "redirect binding failed")
if not verifier:
return oauth_error("invalid_grant", "code verifier is required")
derived = pkce_challenge(verifier)
if not secrets.compare_digest(derived, record["challenge"]):
return oauth_error("invalid_grant", "PKCE verification failed")
# Consume before creating the response so a second exchange cannot pass.
record["consumed"] = True
return jsonify({
"access_token": "lab_at_" + new_random_value(),
"token_type": "Bearer",
"expires_in": 300,
"scope": record["scope"],
})
def run():
client_server = make_server("127.0.0.1", 5000, client_app)
auth_server = make_server("127.0.0.1", 9000, auth_app)
client_thread = threading.Thread(
target=client_server.serve_forever,
daemon=True
)
auth_thread = threading.Thread(
target=auth_server.serve_forever,
daemon=True
)
client_thread.start()
auth_thread.start()
print(f"Client: {CLIENT_BASE_URL}")
print(f"Authorization server: {AUTH_BASE_URL}")
print(f"Registered redirect URI: {REDIRECT_URI}")
print("Press Ctrl+C to stop both services.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
client_server.shutdown()
auth_server.shutdown()
if __name__ == "__main__":
run()
What the implementation enforces
The mock compares the complete submitted redirect URI with the single registered value. OAuth requires the token request's redirect URI to be identical to the value used in the authorization request when that parameter was included. See RFC 6749 Section 4.1.3. Authorization servers also need strict registered redirect comparison rather than prefix or substring matching. Current security guidance appears in RFC 9700 Section 4.1.
The token endpoint validates the grant type, code, client binding, redirect binding, expiry, one-time status, and PKCE proof. It marks the code consumed before creating the token response. Authorization codes must be short-lived and single-use according to RFC 6749 Section 4.1.2. The exact 120-second code lifetime and five-minute transaction lifetime are lab policy, not universal OAuth constants.
| Behavior | Category | Meaning in this project |
|---|---|---|
| Authorization codes are single-use | OAuth requirement | A successful token exchange consumes the code. |
| Redirect binding is enforced | OAuth requirement | The authorization and token stages must use the registered value. |
| S256 is required | Modern security policy | The mock rejects a missing method or a method other than S256. |
| State is required | Lab policy | State is used as an explicit transaction-correlation mechanism. |
| Code lifetime is 120 seconds | Lab policy | The short duration makes expiry behavior practical to test. |
| Tokens are opaque synthetic strings | Lab design | The project tests OAuth mechanics without implying OpenID Connect or JWT support. |
Start and verify the complete flow locally
Start both listeners
With the virtual environment active, run:
python lab.py
The process should report the client, authorization server, and registered callback:
Client: http://127.0.0.1:5000
Authorization server: http://127.0.0.1:9000
Registered redirect URI: http://127.0.0.1:5000/callback
Press Ctrl+C to stop both services.
Check the local endpoints
In a second terminal, request both health endpoints:
curl http://127.0.0.1:5000/healthz
curl http://127.0.0.1:9000/healthz
Each endpoint should return a JSON object with "status":"ok". If either request fails, resolve the listener or port problem before testing redirects.
Complete the browser flow
Open the client application
Visit http://127.0.0.1:5000 in a browser and select Start test login.
Inspect the authorization request
The browser moves to port 9000. Confirm that the URL contains response_type=code, the synthetic client identifier, the exact callback, state, a code challenge, and code_challenge_method=S256. The verifier must not appear in the URL.
Approve the synthetic request
Select Approve. The mock creates a bound authorization code and redirects the browser to the client callback.
Observe the server-side token exchange
The callback locates the transaction by state and sends the code, client identifier, redirect URI, and original verifier to the token endpoint. The token endpoint recomputes the challenge before issuing a synthetic access token.
Confirm completion
The browser ends at /success. This confirms that callback correlation, PKCE verification, redirect binding, code consumption, and token-response validation completed.
Repeat the flow and select Deny. The callback should return a controlled denial message and must not contact the token endpoint. Start two login attempts and compare their URLs. Both state and code challenge should differ.
Inspect the redirects and terminal requests during development. A success page is meaningful only if state was resolved before the exchange, the token endpoint checked every binding, and the code was consumed exactly once.
Add executable positive and negative tests
The tests below start the lab in a subprocess, issue real HTTP requests to both listeners, and stop the process after the test session. They do not require browser automation because they follow the same redirects programmatically.
Create test_lab.py:
import base64
import hashlib
import json
import os
import secrets
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import pytest
CLIENT = "http://127.0.0.1:5000"
AUTH = "http://127.0.0.1:9000"
CLIENT_ID = "lab-public-client"
REDIRECT_URI = f"{CLIENT}/callback"
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
NO_REDIRECT = urllib.request.build_opener(NoRedirect)
def request(url, data=None):
req = urllib.request.Request(url, data=data)
try:
with NO_REDIRECT.open(req, timeout=5) as response:
return response.status, response.headers, response.read()
except urllib.error.HTTPError as error:
return error.code, error.headers, error.read()
def wait_until_ready():
deadline = time.time() + 10
while time.time() < deadline:
try:
with urllib.request.urlopen(
f"{CLIENT}/healthz", timeout=1
) as response:
if response.status == 200:
return
except urllib.error.URLError:
time.sleep(0.1)
raise RuntimeError("Lab did not start")
@pytest.fixture(scope="session", autouse=True)
def running_lab():
env = os.environ.copy()
env.pop("REDIRECT_URI", None)
process = subprocess.Popen(
[sys.executable, "lab.py"],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
wait_until_ready()
yield
process.terminate()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
def base64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def verifier_and_challenge():
verifier = base64url(secrets.token_bytes(32))
challenge = base64url(
hashlib.sha256(verifier.encode("ascii")).digest()
)
return verifier, challenge
def direct_authorization(verifier=None, redirect_uri=REDIRECT_URI):
if verifier is None:
verifier, challenge = verifier_and_challenge()
else:
challenge = base64url(
hashlib.sha256(verifier.encode("ascii")).digest()
)
state = base64url(secrets.token_bytes(32))
query = urllib.parse.urlencode({
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": "read:profile",
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"decision": "approve",
})
status, headers, body = request(f"{AUTH}/authorize?{query}")
return status, headers, body, verifier, state
def post_token(code, verifier, redirect_uri=REDIRECT_URI):
form = urllib.parse.urlencode({
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
}).encode("ascii")
return request(f"{AUTH}/token", data=form)
def test_complete_browser_style_flow_succeeds():
status, headers, _ = request(f"{CLIENT}/login")
assert status == 302
authorization_url = headers["Location"]
separator = "&" if "?" in authorization_url else "?"
status, headers, _ = request(
authorization_url + separator + "decision=approve"
)
assert status == 302
callback_url = headers["Location"]
status, headers, _ = request(callback_url)
assert status == 302
assert headers["Location"] == "/success"
status, _, body = request(f"{CLIENT}/success")
assert status == 200
assert b"OAuth lab success" in body
def test_wrong_state_is_rejected_before_exchange():
status, headers, _ = request(f"{CLIENT}/login")
assert status == 302
authorization_url = headers["Location"] + "&decision=approve"
status, headers, _ = request(authorization_url)
assert status == 302
callback = urllib.parse.urlparse(headers["Location"])
params = urllib.parse.parse_qs(callback.query)
params["state"] = ["tampered-state"]
tampered = urllib.parse.urlunparse(
callback._replace(query=urllib.parse.urlencode(params, doseq=True))
)
status, _, body = request(tampered)
assert status == 400
assert b"Unknown or missing OAuth transaction" in body
def test_wrong_verifier_is_rejected():
status, headers, _, verifier, _ = direct_authorization()
assert status == 302
code = urllib.parse.parse_qs(
urllib.parse.urlparse(headers["Location"]).query
)["code"][0]
wrong_verifier = base64url(secrets.token_bytes(32))
assert wrong_verifier != verifier
status, _, body = post_token(code, wrong_verifier)
assert status == 400
payload = json.loads(body)
assert payload["error"] == "invalid_grant"
def test_authorization_code_replay_is_rejected():
status, headers, _, verifier, _ = direct_authorization()
assert status == 302
code = urllib.parse.parse_qs(
urllib.parse.urlparse(headers["Location"]).query
)["code"][0]
first_status, _, first_body = post_token(code, verifier)
assert first_status == 200
assert json.loads(first_body)["token_type"] == "Bearer"
second_status, _, second_body = post_token(code, verifier)
assert second_status == 400
assert json.loads(second_body)["error"] == "invalid_grant"
def test_unregistered_redirect_is_rejected_without_redirecting():
bad_redirect = "http://127.0.0.1:5000/not-registered"
status, headers, body, _, _ = direct_authorization(
redirect_uri=bad_redirect
)
assert status == 400
assert "Location" not in headers
assert b"Unregistered redirect URI" in body
Run the suite while no separate copy of lab.py is using ports 5000 or 9000:
python -m pytest -v
The expected result is five passing tests. The positive case proves that the client and authorization server can complete the real redirect and token path. The negative cases prove that rejection occurs at the intended boundary rather than merely causing an unrelated downstream error.
Extend the OAuth and PKCE failure matrix
The supplied tests cover the central runnable path, but a production application should have a broader matrix. Generate a fresh transaction and code for every case. Assert that no authenticated session or usable token is created, not only that an HTTP error occurred.
| Test case | Expected rejection point | Required assertion |
|---|---|---|
| Missing or unknown state | Client callback | No token request and no application session. |
| Expired transaction | Client callback | A fresh login is required. |
| Unregistered redirect URI | Authorization endpoint | No code and no redirect to the submitted destination. |
| Changed redirect during exchange | Token endpoint | No token is issued. |
| Missing PKCE challenge | Authorization endpoint | The PKCE-required client cannot begin a downgraded flow. |
| Method other than S256 | Authorization endpoint | The mock rejects the request instead of silently changing methods. |
| Missing or incorrect verifier | Token endpoint | The response is invalid_grant and contains no token. |
| Expired code | Token endpoint | The application handles the error without creating a partial session. |
| Sequential or concurrent replay | Token endpoint | Only one exchange can succeed. |
| Code issued to another client | Token endpoint | Client binding prevents exchange. |
| Authorization denial | Client callback | No token request and a non-sensitive user message. |
| Malformed token JSON | Client token parser | No partial session and no secret-bearing diagnostics. |
| Token endpoint timeout | Client exchange layer | The failure is bounded and does not trigger uncontrolled replay. |
Test state as transaction correlation
A string comparison alone does not describe the full transaction lifecycle. Test a state from an expired attempt, a completed attempt, and a different browser context. Test two simultaneous attempts completed in reverse order. If the application permits only one active attempt, verify that it invalidates the earlier transaction deliberately instead of overwriting it accidentally.
If an application relies on PKCE as its callback CSRF defense under RFC 9700 rather than state, its tests must prove the required binding. The verifier must be unique, confidential to the client transaction, and unavailable to an attacker injecting a code. The client must also reject flows where the expected PKCE protection was not applied.
Test exact redirect comparison
Exercise differences in scheme, hostname, port, path, trailing slash, query string, and encoding. Do not use wildcard, prefix, or substring matching. If the authorization request contains an unregistered destination, the authorization server should display a local error rather than redirecting to that destination with protocol data.
Test code consumption under concurrency
The reference process handles requests through the local Werkzeug server and marks the code consumed before returning a token. A production server with multiple workers or instances needs an atomic database transaction, compare-and-set operation, or equivalent synchronization. Send two exchanges simultaneously and assert that no more than one succeeds.
Add a public HTTPS callback with Localtonet
Add a tunnel only after the local flow and automated suite pass. With Localtonet, the client application on your device establishes an outbound connection to our relay. The HTTP tunnel supplies a public HTTPS address for the local web listener without inbound router port forwarding, firewall changes, a VPN, or a public IP address.
This lab must be restarted with the exact public callback URI because the client and mock registration are derived from the REDIRECT_URI environment variable. The mock authorization endpoint can remain local on port 9000 when the browser is running on the same machine. Only the client listener on port 5000 is the Localtonet target.
Install and run the Localtonet client
Install the Localtonet application for your operating system on the device running this lab, then launch it. Use the current installation workflow for that operating system because packaging can change.
Authenticate or select the device
Use the device-specific authentication token through the supported Localtonet workflow and select the device that will run the tunnel. Do not place the token in this project, terminal captures, screenshots, or logs.
Select an available relay server
Choose a relay server or region from the values currently shown in the dashboard. Do not hardcode a server code from another account or environment.
Create an HTTP tunnel to the client listener
Select the appropriate HTTP process type and set the local target to the IP address and port used by the client application, which is 127.0.0.1:5000 in this reference project. Do not target port 9000 unless the mock authorization server must intentionally be public.
Start the tunnel
Creating the configuration does not make it active. Press Start and confirm that the selected device is connected and the tunnel is running.
Configure and verify the public callback
Copy the assigned public HTTPS address, append /callback, restart the lab with that exact URI in REDIRECT_URI, and begin a new login transaction. Verify the public health route before completing the OAuth flow.
For current dashboard fields, process-type availability, client installation details, and server choices, use our Localtonet HTTP tunnel documentation.
Restart the lab with the public callback
Stop the local process with Ctrl+C. Replace the sample hostname below with the actual HTTPS hostname assigned to your tunnel.
On macOS or Linux:
REDIRECT_URI="https://your-assigned-hostname.example/callback" python lab.py
On Windows PowerShell:
$env:REDIRECT_URI = "https://your-assigned-hostname.example/callback"
python lab.py
The hostname in these commands is a placeholder, not a Localtonet domain claim. Use the exact address displayed for your running tunnel.
Verify the public route before OAuth
Request the public health path:
curl https://your-assigned-hostname.example/healthz
Confirm that it reaches the client service and returns the expected JSON. Then open http://127.0.0.1:5000/login. The authorization request should now contain the public HTTPS callback. Approving the request sends the browser through the Localtonet URL and back to the local client listener.
The tunnel publishes every route served on port 5000, not only the conceptual callback. This reference listener contains only lab routes, but you should still use synthetic data and stop or delete the tunnel when testing ends. Do not combine it with debug consoles, administrative interfaces, or unrelated applications.
Operate, log, and shut down the lab safely
Log outcomes instead of credentials
Callback query strings can contain authorization codes and state. Token request bodies can contain codes, verifiers, refresh tokens, and client credentials. Token responses can contain bearer tokens and identity data. Avoid request-body and query-string logging on these routes.
| Data | Safe diagnostic | Avoid |
|---|---|---|
| State | Transaction status or a short one-way fingerprint | Full state in access logs |
| Authorization code | Issued, accepted, expired, or replayed status | The code or complete callback URL |
| PKCE verifier | Presence and validation result | Raw verifier or token request body |
| Access token | Token type and expected metadata | Complete bearer token |
| Redirect URI | Known configuration value without query secrets | Blind logging of the incoming callback URL |
Keep tests independent
Generate fresh state, verifier, challenge, and code values for every test. Change only the field named by the negative case. A wrong-verifier test that also changes the redirect URI cannot prove which control rejected the request.
The in-memory stores are appropriate for an isolated process, but production tests may run in parallel across workers. Namespace records by test run or reset the mock between cases. Never share one fixed authorization code across a suite.
Shut down cleanly
Stop the browser test, press Ctrl+C in the terminal running lab.py, and let both listeners close. If pytest started the process, its session fixture terminates it automatically. Confirm that ports 5000 and 9000 are no longer listening.
Stop or delete the Localtonet tunnel after public testing. A tunnel remains available only while the selected client device is connected and the tunnel is running, but closing the browser does not stop public access. Remove temporary callback registrations from any shared provider configuration and rotate any credential accidentally captured in logs.
Troubleshooting implementation and callback failures
Python cannot import Flask or pytest
Confirm that the virtual environment is active and that package installation used the same Python interpreter that runs the lab:
python -m pip show Flask
python -m pip show pytest
python -c "import flask; print(flask.__version__)"
If the environment was created with another interpreter, remove .venv, recreate it with Python 3.11 or newer, and reinstall requirements.txt.
Port 5000 or 9000 is already in use
Stop the conflicting development server before starting the lab. The provided startup function binds fixed ports, and the automated tests expect those same values. If you change the ports, update CLIENT_BASE_URL, AUTH_BASE_URL, the server bindings in run(), and the test constants together.
The authorization endpoint reports an unregistered redirect
Compare the configured REDIRECT_URI with the decoded request parameter character for character. Check scheme, hostname, port, path, trailing slash, and query string. Restart the process after changing the environment because registration is read at startup.
The browser reaches the callback but the token exchange fails
Confirm that port 9000 is still running and reachable from the client process. Check the grant type, code, client identifier, redirect URI, and verifier. A successful browser callback does not prove that the server-to-server request is correct.
If the failure follows a long pause on the approval page, the code may have passed its 120-second lab lifetime. Begin a new flow rather than reusing the old callback URL.
PKCE succeeds with a fixed value but fails with generated values
Verify that the input to SHA-256 is the verifier's ASCII byte sequence and that the digest is encoded with unpadded base64url. Standard base64 can contain +, /, and =, which are not the required S256 challenge representation.
Also make sure the verifier is retrieved from the transaction associated with the returned state. A valid verifier from another login attempt must fail against the current code.
The public health route works but OAuth returns a redirect mismatch
A working tunnel proves routing, not OAuth configuration. Restart the lab with the exact public HTTPS callback in REDIRECT_URI, then start a completely new login. An authorization code issued for the local callback cannot be repaired by changing the redirect during exchange.
The public callback cannot find the transaction
Confirm that the same lab.py process created the login transaction and receives the callback. Restarting the process clears the in-memory store. Start the tunnel, configure the public URI, restart the lab once, and then begin a fresh login.
A replayed authorization code succeeds
The code check and consumption update must be atomic in a concurrent deployment. The reference project demonstrates the required order, but a multi-worker implementation needs shared transactional storage. Add a concurrent test rather than relying only on sequential replay.
Frequently asked questions
Can PKCE replace the OAuth state parameter?
In qualifying deployments, PKCE can provide the callback CSRF defense when the authorization server supports PKCE, the verifier is securely bound to the initiating transaction, and the client can determine that PKCE protection was applied. State remains a valid and common transaction-correlation mechanism. This lab retains both so state identifies the transaction and PKCE binds the code exchange.
Does this public client need a client secret?
No. A public client cannot safely protect a client secret, so the reference project does not create one. It uses PKCE. A confidential client should use its registered token-endpoint authentication method in addition to any required PKCE processing.
Why are the client and authorization server on different ports?
Separate listeners make the browser redirect boundary and direct token boundary visible. They also let a Localtonet tunnel publish only the client listener on port 5000 while the mock authorization server remains local on port 9000.
Must the token request use the same redirect URI?
When the redirect URI was included in the authorization request, the token exchange must use the identical value as required by the authorization code flow. The reference mock stores that value with the code and rejects a mismatch.
Can I reuse an authorization code in several tests?
No. Each test should receive a fresh code bound to its client, redirect URI, PKCE challenge, and lifetime. A successfully exchanged code must not work again. Reusing a fixed code prevents meaningful replay testing.
Is an ID token part of this lab?
No. An ID token belongs to OpenID Connect and is not required in an OAuth token response. This lab returns an opaque access token. Add discovery, issuer, audience, signature, nonce, and claim tests only when the application also implements OpenID Connect.
Do I need to expose the mock authorization server through Localtonet?
Usually not. The browser and client can reach the mock locally in this project. Target port 5000 so only the client listener receives the public HTTPS route. Expose port 9000 only if a remote participant explicitly needs the mock and you have reviewed the additional attack surface.
Does creating a Localtonet tunnel make it active?
No. Creating the configuration does not start it. Use the Start button, keep the selected client device connected, verify the public route, and stop or delete the tunnel after testing.
Test your OAuth callback with Localtonet
Build and verify the authorization code and PKCE flow locally first. When the callback needs a public HTTPS address, start a Localtonet HTTP tunnel to the isolated client listener, register the exact callback URI, run the positive and negative tests, and stop the tunnel when the session is complete.
Get Started Free โ