mirror of https://github.com/jitsi/jitsi-meet
* fix(meeting_id): Depends on jitsi_session(uses session.user_region). * feat(authentication): A static page that can be used with some auth providers like keycloak. * fix(authentication): Implements inline authentication. squash: Adds refresh token use when refresh token is needed on connection resuming. squash: Fix bugs and move to PKCE flow. * squash: Adds nonce verification. * squash: Drops the closing logic. * squash: Replace resuming event with CONNECTION_TOKEN_EXPIRED one. * squash: Fixes comments. * squash: Make sure we use tokenAuthUrl only when it is set and is not jaas. * squash: Move CONNECTION_TOKEN_EXPIRED to web only middleware as it uses web only logic for now. * squash: Fix comments.pull/16990/head jitsi-meet_10797
parent
c48834a116
commit
96d02e8484
@ -0,0 +1,100 @@ |
||||
import { IStore } from '../../app/types'; |
||||
import { loginWithPopup } from '../../authentication/actions'; |
||||
import LoginQuestionDialog from '../../authentication/components/web/LoginQuestionDialog'; |
||||
import { getTokenAuthUrl, isTokenAuthEnabled, isTokenAuthInline } from '../../authentication/functions'; |
||||
import { hideNotification, showNotification } from '../../notifications/actions'; |
||||
import { NOTIFICATION_TIMEOUT_TYPE, NOTIFICATION_TYPE } from '../../notifications/constants'; |
||||
import { CONNECTION_TOKEN_EXPIRED } from '../connection/actionTypes'; |
||||
import { openDialog } from '../dialog/actions'; |
||||
import { browser } from '../lib-jitsi-meet'; |
||||
import MiddlewareRegistry from '../redux/MiddlewareRegistry'; |
||||
import { parseURIString } from '../util/uri'; |
||||
|
||||
import { setJWT } from './actions'; |
||||
import logger from './logger'; |
||||
|
||||
const PROMPT_LOGIN_NOTIFICATION_ID = 'PROMPT_LOGIN_NOTIFICATION_ID'; |
||||
|
||||
/** |
||||
* Middleware to handle token expiration on web - prompts the user to re-authenticate. |
||||
* |
||||
* @param {Store} store - The redux store. |
||||
* @private |
||||
* @returns {Function} |
||||
*/ |
||||
MiddlewareRegistry.register((store: IStore) => next => action => { |
||||
if (action.type === CONNECTION_TOKEN_EXPIRED) { |
||||
const state = store.getState(); |
||||
const jwt = state['features/base/jwt'].jwt; |
||||
const refreshToken = state['features/base/jwt'].refreshToken; |
||||
|
||||
if (typeof APP !== 'undefined' && jwt && isTokenAuthEnabled(state)) { |
||||
const { connection, locationURL = { href: '' } as URL } = state['features/base/connection']; |
||||
const { tenant } = parseURIString(locationURL.href) || {}; |
||||
const room = state['features/base/conference'].room; |
||||
const dispatch = store.dispatch; |
||||
|
||||
getTokenAuthUrl( |
||||
state['features/base/config'], |
||||
locationURL, |
||||
{ |
||||
audioMuted: false, |
||||
audioOnlyEnabled: false, |
||||
skipPrejoin: false, |
||||
videoMuted: false |
||||
}, |
||||
room, |
||||
tenant, |
||||
refreshToken |
||||
) |
||||
.then((url: string | undefined) => { |
||||
if (url) { |
||||
dispatch(showNotification({ |
||||
descriptionKey: 'dialog.loginOnResume', |
||||
titleKey: 'dialog.login', |
||||
uid: PROMPT_LOGIN_NOTIFICATION_ID, |
||||
customActionNameKey: [ 'dialog.login' ], |
||||
customActionHandler: [ () => { |
||||
store.dispatch(hideNotification(PROMPT_LOGIN_NOTIFICATION_ID)); |
||||
|
||||
if (isTokenAuthInline(state['features/base/config'])) { |
||||
loginWithPopup(url) |
||||
.then((result: { accessToken: string; idToken: string; refreshToken?: string; }) => { |
||||
const token: string = result.accessToken; |
||||
const idToken: string = result.idToken; |
||||
const newRefreshToken: string | undefined = result.refreshToken; |
||||
|
||||
dispatch(setJWT(token, idToken, newRefreshToken || refreshToken)); |
||||
|
||||
connection?.refreshToken(token) |
||||
.catch((err: any) => { |
||||
dispatch(setJWT()); |
||||
logger.error(err); |
||||
}); |
||||
}).catch(logger.error); |
||||
} else { |
||||
dispatch(openDialog('LoginQuestionDialog', LoginQuestionDialog, { |
||||
handler: () => { |
||||
// Give time for the dialog to close.
|
||||
setTimeout(() => { |
||||
if (browser.isElectron()) { |
||||
window.open(url, '_blank'); |
||||
} else { |
||||
window.location.href = url; |
||||
} |
||||
}, 500); |
||||
} |
||||
})); |
||||
} |
||||
|
||||
} ], |
||||
appearance: NOTIFICATION_TYPE.ERROR |
||||
}, NOTIFICATION_TIMEOUT_TYPE.STICKY)); |
||||
} |
||||
}) |
||||
.catch(logger.error); |
||||
} |
||||
} |
||||
|
||||
return next(action); |
||||
}); |
||||
@ -0,0 +1,43 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8"> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
||||
<title>Logged Out</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; |
||||
} |
||||
.success { |
||||
color: #27ae60; |
||||
font-size: 48px; |
||||
margin-bottom: 20px; |
||||
} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<div class="container"> |
||||
<div class="success">✓</div> |
||||
<p>You have been logged out successfully.</p> |
||||
</div> |
||||
|
||||
<script> |
||||
// Notify parent window that logout is complete |
||||
if (window.parent) { |
||||
window.parent.postMessage({ |
||||
type: 'logout-success' |
||||
}, window.location.origin); |
||||
} |
||||
</script> |
||||
</body> |
||||
</html> |
||||
@ -0,0 +1,364 @@ |
||||
<!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> |
||||
Loading…
Reference in new issue