Skip to content

Add postMessage-based OAuth token auto-forward from broker to frontend #7

@bryanchriswhite

Description

@bryanchriswhite

Summary

Add automatic token forwarding from the OAuth broker to the PinShare frontend using the postMessage API. This will streamline the OAuth flow for both hosted (share.episkopos.community) and self-hosted instances by eliminating the manual copy/paste step, while maintaining full backward compatibility.

Current Implementation

OAuth Broker Flow (oauth-broker/main.go):

  1. User clicks "Get Token from OAuth Broker" in frontend
  2. Opens broker URL in new tab: https://oauth.episkopos.community
  3. User authorizes with Google
  4. Broker displays JSON token in browser
  5. User manually copies entire JSON token
  6. User pastes into textarea in PinShare UI
  7. Frontend validates and POSTs to /api/google-drive/set-token

Pain Points:

  • Manual copy/paste is tedious and error-prone
  • Not user-friendly for non-technical users
  • Token briefly visible in browser (though ephemeral)
  • Extra steps reduce conversion

Proposed Solution

postMessage-based Auto-Forward

Use the browser's postMessage API to securely send the token from the OAuth broker popup directly to the frontend window.

New Flow:

  1. Frontend opens broker URL in popup window (not new tab)
  2. User authorizes with Google
  3. Broker detects popup context via window.opener
  4. Broker uses window.opener.postMessage() to send token to frontend
  5. Frontend receives message, validates origin, auto-submits token to backend
  6. Popup closes automatically
  7. Frontend shows "Token received!" and proceeds

Fallback Flow (backward compatible):

  • If popup blocked → show instructions + copy/paste option
  • If window.opener unavailable → show copy/paste UI
  • If postMessage fails → show copy/paste UI (existing behavior)
  • Old broker version → copy/paste works as today

Why postMessage?

Works for both cloud-hosted and self-hosted:

  • Cloud frontend → Cloud broker (cross-origin)
  • Self-hosted frontend → Cloud broker (cross-origin)
  • Self-hosted frontend → Self-hosted broker (same-origin)

Secure:

  • Origin validation on both sender and receiver
  • Token never exposed in URL
  • No CORS configuration changes needed

Backward compatible:

  • Copy/paste flow remains available
  • Works with old frontend or old broker versions
  • Self-hosted instances without frontend UI still work

Implementation Details

1. OAuth Broker Changes (oauth-broker/main.go)

Modify renderSuccess() function (lines ~276-380):

Add JavaScript to success page:

// Detect if opened as popup
if (window.opener && !window.opener.closed) {
    // Attempt postMessage
    const message = {
        type: 'PINSHARE_OAUTH_TOKEN',
        token: tokenData, // { access_token, refresh_token, expiry, token_type }
        source: 'pinshare-oauth-broker'
    };
    
    // Send to opener (frontend)
    window.opener.postMessage(message, '*'); // Will be validated by receiver
    
    // Wait for acknowledgment (5 second timeout)
    let acknowledged = false;
    window.addEventListener('message', (event) => {
        if (event.data.type === 'PINSHARE_TOKEN_RECEIVED') {
            acknowledged = true;
            // Show brief success message
            document.body.innerHTML = '<h2>✓ Token sent! Closing...</h2>';
            setTimeout(() => window.close(), 1000);
        }
    });
    
    setTimeout(() => {
        if (!acknowledged) {
            // Fallback to copy/paste UI
            showCopyPasteUI();
        }
    }, 5000);
} else {
    // Not a popup - show copy/paste UI immediately
    showCopyPasteUI();
}

Message format:

{
  "type": "PINSHARE_OAUTH_TOKEN",
  "token": {
    "access_token": "ya29.a0...",
    "token_type": "Bearer",
    "refresh_token": "1//0g...",
    "expiry": "2024-11-17T23:45:00Z"
  },
  "source": "pinshare-oauth-broker"
}

2. Frontend Changes (pinshare-ui/src/pages/GoogleDriveImport.jsx)

Update "Get Token from OAuth Broker" button handler (currently ~line 314):

