moved to SPA auth architecture with MSAL.js in the browser

This commit is contained in:
michael 2025-11-03 10:50:40 -06:00
parent b778276bd3
commit b8a34d8b48
3 changed files with 275 additions and 147 deletions

245
index.php
View file

@ -1,139 +1,21 @@
<?php
require_once 'config.php';
use League\OAuth2\Client\Provider\GenericProvider;
// Check if this might be an MSAL redirect callback
// MSAL uses hash fragments for tokens, so we need to let the page load
// and let JavaScript handle the callback before PHP redirects
$isPotentialMSALCallback = !empty($_SERVER['HTTP_REFERER']) &&
strpos($_SERVER['HTTP_REFERER'], 'login.microsoftonline.com') !== false;
// HANDLE OAUTH CALLBACK FROM AZURE AD
// Azure redirects to root URL with ?code= and ?state= parameters
if (isset($_GET['code']) && isset($_GET['state'])) {
// Start session to access PKCE verifier and state
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Verify state to prevent CSRF attacks
if (empty($_GET['state']) || (isset($_SESSION['oauth2state']) && $_GET['state'] !== $_SESSION['oauth2state'])) {
unset($_SESSION['oauth2state']);
unset($_SESSION['oauth2_code_verifier']);
die('Invalid state. Possible CSRF attack.');
}
try {
// Retrieve code verifier from session
if (!isset($_SESSION['oauth2_code_verifier'])) {
die('Code verifier not found in session.');
}
$codeVerifier = $_SESSION['oauth2_code_verifier'];
// Configure Azure AD OAuth2 Provider
$provider = new GenericProvider([
'clientId' => AZURE_CLIENT_ID,
'redirectUri' => AZURE_REDIRECT_URI,
'urlAuthorize' => AZURE_AUTHORITY . '/oauth2/v2.0/authorize',
'urlAccessToken' => AZURE_AUTHORITY . '/oauth2/v2.0/token',
'urlResourceOwnerDetails' => 'https://graph.microsoft.com/v1.0/me',
'scopes' => 'openid profile email User.Read'
]);
// Exchange authorization code for access token with PKCE
$accessToken = $provider->getAccessToken('authorization_code', [
'code' => $_GET['code'],
'code_verifier' => $codeVerifier
]);
// Get user information from Microsoft Graph API
$request = $provider->getAuthenticatedRequest(
'GET',
'https://graph.microsoft.com/v1.0/me',
$accessToken->getToken()
);
$client = new \GuzzleHttp\Client();
$response = $client->send($request);
$userData = json_decode($response->getBody(), true);
// Store user information in session
$_SESSION['authenticated'] = true;
$_SESSION['user_id'] = $userData['id'];
$_SESSION['user_name'] = $userData['displayName'] ?? $userData['userPrincipalName'];
$_SESSION['user_email'] = $userData['userPrincipalName'] ?? $userData['mail'];
$_SESSION['access_token'] = $accessToken->getToken();
$_SESSION['last_activity'] = time();
// Initialize user files array for tracking uploads
$_SESSION['user_files'] = [];
// Clean up temporary session variables
unset($_SESSION['oauth2state']);
unset($_SESSION['oauth2_code_verifier']);
// Regenerate session ID for security
session_regenerate_id(true);
// Redirect to main application (clean URL, no query parameters)
header('Location: index.php');
exit;
} catch (\League\OAuth2\Client\Provider\Exception\IdentityProviderException $e) {
// Handle authentication errors - DETAILED DEBUG OUTPUT
echo '<html><body style="font-family: monospace; padding: 20px;">';
echo '<h2 style="color: #c00;">Authentication Failed</h2>';
echo '<p><strong>Error Message:</strong> ' . htmlspecialchars($e->getMessage()) . '</p>';
echo '<h3>Azure AD Response Body:</h3>';
echo '<pre style="background: #f5f5f5; padding: 10px; border: 1px solid #ccc;">';
$responseBody = $e->getResponseBody();
if (is_array($responseBody)) {
echo htmlspecialchars(print_r($responseBody, true));
} else {
echo htmlspecialchars($responseBody);
}
echo '</pre>';
echo '<h3>Session Variables:</h3>';
echo '<pre style="background: #f5f5f5; padding: 10px; border: 1px solid #ccc;">';
echo 'oauth2state: ' . (isset($_SESSION['oauth2state']) ? $_SESSION['oauth2state'] : '<span style="color:red;">NOT SET</span>') . "\n";
echo 'oauth2_code_verifier: ' . (isset($_SESSION['oauth2_code_verifier']) ? 'SET (length: ' . strlen($_SESSION['oauth2_code_verifier']) . ')' : '<span style="color:red;">NOT SET</span>') . "\n";
echo 'authenticated: ' . (isset($_SESSION['authenticated']) ? ($_SESSION['authenticated'] ? 'true' : 'false') : '<span style="color:red;">NOT SET</span>') . "\n";
echo '</pre>';
echo '<h3>Request Parameters:</h3>';
echo '<pre style="background: #f5f5f5; padding: 10px; border: 1px solid #ccc;">';
echo 'GET code: ' . (isset($_GET['code']) ? 'present (length: ' . strlen($_GET['code']) . ')' : '<span style="color:red;">missing</span>') . "\n";
echo 'GET state: ' . (isset($_GET['state']) ? $_GET['state'] : '<span style="color:red;">missing</span>') . "\n";
echo '</pre>';
echo '<h3>Configuration:</h3>';
echo '<pre style="background: #f5f5f5; padding: 10px; border: 1px solid #ccc;">';
echo 'AZURE_CLIENT_ID: ' . substr(AZURE_CLIENT_ID, 0, 8) . '...' . "\n";
echo 'AZURE_AUTHORITY: ' . AZURE_AUTHORITY . "\n";
echo 'AZURE_REDIRECT_URI: ' . AZURE_REDIRECT_URI . "\n";
echo 'DEV_MODE: ' . (DEV_MODE ? 'true' : 'false') . "\n";
echo '</pre>';
echo '<p><a href="login.php">← Back to Login</a></p>';
echo '</body></html>';
die();
} catch (\Exception $e) {
// Handle other errors
echo '<html><body style="font-family: monospace; padding: 20px;">';
echo '<h2 style="color: #c00;">An Error Occurred</h2>';
echo '<p><strong>Error:</strong> ' . htmlspecialchars($e->getMessage()) . '</p>';
echo '<pre style="background: #f5f5f5; padding: 10px; border: 1px solid #ccc;">';
echo htmlspecialchars($e->getTraceAsString());
echo '</pre>';
echo '<p><a href="login.php">← Back to Login</a></p>';
echo '</body></html>';
die();
}
// If not authenticated and not a potential MSAL callback, redirect to login
if (!$isPotentialMSALCallback && !isAuthenticated()) {
header('Location: login.php');
exit;
}
// Normal flow - require authentication
requireAuth();
// Get current user info
// Get current user info (may be null if MSAL callback is being processed)
$user = getCurrentUser();
$isAuthenticated = isAuthenticated();
?>
<!DOCTYPE html>
<html lang="en">
@ -146,22 +28,23 @@ $user = getCurrentUser();
<script src="https://cdn.jsdelivr.net/npm/dompurify@2.3.3/dist/purify.min.js"></script>
</head>
<body>
<div class="app-container">
<?php if (DEV_MODE): ?>
<div class="dev-mode-banner">
🔧 DEV MODE ACTIVE - Authentication Bypassed
</div>
<?php endif; ?>
<div class="user-header">
<div class="user-info">
<span class="user-name"><?php echo htmlspecialchars($user['name']); ?></span>
<span class="user-email"><?php echo htmlspecialchars($user['email']); ?></span>
<div class="app-container" id="appContainer">
<?php if ($isAuthenticated): ?>
<?php if (DEV_MODE): ?>
<div class="dev-mode-banner">
🔧 DEV MODE ACTIVE - Authentication Bypassed
</div>
<a href="logout.php" class="logout-btn">Logout</a>
</div>
<?php endif; ?>
<img src="V2T.svg" alt="Voice to Text" class="logo">
<div class="user-header">
<div class="user-info">
<span class="user-name"><?php echo htmlspecialchars($user['name']); ?></span>
<span class="user-email"><?php echo htmlspecialchars($user['email']); ?></span>
</div>
<a href="logout.php" class="logout-btn">Logout</a>
</div>
<img src="V2T.svg" alt="Voice to Text" class="logo">
<div id="initialInstruction" class="initial-instruction">
Before we start, select output format and upload the Voice File (Max 350 Megabytes in size)
@ -227,13 +110,84 @@ $user = getCurrentUser();
<input type="file" id="fileUpload" name="voiceFile" hidden>
</div>
<div id="chatArea" class="chat-area"></div>
<div id="chatArea" class="chat-area"></div>
<button id="downloadButton" style="display: none;">Download Response</button>
<button id="downloadButton" style="display: none;">Download Response</button>
<?php else: ?>
<!-- Loading/Authentication in progress -->
<div style="text-align: center; padding: 40px;">
<div style="color: #FFC407; font-size: 18px; margin-bottom: 20px;">
Processing authentication...
</div>
<div class="progress-bar">
<div class="progress-bar-fill"></div>
</div>
</div>
<?php endif; ?>
</div>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://alcdn.msauth.net/browser/2.38.1/js/msal-browser.min.js"></script>
<script>
// MSAL Configuration for handling OAuth redirect callback
const msalConfig = {
auth: {
clientId: '<?php echo AZURE_CLIENT_ID; ?>',
authority: '<?php echo AZURE_AUTHORITY; ?>',
redirectUri: '<?php echo AZURE_REDIRECT_URI; ?>'
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false
}
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
// Handle redirect callback from Azure AD
// This runs when Azure redirects back after authentication
msalInstance.handleRedirectPromise()
.then(async (response) => {
if (response) {
// Just authenticated - have access token
console.log('Authentication successful, creating session...');
try {
// Send token to PHP backend to create session
const validateResponse = await fetch('validate_token.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
accessToken: response.accessToken
})
});
const result = await validateResponse.json();
if (result.success) {
console.log('Session created, reloading page...');
// Session created - reload page to show app
window.location.href = 'index.php';
} else {
alert('Session creation failed: ' + result.error);
window.location.href = 'login.php';
}
} catch (error) {
console.error('Session creation error:', error);
alert('Failed to create session: ' + error.message);
window.location.href = 'login.php';
}
}
})
.catch((error) => {
console.error('MSAL redirect error:', error);
});
</script>
<script>
<?php if ($isAuthenticated): ?>
// Application functionality - only load if authenticated
$(document).ready(function() {
// Toggle language selector when translation is enabled/disabled
$('#enableTranslation').on('change', function() {
@ -348,6 +302,7 @@ $user = getCurrentUser();
URL.revokeObjectURL(url);
});
});
<?php endif; ?>
</script>
</body>
</html>

View file

@ -162,7 +162,7 @@ if (isAuthenticated()) {
Translate with DeepL into 30+ languages
</p>
<a href="auth.php" class="microsoft-login-btn">
<button id="loginButton" class="microsoft-login-btn">
<svg class="microsoft-icon" viewBox="0 0 23 23" xmlns="http://www.w3.org/2000/svg">
<rect width="11" height="11" fill="#f25022"/>
<rect x="12" width="11" height="11" fill="#7fba00"/>
@ -170,7 +170,10 @@ if (isAuthenticated()) {
<rect x="12" y="12" width="11" height="11" fill="#ffb900"/>
</svg>
Sign in with Microsoft
</a>
</button>
<div id="loginError" style="display: none; margin-top: 20px; padding: 15px; background: #ff3333; color: white; border-radius: 8px; font-size: 14px;"></div>
<div id="loginProgress" style="display: none; margin-top: 20px; padding: 15px; background: #0a0a0a; color: #FFC407; border-radius: 8px; font-size: 14px; border: 1px solid #333;"></div>
<div class="info-box">
<p>
@ -179,5 +182,89 @@ if (isAuthenticated()) {
</p>
</div>
</div>
<!-- MSAL.js Library -->
<script src="https://alcdn.msauth.net/browser/2.38.1/js/msal-browser.min.js"></script>
<script>
// MSAL Configuration
const msalConfig = {
auth: {
clientId: '<?php echo AZURE_CLIENT_ID; ?>',
authority: '<?php echo AZURE_AUTHORITY; ?>',
redirectUri: '<?php echo AZURE_REDIRECT_URI; ?>'
},
cache: {
cacheLocation: 'sessionStorage',
storeAuthStateInCookie: false
}
};
// Create MSAL instance
const msalInstance = new msal.PublicClientApplication(msalConfig);
// Login request configuration
const loginRequest = {
scopes: ['openid', 'profile', 'email', 'User.Read']
};
// Handle login button click
document.getElementById('loginButton').addEventListener('click', async function() {
try {
document.getElementById('loginProgress').style.display = 'block';
document.getElementById('loginProgress').textContent = 'Redirecting to Microsoft...';
document.getElementById('loginError').style.display = 'none';
// Sign in with redirect
await msalInstance.loginRedirect(loginRequest);
} catch (error) {
console.error('Login error:', error);
document.getElementById('loginError').style.display = 'block';
document.getElementById('loginError').textContent = 'Login failed: ' + error.message;
document.getElementById('loginProgress').style.display = 'none';
}
});
// Handle redirect callback
msalInstance.handleRedirectPromise()
.then(async (response) => {
if (response) {
// User just logged in - we have a response
document.getElementById('loginProgress').style.display = 'block';
document.getElementById('loginProgress').textContent = 'Authentication successful! Creating session...';
const account = response.account;
const accessToken = response.accessToken;
// Send token to PHP backend to create session
const validateResponse = await fetch('validate_token.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
accessToken: accessToken
})
});
const result = await validateResponse.json();
if (result.success) {
// Session created successfully - redirect to app
document.getElementById('loginProgress').textContent = 'Session created! Redirecting...';
window.location.href = 'index.php';
} else {
document.getElementById('loginError').style.display = 'block';
document.getElementById('loginError').textContent = 'Session creation failed: ' + result.error;
document.getElementById('loginProgress').style.display = 'none';
}
}
})
.catch((error) => {
console.error('Redirect callback error:', error);
document.getElementById('loginError').style.display = 'block';
document.getElementById('loginError').textContent = 'Authentication error: ' + error.message;
});
</script>
</body>
</html>

86
validate_token.php Normal file
View file

@ -0,0 +1,86 @@
<?php
/**
* Token Validation Endpoint
* Receives access token from client-side MSAL.js and creates PHP session
*/
require_once 'config.php';
// Only accept POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
exit;
}
// Get JSON payload
$json = file_get_contents('php://input');
$data = json_decode($json, true);
if (!isset($data['accessToken'])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Access token required']);
exit;
}
$accessToken = $data['accessToken'];
try {
// Validate token by calling Microsoft Graph API
// If the token is valid, Graph API will return user info
// If invalid, it will fail
$ch = curl_init('https://graph.microsoft.com/v1.0/me');
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json'
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
http_response_code(401);
echo json_encode(['success' => false, 'error' => 'Invalid token']);
exit;
}
// Parse user data
$userData = json_decode($response, true);
if (!$userData || !isset($userData['id'])) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to retrieve user information']);
exit;
}
// Start session and store user information
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
$_SESSION['authenticated'] = true;
$_SESSION['user_id'] = $userData['id'];
$_SESSION['user_name'] = $userData['displayName'] ?? $userData['userPrincipalName'];
$_SESSION['user_email'] = $userData['userPrincipalName'] ?? $userData['mail'];
$_SESSION['access_token'] = $accessToken;
$_SESSION['last_activity'] = time();
$_SESSION['user_files'] = [];
// Regenerate session ID for security
session_regenerate_id(true);
// Return success
echo json_encode([
'success' => true,
'user' => [
'name' => $_SESSION['user_name'],
'email' => $_SESSION['user_email']
]
]);
} catch (\Exception $e) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => $e->getMessage()]);
}