Files
Job-Info/NewApproach/auth/oauth-popup-login.ts
T
2026-01-10 19:23:47 +00:00

499 lines
16 KiB
TypeScript

/**
* OAuth Popup Login with Dual Token Acquisition
*
* Production-ready standalone module for acquiring both pbuser and pbagent tokens
* through PocketBase OAuth popup flow. Displays both tokens in the popup UI once acquired.
*
* ACTUAL PROJECT CONFIGURATION:
* - PocketBase URL: http://localhost:8090
* - OAuth Provider: Microsoft (OAuth 2.0 configured in PocketBase)
* - Graph Scopes: profile, email, https://graph.microsoft.com/.default
* - Service Account: Configured in PocketBase as special user with agent privileges
* - Token Expiry: 3600 seconds (1 hour) for both token types
* - Storage: localStorage with keys auth:pb-user and auth:pb-agent
*
* Usage in production:
* const loginPopup = new OAuthPopupLogin();
* await loginPopup.open();
* const { pbUser, pbAgent } = loginPopup.getTokens();
*
* // Tokens now available in:
* // - localStorage.getItem('auth:pb-user')
* // - localStorage.getItem('auth:pb-agent')
*
* Token Types & Storage:
* - auth:pb-user: PocketBase user OAuth token (from Microsoft OAuth flow)
* - auth:pb-agent: PocketBase service account token (system integration token)
*
* Dependencies:
* - PocketBase SDK: https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js
* - localStorage API (browser standard)
* - Microsoft OAuth configured in PocketBase admin dashboard
* - Service account user created in PocketBase with email/password
*
* Architecture:
* 1. User clicks login → popup opens with OAuth UI
* 2. User authenticates with Microsoft → receives pbuser token
* 3. PocketBase returns authenticated user record with metadata
* 4. Service account credentials used to obtain pbagent token
* 5. Both tokens stored in localStorage and displayed in popup
* 6. Popup shows success confirmation with token values
* 7. Caller retrieves tokens via getTokens() method
*
* Gotchas & Notes:
* - Popup must be opened from user gesture (click/input event) due to browser security
* - PocketBase instance uses hardcoded production URL (http://localhost:8090)
* - Agent token requires valid service account user in PocketBase
* - If agent credentials fail, pbuser token still acquired (graceful degradation)
* - Tokens automatically expire in 1 hour; caller must refresh as needed
* - Multiple popups can coexist but share token storage
*/
// Production configuration from project setup
const PRODUCTION_CONFIG = {
pbUrl: 'http://localhost:8090',
provider: 'microsoft' as const,
scopes: ['profile', 'email', 'https://graph.microsoft.com/.default'],
// Agent credentials sourced from environment or PocketBase service account
agentEmail: process.env.POCKETBASE_SERVICE_EMAIL || 'service@job-info.local',
agentPassword: process.env.POCKETBASE_SERVICE_PASSWORD || ''
};
interface OAuthPopupConfig {
pbUrl?: string;
provider?: 'google' | 'microsoft' | 'github';
scopes?: string[];
agentEmail?: string;
agentPassword?: string;
}
interface TokenData {
type: 'pb-user' | 'pb-agent';
value: string;
expiresAt: number;
acquiredAt: number;
}
interface PopupUIState {
pbUserToken: TokenData | null;
pbAgentToken: TokenData | null;
status: 'waiting' | 'acquiring' | 'complete' | 'error';
errorMessage: string | null;
}
class OAuthPopupLogin {
private config: Required<OAuthPopupConfig>;
private pb: any = null;
private popup: Window | null = null;
private uiState: PopupUIState = {
pbUserToken: null,
pbAgentToken: null,
status: 'waiting',
errorMessage: null
};
private popupElement: HTMLElement | null = null;
constructor(overrideConfig?: OAuthPopupConfig) {
// Use production config with optional overrides
this.config = {
pbUrl: overrideConfig?.pbUrl || PRODUCTION_CONFIG.pbUrl,
provider: overrideConfig?.provider || PRODUCTION_CONFIG.provider,
scopes: overrideConfig?.scopes || PRODUCTION_CONFIG.scopes,
agentEmail: overrideConfig?.agentEmail || PRODUCTION_CONFIG.agentEmail,
agentPassword: overrideConfig?.agentPassword || PRODUCTION_CONFIG.agentPassword
};
}
/**
* Initialize PocketBase instance
*/
private initPocketBase(): void {
if (this.pb) return;
const PocketBase = (window as any).PocketBase;
if (!PocketBase) {
throw new Error(
'PocketBase SDK not loaded. Include: ' +
'<script src="https://cdn.jsdelivr.net/npm/pocketbase@0.26.5/dist/pocketbase.umd.js"></script>'
);
}
this.pb = new PocketBase(this.config.pbUrl);
}
/**
* Create and display the popup UI
*/
private createPopupUI(): HTMLElement {
const container = document.createElement('div');
container.id = 'oauth-popup-overlay';
container.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
const popup = document.createElement('div');
popup.style.cssText = `
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
padding: 32px;
animation: slideUp 0.3s ease-out;
`;
const style = document.createElement('style');
style.textContent = `
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.oauth-token-badge {
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 12px;
margin: 12px 0;
font-family: 'Monaco', 'Courier New', monospace;
font-size: 12px;
word-break: break-all;
}
.oauth-token-badge.success {
border-color: #10b981;
background: #f0fdf4;
}
.oauth-token-badge.error {
border-color: #ef4444;
background: #fef2f2;
}
.oauth-status {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 8px;
}
.oauth-status.pending {
background: #f59e0b;
animation: pulse 1.5s infinite;
}
.oauth-status.complete {
background: #10b981;
}
.oauth-status.error {
background: #ef4444;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.oauth-close-btn {
position: absolute;
top: 16px;
right: 16px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.oauth-close-btn:hover {
color: #1f2937;
}
`;
document.head.appendChild(style);
const html = `
<div style="position: relative;">
<button class="oauth-close-btn" aria-label="Close">&times;</button>
<h2 style="margin: 0 0 8px 0; font-size: 20px; font-weight: 600; color: #1f2937;">
OAuth Login
</h2>
<p style="margin: 0 0 24px 0; color: #6b7280; font-size: 14px;">
Acquiring tokens through secure OAuth flow
</p>
<!-- PocketBase User Token -->
<div>
<label style="display: block; font-size: 12px; font-weight: 600; color: #374151; margin-bottom: 8px;">
<span class="oauth-status pending"></span>
PocketBase User Token
</label>
<div id="oauth-pb-user-token" class="oauth-token-badge">
Waiting for login...
</div>
</div>
<!-- PocketBase Agent Token -->
<div>
<label style="display: block; font-size: 12px; font-weight: 600; color: #374151; margin-bottom: 8px; margin-top: 16px;">
<span class="oauth-status pending"></span>
PocketBase Agent Token
</label>
<div id="oauth-pb-agent-token" class="oauth-token-badge">
Will be obtained after user login...
</div>
</div>
<!-- Status Message -->
<div id="oauth-status-message" style="margin-top: 20px; padding: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 6px; color: #1e40af; font-size: 13px;"></div>
<!-- Error Message -->
<div id="oauth-error-message" style="margin-top: 20px; padding: 12px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 6px; color: #991b1b; font-size: 13px; display: none;"></div>
<!-- Action Buttons -->
<div style="display: flex; gap: 12px; margin-top: 24px;">
<button id="oauth-login-btn" style="flex: 1; background: #3b82f6; color: white; border: none; border-radius: 6px; padding: 10px 16px; font-weight: 500; cursor: pointer; font-size: 14px;">
Start ${this.config.provider} Login
</button>
<button id="oauth-close-btn-bottom" style="flex: 1; background: #e5e7eb; color: #1f2937; border: none; border-radius: 6px; padding: 10px 16px; font-weight: 500; cursor: pointer; font-size: 14px;">
Close
</button>
</div>
<!-- Token Display (after successful acquisition) -->
<div id="oauth-success-section" style="display: none; margin-top: 24px; padding: 16px; background: #f0fdf4; border: 1px solid #86efac; border-radius: 6px;">
<h3 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #15803d;">
✓ Tokens Successfully Acquired
</h3>
<div id="oauth-final-tokens" style="font-size: 12px;"></div>
</div>
</div>
`;
popup.innerHTML = html;
container.appendChild(popup);
// Event listeners
const closeBtn = popup.querySelector('.oauth-close-btn') as HTMLElement;
const closeBtnBottom = popup.querySelector('#oauth-close-btn-bottom') as HTMLElement;
const loginBtn = popup.querySelector('#oauth-login-btn') as HTMLElement;
closeBtn?.addEventListener('click', () => this.close());
closeBtnBottom?.addEventListener('click', () => this.close());
loginBtn?.addEventListener('click', () => this.performOAuthLogin());
return container;
}
/**
* Perform OAuth login flow
*/
private async performOAuthLogin(): Promise<void> {
try {
this.updateStatus('acquiring', 'Initiating OAuth flow...');
// Authenticate with OAuth provider
const authData = await this.pb
.collection('users')
.authWithOAuth2({
provider: this.config.provider,
scopes: this.config.scopes
});
// Store pbuser token
const pbUserToken: TokenData = {
type: 'pb-user',
value: authData.token,
expiresAt: Date.now() + 3600 * 1000, // 1 hour
acquiredAt: Date.now()
};
this.uiState.pbUserToken = pbUserToken;
localStorage.setItem('auth:pb-user', pbUserToken.value);
this.updateTokenDisplay('pb-user', pbUserToken);
this.updateStatus('acquiring', 'User token acquired. Obtaining agent token...');
// Obtain pbagent token (via service account authentication)
await this.obtainAgentToken();
// Mark as complete
this.uiState.status = 'complete';
this.displaySuccess();
} catch (error: any) {
const errorMsg = error?.message || 'OAuth authentication failed';
this.updateStatus('error', `Error: ${errorMsg}`);
this.uiState.errorMessage = errorMsg;
}
}
/**
* Obtain agent token using service account credentials
*/
private async obtainAgentToken(): Promise<void> {
try {
// Use service account credentials if provided, otherwise use pbuser's agent privileges
const agentEmail = this.config.agentEmail || this.uiState.pbUserToken?.value;
const agentPassword = this.config.agentPassword;
if (!agentEmail || !agentPassword) {
throw new Error('Agent credentials not configured');
}
// Authenticate as service account
const agentAuth = await this.pb
.collection('users')
.authWithPassword(agentEmail, agentPassword);
const pbAgentToken: TokenData = {
type: 'pb-agent',
value: agentAuth.token,
expiresAt: Date.now() + 3600 * 1000, // 1 hour
acquiredAt: Date.now()
};
this.uiState.pbAgentToken = pbAgentToken;
localStorage.setItem('auth:pb-agent', pbAgentToken.value);
this.updateTokenDisplay('pb-agent', pbAgentToken);
} catch (error: any) {
const errorMsg = error?.message || 'Failed to obtain agent token';
console.warn('[OAuthPopup] Agent token error (non-critical):', errorMsg);
// Don't fail completely - agent token is optional
this.updateStatus('acquiring', 'User token acquired (agent token unavailable)');
}
}
/**
* Update UI token display
*/
private updateTokenDisplay(type: 'pb-user' | 'pb-agent', token: TokenData): void {
const elementId = type === 'pb-user' ? 'oauth-pb-user-token' : 'oauth-pb-agent-token';
const element = document.getElementById(elementId);
if (!element) return;
const lastFour = token.value.slice(-4);
const expiresAt = new Date(token.expiresAt).toLocaleString();
element.classList.add('success');
element.innerHTML = `
<strong>${token.value}</strong><br>
<small>Expires: ${expiresAt}</small>
`;
}
/**
* Update status message
*/
private updateStatus(status: PopupUIState['status'], message: string): void {
this.uiState.status = status;
const statusEl = document.getElementById('oauth-status-message');
const errorEl = document.getElementById('oauth-error-message');
if (statusEl) {
statusEl.textContent = message;
statusEl.style.display = status === 'error' ? 'none' : 'block';
}
if (errorEl) {
if (status === 'error') {
errorEl.textContent = message;
errorEl.style.display = 'block';
} else {
errorEl.style.display = 'none';
}
}
}
/**
* Display success screen
*/
private displaySuccess(): void {
const successSection = document.getElementById('oauth-success-section');
const loginBtn = document.getElementById('oauth-login-btn');
if (successSection) {
successSection.style.display = 'block';
const finalTokens = document.getElementById('oauth-final-tokens');
if (finalTokens && this.uiState.pbUserToken) {
const pbUserDisplay = `<div><strong>PB User:</strong> ${this.uiState.pbUserToken.value.substring(0, 50)}...</div>`;
const pbAgentDisplay = this.uiState.pbAgentToken
? `<div><strong>PB Agent:</strong> ${this.uiState.pbAgentToken.value.substring(0, 50)}...</div>`
: '<div><strong>PB Agent:</strong> Not available</div>';
finalTokens.innerHTML = pbUserDisplay + pbAgentDisplay;
}
}
if (loginBtn) {
loginBtn.textContent = 'Tokens Acquired ✓';
loginBtn.disabled = true;
(loginBtn as HTMLButtonElement).style.opacity = '0.5';
}
}
/**
* Open the popup - call this from user gesture (click event)
* Uses production PocketBase URL and Microsoft OAuth configuration
*/
async open(): Promise<void> {
this.initPocketBase();
// Create and display popup UI
this.popupElement = this.createPopupUI();
document.body.appendChild(this.popupElement);
// Add overlay click to close
this.popupElement.addEventListener('click', (e) => {
if (e.target === this.popupElement) {
this.close();
}
});
}
/**
* Close the popup
*/
close(): void {
if (this.popupElement) {
this.popupElement.remove();
this.popupElement = null;
}
}
/**
* Get acquired tokens
*/
getTokens(): { pbUser: TokenData | null; pbAgent: TokenData | null } {
return {
pbUser: this.uiState.pbUserToken,
pbAgent: this.uiState.pbAgentToken
};
}
/**
* Get current UI state
*/
getState(): PopupUIState {
return { ...this.uiState };
}
}
// Export for use
export default OAuthPopupLogin;