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:
Olivier Dumont
2025-12-30 12:17:40 +01:00
parent 986ac88e14
commit 020fcb9878
21 changed files with 1873 additions and 8 deletions

14
validation/Dockerfile Normal file
View 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
View 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
View 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"

View 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

View 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
View 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
View 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
View 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()