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:
2025-08-08 09:10:55 +02:00
commit a4908ac492
6 changed files with 1059 additions and 0 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
k8s-manifests
__pycache__

53
Dockerfile Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

632
templates/index.html Normal file
View 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>