Files
demo-oidc/templates/index.html
T
herel a4908ac492 Initial commit
Adds OIDC token validator application with FastAPI backend and HTML/JavaScript frontend.
Includes Docker configuration and Kubernetes readiness.
2025-08-08 09:16:40 +02:00

632 lines
30 KiB
HTML

<!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>