Initial commit
Adds OIDC token validator application with FastAPI backend and HTML/JavaScript frontend. Includes Docker configuration and Kubernetes readiness.
This commit is contained in:
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
k8s-manifests
|
||||
__pycache__
|
||||
53
Dockerfile
Normal file
53
Dockerfile
Normal file
@@ -0,0 +1,53 @@
|
||||
# Multi-stage build for smaller image size
|
||||
FROM python:3.9-alpine AS python-base
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S app &&\
|
||||
adduser -u 1001 -S app -G app
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
#############################################
|
||||
# Builder stage
|
||||
#############################################
|
||||
FROM python-base AS builder
|
||||
|
||||
# Install build dependencies needed for cryptography
|
||||
RUN apk add --no-cache gcc musl-dev libffi-dev openssl-dev
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
#############################################
|
||||
# Final stage
|
||||
#############################################
|
||||
FROM python-base
|
||||
|
||||
# Copy installed Python packages and binaries from builder stage
|
||||
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages
|
||||
COPY --from=builder /usr/local/bin /usr/local/bin
|
||||
|
||||
# Copy application files
|
||||
COPY main.py .
|
||||
COPY templates/ templates/
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R app:app /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8000/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
366
main.py
Normal file
366
main.py
Normal file
@@ -0,0 +1,366 @@
|
||||
from fastapi import FastAPI, HTTPException, Header
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import jwt
|
||||
import requests
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
app = FastAPI(title="OIDC Token Validator")
|
||||
|
||||
# Mount static files directory (if needed for CSS, JS, images, etc.)
|
||||
# app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
# Add CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify exact origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Configuration
|
||||
ISSUER = os.getenv("OIDC_ISSUER", "https://login.infomaniak.com") # OIDC issuer URL
|
||||
CLIENT_ID = os.getenv("CLIENT_ID") # Client ID should be set as environment variable
|
||||
CLIENT_SECRET = os.getenv("CLIENT_SECRET", "") # Client secret should be set as environment variable
|
||||
WELL_KNOWN_CONFIG_URL = f"{ISSUER}/.well-known/openid-configuration"
|
||||
|
||||
# List of authorized users (in a real app, this would come from a database)
|
||||
AUTHORIZED_USERS = {
|
||||
"rene.luria@infomaniak.com": "Welcome to the secret club!",
|
||||
"admin@example.com": "Admin super secret phrase!"
|
||||
}
|
||||
|
||||
class TokenValidationRequest(BaseModel):
|
||||
id_token: str
|
||||
access_token: Optional[str] = None
|
||||
|
||||
class TokenRefreshRequest(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class TokenRefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
id_token: Optional[str] = None
|
||||
expires_in: int
|
||||
token_type: str
|
||||
error: Optional[str] = None
|
||||
|
||||
class UserInfo(BaseModel):
|
||||
email: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
|
||||
class TokenValidationResponse(BaseModel):
|
||||
valid: bool
|
||||
user: Optional[UserInfo] = None
|
||||
secret_phrase: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
class ClientConfig(BaseModel):
|
||||
client_id: str
|
||||
issuer: str
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_well_known_config():
|
||||
"""Fetch OIDC well-known configuration (cached)"""
|
||||
try:
|
||||
response = requests.get(WELL_KNOWN_CONFIG_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
if not response.text:
|
||||
raise ValueError("Empty response from well-known configuration endpoint")
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ValueError(f"Failed to fetch well-known configuration: {str(e)}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid well-known configuration response: {str(e)}")
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_jwks():
|
||||
"""Fetch JWKS from the issuer (cached)"""
|
||||
try:
|
||||
# Get the JWKS URL from the well-known configuration
|
||||
well_known_config = get_well_known_config()
|
||||
jwks_url = well_known_config.get("jwks_uri")
|
||||
|
||||
if not jwks_url:
|
||||
raise ValueError("JWKS URI not found in well-known configuration")
|
||||
|
||||
response = requests.get(jwks_url, timeout=10)
|
||||
response.raise_for_status()
|
||||
if not response.text:
|
||||
raise ValueError("Empty response from JWKS endpoint")
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ValueError(f"Failed to fetch JWKS: {str(e)}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid JWKS response: {str(e)}")
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_userinfo_endpoint():
|
||||
"""Get the userinfo endpoint URL from the well-known configuration"""
|
||||
try:
|
||||
well_known_config = get_well_known_config()
|
||||
userinfo_endpoint = well_known_config.get("userinfo_endpoint")
|
||||
|
||||
if not userinfo_endpoint:
|
||||
raise ValueError("Userinfo endpoint not found in well-known configuration")
|
||||
|
||||
return userinfo_endpoint
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to get userinfo endpoint: {str(e)}")
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_token_endpoint():
|
||||
"""Get the token endpoint URL from the well-known configuration"""
|
||||
try:
|
||||
well_known_config = get_well_known_config()
|
||||
token_endpoint = well_known_config.get("token_endpoint")
|
||||
|
||||
if not token_endpoint:
|
||||
raise ValueError("Token endpoint not found in well-known configuration")
|
||||
|
||||
return token_endpoint
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to get token endpoint: {str(e)}")
|
||||
|
||||
def get_user_info_from_endpoint(access_token: str):
|
||||
"""Fetch user information from the userinfo endpoint"""
|
||||
try:
|
||||
userinfo_endpoint = get_userinfo_endpoint()
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
}
|
||||
|
||||
response = requests.get(userinfo_endpoint, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
if not response.text:
|
||||
raise ValueError("Empty response from userinfo endpoint")
|
||||
|
||||
user_info = response.json()
|
||||
|
||||
# Map the user info fields
|
||||
# Note: Field names may vary depending on the OIDC provider
|
||||
first_name = user_info.get("given_name") or user_info.get("first_name") or user_info.get("firstName")
|
||||
last_name = user_info.get("family_name") or user_info.get("last_name") or user_info.get("lastName")
|
||||
|
||||
return {
|
||||
"email": user_info.get("email", ""),
|
||||
"first_name": first_name,
|
||||
"last_name": last_name
|
||||
}
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ValueError(f"Failed to fetch user info: {str(e)}")
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Invalid user info response: {str(e)}")
|
||||
|
||||
def refresh_token(refresh_token: str):
|
||||
"""Refresh an access token using a refresh token"""
|
||||
try:
|
||||
# Validate input
|
||||
if not refresh_token or not isinstance(refresh_token, str):
|
||||
raise ValueError("Invalid refresh token provided")
|
||||
|
||||
if not CLIENT_SECRET:
|
||||
raise ValueError("Client secret not configured")
|
||||
|
||||
# Get token endpoint
|
||||
token_endpoint = get_token_endpoint()
|
||||
|
||||
# Prepare refresh request
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET
|
||||
}
|
||||
|
||||
# Make token refresh request
|
||||
response = requests.post(token_endpoint, data=data, timeout=10)
|
||||
|
||||
if not response.ok:
|
||||
raise ValueError(f"Token refresh failed: {response.status_code} - {response.text}")
|
||||
|
||||
token_response = response.json()
|
||||
|
||||
# Validate required fields in response
|
||||
if "access_token" not in token_response:
|
||||
raise ValueError("Access token not found in refresh response")
|
||||
|
||||
return {
|
||||
"access_token": token_response["access_token"],
|
||||
"id_token": token_response.get("id_token"),
|
||||
"expires_in": token_response.get("expires_in", 0),
|
||||
"token_type": token_response.get("token_type", "Bearer")
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise ValueError(f"Failed to refresh token: {str(e)}")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token refresh failed: {str(e)}")
|
||||
|
||||
def verify_token(id_token: str):
|
||||
"""Verify JWT token and return payload if valid"""
|
||||
try:
|
||||
# Validate input
|
||||
if not id_token or not isinstance(id_token, str):
|
||||
raise ValueError("Invalid token provided")
|
||||
|
||||
# Get JWKS
|
||||
jwks = get_jwks()
|
||||
|
||||
# Decode token header to get kid
|
||||
header = jwt.get_unverified_header(id_token)
|
||||
kid = header.get("kid")
|
||||
|
||||
if not kid:
|
||||
raise ValueError("Token header missing 'kid' field")
|
||||
|
||||
# Find the matching key in JWKS
|
||||
key = None
|
||||
if not jwks or "keys" not in jwks:
|
||||
raise ValueError("Invalid JWKS format")
|
||||
|
||||
for jwk in jwks.get("keys", []):
|
||||
if jwk.get("kid") == kid:
|
||||
key = jwt.algorithms.RSAAlgorithm.from_jwk(jwk)
|
||||
break
|
||||
|
||||
if not key:
|
||||
raise ValueError("Unable to find matching key in JWKS")
|
||||
|
||||
# Verify and decode token
|
||||
payload = jwt.decode(
|
||||
id_token,
|
||||
key=key,
|
||||
algorithms=[header.get("alg", "RS256")],
|
||||
audience=CLIENT_ID,
|
||||
issuer=ISSUER
|
||||
)
|
||||
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise ValueError("Token has expired")
|
||||
except jwt.InvalidAudienceError:
|
||||
raise ValueError("Invalid audience")
|
||||
except jwt.InvalidIssuerError:
|
||||
raise ValueError("Invalid issuer")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Token validation failed: {str(e)}")
|
||||
|
||||
@app.post("/validate-token", response_model=TokenValidationResponse)
|
||||
async def validate_token(request: TokenValidationRequest):
|
||||
"""Validate ID token and return secret phrase for authorized users"""
|
||||
# Log the incoming request for debugging
|
||||
print(f"Validating token, length: {len(request.id_token) if request.id_token else 0}")
|
||||
|
||||
try:
|
||||
# Verify token
|
||||
payload = verify_token(request.id_token)
|
||||
|
||||
# Extract user email
|
||||
user_email = payload.get("email")
|
||||
if not user_email:
|
||||
return TokenValidationResponse(
|
||||
valid=False,
|
||||
error="Email not found in token"
|
||||
)
|
||||
|
||||
# Initialize user info with email from token
|
||||
user_info = {
|
||||
"email": user_email,
|
||||
"first_name": None,
|
||||
"last_name": None
|
||||
}
|
||||
|
||||
# If access token is provided, fetch additional user info from userinfo endpoint
|
||||
if request.access_token:
|
||||
try:
|
||||
additional_info = get_user_info_from_endpoint(request.access_token)
|
||||
user_info.update(additional_info)
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to fetch user info from userinfo endpoint: {str(e)}")
|
||||
# Continue with basic user info from token
|
||||
|
||||
# Check if user is authorized
|
||||
secret_phrase = AUTHORIZED_USERS.get(user_email)
|
||||
|
||||
return TokenValidationResponse(
|
||||
valid=True,
|
||||
user=UserInfo(**user_info),
|
||||
secret_phrase=secret_phrase
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
print(f"ValueError during token validation: {str(e)}")
|
||||
return TokenValidationResponse(
|
||||
valid=False,
|
||||
error=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Unexpected error during token validation: {str(e)}")
|
||||
return TokenValidationResponse(
|
||||
valid=False,
|
||||
error=f"Unexpected error: {str(e)}"
|
||||
)
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def read_root():
|
||||
"""Serve the frontend HTML file"""
|
||||
with open("templates/index.html", "r") as file:
|
||||
html_content = file.read()
|
||||
return HTMLResponse(content=html_content, status_code=200)
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for Kubernetes deployment"""
|
||||
return {"status": "healthy", "timestamp": os.getenv("HOSTNAME", "unknown")}
|
||||
|
||||
@app.post("/refresh-token", response_model=TokenRefreshResponse)
|
||||
async def refresh_token_endpoint(request: TokenRefreshRequest):
|
||||
"""Refresh an access token using a refresh token"""
|
||||
try:
|
||||
# Refresh the token
|
||||
refreshed_tokens = refresh_token(request.refresh_token)
|
||||
|
||||
return TokenRefreshResponse(
|
||||
access_token=refreshed_tokens["access_token"],
|
||||
id_token=refreshed_tokens["id_token"],
|
||||
expires_in=refreshed_tokens["expires_in"],
|
||||
token_type=refreshed_tokens["token_type"]
|
||||
)
|
||||
except ValueError as e:
|
||||
return TokenRefreshResponse(
|
||||
access_token="", # Required field, but empty since there's an error
|
||||
id_token=None,
|
||||
expires_in=0,
|
||||
token_type="",
|
||||
error=str(e)
|
||||
)
|
||||
except Exception as e:
|
||||
return TokenRefreshResponse(
|
||||
access_token="", # Required field, but empty since there's an error
|
||||
id_token=None,
|
||||
expires_in=0,
|
||||
token_type="",
|
||||
error=f"Unexpected error: {str(e)}"
|
||||
)
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
async def favicon():
|
||||
"""Serve the favicon"""
|
||||
return FileResponse("templates/favicon.ico")
|
||||
|
||||
@app.get("/config", response_model=ClientConfig)
|
||||
async def get_client_config():
|
||||
"""Serve client configuration to frontend"""
|
||||
return ClientConfig(client_id=CLIENT_ID, issuer=ISSUER)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
fastapi>=0.68.0
|
||||
uvicorn>=0.15.0
|
||||
pyjwt>=2.4.0
|
||||
requests>=2.25.0
|
||||
cryptography>=3.4.8
|
||||
python-jose>=3.3.0
|
||||
BIN
templates/favicon.ico
Normal file
BIN
templates/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
632
templates/index.html
Normal file
632
templates/index.html
Normal file
@@ -0,0 +1,632 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OIDC Login</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h1 class="text-center h3">OIDC Login</h1>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Login Section -->
|
||||
<section id="login-section" class="text-center">
|
||||
<p>Login with your Infomaniak account</p>
|
||||
<button id="login-btn" class="btn btn-primary w-100" aria-label="Login with Infomaniak">Login with Infomaniak</button>
|
||||
</section>
|
||||
|
||||
<!-- User Info Section -->
|
||||
<section id="user-info" class="d-none">
|
||||
<div class="mb-4">
|
||||
<h2 class="h5">Welcome, <span id="user-name">User</span>!</h2>
|
||||
<p class="mb-0">Email: <span id="user-email"></span></p>
|
||||
</div>
|
||||
|
||||
<!-- Secret Phrase Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3 class="h5 mb-0">Secret Phrase</h3>
|
||||
<button id="refresh-secret-btn" class="btn btn-secondary btn-sm" aria-label="Refresh secret phrase">Refresh</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="secret-result" class="mb-0">Checking for secret phrase...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bitcoin Price Card -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="h5 mb-0">Bitcoin Price in CHF</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<button id="fetch-btc-btn" class="btn btn-success mb-3" aria-label="Fetch Bitcoin Price">Fetch Bitcoin Price</button>
|
||||
<div id="btc-price-result" class="mb-0"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="logout-btn" class="btn btn-danger w-100" aria-label="Logout">Logout</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* OIDC Login Application
|
||||
* Handles authentication with Infomaniak OIDC and displays user information
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Configuration
|
||||
let CONFIG = {
|
||||
issuer: '',
|
||||
clientId: '',
|
||||
redirectUri: window.location.origin + window.location.pathname,
|
||||
scopes: 'openid email profile'
|
||||
};
|
||||
|
||||
// DOM Elements
|
||||
const elements = {
|
||||
loginSection: document.getElementById('login-section'),
|
||||
userInfo: document.getElementById('user-info'),
|
||||
loginBtn: document.getElementById('login-btn'),
|
||||
logoutBtn: document.getElementById('logout-btn'),
|
||||
userEmail: document.getElementById('user-email'),
|
||||
refreshSecretBtn: document.getElementById('refresh-secret-btn'),
|
||||
secretResult: document.getElementById('secret-result'),
|
||||
fetchBtcBtn: document.getElementById('fetch-btc-btn'),
|
||||
btcPriceResult: document.getElementById('btc-price-result'),
|
||||
userName: document.getElementById('user-name')
|
||||
};
|
||||
|
||||
// Utility Functions
|
||||
const utils = {
|
||||
/**
|
||||
* Generate a random string of specified length
|
||||
* @param {number} length - Length of the string to generate
|
||||
* @returns {string} Random string
|
||||
*/
|
||||
generateRandomString(length) {
|
||||
const array = new Uint8Array(length);
|
||||
window.crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate SHA-256 hash of a string
|
||||
* @param {string} plain - String to hash
|
||||
* @returns {Promise<ArrayBuffer>} Hash buffer
|
||||
*/
|
||||
async sha256(plain) {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plain);
|
||||
return window.crypto.subtle.digest('SHA-256', data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Encode buffer to Base64 URL format
|
||||
* @param {ArrayBuffer} buffer - Buffer to encode
|
||||
* @returns {string} Base64 URL encoded string
|
||||
*/
|
||||
base64URLEncode(buffer) {
|
||||
let binary = '';
|
||||
const bytes = new Uint8Array(buffer);
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a nonce for security
|
||||
* @returns {string} Nonce string
|
||||
*/
|
||||
generateNonce() {
|
||||
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an alert message in the specified element
|
||||
* @param {HTMLElement} element - Element to show the message in
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - Bootstrap alert type (success, danger, warning, info)
|
||||
*/
|
||||
showAlert(element, message, type = 'info') {
|
||||
element.innerHTML = `<div class="alert alert-${type}" role="alert">${message}</div>`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user's full name from first and last name
|
||||
* @param {Object} user - User object with first_name and last_name properties
|
||||
* @returns {string} Full name or username
|
||||
*/
|
||||
getUserDisplayName(user) {
|
||||
const firstName = user.first_name;
|
||||
const lastName = user.last_name;
|
||||
const email = user.email || 'Unknown user';
|
||||
|
||||
if (firstName || lastName) {
|
||||
return [firstName, lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
return email.split('@')[0];
|
||||
},
|
||||
|
||||
/**
|
||||
* Parse JWT token to get expiration time
|
||||
* @param {string} token - JWT token
|
||||
* @returns {number|null} Expiration time in seconds or null if invalid
|
||||
*/
|
||||
getTokenExpiration(token) {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
return payload.exp || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
* @param {string} token - JWT token
|
||||
* @returns {boolean} True if token is expired or invalid
|
||||
*/
|
||||
isTokenExpired(token) {
|
||||
const exp = utils.getTokenExpiration(token);
|
||||
if (!exp) return true;
|
||||
return Date.now() >= exp * 1000;
|
||||
}
|
||||
};
|
||||
|
||||
// Authentication Functions
|
||||
const auth = {
|
||||
/**
|
||||
* Login function - redirects to OIDC provider
|
||||
*/
|
||||
async login() {
|
||||
const nonce = utils.generateNonce();
|
||||
|
||||
// Store nonce for later use
|
||||
localStorage.setItem('nonce', nonce);
|
||||
|
||||
// Build authorization URL for implicit flow
|
||||
const authUrl = new URL(CONFIG.issuer + '/authorize');
|
||||
authUrl.searchParams.append('client_id', CONFIG.clientId);
|
||||
authUrl.searchParams.append('redirect_uri', CONFIG.redirectUri);
|
||||
authUrl.searchParams.append('response_type', 'id_token token');
|
||||
authUrl.searchParams.append('scope', CONFIG.scopes);
|
||||
authUrl.searchParams.append('nonce', nonce);
|
||||
|
||||
// Redirect to authorization server
|
||||
window.location.href = authUrl.toString();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle callback from OIDC provider
|
||||
*/
|
||||
handleCallback() {
|
||||
// Check for tokens in URL fragment (hash)
|
||||
const hash = window.location.hash.substring(1);
|
||||
const urlParams = new URLSearchParams(hash);
|
||||
|
||||
const accessToken = urlParams.get('access_token');
|
||||
const idToken = urlParams.get('id_token');
|
||||
const refreshToken = urlParams.get('refresh_token');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
if (error) {
|
||||
alert('Authentication failed: ' + error);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
if (accessToken && idToken) {
|
||||
// Clear URL fragment
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
try {
|
||||
// Store tokens
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('id_token', idToken);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
}
|
||||
|
||||
// Clean up
|
||||
localStorage.removeItem('nonce');
|
||||
|
||||
// Get user info from backend after a short delay
|
||||
// Only call getSecretPhrase if we don't already have a recent cached secret
|
||||
const cachedSecret = localStorage.getItem('secret_phrase');
|
||||
const lastFetch = localStorage.getItem('secret_last_fetch');
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
if (!(cachedSecret && lastFetch && (now - parseInt(lastFetch)) < fiveMinutes)) {
|
||||
setTimeout(ui.getSecretPhrase, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Login failed. Please try again.');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
async refreshToken() {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
// Store new tokens
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
if (data.id_token) {
|
||||
localStorage.setItem('id_token', data.id_token);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Clear tokens on refresh failure
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if tokens need to be refreshed and refresh if needed
|
||||
*/
|
||||
async checkAndRefreshToken() {
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
// If we don't have a refresh token, we can't refresh
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we don't have an access token, we need to refresh
|
||||
if (!accessToken) {
|
||||
await auth.refreshToken();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if access token is expired or will expire soon (within 5 minutes)
|
||||
const exp = utils.getTokenExpiration(accessToken);
|
||||
if (exp) {
|
||||
const now = Date.now() / 1000;
|
||||
if (exp - now < 300) { // 5 minutes
|
||||
await auth.refreshToken();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Logout function - clears auth data and hides user info
|
||||
*/
|
||||
logout() {
|
||||
// Clear all auth data
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('id_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
localStorage.removeItem('nonce');
|
||||
localStorage.removeItem('secret_phrase');
|
||||
localStorage.removeItem('secret_last_fetch');
|
||||
|
||||
ui.hideUserInfo();
|
||||
}
|
||||
};
|
||||
|
||||
// UI Functions
|
||||
const ui = {
|
||||
/**
|
||||
* Show user information section
|
||||
* @param {Object} user - User object with email, first_name, last_name
|
||||
*/
|
||||
showUserInfo(user) {
|
||||
elements.loginSection.classList.add('d-none');
|
||||
elements.userInfo.classList.remove('d-none');
|
||||
|
||||
// Display user's full name
|
||||
elements.userName.textContent = utils.getUserDisplayName(user);
|
||||
|
||||
// Display email
|
||||
elements.userEmail.textContent = user.email || 'Unknown user';
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide user information section
|
||||
*/
|
||||
hideUserInfo() {
|
||||
elements.loginSection.classList.remove('d-none');
|
||||
elements.userInfo.classList.add('d-none');
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user is already logged in and display appropriate UI
|
||||
*/
|
||||
async checkLoginStatus() {
|
||||
const idToken = localStorage.getItem('id_token');
|
||||
|
||||
if (idToken) {
|
||||
// Check if we have a cached secret phrase
|
||||
const cachedSecret = localStorage.getItem('secret_phrase');
|
||||
const lastFetch = localStorage.getItem('secret_last_fetch');
|
||||
const now = Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
// Check if token needs refresh
|
||||
try {
|
||||
const wasRefreshed = await auth.checkAndRefreshToken();
|
||||
if (wasRefreshed) {
|
||||
// If we refreshed the token, we should fetch fresh data
|
||||
// Only call getSecretPhrase if we don't already have a recent cached secret
|
||||
if (!(cachedSecret && lastFetch && (now - parseInt(lastFetch)) < fiveMinutes)) {
|
||||
setTimeout(ui.getSecretPhrase, 500);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Token refresh failed, log out user
|
||||
utils.showAlert(elements.secretResult, `Session expired. Please log in again.`, 'danger');
|
||||
auth.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cachedSecret && lastFetch && (now - parseInt(lastFetch)) < fiveMinutes) {
|
||||
// Use cached secret phrase and user info
|
||||
const cachedUser = localStorage.getItem('user_data');
|
||||
if (cachedUser) {
|
||||
const user = JSON.parse(cachedUser);
|
||||
ui.showUserInfo(user);
|
||||
}
|
||||
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${cachedSecret}`, 'success');
|
||||
} else {
|
||||
// Fetch fresh secret phrase and user info
|
||||
setTimeout(ui.getSecretPhrase, 500);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get secret phrase and user info from backend
|
||||
*/
|
||||
async getSecretPhrase() {
|
||||
// Check if we need to refresh the token first
|
||||
try {
|
||||
const wasRefreshed = await auth.checkAndRefreshToken();
|
||||
if (wasRefreshed) {
|
||||
utils.showAlert(elements.secretResult, 'Token refreshed successfully. Validating...', 'info');
|
||||
}
|
||||
} catch (error) {
|
||||
utils.showAlert(elements.secretResult, `Token refresh failed: ${error.message}. Please log in again.`, 'danger');
|
||||
auth.logout();
|
||||
return;
|
||||
}
|
||||
|
||||
const idToken = localStorage.getItem('id_token');
|
||||
const accessToken = localStorage.getItem('access_token');
|
||||
|
||||
if (!idToken) {
|
||||
utils.showAlert(elements.secretResult, 'No ID token found. Please log in first.', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
utils.showAlert(elements.secretResult, 'Validating token...', 'info');
|
||||
|
||||
const response = await fetch('/validate-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id_token: idToken,
|
||||
access_token: accessToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Token is invalid, try to refresh
|
||||
try {
|
||||
await auth.refreshToken();
|
||||
// Retry the request with refreshed tokens
|
||||
const refreshedIdToken = localStorage.getItem('id_token');
|
||||
const refreshedAccessToken = localStorage.getItem('access_token');
|
||||
|
||||
const retryResponse = await fetch('/validate-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id_token: refreshedIdToken,
|
||||
access_token: refreshedAccessToken
|
||||
})
|
||||
});
|
||||
|
||||
if (!retryResponse.ok) {
|
||||
throw new Error(`HTTP error! status: ${retryResponse.status} - ${retryResponse.statusText}`);
|
||||
}
|
||||
|
||||
const retryData = await retryResponse.json();
|
||||
await ui.handleValidationResponse(retryData);
|
||||
return;
|
||||
} catch (refreshError) {
|
||||
utils.showAlert(elements.secretResult, `Session expired. Please log in again.`, 'danger');
|
||||
auth.logout();
|
||||
return;
|
||||
}
|
||||
} else if (response.status === 501) {
|
||||
throw new Error('Backend service not running or endpoint not found. Please start the backend service.');
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
await ui.handleValidationResponse(data);
|
||||
} catch (error) {
|
||||
utils.showAlert(elements.secretResult, `Error validating token: ${error.message}`, 'danger');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle the response from token validation
|
||||
* @param {Object} data - Response data from validation endpoint
|
||||
*/
|
||||
async handleValidationResponse(data) {
|
||||
if (data.valid) {
|
||||
// Store user info
|
||||
if (data.user) {
|
||||
localStorage.setItem('user_data', JSON.stringify(data.user));
|
||||
ui.showUserInfo(data.user);
|
||||
}
|
||||
|
||||
if (data.secret_phrase) {
|
||||
// Cache the secret phrase
|
||||
localStorage.setItem('secret_phrase', data.secret_phrase);
|
||||
localStorage.setItem('secret_last_fetch', Date.now().toString());
|
||||
|
||||
utils.showAlert(elements.secretResult, `<strong>Secret Phrase:</strong> ${data.secret_phrase}`, 'success');
|
||||
} else {
|
||||
// Clear cached secret if user is no longer authorized
|
||||
localStorage.removeItem('secret_phrase');
|
||||
localStorage.removeItem('secret_last_fetch');
|
||||
|
||||
utils.showAlert(elements.secretResult, `Valid user, but no secret phrase available for ${data.user.email}`, 'warning');
|
||||
}
|
||||
} else {
|
||||
// Clear cached secret and user data on validation failure
|
||||
localStorage.removeItem('secret_phrase');
|
||||
localStorage.removeItem('secret_last_fetch');
|
||||
localStorage.removeItem('user_data');
|
||||
|
||||
utils.showAlert(elements.secretResult, `Token validation failed: ${data.error || 'Unknown error'}`, 'danger');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch Bitcoin price in CHF
|
||||
*/
|
||||
async fetchBitcoinPrice() {
|
||||
try {
|
||||
utils.showAlert(elements.btcPriceResult, 'Fetching Bitcoin price...', 'info');
|
||||
|
||||
const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=chf');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.bitcoin && data.bitcoin.chf) {
|
||||
const price = data.bitcoin.chf;
|
||||
utils.showAlert(
|
||||
elements.btcPriceResult,
|
||||
`<strong>1 Bitcoin (BTC) = CHF ${price.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</strong>`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
throw new Error('Unexpected response format');
|
||||
}
|
||||
} catch (error) {
|
||||
utils.showAlert(elements.btcPriceResult, `Failed to retrieve Bitcoin price: ${error.message}`, 'danger');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Event Listeners
|
||||
function bindEvents() {
|
||||
elements.loginBtn.addEventListener('click', auth.login);
|
||||
elements.logoutBtn.addEventListener('click', auth.logout);
|
||||
elements.refreshSecretBtn.addEventListener('click', ui.getSecretPhrase);
|
||||
elements.fetchBtcBtn.addEventListener('click', ui.fetchBitcoinPrice);
|
||||
}
|
||||
|
||||
// Initialize Application
|
||||
async function init() {
|
||||
try {
|
||||
// Fetch client configuration from backend
|
||||
const configResponse = await fetch('/config');
|
||||
if (configResponse.ok) {
|
||||
const configData = await configResponse.json();
|
||||
CONFIG = {
|
||||
...CONFIG,
|
||||
issuer: configData.issuer,
|
||||
clientId: configData.client_id
|
||||
};
|
||||
} else {
|
||||
console.warn('Failed to fetch client configuration, using defaults');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error fetching client configuration, using defaults:', error);
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
// Handle OIDC callback if present
|
||||
const hash = window.location.hash.substring(1);
|
||||
const hasTokensInUrl = hash.includes('id_token') && hash.includes('access_token');
|
||||
|
||||
if (hasTokensInUrl) {
|
||||
// We have tokens in URL, handle the callback
|
||||
auth.handleCallback();
|
||||
} else {
|
||||
// No tokens in URL, check if user is already logged in
|
||||
await ui.checkLoginStatus();
|
||||
}
|
||||
}
|
||||
|
||||
// Start the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init().catch(error => {
|
||||
console.error('Application initialization failed:', error);
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user