31 min read

Build an OAuth Authorization Code and PKCE Test Lab

Create a provider-agnostic OAuth test lab for PKCE, state, redirect URIs, token exchange failures, and public HTTPS callbacks with Localtonet.

Developer Guides ยท OAuth PKCE Testing ยท Localtonet ยท 2026

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.

๐Ÿ”’ S256 PKCE, callback CSRF protection, and single-use codes ๐ŸŒ Local and public HTTPS callback testing โšก Runnable browser flow and automated failure cases

What the runnable OAuth and PKCE lab contains

OAuth authorization code lab showing browser redirects, PKCE validation, and token exchange.
The lab separates browser-facing authorization redirects from the direct token exchange.

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.

๐Ÿงญ Authorization request Checks the response type, client identifier, exact redirect URI, state, PKCE challenge, and S256 method.
๐Ÿ” Browser callback Correlates the returned state with a live transaction before exchanging the authorization code.
๐Ÿ” PKCE exchange Recomputes the S256 challenge from the submitted verifier and rejects a missing or incorrect proof.
๐ŸŽŸ๏ธ Code lifecycle Binds each code to a client and redirect URI, applies an expiry, and consumes it before issuing tokens.
๐Ÿงช Executable failures Includes automated tests for state mismatch, redirect mismatch, an incorrect verifier, and code replay.
๐ŸŒ Public callback option Uses a Localtonet HTTP tunnel only after the complete flow works locally.

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.

Provider-agnostic OAuth PKCE sequence from verifier generation through token exchange.
The verifier remains in client-side transaction storage until the direct token request.

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.

Normative requirement versus lab policy

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:5000 when 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.

Do not adapt this lab with production credentials

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

1

Open the client application

Visit http://127.0.0.1:5000 in a browser and select Start test login.

2

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.

3

Approve the synthetic request

Select Approve. The mock creates a bound authorization code and redirects the browser to the client callback.

4

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.

5

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.

Do not judge the flow only by the final page

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

OAuth failure matrix comparing valid requests with state, redirect, verifier, and code errors.
Change one input at a time so each rejection proves a specific validation rule.

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

Public OAuth HTTPS callback routed through Localtonet to a localhost callback service.
The Localtonet HTTP tunnel forwards the public callback to the client listener while the mock token endpoint remains local.

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.

1

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.

2

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.

3

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.

4

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.

5

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.

6

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 exposes the selected 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 โ†’

Corrections & updates

Substantive changes approved by the Localtonet editorial team are listed transparently below.

Remove the outer article wrapper and place the hero first, followed immediately by the existing clickable guide navigation. Move the lead architecture figure into the architecture section or directly after the guide card. Convert the conceptual lab into a reproducible reference project by choosing a documented language and maintained implementation path; listing runtime, package, browser, and Localtonet prerequisites; providing dependency installation, file layout, complete configuration, safe synthetic client data, startup commands

Localtonet is a secure multi-protocol tunneling and proxy platform designed to expose localhost, devices, private services, and AI agents to the public internet supporting HTTP/HTTPS tunnels, TCP/UDP forwarding, mobile proxy infrastructure, file server publishing, latency-optimized game connectivity, and developer-ready AI agent endpoint exposure from a single unified control plane.

support