mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-12-31 04:22:28 +00:00
Add OIDC provider functionality with validation setup
This commit adds OpenID Connect (OIDC) provider functionality to tinyauth, allowing it to act as an OIDC identity provider for other applications. Features: - OIDC discovery endpoint at /.well-known/openid-configuration - Authorization endpoint for OAuth 2.0 authorization code flow - Token endpoint for exchanging authorization codes for tokens - ID token generation with JWT signing - JWKS endpoint for public key distribution - Support for PKCE (code challenge/verifier) - Nonce validation for ID tokens - Configurable OIDC clients with redirect URIs, scopes, and grant types Validation: - Docker Compose setup for local testing - OIDC test client (oidc-whoami) with session management - Nginx reverse proxy configuration - DNS server (dnsmasq) for custom domain resolution - Chrome launch script for easy testing Configuration: - OIDC configuration in config.yaml - Example configuration in config.example.yaml - Database migrations for OIDC client storage
This commit is contained in:
14
validation/Dockerfile
Normal file
14
validation/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir requests authlib
|
||||
|
||||
COPY oidc_whoami.py /app/oidc_whoami.py
|
||||
|
||||
RUN chmod +x /app/oidc_whoami.py
|
||||
|
||||
EXPOSE 8765
|
||||
|
||||
CMD ["python3", "/app/oidc_whoami.py"]
|
||||
|
||||
181
validation/README.md
Normal file
181
validation/README.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# OIDC Validation Setup
|
||||
|
||||
This directory contains a docker-compose setup for testing tinyauth's OIDC provider functionality with a minimal test client.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Build the OIDC test client image:**
|
||||
```bash
|
||||
docker build -t oidc-whoami-test:latest .
|
||||
```
|
||||
|
||||
2. **Start the services:**
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
## Services
|
||||
|
||||
### nginx
|
||||
- **Purpose:** Reverse proxy for `auth.example.com` → tinyauth
|
||||
- **Ports:** 80 (exposed to host)
|
||||
- **Access:** http://auth.example.com/ (via nginx on port 80)
|
||||
|
||||
### dns
|
||||
- **Purpose:** DNS server (dnsmasq) that resolves `auth.example.com` to the tinyauth container
|
||||
- **Configuration:** Resolves `auth.example.com` to the `tinyauth` container IP (172.28.0.20) within the Docker network
|
||||
- **Ports:** 53 (UDP/TCP) - not exposed to host (only for container-to-container communication)
|
||||
|
||||
### tinyauth
|
||||
- **URL:** http://auth.example.com/ (via nginx)
|
||||
- **Credentials:** `user` / `pass`
|
||||
- **OIDC Discovery:** http://auth.example.com/api/.well-known/openid-configuration
|
||||
- **OIDC Client ID:** `testclient`
|
||||
- **OIDC Client Secret:** `test-secret-123`
|
||||
- **Ports:** Not exposed to host (accessed via nginx on port 80)
|
||||
|
||||
### oidc-whoami
|
||||
- **Callback URL:** http://localhost:8765/callback
|
||||
- **Purpose:** Minimal OIDC test client that validates the OIDC flow
|
||||
- **Ports:** 8765 (exposed to host)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Start all services:**
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
2. **Launch Chrome with host-resolver-rules:**
|
||||
```bash
|
||||
./launch-chrome-host.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
google-chrome \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
|
||||
--disable-features=HttpsOnlyMode \
|
||||
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com \
|
||||
--user-data-dir=/tmp/chrome-test-profile \
|
||||
http://auth.example.com/
|
||||
```
|
||||
|
||||
**Note:** The `--user-data-dir` flag uses a temporary profile to avoid HSTS (HTTP Strict Transport Security) issues that might force HTTPS redirects.
|
||||
|
||||
3. **Access tinyauth:** http://auth.example.com/
|
||||
- Login with: `user` / `pass`
|
||||
|
||||
4. **Test OIDC flow:**
|
||||
```bash
|
||||
# Get authorization URL from oidc-whoami logs
|
||||
docker compose logs oidc-whoami | grep "Authorization URL"
|
||||
# Open that URL in Chrome (already configured with host-resolver-rules)
|
||||
```
|
||||
|
||||
## Connecting from Chrome/Browser
|
||||
|
||||
Since the DNS server is only accessible within the Docker network, you have several options to access `auth.example.com` from your browser:
|
||||
|
||||
### Option 1: Use /etc/hosts (Simplest)
|
||||
|
||||
Add this line to your `/etc/hosts` file (or `C:\Windows\System32\drivers\etc\hosts` on Windows):
|
||||
|
||||
```
|
||||
127.0.0.1 auth.example.com
|
||||
```
|
||||
|
||||
Then access: http://auth.example.com/
|
||||
|
||||
**To edit /etc/hosts on Linux/Mac:**
|
||||
```bash
|
||||
sudo nano /etc/hosts
|
||||
# Add: 127.0.0.1 auth.example.com
|
||||
```
|
||||
|
||||
**To edit hosts on Windows:**
|
||||
1. Open Notepad as Administrator
|
||||
2. Open `C:\Windows\System32\drivers\etc\hosts`
|
||||
3. Add: `127.0.0.1 auth.example.com`
|
||||
|
||||
### Option 2: Use Chrome's `--host-resolver-rules` (Chrome-specific, No System Changes)
|
||||
|
||||
Chrome has a command-line flag that lets you map hostnames directly, bypassing DNS entirely. This is perfect for testing without modifying system settings.
|
||||
|
||||
**To use it:**
|
||||
|
||||
1. **Make sure services are running:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
2. **Launch Chrome with the host resolver rule:**
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
**Mac:**
|
||||
```bash
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
"C:\Program Files\Google\ Chrome\Application\chrome.exe" --host-resolver-rules="MAP auth.example.com 127.0.0.1"
|
||||
```
|
||||
|
||||
3. **Or modify Chrome's shortcut:**
|
||||
- Right-click Chrome shortcut → Properties
|
||||
- In "Target" field, append: ` --host-resolver-rules="MAP auth.example.com 127.0.0.1"`
|
||||
- Click OK
|
||||
|
||||
4. **Access:** http://auth.example.com/
|
||||
|
||||
**Note:** This only affects Chrome, not other applications. The DNS server on port 5353 isn't needed for this approach.
|
||||
|
||||
### Option 3: Use System DNS (All Applications)
|
||||
|
||||
If you want to use the DNS server on port 5353 for all applications (not just Chrome), configure your system DNS:
|
||||
|
||||
**Linux (with systemd-resolved):**
|
||||
```bash
|
||||
# Configure systemd-resolved to use our DNS
|
||||
sudo resolvectl dns lo 127.0.0.1:5353
|
||||
```
|
||||
|
||||
**Linux (without systemd-resolved):**
|
||||
```bash
|
||||
# Edit /etc/resolv.conf
|
||||
sudo nano /etc/resolv.conf
|
||||
# Add: nameserver 127.0.0.1
|
||||
# Note: This won't work with port 5353, you'd need port 53
|
||||
```
|
||||
|
||||
**Note:** Most systems expect DNS on port 53. To use port 5353, you'd need a DNS proxy or configure Chrome specifically (see Option 2 above).
|
||||
|
||||
## Testing
|
||||
|
||||
1. Start the services with `docker compose up --build -d`
|
||||
2. Launch Chrome: `./launch-chrome-host.sh` (or use `--host-resolver-rules` manually)
|
||||
3. Navigate to: http://auth.example.com/
|
||||
4. Login with `user` / `pass`
|
||||
5. Test the OIDC flow by accessing the discovery endpoint: http://auth.example.com/api/.well-known/openid-configuration
|
||||
|
||||
## Configuration
|
||||
|
||||
The tinyauth configuration is in `config.yaml`:
|
||||
- OIDC is enabled
|
||||
- Single user: `user` with password `pass`
|
||||
- OIDC client `testclient` is configured with redirect URI `http://localhost:8765/callback`
|
||||
- App URL and OIDC issuer: `http://auth.example.com` (via nginx on port 80)
|
||||
|
||||
## Notes
|
||||
|
||||
- All containers are on a custom Docker network (`tinyauth-network`) with a DNS server for domain resolution
|
||||
- The DNS server resolves `auth.example.com` to the tinyauth container within the network
|
||||
- The redirect URI must match exactly what's configured in tinyauth
|
||||
- Data is persisted in the `./data` directory
|
||||
- The domain `auth.example.com` is used to satisfy cookie domain validation requirements (needs at least 3 domain parts and not in public suffix list)
|
||||
36
validation/config.yaml
Normal file
36
validation/config.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
appUrl: "http://auth.example.com"
|
||||
logLevel: "info"
|
||||
databasePath: "/data/tinyauth.db"
|
||||
|
||||
auth:
|
||||
users: "user:$2b$12$mWEdxub8KTTBLK/f7dloKOS4t3kIeLOpme5pMXci5.lXNPANjCT5u" # user:pass
|
||||
secureCookie: false
|
||||
sessionExpiry: 3600
|
||||
loginTimeout: 300
|
||||
loginMaxRetries: 3
|
||||
|
||||
oidc:
|
||||
enabled: true
|
||||
issuer: "http://auth.example.com"
|
||||
accessTokenExpiry: 3600
|
||||
idTokenExpiry: 3600
|
||||
clients:
|
||||
testclient:
|
||||
clientSecret: "test-secret-123"
|
||||
clientName: "OIDC Test Client"
|
||||
redirectUris:
|
||||
- "http://client.example.com/callback"
|
||||
- "http://localhost:8765/callback"
|
||||
- "http://127.0.0.1:8765/callback"
|
||||
grantTypes:
|
||||
- "authorization_code"
|
||||
responseTypes:
|
||||
- "code"
|
||||
scopes:
|
||||
- "openid"
|
||||
- "profile"
|
||||
- "email"
|
||||
|
||||
ui:
|
||||
title: "Tinyauth OIDC Test"
|
||||
|
||||
91
validation/docker-compose.yml
Normal file
91
validation/docker-compose.yml
Normal file
@@ -0,0 +1,91 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
dns:
|
||||
container_name: dns-server
|
||||
image: strm/dnsmasq:latest
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
command:
|
||||
- "--no-daemon"
|
||||
- "--log-queries"
|
||||
- "--no-resolv"
|
||||
- "--server=8.8.8.8"
|
||||
- "--server=8.8.4.4"
|
||||
- "--address=/auth.example.com/172.28.0.2"
|
||||
- "--address=/client.example.com/172.28.0.2"
|
||||
# DNS port not exposed to host - only needed for container-to-container communication
|
||||
# Chrome uses --host-resolver-rules instead
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.10
|
||||
|
||||
nginx:
|
||||
container_name: nginx-proxy
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
networks:
|
||||
- tinyauth-network
|
||||
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
|
||||
# Our custom DNS (172.28.0.10) is only used via resolver directive in nginx.conf
|
||||
depends_on:
|
||||
- tinyauth
|
||||
- dns
|
||||
- oidc-whoami
|
||||
|
||||
|
||||
tinyauth:
|
||||
container_name: tinyauth-oidc-test
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
command: ["--experimental.configfile=/config/config.yaml"]
|
||||
# Port not exposed to host - accessed via nginx
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config.yaml:/config/config.yaml:ro
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.20
|
||||
depends_on:
|
||||
- dns
|
||||
healthcheck:
|
||||
test: ["CMD", "tinyauth", "healthcheck"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
oidc-whoami:
|
||||
container_name: oidc-whoami-test
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
environment:
|
||||
- OIDC_ISSUER=http://auth.example.com
|
||||
- CLIENT_ID=testclient
|
||||
- CLIENT_SECRET=test-secret-123
|
||||
# Port not exposed to host - accessed via nginx
|
||||
depends_on:
|
||||
- tinyauth
|
||||
- dns
|
||||
# Use Docker's built-in DNS first, then our custom DNS for custom domains
|
||||
dns:
|
||||
- 127.0.0.11
|
||||
- 172.28.0.10
|
||||
networks:
|
||||
tinyauth-network:
|
||||
ipv4_address: 172.28.0.30
|
||||
# Note: Using custom network with DNS server to resolve auth.example.test
|
||||
# The redirect URI must match what's configured in tinyauth (http://localhost:8765/callback)
|
||||
# Using auth.example.test domain to satisfy cookie domain validation requirements (needs 3+ parts, not in public suffix list)
|
||||
|
||||
networks:
|
||||
tinyauth-network:
|
||||
driver: bridge
|
||||
ipam:
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
|
||||
39
validation/launch-chrome-host.sh
Executable file
39
validation/launch-chrome-host.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
# Launch Chrome from host (not in container)
|
||||
# This script should be run on your host machine
|
||||
|
||||
set -e
|
||||
|
||||
echo "Launching Chrome for OIDC test setup..."
|
||||
|
||||
# Detect Chrome
|
||||
if command -v google-chrome &> /dev/null; then
|
||||
CHROME_CMD="google-chrome"
|
||||
elif command -v chromium-browser &> /dev/null; then
|
||||
CHROME_CMD="chromium-browser"
|
||||
elif command -v chromium &> /dev/null; then
|
||||
CHROME_CMD="chromium"
|
||||
elif [ -f "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" ]; then
|
||||
CHROME_CMD="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
else
|
||||
echo "Error: Chrome not found. Please install Google Chrome or Chromium."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using: $CHROME_CMD"
|
||||
echo "Opening: http://client.example.com/ (OIDC test client)"
|
||||
echo ""
|
||||
|
||||
$CHROME_CMD \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1, MAP client.example.com 127.0.0.1" \
|
||||
--disable-features=HttpsOnlyMode \
|
||||
--unsafely-treat-insecure-origin-as-secure=http://auth.example.com,http://client.example.com \
|
||||
--user-data-dir=/tmp/chrome-test-profile-$(date +%s) \
|
||||
--new-window \
|
||||
http://client.example.com/ \
|
||||
> /dev/null 2>&1 &
|
||||
|
||||
echo "Chrome launched!"
|
||||
echo "OIDC test client: http://client.example.com/"
|
||||
echo "Tinyauth: http://auth.example.com/"
|
||||
|
||||
68
validation/launch-chrome.sh
Executable file
68
validation/launch-chrome.sh
Executable file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "=========================================="
|
||||
echo "Chrome Launcher for OIDC Test Setup"
|
||||
echo "=========================================="
|
||||
|
||||
# Wait for nginx to be ready
|
||||
echo "Waiting for nginx to be ready..."
|
||||
for i in {1..30}; do
|
||||
if curl -s http://127.0.0.1/ > /dev/null 2>&1; then
|
||||
echo "✓ Nginx is ready"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "✗ Nginx not ready after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Try to find Chrome on the host system
|
||||
# Since we're in a container, we need to check common locations
|
||||
CHROME_PATHS=(
|
||||
"/usr/bin/google-chrome"
|
||||
"/usr/bin/google-chrome-stable"
|
||||
"/usr/bin/chromium-browser"
|
||||
"/usr/bin/chromium"
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
||||
)
|
||||
|
||||
CHROME_CMD=""
|
||||
for path in "${CHROME_PATHS[@]}"; do
|
||||
if [ -f "$path" ] || command -v "$(basename "$path")" &> /dev/null; then
|
||||
CHROME_CMD="$(basename "$path")"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$CHROME_CMD" ]; then
|
||||
echo ""
|
||||
echo "Chrome not found in container. This is expected."
|
||||
echo "Please launch Chrome manually on your host with:"
|
||||
echo ""
|
||||
echo ' google-chrome --host-resolver-rules="MAP auth.example.com 127.0.0.1" http://auth.example.com/'
|
||||
echo ""
|
||||
echo "Or use the launch script on your host:"
|
||||
echo " ./launch-chrome.sh"
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found Chrome: $CHROME_CMD"
|
||||
echo "Launching Chrome with host-resolver-rules..."
|
||||
echo ""
|
||||
|
||||
$CHROME_CMD \
|
||||
--host-resolver-rules="MAP auth.example.com 127.0.0.1" \
|
||||
--new-window \
|
||||
http://auth.example.com/ \
|
||||
> /dev/null 2>&1 &
|
||||
|
||||
echo "✓ Chrome launched!"
|
||||
echo ""
|
||||
echo "Access tinyauth at: http://auth.example.com/"
|
||||
echo "OIDC test client callback: http://127.0.0.1:8765/callback"
|
||||
echo ""
|
||||
|
||||
43
validation/nginx.conf
Normal file
43
validation/nginx.conf
Normal file
@@ -0,0 +1,43 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
# Use Docker's built-in DNS (127.0.0.11) for service name resolution
|
||||
# This allows nginx to resolve Docker service names like "tinyauth" and "oidc-whoami"
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
resolver_timeout 5s;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name auth.example.com;
|
||||
|
||||
location / {
|
||||
# Use variable to enable dynamic resolution at request time
|
||||
set $backend "tinyauth:3000";
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name client.example.com;
|
||||
|
||||
location / {
|
||||
# Use variable to enable dynamic resolution at request time
|
||||
set $backend "oidc-whoami:8765";
|
||||
proxy_pass http://$backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
297
validation/oidc_whoami.py
Normal file
297
validation/oidc_whoami.py
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import webbrowser
|
||||
import secrets
|
||||
import time
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from http.cookies import SimpleCookie
|
||||
|
||||
import requests
|
||||
from authlib.integrations.requests_client import OAuth2Session
|
||||
from authlib.oidc.core import CodeIDToken
|
||||
from authlib.jose import jwt
|
||||
|
||||
# ---- config via env ----
|
||||
ISSUER = os.environ["OIDC_ISSUER"]
|
||||
CLIENT_ID = os.environ["CLIENT_ID"]
|
||||
CLIENT_SECRET= os.environ.get("CLIENT_SECRET") # optional (public clients ok)
|
||||
REDIRECT_URI = "http://client.example.com/callback"
|
||||
SCOPE = "openid profile email"
|
||||
|
||||
# ---- discovery ----
|
||||
# Retry discovery in case nginx isn't ready yet
|
||||
discovery = None
|
||||
for attempt in range(10):
|
||||
try:
|
||||
discovery = requests.get(
|
||||
f"{ISSUER.rstrip('/')}/api/.well-known/openid-configuration",
|
||||
timeout=5
|
||||
).json()
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt < 9:
|
||||
print(f"Discovery attempt {attempt + 1} failed: {e}, retrying...")
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise
|
||||
|
||||
if discovery is None:
|
||||
raise RuntimeError("Failed to fetch OIDC discovery document after 10 attempts")
|
||||
|
||||
state = secrets.token_urlsafe(16)
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
|
||||
client = OAuth2Session(
|
||||
client_id=CLIENT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
scope=SCOPE,
|
||||
redirect_uri=REDIRECT_URI,
|
||||
)
|
||||
|
||||
auth_result = client.create_authorization_url(
|
||||
discovery["authorization_endpoint"],
|
||||
state=state,
|
||||
nonce=nonce,
|
||||
code_challenge_method="S256",
|
||||
)
|
||||
auth_url = auth_result[0]
|
||||
code_verifier = auth_result[1] if len(auth_result) > 1 else None
|
||||
|
||||
# Cache JWKS for token validation
|
||||
jwk_set_cache = None
|
||||
jwk_set_cache_time = None
|
||||
|
||||
def get_jwk_set():
|
||||
"""Get JWKS with caching"""
|
||||
global jwk_set_cache, jwk_set_cache_time
|
||||
# Cache for 1 hour
|
||||
if jwk_set_cache is None or (jwk_set_cache_time and time.time() - jwk_set_cache_time > 3600):
|
||||
jwk_set_cache = requests.get(discovery["jwks_uri"]).json()
|
||||
jwk_set_cache_time = time.time()
|
||||
return jwk_set_cache
|
||||
|
||||
def parse_cookies(cookie_header):
|
||||
"""Parse cookies from Cookie header"""
|
||||
if not cookie_header:
|
||||
return {}
|
||||
cookie = SimpleCookie()
|
||||
cookie.load(cookie_header)
|
||||
return {k: v.value for k, v in cookie.items()}
|
||||
|
||||
def validate_id_token(id_token):
|
||||
"""Validate and decode ID token"""
|
||||
try:
|
||||
jwk_set = get_jwk_set()
|
||||
claims_options = {
|
||||
"iss": {"essential": True, "value": discovery["issuer"]},
|
||||
"aud": {"essential": True, "value": CLIENT_ID},
|
||||
}
|
||||
decoded = jwt.decode(
|
||||
id_token,
|
||||
key=jwk_set,
|
||||
claims_options=claims_options
|
||||
)
|
||||
decoded.validate()
|
||||
return dict(decoded)
|
||||
except Exception as e:
|
||||
print(f"Token validation failed: {e}")
|
||||
return None
|
||||
|
||||
# ---- tiny callback server ----
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
# Handle root path - check if already logged in
|
||||
if self.path == "/" or self.path == "":
|
||||
cookies = parse_cookies(self.headers.get("Cookie"))
|
||||
id_token = cookies.get("id_token")
|
||||
|
||||
# Check if we have a valid token
|
||||
if id_token:
|
||||
claims = validate_id_token(id_token)
|
||||
if claims and claims.get("exp", 0) > time.time():
|
||||
# Already logged in - show main page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OIDC Test Client - Welcome</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}}
|
||||
.main-box {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #4285f4;
|
||||
margin-top: 0;
|
||||
}}
|
||||
.user-info {{
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
border-left: 4px solid #4285f4;
|
||||
}}
|
||||
pre {{
|
||||
background: #f9f9f9;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid #ddd;
|
||||
}}
|
||||
.logout-btn {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-box">
|
||||
<h1>✅ Welcome back!</h1>
|
||||
<div class="user-info">
|
||||
<h2>User Information</h2>
|
||||
<p><strong>Username:</strong> {claims.get('preferred_username', claims.get('sub', 'N/A'))}</p>
|
||||
<p><strong>Name:</strong> {claims.get('name', 'N/A')}</p>
|
||||
<p><strong>Email:</strong> {claims.get('email', 'N/A')}</p>
|
||||
</div>
|
||||
<hr>
|
||||
<h2>ID Token Claims:</h2>
|
||||
<pre>{json.dumps(claims, indent=2)}</pre>
|
||||
<a href="/logout" class="logout-btn">Logout</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html.encode())
|
||||
return
|
||||
|
||||
# Not logged in - show login page
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>OIDC Test Client</title></head>
|
||||
<body>
|
||||
<h1>OIDC Test Client</h1>
|
||||
<p>Click the button below to start the OIDC flow:</p>
|
||||
<a href="{auth_url}" style="display: inline-block; padding: 10px 20px; background: #4285f4; color: white; text-decoration: none; border-radius: 4px;">Login with OIDC</a>
|
||||
<hr>
|
||||
<p><small>Authorization URL: <code>{auth_url}</code></small></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self.wfile.write(html.encode())
|
||||
return
|
||||
|
||||
# Handle logout
|
||||
if self.path == "/logout":
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Set-Cookie", "id_token=; Path=/; Max-Age=0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# Handle callback
|
||||
if not self.path.startswith("/callback"):
|
||||
self.send_error(404, "Not Found")
|
||||
return
|
||||
|
||||
qs = parse_qs(urlparse(self.path).query)
|
||||
|
||||
if qs.get("state", [None])[0] != state:
|
||||
self.send_error(400, "Invalid state")
|
||||
return
|
||||
|
||||
code = qs.get("code", [None])[0]
|
||||
if not code:
|
||||
self.send_error(400, "Missing code")
|
||||
return
|
||||
|
||||
token = client.fetch_token(
|
||||
discovery["token_endpoint"],
|
||||
code=code,
|
||||
code_verifier=code_verifier,
|
||||
)
|
||||
|
||||
# ---- ID token validation ----
|
||||
# Decode and validate the ID token using cached JWKS
|
||||
jwk_set = get_jwk_set()
|
||||
|
||||
# Decode the JWT - make nonce optional if not provided
|
||||
claims_options = {
|
||||
"iss": {"essential": True, "value": discovery["issuer"]},
|
||||
"aud": {"essential": True, "value": CLIENT_ID},
|
||||
}
|
||||
if nonce:
|
||||
claims_options["nonce"] = {"essential": True, "value": nonce}
|
||||
|
||||
decoded = jwt.decode(
|
||||
token["id_token"],
|
||||
key=jwk_set,
|
||||
claims_options=claims_options
|
||||
)
|
||||
decoded.validate()
|
||||
|
||||
# Convert JWTClaims to dict for display
|
||||
id_token_claims = dict(decoded)
|
||||
|
||||
# Store ID token in cookie (expires when token expires)
|
||||
token_expiry = id_token_claims.get("exp", 0) - time.time()
|
||||
max_age = max(0, int(token_expiry))
|
||||
|
||||
# Redirect to main page with cookie set
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/")
|
||||
self.send_header("Set-Cookie", f"id_token={token['id_token']}; Path=/; Max-Age={max_age}; HttpOnly")
|
||||
self.end_headers()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("✅ OIDC Authentication Successful!")
|
||||
print("=" * 60)
|
||||
print("\nID Token Claims:")
|
||||
print(json.dumps(id_token_claims, indent=2))
|
||||
print("\n" + "=" * 60)
|
||||
# Don't exit - keep server running for multiple test flows
|
||||
|
||||
# ---- run ----
|
||||
print("=" * 60)
|
||||
print("OIDC Test Client")
|
||||
print("=" * 60)
|
||||
print(f"\nAuthorization URL: {auth_url}")
|
||||
print("\nTo test the OIDC flow:")
|
||||
print("1. Open the authorization URL above in your browser")
|
||||
print("2. Login with credentials: user / pass")
|
||||
print("3. You will be redirected back to the callback")
|
||||
print("4. The ID token claims will be displayed below")
|
||||
print(f"\nWaiting for callback on {REDIRECT_URI}...")
|
||||
print("=" * 60)
|
||||
|
||||
# Try to open browser (may fail in Docker, that's OK)
|
||||
try:
|
||||
webbrowser.open(auth_url)
|
||||
except Exception as e:
|
||||
print(f"Could not open browser automatically: {e}")
|
||||
print("Please open the authorization URL manually")
|
||||
|
||||
HTTPServer(("0.0.0.0", 8765), CallbackHandler).serve_forever()
|
||||
Reference in New Issue
Block a user