Skip to content

Instantly share code, notes, and snippets.

@NiklasRosenstein
Created January 13, 2026 13:47
Show Gist options
  • Select an option

  • Save NiklasRosenstein/97b09b2a1b7d71a415b819b9244a0dc7 to your computer and use it in GitHub Desktop.

Select an option

Save NiklasRosenstein/97b09b2a1b7d71a415b819b9244a0dc7 to your computer and use it in GitHub Desktop.
#!/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