365 lines
15 KiB
HTML
365 lines
15 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SSO Authentication</title>
|
|
<style>
|
|
body {
|
|
font-family: Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
height: 100vh;
|
|
margin: 0;
|
|
background-color: #f5f5f5;
|
|
}
|
|
.container {
|
|
text-align: center;
|
|
padding: 20px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}
|
|
.spinner {
|
|
display: none;
|
|
border: 4px solid #f3f3f3;
|
|
border-top: 4px solid #3498db;
|
|
border-radius: 50%;
|
|
width: 40px;
|
|
height: 40px;
|
|
animation: spin 1s linear infinite;
|
|
margin: 20px auto;
|
|
}
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
.error {
|
|
display: none;
|
|
color: #e74c3c;
|
|
margin-top: 20px;
|
|
}
|
|
.success {
|
|
display: none;
|
|
color: #27ae60;
|
|
font-size: 48px;
|
|
margin-bottom: 20px;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div id="spinner" class="spinner"></div>
|
|
<p id="spinner-msg" style="display: none;">Completing authentication...</p>
|
|
<div id="success" class="success">✓</div>
|
|
<p id="message-for-opener" style="display: none;">Authentication successful! You can close this window.</p>
|
|
<div id="error" class="error"></div>
|
|
</div>
|
|
|
|
<script>
|
|
<!--#include virtual="/config.js" -->
|
|
(function() {
|
|
/**
|
|
* Decodes a JWT token and returns the payload
|
|
* @param {string} token - The JWT token to decode
|
|
* @returns {object} The decoded payload
|
|
*/
|
|
function decodeJWT(token) {
|
|
try {
|
|
const base64Url = token.split('.')[1];
|
|
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
|
const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) {
|
|
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
|
|
}).join(''));
|
|
return JSON.parse(jsonPayload);
|
|
} catch (error) {
|
|
throw new Error('Failed to decode JWT: ' + error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates that the nonce in the ID token matches the stored nonce
|
|
* @param {string} idToken - The ID token to validate
|
|
* @param {string} expectedNonce - The nonce stored in sessionStorage
|
|
*/
|
|
function validateNonce(idToken, expectedNonce) {
|
|
const payload = decodeJWT(idToken);
|
|
if (!payload.nonce) {
|
|
throw new Error('ID token does not contain a nonce claim');
|
|
}
|
|
if (payload.nonce !== expectedNonce) {
|
|
throw new Error('Nonce validation failed: ID token nonce does not match stored nonce');
|
|
}
|
|
}
|
|
|
|
async function handleAuthorizationCode() {
|
|
try {
|
|
// Parse query parameters (Authorization Code Flow uses query params, not hash)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
|
|
// Get state parameter first to check for refresh token
|
|
const stateParam = urlParams.get('state');
|
|
if (!stateParam) {
|
|
throw new Error('No state parameter received');
|
|
}
|
|
|
|
const state = JSON.parse(decodeURIComponent(stateParam));
|
|
|
|
// Get SSO configuration from config
|
|
const { sso } = config;
|
|
if (!sso || !sso.tokenService || !sso.clientId) {
|
|
throw new Error('Missing SSO configuration (tokenService or clientId)');
|
|
}
|
|
|
|
// Check if we have a refresh token in state - use it to refresh tokens
|
|
if (state.refreshToken) {
|
|
console.log('Using refresh token to get new tokens');
|
|
|
|
// Show spinner while refreshing tokens
|
|
document.getElementById('spinner').style.display = 'block';
|
|
document.getElementById('spinner-msg').style.display = 'block';
|
|
|
|
// Use refresh token to get new tokens
|
|
const tokenResponse = await fetch(`https://${sso.tokenService}/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: state.refreshToken,
|
|
client_id: sso.clientId
|
|
})
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorData = await tokenResponse.json().catch(() => ({}));
|
|
throw new Error(errorData.error_description || errorData.error || 'Token refresh failed');
|
|
}
|
|
|
|
const tokens = await tokenResponse.json();
|
|
const accessToken = tokens.access_token;
|
|
const idToken = tokens.id_token;
|
|
const refreshToken = tokens.refresh_token || state.refreshToken; // Use new refresh token if provided, otherwise keep old one
|
|
|
|
if (!accessToken || !idToken) {
|
|
throw new Error('Missing tokens in refresh response');
|
|
}
|
|
|
|
// Validate nonce if available in sessionStorage (optional for refresh flows)
|
|
const refreshNonce = sessionStorage.getItem('oauth_nonce');
|
|
if (refreshNonce) {
|
|
try {
|
|
validateNonce(idToken, refreshNonce);
|
|
} catch (nonceError) {
|
|
console.warn('Nonce validation failed during refresh:', nonceError.message);
|
|
// Don't fail the refresh flow if nonce validation fails
|
|
}
|
|
}
|
|
|
|
const message = {
|
|
type: 'oauth-success',
|
|
accessToken: accessToken,
|
|
idToken: idToken,
|
|
refreshToken: refreshToken
|
|
};
|
|
|
|
// Send message to parent window
|
|
if (window.opener) {
|
|
document.getElementById('spinner').style.display = 'none';
|
|
document.getElementById('spinner-msg').style.display = 'none';
|
|
document.getElementById('success').style.display = 'block';
|
|
document.getElementById('message-for-opener').style.display = 'block';
|
|
|
|
window.opener.postMessage(message, window.location.origin);
|
|
} else if (window.parent && window.parent !== window) {
|
|
console.log('Sending refreshed tokens to parent (iframe)');
|
|
window.parent.postMessage(message, window.location.origin);
|
|
} else {
|
|
// Standalone page - redirect to room
|
|
const tenant = state.tenant || '';
|
|
const roomSafe = state.roomSafe || state.room || '';
|
|
|
|
if (!roomSafe) {
|
|
throw new Error('No room specified in state');
|
|
}
|
|
|
|
const protocol = window.location.protocol;
|
|
const host = window.location.host;
|
|
const path = tenant ? `/${tenant}/${roomSafe}` : `/${roomSafe}`;
|
|
const hashParams = new URLSearchParams();
|
|
|
|
for (const [key, value] of Object.entries(state)) {
|
|
if (key.startsWith('config.')) {
|
|
hashParams.append(key, String(value));
|
|
}
|
|
}
|
|
|
|
hashParams.append('jwt', `"${accessToken}"`);
|
|
const redirectUrl = `${protocol}//${host}${path}#${hashParams.toString()}`;
|
|
window.location.href = redirectUrl;
|
|
}
|
|
|
|
return; // Exit after handling refresh token
|
|
}
|
|
|
|
// No refresh token in state - proceed with authorization code flow
|
|
// Check for OAuth errors
|
|
const error = urlParams.get('error');
|
|
if (error) {
|
|
const errorDescription = urlParams.get('error_description');
|
|
console.error('OAuth error:', error, errorDescription);
|
|
|
|
if (window.opener) {
|
|
window.opener.postMessage({
|
|
type: 'oauth-error',
|
|
error: errorDescription || error
|
|
}, window.location.origin);
|
|
} else if (window.parent && window.parent !== window) {
|
|
window.parent.postMessage({
|
|
type: 'oauth-error',
|
|
error: errorDescription || error
|
|
}, window.location.origin);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Get authorization code
|
|
const code = urlParams.get('code');
|
|
if (!code) {
|
|
throw new Error('No authorization code received');
|
|
}
|
|
|
|
// Retrieve code_verifier and oauth_nonce from sessionStorage (set during login initiation)
|
|
const codeVerifier = sessionStorage.getItem('code_verifier');
|
|
if (!codeVerifier) {
|
|
throw new Error('No PKCE code verifier found in session');
|
|
}
|
|
|
|
const oauthNonce = sessionStorage.getItem('oauth_nonce');
|
|
if (!oauthNonce) {
|
|
throw new Error('No OAuth nonce found in session');
|
|
}
|
|
|
|
// Show spinner while exchanging code for tokens
|
|
document.getElementById('spinner').style.display = 'block';
|
|
document.getElementById('spinner-msg').style.display = 'block';
|
|
|
|
// Exchange authorization code for tokens
|
|
const tokenResponse = await fetch(`https://${sso.tokenService}/token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded'
|
|
},
|
|
body: new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code: code,
|
|
redirect_uri: window.location.origin + window.location.pathname,
|
|
client_id: sso.clientId,
|
|
code_verifier: codeVerifier
|
|
})
|
|
});
|
|
|
|
if (!tokenResponse.ok) {
|
|
const errorData = await tokenResponse.json().catch(() => ({}));
|
|
throw new Error(errorData.error_description || errorData.error || 'Token exchange failed');
|
|
}
|
|
|
|
const tokens = await tokenResponse.json();
|
|
const accessToken = tokens.access_token;
|
|
const idToken = tokens.id_token;
|
|
const refreshToken = tokens.refresh_token;
|
|
|
|
if (!accessToken || !idToken) {
|
|
throw new Error('Missing tokens in response');
|
|
}
|
|
|
|
// Validate nonce in ID token
|
|
validateNonce(idToken, oauthNonce);
|
|
|
|
const message = {
|
|
type: 'oauth-success',
|
|
accessToken: accessToken,
|
|
idToken: idToken,
|
|
refreshToken: refreshToken
|
|
};
|
|
|
|
// Send message to parent window
|
|
if (window.opener) {
|
|
// Opened via window.open() - popup
|
|
document.getElementById('spinner').style.display = 'none';
|
|
document.getElementById('spinner-msg').style.display = 'none';
|
|
document.getElementById('success').style.display = 'block';
|
|
document.getElementById('message-for-opener').style.display = 'block';
|
|
|
|
window.opener.postMessage(message, window.location.origin);
|
|
} else if (window.parent && window.parent !== window) {
|
|
// Loaded in iframe
|
|
console.log('Sending message to parent (iframe)');
|
|
window.parent.postMessage(message, window.location.origin);
|
|
} else {
|
|
// Standalone page - redirect to room
|
|
// Extract required values
|
|
const tenant = state.tenant || '';
|
|
const roomSafe = state.roomSafe || state.room || '';
|
|
|
|
if (!roomSafe) {
|
|
throw new Error('No room specified in state');
|
|
}
|
|
|
|
// Build the new URL
|
|
const protocol = window.location.protocol;
|
|
const host = window.location.host;
|
|
|
|
// Construct path: /tenant/roomSafe
|
|
const path = tenant ? `/${tenant}/${roomSafe}` : `/${roomSafe}`;
|
|
|
|
// Build hash parameters - only config.* values and jwt
|
|
const hashParams = new URLSearchParams();
|
|
|
|
// Add only config.* parameters from state
|
|
for (const [key, value] of Object.entries(state)) {
|
|
if (key.startsWith('config.')) {
|
|
hashParams.append(key, String(value));
|
|
}
|
|
}
|
|
|
|
// Add access_token as jwt with quotes
|
|
hashParams.append('jwt', `"${accessToken}"`);
|
|
|
|
// Build final URL
|
|
const redirectUrl = `${protocol}//${host}${path}#${hashParams.toString()}`;
|
|
|
|
console.log('Redirecting to:', redirectUrl);
|
|
|
|
// Redirect
|
|
window.location.href = redirectUrl;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('SSO Error:', error);
|
|
const errorMessage = {
|
|
type: 'oauth-error',
|
|
error: error.message
|
|
};
|
|
if (window.opener) {
|
|
window.opener.postMessage(errorMessage, window.location.origin);
|
|
} else if (window.parent && window.parent !== window) {
|
|
window.parent.postMessage(errorMessage, window.location.origin);
|
|
} else {
|
|
document.getElementById('error').textContent = `Error: ${error.message}`;
|
|
document.getElementById('error').style.display = 'block';
|
|
document.getElementById('spinner').style.display = 'none';
|
|
document.getElementById('spinner-msg').style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start authentication flow
|
|
handleAuthorizationCode();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|