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.
- oauth2_debugger-0.0.1/.gitignore +6 -0
- oauth2_debugger-0.0.1/LICENSE +21 -0
- oauth2_debugger-0.0.1/PKG-INFO +111 -0
- oauth2_debugger-0.0.1/README.md +97 -0
- oauth2_debugger-0.0.1/oauth2_debugger/__init__.py +1 -0
- oauth2_debugger-0.0.1/oauth2_debugger/__main__.py +4 -0
- oauth2_debugger-0.0.1/oauth2_debugger/cli.py +73 -0
- oauth2_debugger-0.0.1/oauth2_debugger/discovery.py +17 -0
- oauth2_debugger-0.0.1/oauth2_debugger/jwt_utils.py +53 -0
- oauth2_debugger-0.0.1/oauth2_debugger/pkce.py +19 -0
- oauth2_debugger-0.0.1/oauth2_debugger/webapp.py +690 -0
- oauth2_debugger-0.0.1/pyproject.toml +29 -0
|
@@ -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,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 <issuer>/.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">↻</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">↻</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, '&').replace(/</g, '<').replace(/"/g, '"');
|
|
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="/">← 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="/">← 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"]
|