oauth2-debugger 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yike Xiao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,111 @@
1
+ Metadata-Version: 2.5
2
+ Name: oauth2-debugger
3
+ Version: 0.0.1
4
+ Summary: Local debugger for the OAuth2/OIDC Authorization Code flow (state, scopes, PKCE)
5
+ Project-URL: Homepage, https://github.com/Shawyeok/oauth2-debugger
6
+ Project-URL: Repository, https://github.com/Shawyeok/oauth2-debugger
7
+ Author-email: Yike Xiao <kmter@live.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Python: >=3.9
11
+ Provides-Extra: verify
12
+ Requires-Dist: cryptography>=41; extra == 'verify'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # oauth2-debugger
16
+
17
+ A small, dependency-free local web app for manually driving an OAuth2/OIDC
18
+ Authorization Code (+ PKCE) flow end-to-end against a real authorization server.
19
+ Client ID, scope, issuer, and PKCE method are all configured on the page itself —
20
+ no flags to memorize for a one-off test.
21
+
22
+ A generic RFC 6749 / RFC 7636 client — point it at any OIDC-compliant
23
+ authorization server via `--issuer` (or fill it in on the page itself).
24
+
25
+ ## Quick start
26
+
27
+ ```bash
28
+ uvx oauth2-debugger
29
+ ```
30
+
31
+ Or from a checkout:
32
+
33
+ ```bash
34
+ cd oauth2-debugger
35
+ uv run oauth2-debugger
36
+ ```
37
+
38
+ Or without `uv`:
39
+
40
+ ```bash
41
+ pip install -e .
42
+ oauth2-debugger
43
+ ```
44
+
45
+ Or straight from the source tree with plain stdlib Python, no install:
46
+
47
+ ```bash
48
+ python3 -m oauth2_debugger
49
+ ```
50
+
51
+ This opens `http://127.0.0.1:<port>/` in your browser (an OS-assigned ephemeral
52
+ port by default — printed to the console either way). Fill in the client_id and
53
+ scope, click **Start Authorization**, approve consent if prompted, and you land
54
+ back on the same page with the full token response and decoded/verified claims.
55
+
56
+ ## What it does
57
+
58
+ 1. **Form page** (`GET /`): issuer, client_id, scope, PKCE method, endpoint
59
+ overrides, extra authorize-request params — all editable, pre-filled from
60
+ whatever you ran last (and from the `--client-id`/`--scope`/etc. CLI flags on
61
+ first load).
62
+ 2. **`POST /start`**: discovers the authorize/token/jwks endpoints from
63
+ `<issuer>/.well-known/openid-configuration` (unless you filled in the endpoint
64
+ overrides), generates a random `state` and a PKCE verifier/challenge pair,
65
+ remembers them server-side keyed by `state`, and 302s your browser to the real
66
+ authorization server.
67
+ 3. The redirect_uri is always `http://127.0.0.1:<this server's port>/callback` —
68
+ this relies on the authorization server implementing RFC 8252 §7.3 loopback
69
+ redirect matching (any port accepted as long as host + path match what's
70
+ registered; Spring Authorization Server does this). So the *registered* client
71
+ only ever needs `http://127.0.0.1/callback` on file, regardless of which port
72
+ this tool happens to bind on a given run.
73
+ 4. You approve consent (or it's skipped if already granted for that
74
+ client+account+scope set) and land back on **`GET /callback`**, which validates
75
+ the returned `state` against what was stored for it (rejects unknown/reused/
76
+ expired state — pending flows are kept for 15 minutes), extracts `code`, and
77
+ exchanges it at the token endpoint (with `code_verifier` if PKCE is enabled).
78
+ 5. **Results page**: full token response JSON, each of `access_token`/`id_token`
79
+ decoded and displayed with a copy button, and — if "verify JWT signatures" is
80
+ on — an inline PASS/FAIL badge from checking the RS256 signature against the
81
+ issuer's published JWKS. `refresh_token` gets its own copy button too.
82
+
83
+ ## CLI flags (seed the form / control the local server)
84
+
85
+ ```
86
+ --host HOST default: 127.0.0.1
87
+ --port N default: 0 (OS-assigned ephemeral, to avoid
88
+ colliding with whatever else is running on your
89
+ machine; pass a fixed port for a bookmarkable URL)
90
+ --issuer URL default: https://auth.example.com
91
+ --client-id ID default form value (still editable on the page)
92
+ --scope "a b c" default form value (default: openid)
93
+ --pkce-method S256|plain|none default form value (default: S256)
94
+ --no-verify-jwks default the verify-signature toggle to off
95
+ --no-browser don't auto-open the form page
96
+ ```
97
+
98
+ ## Registering a test client
99
+
100
+ Point `--issuer` at your own authorization server and register a public
101
+ (no client secret), loopback-redirect client against it:
102
+
103
+ ```
104
+ redirect_uris: http://127.0.0.1/callback (any port accepted per RFC 8252,
105
+ if your server supports it)
106
+ auth method: none (public client, PKCE required)
107
+ grant types: authorization_code, refresh_token
108
+ ```
109
+
110
+ Consult your authorization server's own docs for how to register a client —
111
+ that part is intentionally out of scope for this generic debugging tool.
@@ -0,0 +1,97 @@
1
+ # oauth2-debugger
2
+
3
+ A small, dependency-free local web app for manually driving an OAuth2/OIDC
4
+ Authorization Code (+ PKCE) flow end-to-end against a real authorization server.
5
+ Client ID, scope, issuer, and PKCE method are all configured on the page itself —
6
+ no flags to memorize for a one-off test.
7
+
8
+ A generic RFC 6749 / RFC 7636 client — point it at any OIDC-compliant
9
+ authorization server via `--issuer` (or fill it in on the page itself).
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ uvx oauth2-debugger
15
+ ```
16
+
17
+ Or from a checkout:
18
+
19
+ ```bash
20
+ cd oauth2-debugger
21
+ uv run oauth2-debugger
22
+ ```
23
+
24
+ Or without `uv`:
25
+
26
+ ```bash
27
+ pip install -e .
28
+ oauth2-debugger
29
+ ```
30
+
31
+ Or straight from the source tree with plain stdlib Python, no install:
32
+
33
+ ```bash
34
+ python3 -m oauth2_debugger
35
+ ```
36
+
37
+ This opens `http://127.0.0.1:<port>/` in your browser (an OS-assigned ephemeral
38
+ port by default — printed to the console either way). Fill in the client_id and
39
+ scope, click **Start Authorization**, approve consent if prompted, and you land
40
+ back on the same page with the full token response and decoded/verified claims.
41
+
42
+ ## What it does
43
+
44
+ 1. **Form page** (`GET /`): issuer, client_id, scope, PKCE method, endpoint
45
+ overrides, extra authorize-request params — all editable, pre-filled from
46
+ whatever you ran last (and from the `--client-id`/`--scope`/etc. CLI flags on
47
+ first load).
48
+ 2. **`POST /start`**: discovers the authorize/token/jwks endpoints from
49
+ `<issuer>/.well-known/openid-configuration` (unless you filled in the endpoint
50
+ overrides), generates a random `state` and a PKCE verifier/challenge pair,
51
+ remembers them server-side keyed by `state`, and 302s your browser to the real
52
+ authorization server.
53
+ 3. The redirect_uri is always `http://127.0.0.1:<this server's port>/callback` —
54
+ this relies on the authorization server implementing RFC 8252 §7.3 loopback
55
+ redirect matching (any port accepted as long as host + path match what's
56
+ registered; Spring Authorization Server does this). So the *registered* client
57
+ only ever needs `http://127.0.0.1/callback` on file, regardless of which port
58
+ this tool happens to bind on a given run.
59
+ 4. You approve consent (or it's skipped if already granted for that
60
+ client+account+scope set) and land back on **`GET /callback`**, which validates
61
+ the returned `state` against what was stored for it (rejects unknown/reused/
62
+ expired state — pending flows are kept for 15 minutes), extracts `code`, and
63
+ exchanges it at the token endpoint (with `code_verifier` if PKCE is enabled).
64
+ 5. **Results page**: full token response JSON, each of `access_token`/`id_token`
65
+ decoded and displayed with a copy button, and — if "verify JWT signatures" is
66
+ on — an inline PASS/FAIL badge from checking the RS256 signature against the
67
+ issuer's published JWKS. `refresh_token` gets its own copy button too.
68
+
69
+ ## CLI flags (seed the form / control the local server)
70
+
71
+ ```
72
+ --host HOST default: 127.0.0.1
73
+ --port N default: 0 (OS-assigned ephemeral, to avoid
74
+ colliding with whatever else is running on your
75
+ machine; pass a fixed port for a bookmarkable URL)
76
+ --issuer URL default: https://auth.example.com
77
+ --client-id ID default form value (still editable on the page)
78
+ --scope "a b c" default form value (default: openid)
79
+ --pkce-method S256|plain|none default form value (default: S256)
80
+ --no-verify-jwks default the verify-signature toggle to off
81
+ --no-browser don't auto-open the form page
82
+ ```
83
+
84
+ ## Registering a test client
85
+
86
+ Point `--issuer` at your own authorization server and register a public
87
+ (no client secret), loopback-redirect client against it:
88
+
89
+ ```
90
+ redirect_uris: http://127.0.0.1/callback (any port accepted per RFC 8252,
91
+ if your server supports it)
92
+ auth method: none (public client, PKCE required)
93
+ grant types: authorization_code, refresh_token
94
+ ```
95
+
96
+ Consult your authorization server's own docs for how to register a client —
97
+ that part is intentionally out of scope for this generic debugging tool.
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,73 @@
1
+ """Entry point: starts the local web app for driving OAuth2/OIDC Authorization Code
2
+ (+ PKCE) flows. Client ID, scope, issuer, and PKCE method are all configured on the
3
+ page itself (http://127.0.0.1:<port>/) rather than as required CLI flags -- the flags
4
+ below only seed the form's defaults / control how the local server itself runs.
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from . import webapp
12
+
13
+ DEFAULT_ISSUER = "https://auth.example.com"
14
+
15
+
16
+ def build_arg_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="oauth2-debugger",
19
+ description=(
20
+ "Local web app for driving an OAuth2/OIDC Authorization Code (+ PKCE) flow against a "
21
+ "real authorization server. Configure client_id/scope/issuer on the page it opens."
22
+ ),
23
+ )
24
+ parser.add_argument("--host", default="127.0.0.1", help="Host to bind the local web app to (default: 127.0.0.1)")
25
+ parser.add_argument(
26
+ "--port",
27
+ type=int,
28
+ default=0,
29
+ help="Port to bind the local web app to (default: 0 = OS-assigned ephemeral port, to avoid "
30
+ "colliding with whatever else is already running on your machine). Loopback redirect_uris "
31
+ "match on host+path regardless of port (RFC 8252), so any port works against a "
32
+ "spec-compliant authorization server. Pass a fixed port if you want a bookmarkable URL.",
33
+ )
34
+ parser.add_argument("--issuer", default=DEFAULT_ISSUER, help=f"Default issuer shown on the form (default: {DEFAULT_ISSUER})")
35
+ parser.add_argument("--client-id", default="", help="Default client_id shown on the form")
36
+ parser.add_argument("--scope", default="openid", help="Default scope shown on the form")
37
+ parser.add_argument(
38
+ "--pkce-method",
39
+ choices=["S256", "plain", "none"],
40
+ default="S256",
41
+ help="Default PKCE method shown on the form (default: S256)",
42
+ )
43
+ parser.add_argument(
44
+ "--no-verify-jwks",
45
+ action="store_true",
46
+ help="Default the 'verify JWT signatures' toggle to off (it's on by default)",
47
+ )
48
+ parser.add_argument("--no-browser", action="store_true", help="Don't auto-open the form page in a browser")
49
+ return parser
50
+
51
+
52
+ def main(argv: Optional[List[str]] = None) -> int:
53
+ args = build_arg_parser().parse_args(argv)
54
+ defaults = {
55
+ "issuer": args.issuer,
56
+ "client_id": args.client_id,
57
+ "scope": args.scope,
58
+ "pkce_method": args.pkce_method,
59
+ "verify_jwks": "no" if args.no_verify_jwks else "yes",
60
+ "authorize_endpoint": "",
61
+ "token_endpoint": "",
62
+ }
63
+ try:
64
+ webapp.run(host=args.host, port=args.port, defaults=defaults, open_browser=not args.no_browser)
65
+ except OSError as exc:
66
+ print(f"error: could not bind {args.host}:{args.port}: {exc}", file=sys.stderr)
67
+ print(" try a different --port, or --port 0 for an OS-assigned one.", file=sys.stderr)
68
+ return 1
69
+ return 0
70
+
71
+
72
+ if __name__ == "__main__":
73
+ raise SystemExit(main())
@@ -0,0 +1,17 @@
1
+ """Fetch OIDC / RFC 8414 OAuth2 discovery metadata."""
2
+
3
+ import json
4
+ import urllib.request
5
+
6
+
7
+ def fetch_metadata(issuer: str) -> dict:
8
+ issuer = issuer.rstrip("/")
9
+ last_error = None
10
+ for suffix in ("/.well-known/openid-configuration", "/.well-known/oauth-authorization-server"):
11
+ url = issuer + suffix
12
+ try:
13
+ with urllib.request.urlopen(url, timeout=10) as resp:
14
+ return json.load(resp)
15
+ except Exception as exc: # noqa: BLE001 - fall through and try the next discovery doc
16
+ last_error = exc
17
+ raise RuntimeError(f"could not fetch discovery metadata from {issuer}: {last_error}")
@@ -0,0 +1,53 @@
1
+ """Decode (and optionally verify) JWTs without a heavy JOSE dependency."""
2
+
3
+ import base64
4
+ import json
5
+ import urllib.request
6
+ from typing import Tuple
7
+
8
+
9
+ def _b64url_decode(segment: str) -> bytes:
10
+ padded = segment + "=" * (-len(segment) % 4)
11
+ return base64.urlsafe_b64decode(padded)
12
+
13
+
14
+ def decode(token: str) -> Tuple[dict, dict]:
15
+ header_b64, payload_b64, _sig = token.split(".")
16
+ header = json.loads(_b64url_decode(header_b64))
17
+ payload = json.loads(_b64url_decode(payload_b64))
18
+ return header, payload
19
+
20
+
21
+ def verify_signature(token: str, jwks_uri: str) -> bool:
22
+ """Best-effort RS256 verification against a JWKS endpoint. Requires `cryptography`."""
23
+ try:
24
+ from cryptography.exceptions import InvalidSignature
25
+ from cryptography.hazmat.primitives import hashes
26
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
27
+ except ImportError as exc: # pragma: no cover - optional dependency
28
+ raise RuntimeError(
29
+ "signature verification requires the 'cryptography' package "
30
+ "(pip install 'oauth2-debugger[verify]')"
31
+ ) from exc
32
+
33
+ header_b64, payload_b64, sig_b64 = token.split(".")
34
+ header = json.loads(_b64url_decode(header_b64))
35
+ if header.get("alg") != "RS256":
36
+ raise RuntimeError(f"only RS256 verification is supported (token uses {header.get('alg')})")
37
+
38
+ with urllib.request.urlopen(jwks_uri, timeout=10) as resp:
39
+ jwks = json.load(resp)
40
+ key = next((k for k in jwks["keys"] if k["kid"] == header.get("kid")), None)
41
+ if key is None:
42
+ raise RuntimeError(f"no key with kid={header.get('kid')!r} found at {jwks_uri}")
43
+
44
+ n = int.from_bytes(_b64url_decode(key["n"]), "big")
45
+ e = int.from_bytes(_b64url_decode(key["e"]), "big")
46
+ public_key = rsa.RSAPublicNumbers(e, n).public_key()
47
+ signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
48
+ signature = _b64url_decode(sig_b64)
49
+ try:
50
+ public_key.verify(signature, signing_input, padding.PKCS1v15(), hashes.SHA256())
51
+ return True
52
+ except InvalidSignature:
53
+ return False
@@ -0,0 +1,19 @@
1
+ """RFC 7636 PKCE code_verifier / code_challenge generation."""
2
+
3
+ import base64
4
+ import hashlib
5
+ import secrets
6
+
7
+
8
+ def generate_verifier(length: int = 64) -> str:
9
+ """A high-entropy verifier, base64url-encoded (43-128 chars per RFC 7636)."""
10
+ return base64.urlsafe_b64encode(secrets.token_bytes(length)).decode("ascii").rstrip("=")
11
+
12
+
13
+ def challenge_for(verifier: str, method: str = "S256") -> str:
14
+ if method == "plain":
15
+ return verifier
16
+ if method == "S256":
17
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
18
+ return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
19
+ raise ValueError(f"unsupported PKCE method: {method}")
@@ -0,0 +1,690 @@
1
+ """A tiny local web app for driving OAuth2/OIDC Authorization Code (+ PKCE) flows.
2
+
3
+ Everything runs on a loopback HTTP server the debugger itself starts:
4
+
5
+ GET / configuration form (issuer, client_id, scope, PKCE method, ...)
6
+ POST /start builds the authorize URL for the submitted config and 302s the
7
+ browser to the real authorization server
8
+ GET /callback the redirect_uri target: receives code/state (or error), validates
9
+ state, exchanges the code for tokens, renders the results page
10
+
11
+ Only one flow needs to be "in flight" per browser tab, but multiple pending flows
12
+ (different states) are tracked so opening several tabs / retrying doesn't collide.
13
+ """
14
+
15
+ import html
16
+ import json
17
+ import secrets
18
+ import threading
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ import webbrowser
24
+ from http.server import BaseHTTPRequestHandler, HTTPServer
25
+
26
+ from . import jwt_utils, pkce
27
+ from .discovery import fetch_metadata
28
+
29
+ PENDING_TTL_SECONDS = 15 * 60
30
+
31
+ PAGE_STYLE = """
32
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
33
+ max-width: 860px; margin: 2rem auto; padding: 0 1.5rem; color: #1a1a1a; }
34
+ h1 { font-size: 1.4rem; }
35
+ h2 { font-size: 1.1rem; margin-top: 2rem; border-bottom: 1px solid #ddd; padding-bottom: .3rem; }
36
+ label { display: block; margin-top: 1rem; font-weight: 600; font-size: .9rem; }
37
+ input, select, textarea { width: 100%; box-sizing: border-box; padding: .5rem; margin-top: .25rem;
38
+ font-family: inherit; font-size: .95rem; border: 1px solid #ccc; border-radius: 4px; }
39
+ textarea { font-family: ui-monospace, monospace; font-size: .85rem; min-height: 4rem; }
40
+ .hint { color: #666; font-size: .8rem; margin-top: .2rem; }
41
+ button { margin-top: 1.5rem; padding: .6rem 1.4rem; font-size: 1rem; border: none; border-radius: 4px;
42
+ background: #2b6cb0; color: white; cursor: pointer; }
43
+ button:hover { background: #245a94; }
44
+ button:disabled { opacity: .65; cursor: not-allowed; }
45
+ button.secondary { background: #555; }
46
+ .spinner { display: inline-block; width: .8em; height: .8em; margin-right: .5em; vertical-align: -1px;
47
+ border: 2px solid rgba(255,255,255,.45); border-top-color: #fff; border-radius: 50%;
48
+ animation: spin .6s linear infinite; }
49
+ @keyframes spin { to { transform: rotate(360deg); } }
50
+ pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; overflow-x: auto; font-size: .82rem; }
51
+ .token-box { position: relative; }
52
+ .copy-btn { position: absolute; top: .5rem; right: .5rem; padding: .25rem .6rem; font-size: .75rem; }
53
+ .badge { display: inline-block; padding: .1rem .5rem; border-radius: 3px; font-size: .75rem; font-weight: 600; }
54
+ .badge.pass { background: #c6f6d5; color: #22543d; }
55
+ .badge.fail { background: #fed7d7; color: #742a2a; }
56
+ .badge.skip { background: #edf2f7; color: #4a5568; }
57
+ .error { background: #fed7d7; color: #742a2a; padding: 1rem; border-radius: 4px; }
58
+ .row { display: flex; gap: 1rem; }
59
+ .row > * { flex: 1; }
60
+ .input-with-btn { display: flex; gap: .4rem; margin-top: .25rem; }
61
+ .input-with-btn input { margin-top: 0; }
62
+ .regen-btn { margin-top: 0; padding: .5rem .7rem; font-size: 1rem; line-height: 1; flex: 0 0 auto;
63
+ background: #555; }
64
+ .regen-btn:hover { background: #3d3d3d; }
65
+ .scope-checkboxes { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; }
66
+ .scope-item { display: inline-flex; align-items: center; gap: .35rem; margin: 0; padding: .35rem .6rem;
67
+ border: 1px solid #ccc; border-radius: 999px; font-weight: 400; font-size: .85rem; cursor: pointer; }
68
+ .scope-item input { width: auto; margin: 0; padding: 0; }
69
+ """
70
+
71
+ FORM_PAGE = """<!doctype html>
72
+ <html><head><meta charset="utf-8"><title>oauth2-debugger</title><style>{style}</style></head>
73
+ <body>
74
+ <h1>OAuth2 / OIDC Authorization Code Debugger</h1>
75
+ <p class="hint">Configure a client and scopes, start the flow, approve consent in the browser
76
+ tab that opens, and land back here with the decoded tokens.</p>
77
+ <form method="POST" action="/start" id="start-form">
78
+ <label>Authorization Server
79
+ <input name="issuer" id="issuer" value="{issuer}">
80
+ </label>
81
+ <p class="hint">Its &lt;issuer&gt;/.well-known/openid-configuration is fetched (through this
82
+ tool's own server, to sidestep CORS) to fill in the authorize/token endpoints below.</p>
83
+
84
+ <div class="row">
85
+ <label>Authorization endpoint (override)
86
+ <input name="authorize_endpoint" id="authorize_endpoint" value="{authorize_endpoint}"
87
+ placeholder="{discovered_authorize_endpoint}">
88
+ </label>
89
+ <label>Token endpoint (override)
90
+ <input name="token_endpoint" id="token_endpoint" value="{token_endpoint}"
91
+ placeholder="{discovered_token_endpoint}">
92
+ </label>
93
+ </div>
94
+ <p class="hint" id="discover-status"></p>
95
+
96
+ <label>Client ID
97
+ <input name="client_id" value="{client_id}" required>
98
+ </label>
99
+
100
+ <label>Scope
101
+ <div class="scope-checkboxes" id="scope-checkboxes">{scope_checkboxes}</div>
102
+ <input name="scope" id="scope" value="{scope}" placeholder="space-delimited scopes">
103
+ </label>
104
+ <p class="hint">Checkboxes above reflect this issuer's discovered <code>scopes_supported</code>
105
+ and stay in sync with the field below. The field is still plain text, though — type (or leave)
106
+ anything there, including a scope the server never advertised, to see how it handles that.</p>
107
+
108
+ <div class="row">
109
+ <label>PKCE method
110
+ <select name="pkce_method">
111
+ <option value="S256" {sel_s256}>S256</option>
112
+ <option value="plain" {sel_plain}>plain</option>
113
+ <option value="none" {sel_none}>none (disable PKCE)</option>
114
+ </select>
115
+ </label>
116
+ <label>Verify JWT signatures against JWKS
117
+ <select name="verify_jwks">
118
+ <option value="yes" {verify_yes}>yes</option>
119
+ <option value="no" {verify_no}>no</option>
120
+ </select>
121
+ </label>
122
+ </div>
123
+
124
+ <div class="row">
125
+ <label>state
126
+ <div class="input-with-btn">
127
+ <input name="state" id="state" value="{state}">
128
+ <button type="button" class="regen-btn" data-target="state" title="Regenerate">&#8635;</button>
129
+ </div>
130
+ </label>
131
+ <label>nonce
132
+ <div class="input-with-btn">
133
+ <input name="nonce" id="nonce" value="{nonce}">
134
+ <button type="button" class="regen-btn" data-target="nonce" title="Regenerate">&#8635;</button>
135
+ </div>
136
+ </label>
137
+ </div>
138
+ <p class="hint">Both are random by default (regenerate freely). <code>state</code> also keys this
139
+ tool's own bookkeeping for the pending flow, so it's never actually sent empty even if you clear
140
+ it here. <code>nonce</code> has no such requirement — clear it to test an authorize request with
141
+ no nonce at all; if it's set and the response includes an <code>id_token</code>, the results page
142
+ checks the nonce came back unchanged.</p>
143
+
144
+ <label>Extra authorize-request params (one KEY=VALUE per line, optional)
145
+ <textarea name="extra_params" placeholder="scene=oidc-authorize"></textarea>
146
+ </label>
147
+
148
+ <button type="submit" id="start-btn">Start Authorization</button>
149
+ </form>
150
+ <script>
151
+ document.getElementById('start-form').addEventListener('submit', function() {{
152
+ var btn = document.getElementById('start-btn');
153
+ btn.disabled = true;
154
+ btn.innerHTML = '<span class="spinner"></span>Starting...';
155
+ }});
156
+ var knownScopes = {known_scopes_json};
157
+
158
+ function currentScopeWords() {{
159
+ return document.getElementById('scope').value.split(/\\s+/).filter(Boolean);
160
+ }}
161
+
162
+ function renderScopeCheckboxes() {{
163
+ var container = document.getElementById('scope-checkboxes');
164
+ if (!knownScopes.length) {{
165
+ container.innerHTML = '';
166
+ return;
167
+ }}
168
+ var checkedWords = currentScopeWords();
169
+ container.innerHTML = knownScopes.map(function(s) {{
170
+ var checked = checkedWords.indexOf(s) !== -1 ? 'checked' : '';
171
+ var safe = s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
172
+ return '<label class="scope-item"><input type="checkbox" class="scope-checkbox" value="' + safe
173
+ + '" ' + checked + '>' + safe + '</label>';
174
+ }}).join('');
175
+ Array.prototype.forEach.call(container.querySelectorAll('.scope-checkbox'), function(cb) {{
176
+ cb.addEventListener('change', syncScopeFromCheckboxes);
177
+ }});
178
+ }}
179
+
180
+ function syncScopeFromCheckboxes() {{
181
+ var words = currentScopeWords();
182
+ var extra = words.filter(function(w) {{ return knownScopes.indexOf(w) === -1; }});
183
+ var checked = Array.prototype.map.call(
184
+ document.querySelectorAll('.scope-checkbox:checked'), function(cb) {{ return cb.value; }});
185
+ document.getElementById('scope').value = checked.concat(extra).join(' ');
186
+ }}
187
+
188
+ function syncCheckboxesFromScopeField() {{
189
+ var words = currentScopeWords();
190
+ Array.prototype.forEach.call(document.querySelectorAll('.scope-checkbox'), function(cb) {{
191
+ cb.checked = words.indexOf(cb.value) !== -1;
192
+ }});
193
+ }}
194
+
195
+ document.getElementById('scope').addEventListener('input', syncCheckboxesFromScopeField);
196
+
197
+ function randomUrlSafeToken(byteLen) {{
198
+ var bytes = new Uint8Array(byteLen);
199
+ crypto.getRandomValues(bytes);
200
+ var binary = '';
201
+ bytes.forEach(function(b) {{ binary += String.fromCharCode(b); }});
202
+ return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
203
+ }}
204
+ Array.prototype.forEach.call(document.querySelectorAll('.regen-btn'), function(btn) {{
205
+ btn.addEventListener('click', function() {{
206
+ document.getElementById(btn.dataset.target).value = randomUrlSafeToken(18);
207
+ }});
208
+ }});
209
+
210
+ function refreshEndpointPlaceholders() {{
211
+ var issuer = document.getElementById('issuer').value.trim();
212
+ var status = document.getElementById('discover-status');
213
+ var azInput = document.getElementById('authorize_endpoint');
214
+ var tkInput = document.getElementById('token_endpoint');
215
+ if (!issuer) {{ status.textContent = ''; return; }}
216
+ status.textContent = 'Discovering endpoints from ' + issuer + ' ...';
217
+ fetch('/discover?issuer=' + encodeURIComponent(issuer))
218
+ .then(function(r) {{ return r.json(); }})
219
+ .then(function(data) {{
220
+ if (data.error) {{
221
+ status.textContent = 'Discovery failed: ' + data.error;
222
+ return;
223
+ }}
224
+ azInput.placeholder = data.authorization_endpoint || '';
225
+ tkInput.placeholder = data.token_endpoint || '';
226
+ knownScopes = data.scopes_supported || [];
227
+ renderScopeCheckboxes();
228
+ status.textContent = 'Discovered from ' + issuer + '/.well-known/openid-configuration';
229
+ }})
230
+ .catch(function(err) {{ status.textContent = 'Discovery failed: ' + err; }});
231
+ }}
232
+ document.addEventListener('DOMContentLoaded', function() {{
233
+ renderScopeCheckboxes(); // wire up listeners on the server-rendered checkboxes immediately
234
+ refreshEndpointPlaceholders(); // then re-fetch to catch up with any change since page render
235
+ }});
236
+ document.getElementById('issuer').addEventListener('change', refreshEndpointPlaceholders);
237
+ </script>
238
+ </body></html>
239
+ """
240
+
241
+ ERROR_PAGE = """<!doctype html>
242
+ <html><head><meta charset="utf-8"><title>oauth2-debugger - error</title><style>{style}</style></head>
243
+ <body>
244
+ <h1>oauth2-debugger</h1>
245
+ <div class="error"><strong>{heading}</strong><p>{message}</p></div>
246
+ <p><a href="/">&larr; back to the form</a></p>
247
+ </body></html>
248
+ """
249
+
250
+ RESULTS_PAGE = """<!doctype html>
251
+ <html><head><meta charset="utf-8"><title>oauth2-debugger - result</title><style>{style}</style>
252
+ <script>
253
+ function copyIt(id) {{
254
+ const el = document.getElementById(id);
255
+ navigator.clipboard.writeText(el.textContent).then(() => {{
256
+ const btn = document.getElementById(id + '-btn');
257
+ const old = btn.textContent;
258
+ btn.textContent = 'Copied!';
259
+ setTimeout(() => {{ btn.textContent = old; }}, 1200);
260
+ }});
261
+ }}
262
+ </script>
263
+ </head>
264
+ <body>
265
+ <h1>Authorization complete</h1>
266
+ <p class="hint">state verified{pkce_note}. client_id=<code>{client_id}</code>, scope=<code>{scope}</code></p>
267
+
268
+ <h2>Token response</h2>
269
+ <pre>{token_response}</pre>
270
+
271
+ {sections}
272
+
273
+ <p><a href="/">&larr; start another flow</a></p>
274
+ </body></html>
275
+ """
276
+
277
+ TOKEN_SECTION = """
278
+ <h2>{label}</h2>
279
+ <div class="token-box">
280
+ <button class="copy-btn" id="{anchor}-btn" onclick="copyIt('{anchor}')">Copy</button>
281
+ <pre id="{anchor}">{token}</pre>
282
+ </div>
283
+ {claims_html}
284
+ """
285
+
286
+ CLAIMS_BLOCK = """
287
+ <p>Claims: {badges}</p>
288
+ <pre>{claims}</pre>
289
+ """
290
+
291
+
292
+ def _esc(value) -> str:
293
+ return html.escape(str(value), quote=True)
294
+
295
+
296
+ def _render_scope_checkboxes(scopes: list, current_scope_value: str) -> str:
297
+ if not scopes:
298
+ return ""
299
+ checked_words = set(current_scope_value.split())
300
+ items = []
301
+ for scope in scopes:
302
+ checked = "checked" if scope in checked_words else ""
303
+ items.append(
304
+ f'<label class="scope-item"><input type="checkbox" class="scope-checkbox" '
305
+ f'value="{_esc(scope)}" {checked}>{_esc(scope)}</label>'
306
+ )
307
+ return "".join(items)
308
+
309
+
310
+ class _PendingFlow:
311
+ __slots__ = (
312
+ "verifier",
313
+ "token_endpoint",
314
+ "jwks_uri",
315
+ "client_id",
316
+ "scope",
317
+ "redirect_uri",
318
+ "nonce",
319
+ "created_at",
320
+ )
321
+
322
+ def __init__(self, verifier, token_endpoint, jwks_uri, client_id, scope, redirect_uri, nonce=None):
323
+ self.verifier = verifier
324
+ self.token_endpoint = token_endpoint
325
+ self.jwks_uri = jwks_uri
326
+ self.client_id = client_id
327
+ self.scope = scope
328
+ self.redirect_uri = redirect_uri
329
+ self.nonce = nonce
330
+ self.created_at = time.time()
331
+
332
+
333
+ class DebuggerServer(HTTPServer):
334
+ def __init__(self, address, handler_cls, defaults: dict):
335
+ super().__init__(address, handler_cls)
336
+ self.defaults = defaults
337
+ self.pending: dict = {}
338
+ self.lock = threading.Lock()
339
+
340
+ def base_url(self) -> str:
341
+ host, port = self.server_address[:2]
342
+ display_host = "127.0.0.1" if host in ("0.0.0.0", "") else host
343
+ return f"http://{display_host}:{port}"
344
+
345
+ def sweep_expired(self) -> None:
346
+ cutoff = time.time() - PENDING_TTL_SECONDS
347
+ with self.lock:
348
+ expired = [state for state, flow in self.pending.items() if flow.created_at < cutoff]
349
+ for state in expired:
350
+ del self.pending[state]
351
+
352
+
353
+ def _post_form(url: str, fields: dict) -> dict:
354
+ data = urllib.parse.urlencode(fields).encode("ascii")
355
+ req = urllib.request.Request(
356
+ url, data=data, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}
357
+ )
358
+ try:
359
+ with urllib.request.urlopen(req, timeout=15) as resp:
360
+ return json.loads(resp.read())
361
+ except urllib.error.HTTPError as exc:
362
+ body = exc.read().decode("utf-8", errors="replace")
363
+ raise RuntimeError(f"token endpoint returned HTTP {exc.code}: {body}") from exc
364
+
365
+
366
+ class Handler(BaseHTTPRequestHandler):
367
+ server: DebuggerServer # type: ignore[assignment]
368
+
369
+ def log_message(self, fmt, *args): # noqa: A002 - silence default request logging
370
+ return
371
+
372
+ def _send_html(self, body: str, status: int = 200) -> None:
373
+ encoded = body.encode("utf-8")
374
+ self.send_response(status)
375
+ self.send_header("Content-Type", "text/html; charset=utf-8")
376
+ self.send_header("Content-Length", str(len(encoded)))
377
+ self.end_headers()
378
+ self.wfile.write(encoded)
379
+
380
+ def _send_error_page(self, heading: str, message: str, status: int = 400) -> None:
381
+ self._send_html(
382
+ ERROR_PAGE.format(style=PAGE_STYLE, heading=_esc(heading), message=_esc(message)), status=status
383
+ )
384
+
385
+ def _send_json(self, payload: dict, status: int = 200) -> None:
386
+ encoded = json.dumps(payload).encode("utf-8")
387
+ self.send_response(status)
388
+ self.send_header("Content-Type", "application/json; charset=utf-8")
389
+ self.send_header("Content-Length", str(len(encoded)))
390
+ self.end_headers()
391
+ self.wfile.write(encoded)
392
+
393
+ def do_GET(self): # noqa: N802
394
+ parsed = urllib.parse.urlparse(self.path)
395
+ if parsed.path == "/":
396
+ self._render_form()
397
+ elif parsed.path == "/discover":
398
+ self._handle_discover(urllib.parse.parse_qs(parsed.query))
399
+ elif parsed.path == "/callback":
400
+ self._handle_callback(urllib.parse.parse_qs(parsed.query))
401
+ else:
402
+ self._send_error_page("Not found", f"No route for {parsed.path}", status=404)
403
+
404
+ def _handle_discover(self, params: dict) -> None:
405
+ issuer = (params.get("issuer") or [""])[0].strip()
406
+ if not issuer:
407
+ self._send_json({"error": "missing issuer"}, status=400)
408
+ return
409
+ try:
410
+ metadata = fetch_metadata(issuer)
411
+ except Exception as exc: # noqa: BLE001
412
+ self._send_json({"error": str(exc)}, status=502)
413
+ return
414
+ self._send_json(
415
+ {
416
+ "authorization_endpoint": metadata.get("authorization_endpoint", ""),
417
+ "token_endpoint": metadata.get("token_endpoint", ""),
418
+ "jwks_uri": metadata.get("jwks_uri", ""),
419
+ "scopes_supported": metadata.get("scopes_supported", []),
420
+ }
421
+ )
422
+
423
+ def do_POST(self): # noqa: N802
424
+ parsed = urllib.parse.urlparse(self.path)
425
+ if parsed.path != "/start":
426
+ self._send_error_page("Not found", f"No route for {parsed.path}", status=404)
427
+ return
428
+ length = int(self.headers.get("Content-Length", 0))
429
+ body = self.rfile.read(length).decode("utf-8")
430
+ form = {k: v[0] for k, v in urllib.parse.parse_qs(body).items()}
431
+ self._handle_start(form)
432
+
433
+ def _render_form(self, values: dict = None) -> None:
434
+ d = dict(self.server.defaults)
435
+ d.update(values or {})
436
+
437
+ discovered_authorize_endpoint = discovered_token_endpoint = ""
438
+ scopes_supported: list = []
439
+ issuer = d.get("issuer", "").strip()
440
+ if issuer:
441
+ try:
442
+ metadata = fetch_metadata(issuer)
443
+ discovered_authorize_endpoint = metadata.get("authorization_endpoint", "")
444
+ discovered_token_endpoint = metadata.get("token_endpoint", "")
445
+ scopes_supported = metadata.get("scopes_supported", []) or []
446
+ except Exception: # noqa: BLE001 - best-effort pre-fill; the page's own JS retries this
447
+ pass
448
+
449
+ scope_value = d.get("scope", "openid")
450
+ self._send_html(
451
+ FORM_PAGE.format(
452
+ style=PAGE_STYLE,
453
+ issuer=_esc(issuer),
454
+ authorize_endpoint=_esc(d.get("authorize_endpoint", "")),
455
+ token_endpoint=_esc(d.get("token_endpoint", "")),
456
+ discovered_authorize_endpoint=_esc(discovered_authorize_endpoint),
457
+ discovered_token_endpoint=_esc(discovered_token_endpoint),
458
+ client_id=_esc(d.get("client_id", "")),
459
+ scope=_esc(scope_value),
460
+ scope_checkboxes=_render_scope_checkboxes(scopes_supported, scope_value),
461
+ known_scopes_json=json.dumps(scopes_supported),
462
+ state=_esc(secrets.token_urlsafe(18)),
463
+ nonce=_esc(secrets.token_urlsafe(18)),
464
+ sel_s256="selected" if d.get("pkce_method", "S256") == "S256" else "",
465
+ sel_plain="selected" if d.get("pkce_method") == "plain" else "",
466
+ sel_none="selected" if d.get("pkce_method") == "none" else "",
467
+ verify_yes="selected" if d.get("verify_jwks", "yes") == "yes" else "",
468
+ verify_no="selected" if d.get("verify_jwks") == "no" else "",
469
+ )
470
+ )
471
+
472
+ def _handle_start(self, form: dict) -> None:
473
+ self.server.sweep_expired()
474
+
475
+ issuer = form.get("issuer", "").strip()
476
+ client_id = form.get("client_id", "").strip()
477
+ scope = form.get("scope", "").strip() or "openid"
478
+ pkce_method = form.get("pkce_method", "S256")
479
+ verify_jwks = form.get("verify_jwks", "yes") == "yes"
480
+
481
+ if not client_id:
482
+ self._send_error_page("Missing client_id", "client_id is required.")
483
+ return
484
+
485
+ authorize_endpoint = form.get("authorize_endpoint", "").strip()
486
+ token_endpoint = form.get("token_endpoint", "").strip()
487
+ jwks_uri = None
488
+
489
+ if not authorize_endpoint or not token_endpoint:
490
+ if not issuer:
491
+ self._send_error_page(
492
+ "Missing issuer",
493
+ "Provide an issuer to discover endpoints from, or fill in both endpoint overrides.",
494
+ )
495
+ return
496
+ try:
497
+ metadata = fetch_metadata(issuer)
498
+ except Exception as exc: # noqa: BLE001
499
+ self._send_error_page("Discovery failed", str(exc))
500
+ return
501
+ authorize_endpoint = authorize_endpoint or metadata["authorization_endpoint"]
502
+ token_endpoint = token_endpoint or metadata["token_endpoint"]
503
+ jwks_uri = metadata.get("jwks_uri")
504
+
505
+ # state also keys this tool's own pending-flow bookkeeping (see _handle_callback), so
506
+ # unlike nonce it can't actually be sent empty even if the field was cleared - fall back
507
+ # to a fresh one in that case rather than silently breaking the callback correlation.
508
+ state = form.get("state", "").strip() or secrets.token_urlsafe(24)
509
+ nonce = form.get("nonce", "").strip()
510
+ verifier = challenge = None
511
+ if pkce_method != "none":
512
+ verifier = pkce.generate_verifier()
513
+ challenge = pkce.challenge_for(verifier, pkce_method)
514
+
515
+ redirect_uri = f"{self.server.base_url()}/callback"
516
+
517
+ query = {
518
+ "response_type": "code",
519
+ "client_id": client_id,
520
+ "redirect_uri": redirect_uri,
521
+ "scope": scope,
522
+ "state": state,
523
+ }
524
+ if nonce:
525
+ query["nonce"] = nonce
526
+ if challenge:
527
+ query["code_challenge"] = challenge
528
+ query["code_challenge_method"] = pkce_method
529
+
530
+ for line in form.get("extra_params", "").splitlines():
531
+ line = line.strip()
532
+ if not line or "=" not in line:
533
+ continue
534
+ key, value = line.split("=", 1)
535
+ query[key.strip()] = value.strip()
536
+
537
+ with self.server.lock:
538
+ self.server.pending[state] = _PendingFlow(
539
+ verifier=verifier,
540
+ token_endpoint=token_endpoint,
541
+ jwks_uri=jwks_uri if verify_jwks else None,
542
+ client_id=client_id,
543
+ scope=scope,
544
+ redirect_uri=redirect_uri,
545
+ nonce=nonce or None,
546
+ )
547
+ # Remember the last-used config as the new defaults, so reopening "/" (or the
548
+ # next flow) starts pre-filled with whatever was just tested.
549
+ self.server.defaults.update(
550
+ {
551
+ "issuer": issuer,
552
+ "client_id": client_id,
553
+ "scope": scope,
554
+ "pkce_method": pkce_method,
555
+ "verify_jwks": "yes" if verify_jwks else "no",
556
+ }
557
+ )
558
+
559
+ authorize_url = f"{authorize_endpoint}?{urllib.parse.urlencode(query)}"
560
+ self.send_response(302)
561
+ self.send_header("Location", authorize_url)
562
+ self.end_headers()
563
+
564
+ def _handle_callback(self, params: dict) -> None:
565
+ params = {k: v[0] for k, v in params.items()}
566
+ state = params.get("state")
567
+
568
+ with self.server.lock:
569
+ flow = self.server.pending.pop(state, None) if state else None
570
+
571
+ if "error" in params:
572
+ self._send_error_page(
573
+ "Authorization failed",
574
+ f"{params.get('error')}: {params.get('error_description', '')}",
575
+ )
576
+ return
577
+
578
+ if flow is None:
579
+ self._send_error_page(
580
+ "Unknown or expired state",
581
+ f"No pending flow for state={state!r}. It may have already been used, expired "
582
+ f"(pending flows are kept for {PENDING_TTL_SECONDS // 60} minutes), or this server "
583
+ "was restarted mid-flow. Start again from the form.",
584
+ )
585
+ return
586
+
587
+ code = params.get("code")
588
+ if not code:
589
+ self._send_error_page("Missing code", f"No 'code' in callback params: {params}")
590
+ return
591
+
592
+ token_request = {
593
+ "grant_type": "authorization_code",
594
+ "code": code,
595
+ "redirect_uri": flow.redirect_uri,
596
+ "client_id": flow.client_id,
597
+ }
598
+ if flow.verifier:
599
+ token_request["code_verifier"] = flow.verifier
600
+
601
+ try:
602
+ token_response = _post_form(flow.token_endpoint, token_request)
603
+ except RuntimeError as exc:
604
+ self._send_error_page("Token exchange failed", str(exc))
605
+ return
606
+
607
+ sections = []
608
+ for label, key in (("Access token", "access_token"), ("ID token", "id_token")):
609
+ token = token_response.get(key)
610
+ if not token:
611
+ continue
612
+ expected_nonce = flow.nonce if key == "id_token" else None
613
+ claims_html = self._render_claims(token, flow.jwks_uri, expected_nonce)
614
+ sections.append(
615
+ TOKEN_SECTION.format(
616
+ label=_esc(label),
617
+ anchor=key,
618
+ token=_esc(token),
619
+ claims_html=claims_html,
620
+ )
621
+ )
622
+ if token_response.get("refresh_token"):
623
+ sections.append(
624
+ TOKEN_SECTION.format(
625
+ label="Refresh token",
626
+ anchor="refresh_token",
627
+ token=_esc(token_response["refresh_token"]),
628
+ claims_html="",
629
+ )
630
+ )
631
+
632
+ pkce_note = "" if flow.verifier is None else " (PKCE verified by the server)"
633
+ self._send_html(
634
+ RESULTS_PAGE.format(
635
+ style=PAGE_STYLE,
636
+ pkce_note=pkce_note,
637
+ client_id=_esc(flow.client_id),
638
+ scope=_esc(flow.scope),
639
+ token_response=_esc(json.dumps(token_response, indent=2, ensure_ascii=False)),
640
+ sections="".join(sections),
641
+ )
642
+ )
643
+
644
+ def _render_claims(self, token: str, jwks_uri, expected_nonce: str = None) -> str:
645
+ try:
646
+ _header, payload = jwt_utils.decode(token)
647
+ except Exception:
648
+ return ""
649
+ badges = ['<span class="badge skip">not verified</span>']
650
+ if jwks_uri:
651
+ try:
652
+ ok = jwt_utils.verify_signature(token, jwks_uri)
653
+ badges = [
654
+ '<span class="badge pass">signature PASS</span>'
655
+ if ok
656
+ else '<span class="badge fail">signature FAIL</span>'
657
+ ]
658
+ except Exception as exc: # noqa: BLE001
659
+ badges = [f'<span class="badge fail">verify error: {_esc(exc)}</span>']
660
+ if expected_nonce:
661
+ actual_nonce = payload.get("nonce")
662
+ if actual_nonce == expected_nonce:
663
+ badges.append('<span class="badge pass">nonce PASS</span>')
664
+ else:
665
+ badges.append(
666
+ f'<span class="badge fail">nonce FAIL (sent {_esc(expected_nonce)}, '
667
+ f"got back {_esc(actual_nonce)})</span>"
668
+ )
669
+ return CLAIMS_BLOCK.format(
670
+ badges=" ".join(badges), claims=_esc(json.dumps(payload, indent=2, ensure_ascii=False))
671
+ )
672
+
673
+
674
+ def run(host: str, port: int, defaults: dict, open_browser: bool) -> None:
675
+ server = DebuggerServer((host, port), Handler, defaults)
676
+ display_host = "127.0.0.1" if host in ("0.0.0.0", "") else host
677
+ url = f"http://{display_host}:{server.server_address[1]}/"
678
+ print(f"oauth2-debugger listening on {url}")
679
+ print("Press Ctrl+C to stop.")
680
+ if open_browser:
681
+ try:
682
+ webbrowser.open(url)
683
+ except webbrowser.Error as exc:
684
+ print(f" (could not open a browser automatically: {exc}; open the URL above manually)")
685
+ try:
686
+ server.serve_forever()
687
+ except KeyboardInterrupt:
688
+ pass
689
+ finally:
690
+ server.server_close()
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "oauth2-debugger"
3
+ version = "0.0.1"
4
+ description = "Local debugger for the OAuth2/OIDC Authorization Code flow (state, scopes, PKCE)"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.9"
8
+ dependencies = []
9
+
10
+ [[project.authors]]
11
+ name = "Yike Xiao"
12
+ email = "kmter@live.com"
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/Shawyeok/oauth2-debugger"
16
+ Repository = "https://github.com/Shawyeok/oauth2-debugger"
17
+
18
+ [project.optional-dependencies]
19
+ verify = ["cryptography>=41"]
20
+
21
+ [project.scripts]
22
+ oauth2-debugger = "oauth2_debugger.cli:main"
23
+
24
+ [build-system]
25
+ requires = ["hatchling"]
26
+ build-backend = "hatchling.build"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["oauth2_debugger"]