const handleGetTokenFromBroker = () => {
    const brokerUrl = `${OAUTH_BASE}/authorize`;
    
    // Open in popup instead of new tab
    const popup = window.open(
        brokerUrl,
        'pinshare-oauth',
        'width=600,height=700,scrollbars=yes'
    );
    
    if (!popup || popup.closed) {
        // Popup blocked - show instructions
        setMessage('Please allow popups for automatic token flow, or use the manual copy/paste method below.');
        return;
    }
    
    // Set up postMessage listener
    const handleMessage = async (event) => {
        // Validate origin
        const allowedOrigin = new URL(OAUTH_BASE).origin;
        if (event.origin !== allowedOrigin) {
            console.warn('Received message from unexpected origin:', event.origin);
            return;
        }
        
        // Validate message structure
        if (event.data?.type !== 'PINSHARE_OAUTH_TOKEN' || 
            event.data?.source !== 'pinshare-oauth-broker') {
            return;
        }
        
        // Send acknowledgment
        popup.postMessage({ type: 'PINSHARE_TOKEN_RECEIVED' }, allowedOrigin);
        
        // Clean up listener
        window.removeEventListener('message', handleMessage);
        clearTimeout(timeoutId);
        
        // Auto-submit token
        try {
            const response = await fetch('/api/google-drive/set-token', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(event.data.token)
            });
            
            if (response.ok) {
                setMessage('✓ Token received and authenticated!');
                checkAuthStatus(); // Refresh auth status
                popup.close();
            } else {
                throw new Error('Token validation failed');
            }
        } catch (error) {
            setMessage('Error storing token: ' + error.message);
        }
    };
    
    window.addEventListener('message', handleMessage);
    
    // Timeout after 30 seconds
    const timeoutId = setTimeout(() => {
        window.removeEventListener('message', handleMessage);
        setMessage('Token auto-forward timed out. Please use the manual copy/paste method.');
    }, 30000);
};

3. Security Considerations

Origin Validation:

  • Frontend validates message origin matches VITE_OAUTH_BASE
  • Broker sends to * but frontend validates (postMessage API design pattern)
  • Message structure validation (type, source fields)

Token Exposure:

  • Token never in URL (unlike redirect-based flows)
  • Only in memory during postMessage transfer
  • Same security as existing copy/paste (clipboard also in memory)

CORS:

  • No CORS changes needed (postMessage works cross-origin by design)
  • Current PS_ALLOWED_ORIGINS configuration unchanged

Popup Blockers:

  • Graceful degradation if popup blocked
  • User-initiated action (button click) usually allows popups
  • Fallback instructions if blocked

4. UI/UX Flow Diagram

Happy Path (Automatic):

User clicks "Get Token from OAuth Broker"
    ↓
Popup opens → User authorizes Google
    ↓
Broker sends token via postMessage
    ↓
Frontend receives token → Auto-submits to backend
    ↓
Success message → Popup closes → Auth status updates

Fallback Path (Manual):

Popup blocked OR postMessage fails
    ↓
Show "Please allow popups or use manual flow"
    ↓
User copies token from broker page
    ↓
User pastes into textarea → Submits manually
    ↓
(Existing copy/paste flow)

5. Backward Compatibility Matrix

Frontend Version Broker Version Result
New New ✅ Automatic postMessage flow
New Old ✅ Fallback to copy/paste (timeout)
Old New ✅ Copy/paste (no popup listener)
Old Old ✅ Copy/paste (existing behavior)

Edge Cases:

  • Self-hosted without frontend UI: Copy/paste works (existing behavior)
  • Direct OAuth flow: Unchanged, still supported
  • Multiple instances: Each validates its own VITE_OAUTH_BASE origin

Testing Checklist

  • Cloud hosted → Cloud broker: Automatic flow works (cross-origin)
  • Self-hosted → Cloud broker: Automatic flow works (cross-origin)
  • Self-hosted → Self-hosted broker: Automatic flow works (same-origin)
  • Popup blocked: Graceful fallback with instructions
  • Old broker (no postMessage): Timeout → fallback to copy/paste
  • Old frontend: Copy/paste works as today
  • Origin validation: Reject messages from wrong origin
  • Malformed message: Ignore invalid message structure
  • Multiple auth attempts: Listener cleanup works correctly
  • Browser compatibility: Test Chrome, Firefox, Safari, Edge

Files to Modify

  1. oauth-broker/main.go (~line 276-380)

    • Update renderSuccess() function
    • Add postMessage JavaScript to success page
    • Keep existing copy/paste UI as fallback
  2. pinshare-ui/src/pages/GoogleDriveImport.jsx (~line 314+)

    • Update "Get Token" button handler
    • Change window.open() from new tab to popup
    • Add message event listener with origin validation
    • Add timeout and cleanup logic
    • Keep existing manual textarea flow
  3. oauth-broker/README.md (optional)

    • Document new automatic flow
    • Document fallback behavior

Rollout Strategy

  1. Phase 1: Deploy updated broker

    • Backward compatible (works with old frontends)
    • Test in staging with manual copy/paste
  2. Phase 2: Deploy updated frontend

    • Progressive enhancement (works with old brokers)
    • Test automatic flow with both hosted and self-hosted
  3. Phase 3: Monitor and iterate

    • Track popup blocker frequency
    • Collect user feedback
    • Add analytics if needed

Benefits

Better UX: No manual copy/paste for 95%+ of users
Works everywhere: Cloud-hosted, self-hosted, cross-origin
Secure: Origin validation, no URL token exposure
Backward compatible: Existing flows unchanged
Low risk: Graceful degradation if anything fails

Related Issues

  • Original broker design: Centralized OAuth credentials for self-hosted instances
  • CORS configuration: Recent commits a94ff64 and e353a42

Priority: Medium - Improves UX but existing flow works fine
Effort: Small - ~2-3 hours implementation + testing
Risk: Low - Fallback to existing behavior if anything fails

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions