Created
January 13, 2026 13:47
-
-
Save NiklasRosenstein/97b09b2a1b7d71a415b819b9244a0dc7 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # /// script | |
| # requires-python = ">=3.11" | |
| # dependencies = [ | |
| # "flask", | |
| # ] | |
| # /// | |
| """ | |
| OIDC Debugger Tool | |
| A Flask-based HTTP server that can receive OAuth2/OIDC callback requests, | |
| exchange authorization codes for tokens, and optionally call userinfo endpoints. | |
| Environment Variables: | |
| - CLIENT_ID: OAuth2 client ID (required) | |
| - CLIENT_SECRET: OAuth2 client secret (required) | |
| - TOKEN_URL: The token endpoint URL (required unless ENTRA_TENANT_ID is set) | |
| - AUTH_URL: The authorization URL (required unless ENTRA_TENANT_ID is set) | |
| - ENTRA_TENANT_ID: Microsoft Entra tenant ID (auto-sets TOKEN_URL, AUTH_URL, USERINFO_URL) | |
| - USERINFO_URL: The userinfo endpoint URL (optional, only called if set) | |
| - DEFAULT_SCOPES: Default OAuth2 scopes (defaults to "openid profile email") | |
| - REDIRECT_URI: Redirect URI (defaults to http://localhost:3123/callback) | |
| - PORT: Server port (defaults to 3123) | |
| """ | |
| import base64 | |
| import json | |
| import os | |
| import urllib.parse | |
| import urllib.request | |
| from dataclasses import dataclass | |
| from typing import Any, cast | |
| from flask import Flask, redirect, render_template_string, request # type: ignore[import-not-found] | |
| def decode_jwt_token(token: str) -> dict[str, Any]: | |
| """Decode JWT token without signature verification (for debugging purposes only)""" | |
| try: | |
| # Split token into parts | |
| parts = token.split(".") | |
| if len(parts) != 3: | |
| return {"error": "Invalid JWT format"} | |
| header_encoded, payload_encoded, signature = parts | |
| # Add padding if needed | |
| header_encoded += "=" * (4 - len(header_encoded) % 4) | |
| payload_encoded += "=" * (4 - len(payload_encoded) % 4) | |
| # Decode header and payload | |
| header = json.loads(base64.urlsafe_b64decode(header_encoded).decode()) | |
| payload = json.loads(base64.urlsafe_b64decode(payload_encoded).decode()) | |
| return {"header": header, "payload": payload, "signature": signature} | |
| except Exception as e: # noqa: BLE001 | |
| return {"error": f"Failed to decode JWT: {str(e)}"} | |
| @dataclass | |
| class OIDCConfig: | |
| token_url: str | |
| client_id: str | |
| client_secret: str | |
| redirect_uri: str | |
| port: int | |
| auth_url: str | None = None | |
| userinfo_url: str | None = None | |
| default_scopes: str = "openid profile email" | |
| @classmethod | |
| def from_env(cls) -> "OIDCConfig": | |
| """Create configuration from environment variables""" | |
| port = int(os.getenv("PORT", 3123)) | |
| redirect_uri = os.getenv("REDIRECT_URI", f"http://localhost:{port}/callback") | |
| # Get explicit URLs from environment | |
| token_url = os.getenv("TOKEN_URL", "") | |
| auth_url = os.getenv("AUTH_URL", "") | |
| userinfo_url = os.getenv("USERINFO_URL", "") | |
| # Use Entra URLs as defaults if tenant ID is provided and URLs aren't explicitly set | |
| entra_tenant_id = os.getenv("ENTRA_TENANT_ID", "") | |
| if entra_tenant_id: | |
| if not token_url: | |
| token_url = f"https://login.microsoftonline.com/{entra_tenant_id}/oauth2/v2.0/token" | |
| if not auth_url: | |
| auth_url = f"https://login.microsoftonline.com/{entra_tenant_id}/oauth2/v2.0/authorize" | |
| if not userinfo_url: | |
| userinfo_url = "https://graph.microsoft.com/oidc/userinfo" | |
| return cls( | |
| token_url=token_url, | |
| client_id=os.getenv("CLIENT_ID", ""), | |
| client_secret=os.getenv("CLIENT_SECRET", ""), | |
| redirect_uri=redirect_uri, | |
| port=port, | |
| auth_url=auth_url if auth_url else None, | |
| userinfo_url=userinfo_url if userinfo_url else None, | |
| default_scopes=os.getenv("DEFAULT_SCOPES", "openid profile email"), | |
| ) | |
| def validate(self) -> list[str]: | |
| """Validate required configuration and return list of missing fields""" | |
| missing = [] | |
| if not self.token_url: | |
| missing.append("TOKEN_URL") | |
| if not self.auth_url: | |
| missing.append("AUTH_URL") | |
| if not self.client_id: | |
| missing.append("CLIENT_ID") | |
| if not self.client_secret: | |
| missing.append("CLIENT_SECRET") | |
| return missing | |
| class OIDCClient: | |
| """Handles OIDC/OAuth2 protocol interactions""" | |
| def __init__(self, config: OIDCConfig): | |
| self.config = config | |
| def exchange_code_for_tokens(self, auth_code: str) -> dict[str, Any]: | |
| """Exchange authorization code for tokens""" | |
| data = { | |
| "grant_type": "authorization_code", | |
| "code": auth_code, | |
| "redirect_uri": self.config.redirect_uri, | |
| "client_id": self.config.client_id, | |
| "client_secret": self.config.client_secret, | |
| } | |
| encoded_data = urllib.parse.urlencode(data).encode() | |
| credentials = base64.b64encode(f"{self.config.client_id}:{self.config.client_secret}".encode()).decode() | |
| req = urllib.request.Request( | |
| self.config.token_url, | |
| data=encoded_data, | |
| headers={"Content-Type": "application/x-www-form-urlencoded", "Authorization": f"Basic {credentials}"}, | |
| ) | |
| try: | |
| print(f"Exchanging code at: {self.config.token_url}") | |
| with urllib.request.urlopen(req) as response: | |
| token_data = json.loads(response.read().decode()) | |
| print("Token exchange successful!") | |
| return cast(dict[str, Any], token_data) | |
| except urllib.error.HTTPError as e: | |
| error_body = e.read().decode() | |
| print(f"Token exchange failed: {e.code} {e.reason}") | |
| print(f"Error response: {error_body}") | |
| raise Exception(f"Token Exchange Failed ({e.code}): {error_body}") from e | |
| except Exception as e: # noqa: BLE001 | |
| print(f"Token exchange error: {e}") | |
| raise Exception(f"Token Exchange Error: {str(e)}") from e | |
| def call_userinfo_endpoint(self, access_token: str) -> dict[str, Any] | None: | |
| """Call userinfo endpoint with access token""" | |
| if not self.config.userinfo_url: | |
| return None | |
| req = urllib.request.Request(self.config.userinfo_url, headers={"Authorization": f"Bearer {access_token}"}) | |
| try: | |
| print(f"Calling userinfo endpoint: {self.config.userinfo_url}") | |
| with urllib.request.urlopen(req) as response: | |
| userinfo_data = json.loads(response.read().decode()) | |
| print("Userinfo call successful!") | |
| return cast(dict[str, Any], userinfo_data) | |
| except urllib.error.HTTPError as e: | |
| error_body = e.read().decode() | |
| print(f"Userinfo call failed: {e.code} {e.reason}") | |
| print(f"Error response: {error_body}") | |
| return {"error": f"HTTP {e.code}", "details": error_body} | |
| except Exception as e: # noqa: BLE001 | |
| print(f"Userinfo call error: {e}") | |
| return {"error": "Request failed", "details": str(e)} | |
| # Flask app setup | |
| app = Flask(__name__) | |
| # Global config variable | |
| config: OIDCConfig | |
| HOME_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>OIDC Debugger</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| </head> | |
| <body class="bg-gray-100 min-h-screen"> | |
| <div class="container mx-auto px-6 py-8"> | |
| <div class="max-w-4xl mx-auto"> | |
| <h1 class="text-4xl font-bold text-gray-900 mb-8">OIDC Debugger</h1> | |
| <div class="bg-white rounded-lg shadow-md p-6 mb-8"> | |
| <p class="text-lg text-gray-700 mb-4">This server is ready to receive OAuth2/OIDC callbacks.</p> | |
| </div> | |
| <div class="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-8"> | |
| <h2 class="text-2xl font-semibold text-gray-900 mb-4">Configuration</h2> | |
| <div class="space-y-2"> | |
| <p class="text-gray-700"><span class="font-semibold">Authorization URL:</span> {{ auth_url or "Not configured" }}</p> | |
| <p class="text-gray-700"><span class="font-semibold">Redirect URI:</span> {{ redirect_uri }}</p> | |
| <p class="text-gray-700"><span class="font-semibold">Redirect URI (URL-encoded):</span> <code class="bg-gray-100 px-1 py-0.5 rounded text-xs">{{ redirect_uri_encoded }}</code></p> | |
| <p class="text-gray-700"><span class="font-semibold">Client ID:</span> {{ client_id }}</p> | |
| <p class="text-gray-700"><span class="font-semibold">Callback Endpoint:</span> <a href="/callback" class="text-blue-600 hover:text-blue-800">/callback</a></p> | |
| </div> | |
| </div> | |
| <div class="bg-white rounded-lg shadow-md p-6 mb-8"> | |
| <h2 class="text-2xl font-semibold text-gray-900 mb-4">Usage</h2> | |
| <p class="text-gray-700 mb-2">Configure your OAuth2 flow to redirect to:</p> | |
| <code class="bg-gray-100 px-2 py-1 rounded text-sm">{{ redirect_uri }}</code> | |
| <p class="text-gray-700 mt-4">The server will automatically exchange the authorization code for tokens and display the results.</p> | |
| </div> | |
| {% if auth_url %} | |
| <div class="bg-green-50 border border-green-200 rounded-lg shadow-md p-6"> | |
| <h2 class="text-2xl font-semibold text-green-900 mb-4">Quick Start</h2> | |
| <p class="text-green-800 mb-4">Configure scopes and start the OAuth2 flow directly:</p> | |
| <form method="GET" action="/login" class="space-y-3"> | |
| <div> | |
| <label for="scopes" class="block text-sm font-medium text-green-800 mb-1">OpenID Scopes:</label> | |
| <input | |
| type="text" | |
| id="scopes" | |
| name="scopes" | |
| value="{{ default_scopes }}" | |
| class="w-full px-3 py-2 border border-green-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500" | |
| placeholder="{{ default_scopes }}" | |
| > | |
| <p class="text-xs text-green-600 mt-1">Space-separated list of OAuth2/OpenID scopes</p> | |
| </div> | |
| <button type="submit" class="inline-block bg-green-600 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"> | |
| 🚀 Start Login Flow | |
| </button> | |
| </form> | |
| <p class="text-sm text-green-700 mt-3">This will redirect you to the authorization server and then back to this debugger.</p> | |
| </div> | |
| {% endif %} | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| ERROR_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>OIDC Debugger - Error</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| </head> | |
| <body class="bg-gray-100 min-h-screen"> | |
| <div class="container mx-auto px-6 py-8"> | |
| <div class="max-w-4xl mx-auto"> | |
| <h1 class="text-4xl font-bold text-gray-900 mb-8">OIDC Debugger - Error</h1> | |
| <div class="mb-4"> | |
| <a href="/" class="inline-block bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> | |
| ← Back to home | |
| </a> | |
| </div> | |
| <div class="bg-red-50 border border-red-200 rounded-lg p-6 mb-8"> | |
| <h2 class="text-2xl font-semibold text-red-900 mb-4">{{ title }}</h2> | |
| <div class="bg-gray-100 p-4 rounded font-mono text-sm overflow-x-auto">{{ details }}</div> | |
| </div> | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| SUCCESS_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>OIDC Debugger - Success</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| </head> | |
| <body class="bg-gray-100 min-h-screen"> | |
| <div class="container mx-auto px-6 py-8"> | |
| <div class="max-w-6xl mx-auto"> | |
| <h1 class="text-4xl font-bold text-gray-900 mb-8">OIDC Debugger - Success</h1> | |
| <div class="mb-4"> | |
| <a href="/" class="inline-block bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> | |
| ← Back to home | |
| </a> | |
| </div> | |
| <div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-8"> | |
| <h2 class="text-2xl font-semibold text-green-900 mb-2">Authorization Successful!</h2> | |
| <p class="text-green-800">The authorization code was successfully exchanged for tokens.</p> | |
| {% if state %} | |
| <p class="text-green-800 mt-2"><span class="font-semibold">State:</span> {{ state }}</p> | |
| {% endif %} | |
| </div> | |
| <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8"> | |
| <!-- Token Response --> | |
| <div class="bg-white rounded-lg shadow-md p-6"> | |
| <h2 class="text-2xl font-semibold text-gray-900 mb-4">Token Response</h2> | |
| <pre class="bg-gray-100 p-4 rounded overflow-x-auto text-sm"><code>{{ token_json }}</code></pre> | |
| </div> | |
| <!-- Userinfo Response --> | |
| <div class="bg-white rounded-lg shadow-md p-6"> | |
| <h2 class="text-2xl font-semibold text-gray-900 mb-4">Userinfo Response</h2> | |
| <pre class="bg-gray-100 p-4 rounded overflow-x-auto text-sm"><code>{{ userinfo_json }}</code></pre> | |
| </div> | |
| </div> | |
| <!-- JWT Token Analysis --> | |
| {% if id_token_decoded %} | |
| <div class="bg-white rounded-lg shadow-md p-6 mb-8"> | |
| <h2 class="text-2xl font-semibold text-gray-900 mb-4">ID Token Analysis</h2> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <div> | |
| <h3 class="text-lg font-semibold text-gray-800 mb-2">Header</h3> | |
| <pre class="bg-blue-50 p-4 rounded overflow-x-auto text-sm"><code>{{ id_token_decoded.header | tojson(indent=2) if id_token_decoded.header else id_token_decoded.error }}</code></pre> | |
| </div> | |
| <div> | |
| <h3 class="text-lg font-semibold text-gray-800 mb-2">Payload</h3> | |
| <pre class="bg-blue-50 p-4 rounded overflow-x-auto text-sm"><code>{{ id_token_decoded.payload | tojson(indent=2) if id_token_decoded.payload else id_token_decoded.error }}</code></pre> | |
| </div> | |
| </div> | |
| </div> | |
| {% endif %} | |
| {% if access_token_decoded %} | |
| <div class="bg-white rounded-lg shadow-md p-6 mb-8"> | |
| <h2 class="text-2xl font-semibold text-gray-900 mb-4">Access Token Analysis</h2> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| <div> | |
| <h3 class="text-lg font-semibold text-gray-800 mb-2">Header</h3> | |
| <pre class="bg-green-50 p-4 rounded overflow-x-auto text-sm"><code>{{ access_token_decoded.header | tojson(indent=2) if access_token_decoded.header else access_token_decoded.error }}</code></pre> | |
| </div> | |
| <div> | |
| <h3 class="text-lg font-semibold text-gray-800 mb-2">Payload</h3> | |
| <pre class="bg-green-50 p-4 rounded overflow-x-auto text-sm"><code>{{ access_token_decoded.payload | tojson(indent=2) if access_token_decoded.payload else access_token_decoded.error }}</code></pre> | |
| </div> | |
| </div> | |
| </div> | |
| {% endif %} | |
| </div> | |
| </div> | |
| </body> | |
| </html> | |
| """ | |
| @app.route("/") # type: ignore[misc] | |
| def home() -> str: | |
| """Serve the home page""" | |
| return render_template_string( # type: ignore[no-any-return] | |
| HOME_TEMPLATE, | |
| auth_url=config.auth_url, | |
| redirect_uri=config.redirect_uri, | |
| redirect_uri_encoded=urllib.parse.quote(config.redirect_uri, safe=""), | |
| client_id=config.client_id, | |
| default_scopes=config.default_scopes, | |
| ) | |
| @app.route("/login") # type: ignore[misc] | |
| def login() -> str | tuple[str, int]: | |
| """Redirect to authorization server with configured scopes""" | |
| if not config.auth_url: | |
| return render_template_string( | |
| ERROR_TEMPLATE, | |
| title="Authorization URL not configured", | |
| details="Cannot start login flow without AUTH_URL configured", | |
| ), 400 | |
| # Get scopes from form, default to configured scopes | |
| scopes = request.args.get("scopes", config.default_scopes).strip() | |
| if not scopes: | |
| scopes = config.default_scopes | |
| # Generate OAuth2 authorization URL | |
| params = { | |
| "client_id": config.client_id, | |
| "redirect_uri": config.redirect_uri, | |
| "response_type": "code", | |
| "scope": scopes, | |
| "state": "debugger-session", | |
| } | |
| login_url = f"{config.auth_url}?{urllib.parse.urlencode(params)}" | |
| print(f"Redirecting to authorization server with scopes: {scopes}") | |
| print(f"Login URL: {login_url}") | |
| return redirect(login_url) # type: ignore[no-any-return] | |
| @app.route("/callback") # type: ignore[misc] | |
| def callback() -> str | tuple[str, int]: | |
| """Handle OAuth2 callback with authorization code""" | |
| # Check for errors | |
| if "error" in request.args: | |
| error = request.args.get("error", "") | |
| error_description = request.args.get("error_description", "") | |
| return render_template_string(ERROR_TEMPLATE, title=f"OAuth2 Error: {error}", details=error_description), 400 | |
| # Get authorization code | |
| auth_code = request.args.get("code") | |
| if not auth_code: | |
| return render_template_string( | |
| ERROR_TEMPLATE, title="Missing authorization code", details="No 'code' parameter found in callback" | |
| ), 400 | |
| state = request.args.get("state", "") | |
| print(f"Received authorization code: {auth_code}") | |
| if state: | |
| print(f"State parameter: {state}") | |
| try: | |
| # Exchange code for tokens | |
| oidc_client = OIDCClient(config) | |
| token_response = oidc_client.exchange_code_for_tokens(auth_code) | |
| # Optionally call userinfo endpoint | |
| userinfo_response = None | |
| if token_response and "access_token" in token_response: | |
| userinfo_response = oidc_client.call_userinfo_endpoint(token_response["access_token"]) | |
| # Decode JWT tokens | |
| id_token_decoded = None | |
| access_token_decoded = None | |
| if "id_token" in token_response: | |
| id_token_decoded = decode_jwt_token(token_response["id_token"]) | |
| if "access_token" in token_response: | |
| access_token_decoded = decode_jwt_token(token_response["access_token"]) | |
| # Format JSON for display | |
| token_json = json.dumps(token_response, indent=2) | |
| userinfo_json = json.dumps(userinfo_response, indent=2) if userinfo_response else "Not called" | |
| # Print to console | |
| print("\n" + "=" * 50) | |
| print("TOKEN RESPONSE:") | |
| print(token_json) | |
| if userinfo_response: | |
| print("\nUSERINFO RESPONSE:") | |
| print(userinfo_json) | |
| print("=" * 50 + "\n") | |
| return render_template_string( # type: ignore[no-any-return] | |
| SUCCESS_TEMPLATE, | |
| token_json=token_json, | |
| userinfo_json=userinfo_json, | |
| state=state, | |
| id_token_decoded=id_token_decoded, | |
| access_token_decoded=access_token_decoded, | |
| ) | |
| except Exception as e: # noqa: BLE001 | |
| return render_template_string(ERROR_TEMPLATE, title="Processing Error", details=str(e)), 500 | |
| def main() -> int: | |
| global config | |
| config = OIDCConfig.from_env() | |
| # Validate configuration | |
| missing_vars = config.validate() | |
| if missing_vars: | |
| print(f"Error: Missing required environment variables: {', '.join(missing_vars)}") | |
| print("\nRequired variables:") | |
| print(" CLIENT_ID - OAuth2 client ID") | |
| print(" CLIENT_SECRET - OAuth2 client secret") | |
| print(" TOKEN_URL - OAuth2 token endpoint URL") | |
| print(" AUTH_URL - OAuth2 authorization endpoint URL") | |
| print("\nOptional variables:") | |
| print(" ENTRA_TENANT_ID - Microsoft Entra tenant ID (auto-sets TOKEN_URL, AUTH_URL, USERINFO_URL)") | |
| print(" USERINFO_URL - OIDC userinfo endpoint (only called if set)") | |
| print(" DEFAULT_SCOPES - Default OAuth2 scopes (default: openid profile email)") | |
| print(f" REDIRECT_URI - Redirect URI (default: http://localhost:{config.port}/callback)") | |
| print(" PORT - Server port (default: 3123)") | |
| return 1 | |
| # Print configuration | |
| print("OIDC Debugger Starting...") | |
| print(f"Port: {config.port}") | |
| print(f"Token URL: {config.token_url}") | |
| print(f"Client ID: {config.client_id}") | |
| print(f"Userinfo URL: {config.userinfo_url or 'Not configured'}") | |
| print(f"Redirect URI: {config.redirect_uri}") | |
| print(f"Redirect URI (URL-encoded): {urllib.parse.quote(config.redirect_uri, safe='')}") | |
| print(f"\nServer running at: http://localhost:{config.port}") | |
| print("Waiting for OAuth2 callbacks...") | |
| try: | |
| app.run(host="localhost", port=config.port, debug=False) | |
| except KeyboardInterrupt: | |
| print("\nShutting down server...") | |
| return 0 | |
| if __name__ == "__main__": | |
| exit(main()) | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment