Add MSAL/Azure AD authentication with toggle support

Implemented complete Microsoft Authentication Library (MSAL) / Azure AD
Single Sign-On (SSO) system following Ferrero app pattern.

KEY FEATURE: Toggle authentication on/off via environment variable
- SSO_ENABLED=false → Mock user, no login required (local dev)
- SSO_ENABLED=true → Full Azure AD authentication (production)

NEW FILES:
- composer.json - Firebase JWT dependency
- .env.example - Environment variable template
- env_loader.php - Parse .env file
- JWTValidator.php - Validate JWT tokens from Azure AD
- AuthMiddleware.php - Core auth orchestrator with login UI
- auth.php - Authentication API (login/logout/status)
- auth-test.php - Debug authentication status
- AUTH_README.md - Complete setup documentation

UPDATED FILES:
- config.php - Load env vars, add SSO constants
- index.php - Require auth, add logout button, MSAL script
- api.php - Add authentication check
- enhance_prompt.php - Add authentication check
- .gitignore - Exclude .env and vendor/

AUTHENTICATION FLOW:
1. User visits app → Auth check
2. If SSO disabled → Mock "Local Developer" user
3. If SSO enabled → Validate JWT from cookie
4. If no token → Show MSAL login page
5. User signs in → Token validated → Cookie set → App loads

SECURITY FEATURES:
 httpOnly cookies (XSS prevention)
 SameSite=Lax (CSRF prevention)
 JWT signature validation
 Claims validation (exp, nbf, aud, iss)
 JWKS from Azure AD
 24-hour token expiration
 Secure flag for HTTPS

DEPENDENCIES INSTALLED:
- firebase/php-jwt v6.11.1

TESTING:
- Local: SSO disabled by default in .env
- Server: Set SSO_ENABLED=true with Azure AD credentials
- Cannot test MSAL locally (redirect URI bound to server)

DEPLOYMENT:
1. Install composer dependencies
2. Configure .env with Azure AD credentials
3. Set SSO_ENABLED=true when ready
4. Visit auth-test.php to verify setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2025-12-16 10:08:07 -05:00
parent 7f2dd95e73
commit 61aa1931bb
12 changed files with 1291 additions and 3 deletions

13
.env.example Normal file
View file

@ -0,0 +1,13 @@
# MSAL Authentication Configuration
# Set SSO_ENABLED=true to require Microsoft login
# Set SSO_ENABLED=false for local development (uses mock user)
SSO_ENABLED=false
# Azure AD Configuration (required when SSO_ENABLED=true)
# Get these values from your Azure AD App Registration
SSO_TENANT_ID=your-azure-tenant-id-here
SSO_CLIENT_ID=your-azure-application-client-id-here
# Example values (replace with your actual Azure AD credentials):
# SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
# SSO_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef

7
.gitignore vendored
View file

@ -1,6 +1,13 @@
# Configuration file with API key
config.php
# Environment configuration
.env
# Composer dependencies
/vendor/
composer.lock
# PHP session files
sessions/

305
AUTH_README.md Normal file
View file

@ -0,0 +1,305 @@
# MSAL Authentication Setup Guide
## Overview
Nano Banana Pro now includes Microsoft Authentication Library (MSAL) / Azure AD Single Sign-On (SSO) authentication. The authentication can be **toggled on/off** via environment variable for seamless testing and deployment.
---
## Quick Start
### Local Development (No Authentication)
```bash
# 1. Ensure .env file exists with:
SSO_ENABLED=false
# 2. Run the app normally in MAMP
# All users get mock "Local Developer" credentials
# No login required
```
### Production (with SSO)
```bash
# 1. Update .env file:
SSO_ENABLED=true
SSO_TENANT_ID=your-azure-tenant-id
SSO_CLIENT_ID=your-azure-application-id
# 2. Deploy to server
# 3. Users must login with Microsoft account
```
---
## Installation Steps
### 1. Install Dependencies
```bash
cd /Users/daveporter/Desktop/CODING-2024/NANO-RESEARCH
composer install
```
This installs the Firebase JWT library required for token validation.
### 2. Configure Environment
```bash
# Copy example file
cp .env.example .env
# Edit .env and set:
SSO_ENABLED=false # Start with authentication disabled
```
### 3. Azure AD Setup (When Enabling SSO)
#### Create Azure AD App Registration:
1. Go to [Azure Portal](https://portal.azure.com)
2. Navigate to: **Azure Active Directory****App registrations** → **New registration**
3. Set name: "Nano Banana Pro"
4. Set redirect URI: `https://your-server-url.com/path/to/app/index.php`
5. Click **Register**
#### Get Credentials:
1. Copy **Application (client) ID** → This is your `SSO_CLIENT_ID`
2. Copy **Directory (tenant) ID** → This is your `SSO_TENANT_ID`
3. Go to **Authentication** → Enable **ID tokens** checkbox
4. Go to **API permissions** → Add: `openid`, `profile`, `email`
#### Update .env:
```bash
SSO_ENABLED=true
SSO_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
SSO_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
```
---
## File Structure
### New Files Created:
```
/NANO-RESEARCH/
├── composer.json # PHP dependencies (Firebase JWT)
├── .env # Environment config (gitignored)
├── .env.example # Template for environment variables
├── env_loader.php # Loads .env file
├── JWTValidator.php # JWT token validation logic
├── AuthMiddleware.php # Auth orchestrator + login UI
├── auth.php # Auth API endpoint
├── auth-test.php # Debugging page
├── AUTH_README.md # This file
└── vendor/ # Composer dependencies (gitignored)
```
### Modified Files:
```
config.php # Added SSO constants
index.php # Added auth check, logout button
api.php # Added auth check
enhance_prompt.php # Added auth check
.gitignore # Added .env and vendor/
```
---
## How It Works
### When SSO_ENABLED=false (Testing Mode)
1. User visits app
2. AuthMiddleware returns mock "Local Developer" user
3. No login page shown
4. All features work normally
5. Perfect for local testing
### When SSO_ENABLED=true (Production Mode)
1. User visits app
2. AuthMiddleware checks for `auth_token` cookie
3. If no token → Show MSAL login page
4. User clicks "Sign In with Microsoft"
5. MSAL popup opens for Azure AD login
6. User authenticates
7. Token sent to `auth.php` for validation
8. JWT validated against Azure AD public keys
9. Token stored in httpOnly cookie (24 hours)
10. User redirected to app
11. Logout button visible in header
---
## Testing
### Test Authentication Status
Visit: `http://your-server/auth-test.php`
Shows:
- SSO configuration (enabled/disabled)
- Tenant ID and Client ID
- Current authentication status
- User information
- Cookie presence
### Test Locally (SSO Disabled)
```bash
# 1. Set SSO_ENABLED=false in .env
# 2. Open app in MAMP
# 3. Should see "Welcome, Local Developer" (if SSO was previously enabled)
# 4. App functions normally
# 5. No login/logout buttons
```
### Test on Server (SSO Enabled)
```bash
# NOTE: Cannot test locally - Azure AD requires exact redirect URI match
# 1. Deploy to production server
# 2. Set SSO_ENABLED=true in .env on server
# 3. Add Azure AD credentials to .env
# 4. Visit app URL
# 5. Should see login page
# 6. Click "Sign In with Microsoft"
# 7. Complete Microsoft login
# 8. Should redirect to app
# 9. Should see "Welcome, [Your Name]" and logout button
```
---
## Security Features
**httpOnly Cookies** - Prevents XSS attacks (JavaScript can't access token)
**SameSite=Lax** - Prevents CSRF attacks
**Secure Flag** - Cookie only sent over HTTPS in production
**JWT Validation** - Cryptographic verification of tokens
**Expiration Check** - Validates `exp` claim
**Not-Before Check** - Validates `nbf` claim
**Audience Validation** - Ensures token is for our app
**Issuer Validation** - Ensures token from Azure AD
**JWKS Verification** - Uses Azure AD public keys
**24-Hour Expiration** - Tokens expire after 1 day
---
## Troubleshooting
### Login Page Shows But Can't Login
- Check Azure AD app registration has correct redirect URI
- Ensure `SSO_TENANT_ID` and `SSO_CLIENT_ID` are correct
- Check browser console for MSAL errors
- Visit `auth-test.php` to verify configuration
### "Authentication Required" Error
- Check `auth_token` cookie exists (browser dev tools)
- Token may have expired (24-hour limit)
- Try logging out and back in
- Check `auth-test.php` for token status
### SSO Not Disabling
- Verify `.env` has `SSO_ENABLED=false` (not "false" in quotes)
- Clear browser cookies
- Restart PHP server/MAMP
- Check `auth-test.php` shows "SSO Enabled: NO"
### Token Validation Failing
- Check server can reach Azure AD endpoints
- Verify tenant ID and client ID match Azure AD
- Check token hasn't expired
- Review `error_log` for JWT validation details
---
## API Endpoints
### Login
```http
POST /auth.php
Content-Type: application/json
{
"action": "login",
"idToken": "eyJ0eXAiOiJKV1QiLCJhbGci...",
"accessToken": "eyJ0eXAiOiJKV1QiLCJhbGci..."
}
```
### Logout
```http
POST /auth.php
Content-Type: application/json
{
"action": "logout"
}
```
### Status Check
```http
POST /auth.php
Content-Type: application/json
{
"action": "status"
}
```
---
## Maintenance
### Rotating Credentials
1. Update Azure AD app registration
2. Update `.env` with new credentials
3. No code changes needed
4. Existing sessions remain valid until cookie expires
### Disabling SSO Temporarily
```bash
# In .env:
SSO_ENABLED=false
# Immediately disables SSO for all users
# No restart needed
# Users get mock "Local Developer" access
```
### Monitoring
- Check `error_log` for authentication failures
- Monitor Azure AD sign-in logs
- Track failed login attempts
- Review token validation errors
---
## Production Checklist
Before enabling SSO in production:
- [ ] Composer dependencies installed (`vendor/` directory exists)
- [ ] `.env` file configured with Azure AD credentials
- [ ] Azure AD app registration created
- [ ] Redirect URI matches production URL exactly
- [ ] ID tokens enabled in Azure AD app
- [ ] API permissions added (`openid`, `profile`, `email`)
- [ ] HTTPS enabled on production server
- [ ] `auth-test.php` shows correct configuration
- [ ] Test login/logout flow works
- [ ] Error logging enabled
---
## Support
For issues with:
- **MSAL errors**: Check [MSAL.js documentation](https://github.com/AzureAD/microsoft-authentication-library-for-js)
- **Azure AD setup**: Check [Azure AD app registration guide](https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app)
- **JWT validation**: Check Firebase JWT library logs in `error_log`
- **Configuration**: Run `auth-test.php` to see current setup
---
## Important Notes
- **Cannot test MSAL locally** - Azure AD requires exact URL match
- **Testing happens on server** after deployment
- **SSO toggle allows testing without auth** before enabling
- **httpOnly cookies** mean token not accessible via JavaScript
- **24-hour token expiration** - users must re-login daily
- **Mock user** (`dev@localhost`) used when SSO disabled

409
AuthMiddleware.php Normal file
View file

@ -0,0 +1,409 @@
<?php
/**
* Authentication Middleware for MSAL / Azure AD
* Central authentication orchestrator with login UI
*/
require_once __DIR__ . '/config.php';
class AuthMiddleware {
private $validator;
private $tenantId;
private $clientId;
private $ssoEnabled;
public function __construct() {
$this->ssoEnabled = SSO_ENABLED;
$this->tenantId = SSO_TENANT_ID;
$this->clientId = SSO_CLIENT_ID;
// Only initialize validator if SSO is enabled
if ($this->ssoEnabled) {
require_once __DIR__ . '/JWTValidator.php';
$this->validator = new JWTValidator($this->tenantId, $this->clientId);
}
}
/**
* Check if SSO is enabled
*
* @return bool
*/
public function isSSOEnabled() {
return $this->ssoEnabled;
}
/**
* Check if user is authenticated
*
* @return array ['authenticated' => bool, 'user' => array|null, 'error' => string|null]
*/
public function isAuthenticated() {
// If SSO is disabled, return authenticated with mock user
if (!$this->ssoEnabled) {
return [
'authenticated' => true,
'user' => [
'name' => 'Local Developer',
'preferred_username' => 'dev@localhost'
]
];
}
// Get token from cookie
$token = $this->getTokenFromCookie();
if (!$token) {
return ['authenticated' => false, 'error' => 'No authentication token found'];
}
// Validate token
$validation = $this->validator->validateToken($token);
if (!$validation['valid']) {
return ['authenticated' => false, 'error' => $validation['error']];
}
return ['authenticated' => true, 'user' => $validation['payload']];
}
/**
* Require authentication - blocks if not authenticated
*
* @return array User data
*/
public function requireAuth() {
// If SSO is disabled, return mock user
if (!$this->ssoEnabled) {
return [
'name' => 'Local Developer',
'preferred_username' => 'dev@localhost'
];
}
// Check authentication
$auth = $this->isAuthenticated();
if (!$auth['authenticated']) {
$this->handleUnauthorized($auth['error'] ?? 'Authentication required');
exit;
}
return $auth['user'];
}
/**
* Set authentication token (after login)
*
* @param string $token JWT token from MSAL
* @return array ['success' => bool, 'user' => array|null, 'error' => string|null]
*/
public function setAuthToken($token) {
if (!$this->ssoEnabled) {
return ['success' => false, 'error' => 'SSO is disabled'];
}
// Validate token
$validation = $this->validator->validateToken($token);
if (!$validation['valid']) {
return ['success' => false, 'error' => $validation['error']];
}
// Set httpOnly cookie with security options
$cookieOptions = [
'expires' => time() + (24 * 60 * 60), // 24 hours
'path' => '/',
'domain' => '',
'secure' => isset($_SERVER['HTTPS']), // Only over HTTPS in production
'httponly' => true,
'samesite' => 'Lax'
];
setcookie('auth_token', $token, $cookieOptions);
return ['success' => true, 'user' => $validation['payload']];
}
/**
* Clear authentication token (logout)
*/
public function clearAuthToken() {
setcookie('auth_token', '', [
'expires' => time() - 3600,
'path' => '/',
'httponly' => true
]);
}
/**
* Get token from cookie
*
* @return string|null
*/
private function getTokenFromCookie() {
return isset($_COOKIE['auth_token']) ? $_COOKIE['auth_token'] : null;
}
/**
* Handle unauthorized access
*
* @param string $error Error message
*/
private function handleUnauthorized($error) {
if ($this->isAjaxRequest()) {
// For AJAX requests, return JSON
header('Content-Type: application/json');
http_response_code(401);
echo json_encode([
'error' => 'Authentication required',
'message' => $error,
'requiresAuth' => true
]);
} else {
// For page requests, show login interface
$this->showLoginPage($error);
}
}
/**
* Check if request is AJAX
*
* @return bool
*/
private function isAjaxRequest() {
return isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest';
}
/**
* Show login page with MSAL integration
*
* @param string $error Optional error message
*/
public function showLoginPage($error = '') {
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In - Nano Banana Pro</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://alcdn.msauth.net/browser/2.15.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Montserrat', sans-serif;
background: #000;
color: #fff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-container {
background: #1a1a1a;
border: 2px solid #FFC407;
border-radius: 12px;
padding: 50px 40px;
max-width: 450px;
width: 100%;
text-align: center;
box-shadow: 0 10px 50px rgba(255, 196, 7, 0.2);
}
.login-logo {
font-size: 4rem;
margin-bottom: 20px;
}
.login-title {
font-size: 2rem;
font-weight: 700;
color: #FFC407;
margin-bottom: 10px;
}
.login-subtitle {
font-size: 1rem;
color: #999;
margin-bottom: 40px;
}
.error-message {
background: #ff4444;
color: #fff;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 0.9rem;
}
.btn-login {
background: linear-gradient(135deg, #FFC407 0%, #ff9500 100%);
color: #000;
border: none;
padding: 18px 40px;
border-radius: 8px;
font-family: 'Montserrat', sans-serif;
font-weight: 700;
font-size: 1.1rem;
cursor: pointer;
transition: all 0.3s;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.btn-login:hover {
transform: translateY(-3px);
box-shadow: 0 5px 20px rgba(255, 196, 7, 0.4);
}
.btn-login:active {
transform: translateY(-1px);
}
.loading {
display: none;
}
.loading.show {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #000;
border-top: 3px solid transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.info-text {
margin-top: 30px;
font-size: 0.85rem;
color: #666;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-logo">🎨</div>
<h1 class="login-title">Nano Banana Pro</h1>
<p class="login-subtitle">AI Image Generation & Editing Tool</p>
<?php if ($error): ?>
<div class="error-message">
<?php echo htmlspecialchars($error); ?>
</div>
<?php endif; ?>
<button class="btn-login" onclick="signIn()" id="loginBtn">
<span id="lockIcon">🔒</span>
<span id="loadingIcon" class="loading"></span>
<span id="btnText">Sign In with Microsoft</span>
</button>
<div class="info-text">
Sign in with your Microsoft account to access the image generator
</div>
</div>
<script>
const msalConfig = {
auth: {
clientId: "<?php echo $this->clientId; ?>",
authority: "https://login.microsoftonline.com/<?php echo $this->tenantId; ?>",
redirectUri: window.location.origin + window.location.pathname
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: true,
}
};
const loginRequest = {
scopes: ["openid", "profile", "email"],
prompt: "select_account"
};
const myMSALObj = new msal.PublicClientApplication(msalConfig);
function signIn() {
const loginBtn = document.getElementById('loginBtn');
const lockIcon = document.getElementById('lockIcon');
const loadingIcon = document.getElementById('loadingIcon');
const btnText = document.getElementById('btnText');
// Show loading state
loginBtn.disabled = true;
lockIcon.style.display = 'none';
loadingIcon.classList.add('show');
btnText.textContent = 'Signing in...';
myMSALObj.loginPopup(loginRequest)
.then(loginResponse => {
// Send token to server for validation and cookie setting
fetch('auth.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'login',
idToken: loginResponse.idToken,
accessToken: loginResponse.accessToken
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert('Authentication failed: ' + (data.error || 'Unknown error'));
resetButton();
}
})
.catch(error => {
console.error('Authentication error:', error);
alert('Authentication failed. Please try again.');
resetButton();
});
})
.catch(error => {
console.error("Login failed:", error);
alert('Login failed: ' + error.message);
resetButton();
});
}
function resetButton() {
const loginBtn = document.getElementById('loginBtn');
const lockIcon = document.getElementById('lockIcon');
const loadingIcon = document.getElementById('loadingIcon');
const btnText = document.getElementById('btnText');
loginBtn.disabled = false;
lockIcon.style.display = 'inline';
loadingIcon.classList.remove('show');
btnText.textContent = 'Sign In with Microsoft';
}
</script>
</body>
</html>
<?php
}
}

196
JWTValidator.php Normal file
View file

@ -0,0 +1,196 @@
<?php
/**
* JWT Token Validator for Azure AD / MSAL
* Validates JWT tokens from Microsoft Azure AD using JWKS
*/
use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
class JWTValidator {
private $tenantId;
private $clientId;
private $jwksCache = null;
private $jwksCacheTime = 0;
private $jwksCacheDuration = 3600; // Cache for 1 hour
public function __construct($tenantId, $clientId) {
$this->tenantId = $tenantId;
$this->clientId = $clientId;
}
/**
* Validate a JWT token
*
* @param string $token The JWT token to validate
* @return array ['valid' => bool, 'payload' => array|null, 'error' => string|null]
*/
public function validateToken($token) {
if (empty($token)) {
return ['valid' => false, 'error' => 'Token is empty'];
}
try {
// Get public keys from Azure AD
$jwks = $this->getJWKS();
if (!$jwks) {
return ['valid' => false, 'error' => 'Could not retrieve public keys from Azure AD'];
}
// Convert JWKS to Key objects
$keys = JWK::parseKeySet($jwks);
// Decode and validate the JWT
$decoded = JWT::decode($token, $keys);
$payload = (array) $decoded;
// Validate claims
$validation = $this->validateClaims($payload);
if (!$validation['valid']) {
return $validation;
}
return ['valid' => true, 'payload' => $payload];
} catch (\Firebase\JWT\ExpiredException $e) {
return ['valid' => false, 'error' => 'Token has expired'];
} catch (\Firebase\JWT\SignatureInvalidException $e) {
return ['valid' => false, 'error' => 'Token signature is invalid'];
} catch (\Firebase\JWT\BeforeValidException $e) {
return ['valid' => false, 'error' => 'Token is not yet valid'];
} catch (Exception $e) {
return ['valid' => false, 'error' => 'JWT validation failed: ' . $e->getMessage()];
}
}
/**
* Validate JWT claims
*
* @param array $payload The decoded JWT payload
* @return array ['valid' => bool, 'error' => string|null]
*/
private function validateClaims($payload) {
$now = time();
// Check expiration (exp claim)
if (isset($payload['exp']) && $payload['exp'] < $now) {
return ['valid' => false, 'error' => 'Token has expired'];
}
// Check not-before (nbf claim)
if (isset($payload['nbf']) && $payload['nbf'] > $now) {
return ['valid' => false, 'error' => 'Token is not yet valid'];
}
// Validate audience (aud claim) - must be our client ID or Microsoft Graph
if (isset($payload['aud'])) {
$validAudiences = [
$this->clientId,
'00000003-0000-0000-c000-000000000000', // Microsoft Graph
'https://graph.microsoft.com'
];
if (!in_array($payload['aud'], $validAudiences)) {
return ['valid' => false, 'error' => 'Invalid audience: ' . $payload['aud']];
}
}
// Validate issuer (iss claim) - must be from Azure AD tenant
if (isset($payload['iss'])) {
$validIssuers = [
"https://login.microsoftonline.com/{$this->tenantId}/v2.0",
"https://login.microsoftonline.com/{$this->tenantId}/",
"https://sts.windows.net/{$this->tenantId}/",
"https://login.microsoftonline.com/common/v2.0"
];
if (!in_array($payload['iss'], $validIssuers)) {
return ['valid' => false, 'error' => 'Invalid issuer: ' . $payload['iss']];
}
}
return ['valid' => true];
}
/**
* Get JWKS (JSON Web Key Set) from Azure AD
*
* @return array|null
*/
private function getJWKS() {
// Return cached JWKS if still valid
if ($this->jwksCache && (time() - $this->jwksCacheTime) < $this->jwksCacheDuration) {
return $this->jwksCache;
}
// Get OpenID configuration from Azure AD
$configUrl = "https://login.microsoftonline.com/{$this->tenantId}/v2.0/.well-known/openid-configuration";
$config = $this->fetchJson($configUrl);
if (!$config || !isset($config['jwks_uri'])) {
error_log("Failed to get OpenID configuration from: $configUrl");
return null;
}
// Fetch JWKS from jwks_uri
$jwks = $this->fetchJson($config['jwks_uri']);
if (!$jwks || !isset($jwks['keys'])) {
error_log("Failed to get JWKS from: " . $config['jwks_uri']);
return null;
}
// Ensure all keys have the 'alg' parameter
foreach ($jwks['keys'] as &$key) {
if (!isset($key['alg'])) {
// Default to RS256 for RSA keys
if (isset($key['kty']) && $key['kty'] === 'RSA') {
$key['alg'] = 'RS256';
}
}
}
// Cache the JWKS
$this->jwksCache = $jwks;
$this->jwksCacheTime = time();
return $jwks;
}
/**
* Fetch JSON from a URL
*
* @param string $url
* @return array|null
*/
private function fetchJson($url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError) {
error_log("CURL Error fetching $url: $curlError");
return null;
}
if ($httpCode !== 200) {
error_log("HTTP Error $httpCode fetching $url");
return null;
}
$json = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("JSON decode error fetching $url: " . json_last_error_msg());
return null;
}
return $json;
}
}

17
api.php
View file

@ -1,10 +1,25 @@
<?php
header('Content-Type: application/json');
// Load configuration and session manager
// Load configuration and authentication
require_once 'config.php';
require_once 'AuthMiddleware.php';
require_once 'session_manager.php';
// Check authentication
$auth = new AuthMiddleware();
$authStatus = $auth->isAuthenticated();
if (!$authStatus['authenticated']) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => 'Authentication required',
'requiresAuth' => true
]);
exit;
}
// Initialize session manager for multi-user support
$sessionManager = new SessionManager();

64
auth-test.php Normal file
View file

@ -0,0 +1,64 @@
<?php
/**
* Authentication Test & Debug Page
* Shows current authentication status and configuration
*/
header('Content-Type: text/plain');
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/AuthMiddleware.php';
echo "=== MSAL Authentication Test ===\n\n";
echo "1. SSO Configuration:\n";
echo " Enabled: " . (SSO_ENABLED ? 'YES' : 'NO') . "\n";
echo " Tenant ID: " . (SSO_TENANT_ID ?: 'NOT SET') . "\n";
echo " Client ID: " . (SSO_CLIENT_ID ?: 'NOT SET') . "\n\n";
echo "2. Testing AuthMiddleware:\n";
try {
$auth = new AuthMiddleware();
echo " ✓ AuthMiddleware loaded successfully\n";
echo " SSO Enabled: " . ($auth->isSSOEnabled() ? 'YES' : 'NO') . "\n\n";
echo "3. Authentication Status:\n";
$status = $auth->isAuthenticated();
echo " Authenticated: " . ($status['authenticated'] ? 'YES' : 'NO') . "\n";
if ($status['authenticated']) {
echo " User Name: " . ($status['user']['name'] ?? 'Unknown') . "\n";
echo " User Email: " . ($status['user']['preferred_username'] ?? $status['user']['upn'] ?? 'Unknown') . "\n";
} else {
echo " Error: " . ($status['error'] ?? 'Unknown') . "\n";
}
echo "\n4. Cookie Check:\n";
echo " auth_token cookie: " . (isset($_COOKIE['auth_token']) ? 'PRESENT' : 'NOT PRESENT') . "\n";
if (isset($_COOKIE['auth_token'])) {
$tokenLength = strlen($_COOKIE['auth_token']);
echo " Token length: " . $tokenLength . " chars\n";
echo " Token preview: " . substr($_COOKIE['auth_token'], 0, 50) . "...\n";
}
echo "\n5. Environment Variables:\n";
echo " SSO_ENABLED env: " . (getenv('SSO_ENABLED') ?: 'NOT SET') . "\n";
echo " SSO_TENANT_ID env: " . (getenv('SSO_TENANT_ID') ?: 'NOT SET') . "\n";
echo " SSO_CLIENT_ID env: " . (getenv('SSO_CLIENT_ID') ?: 'NOT SET') . "\n";
echo "\n6. Session Info:\n";
if (session_status() === PHP_SESSION_ACTIVE) {
echo " Session active: YES\n";
echo " Session ID: " . session_id() . "\n";
} else {
echo " Session active: NO\n";
}
echo "\n=== Test Complete ===\n";
} catch (Exception $e) {
echo " ✗ Error: " . $e->getMessage() . "\n";
echo " Stack trace:\n";
echo $e->getTraceAsString() . "\n";
}

118
auth.php Normal file
View file

@ -0,0 +1,118 @@
<?php
/**
* Authentication API Endpoint
* Handles login, logout, and status requests
*/
header('Content-Type: application/json');
require_once __DIR__ . '/AuthMiddleware.php';
$auth = new AuthMiddleware();
// Get POST data
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['action'])) {
http_response_code(400);
echo json_encode(['error' => 'Invalid request - action required']);
exit;
}
$action = $input['action'];
// Handle different actions
switch ($action) {
case 'login':
handleLogin($auth, $input);
break;
case 'logout':
handleLogout($auth);
break;
case 'status':
handleStatus($auth);
break;
default:
http_response_code(400);
echo json_encode(['error' => 'Unknown action: ' . $action]);
break;
}
/**
* Handle login action
*/
function handleLogin($auth, $input) {
if (!$auth->isSSOEnabled()) {
http_response_code(400);
echo json_encode(['error' => 'SSO is disabled']);
return;
}
// Prefer ID token for validation, fallback to access token
$token = $input['idToken'] ?? $input['accessToken'] ?? null;
if (!$token) {
http_response_code(400);
echo json_encode(['error' => 'Authentication token is required']);
return;
}
// Validate and set token
$result = $auth->setAuthToken($token);
if ($result['success']) {
echo json_encode([
'success' => true,
'message' => 'Authentication successful',
'user' => [
'name' => $result['user']['name'] ?? 'Unknown',
'email' => $result['user']['preferred_username'] ?? $result['user']['upn'] ?? 'Unknown'
]
]);
} else {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => $result['error']
]);
}
}
/**
* Handle logout action
*/
function handleLogout($auth) {
$auth->clearAuthToken();
echo json_encode([
'success' => true,
'message' => 'Logged out successfully'
]);
}
/**
* Handle status check action
*/
function handleStatus($auth) {
$authStatus = $auth->isAuthenticated();
if ($authStatus['authenticated']) {
echo json_encode([
'authenticated' => true,
'sso_enabled' => $auth->isSSOEnabled(),
'user' => [
'name' => $authStatus['user']['name'] ?? 'Unknown',
'email' => $authStatus['user']['preferred_username'] ?? $authStatus['user']['upn'] ?? 'Unknown'
]
]);
} else {
http_response_code(401);
echo json_encode([
'authenticated' => false,
'sso_enabled' => $auth->isSSOEnabled(),
'error' => $authStatus['error'] ?? 'Not authenticated'
]);
}
}

21
composer.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "nano-banana-pro/image-generator",
"description": "AI Image Generation & Editing Tool with MSAL Authentication",
"type": "project",
"require": {
"php": ">=7.4",
"firebase/php-jwt": "^6.0"
},
"require-dev": {},
"autoload": {
"classmap": [
"JWTValidator.php",
"AuthMiddleware.php",
"session_manager.php"
]
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
}
}

View file

@ -5,9 +5,23 @@
*/
require_once 'config.php';
require_once 'AuthMiddleware.php';
header('Content-Type: application/json');
// Check authentication
$auth = new AuthMiddleware();
$authStatus = $auth->isAuthenticated();
if (!$authStatus['authenticated']) {
http_response_code(401);
echo json_encode([
'success' => false,
'error' => 'Authentication required'
]);
exit;
}
// Get POST data
$input = json_decode(file_get_contents('php://input'), true);

55
env_loader.php Normal file
View file

@ -0,0 +1,55 @@
<?php
/**
* Environment Variable Loader
* Parses .env file and sets environment variables
*/
function loadEnvFile($path = null) {
// Default to .env file in same directory
$path = $path ?? __DIR__ . '/.env';
// Check if .env file exists
if (!file_exists($path)) {
error_log("Warning: .env file not found at: $path");
return false;
}
// Read file line by line
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
error_log("Error: Could not read .env file at: $path");
return false;
}
foreach ($lines as $line) {
// Skip comments
if (strpos(trim($line), '#') === 0) {
continue;
}
// Parse KEY=VALUE format
if (strpos($line, '=') !== false) {
list($key, $value) = explode('=', $line, 2);
// Trim whitespace
$key = trim($key);
$value = trim($value);
// Remove surrounding quotes if present
if (preg_match('/^(["\'])(.*)\1$/', $value, $matches)) {
$value = $matches[2];
}
// Set environment variable
putenv("$key=$value");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
return true;
}
// Auto-load .env on include
loadEnvFile();

View file

@ -1,4 +1,10 @@
<?php
// Require authentication
require_once 'AuthMiddleware.php';
$auth = new AuthMiddleware();
$user = $auth->requireAuth(); // Blocks if not authenticated
$ssoEnabled = $auth->isSSOEnabled();
// Initialize session manager for multi-user support
require_once 'session_manager.php';
$sessionManager = new SessionManager();
@ -767,8 +773,22 @@ $imageHistory = $sessionManager->getImageHistory();
<body>
<div class="container">
<header>
<h1>Nano Banana Pro</h1>
<p class="subtitle">AI Image Generation & Iterative Editing</p>
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>Nano Banana Pro</h1>
<p class="subtitle">AI Image Generation & Iterative Editing</p>
</div>
<?php if ($ssoEnabled): ?>
<div style="text-align: right;">
<div style="color: #999; font-size: 0.85rem; margin-bottom: 5px;">
Welcome, <strong style="color: #FFC407;"><?php echo htmlspecialchars($user['name'] ?? $user['preferred_username'] ?? 'User'); ?></strong>
</div>
<button onclick="signOut()" class="btn btn-secondary" style="padding: 8px 20px; font-size: 0.9rem; width: auto;">
🔒 Log Out
</button>
</div>
<?php endif; ?>
</div>
</header>
<div id="errorMessage" class="error-message"></div>
@ -1431,6 +1451,57 @@ Session Directory: <?php echo basename($sessionManager->getSessionDir()); ?></pr
showSuccess('Prompt transferred! Ready to generate image.');
});
// MSAL Authentication (only if SSO is enabled)
<?php if ($ssoEnabled): ?>
const msalConfig = {
auth: {
clientId: "<?php echo SSO_CLIENT_ID; ?>",
authority: "https://login.microsoftonline.com/<?php echo SSO_TENANT_ID; ?>",
redirectUri: window.location.origin + window.location.pathname
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: true,
}
};
const myMSALObj = new msal.PublicClientApplication(msalConfig);
function signOut() {
if (!confirm('Are you sure you want to log out?')) {
return;
}
// Call server to clear cookie
fetch('auth.php', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
action: 'logout'
})
})
.then(() => {
// Also clear MSAL session
myMSALObj.logoutPopup().then(() => {
window.location.href = 'index.php';
}).catch(error => {
console.error("Logout error:", error);
window.location.href = 'index.php';
});
})
.catch(error => {
console.error("Logout error:", error);
window.location.href = 'index.php';
});
}
<?php endif; ?>
</script>
<?php if ($ssoEnabled): ?>
<script src="https://alcdn.msauth.net/browser/2.15.0/js/msal-browser.min.js" crossorigin="anonymous"></script>
<?php endif; ?>
</body>
</html>