/** * @module identity/sso-popup-helper * * DRY client helper for initiating OAuth2 SSO authentication via a popup window * and capturing the returned authorization code. */ import { extractErrorMsg, getUUID } from "@ibgib/helper-gib/dist/helpers/utils-helper.mjs"; import { getApiBridge } from "../api/ibgib-api-bridge.mjs"; export interface SsoPopupOpts { providerId: 'google' | 'github'; parentTjpAddr?: string; } export interface SsoPopupResult { success: boolean; code?: string; stateObj?: any; redirectUri?: string; message?: string; } /** * Executes an OAuth2 SSO login/link authorization flow using a popup window. * * 1. Queries `GET /api/identity/sso/config` for safe public client configs. * 2. Constructs the provider authorization URL with encoded state payload. * 3. Opens a 600x700 popup window and listens for `sso-oauth-callback` postMessage. * 4. Returns `{ success: true, code, stateObj, redirectUri }` upon authorization. */ export async function executeSsoOAuthPopup({ providerId, parentTjpAddr, }: SsoPopupOpts): Promise { const lc = `[${executeSsoOAuthPopup.name}]`; try { // 1. Fetch public SSO configs from server const apiBridge = getApiBridge(); const resConfig = await apiBridge.getSsoConfig(); if (!resConfig.success || !resConfig.providers) { return { success: false, message: resConfig.message || 'SSO configuration retrieval failed.' }; } const providers = resConfig.providers as any; const providerConfig = Array.isArray(providers) ? providers.find((p: any) => p.providerId === providerId) : providers[providerId]; if (!providerConfig) { return { success: false, message: `SSO provider '${providerId}' is not configured on this server environment.` }; } // 2. Generate high-entropy user nonce & state /** * {@link getUUID} uses {@link globalThis.crypto.getRandomValues} */ const userNonce = await getUUID(); const stateObj = { providerId, parentTjpAddr, userNonce }; const state = btoa(JSON.stringify(stateObj)); // 3. Construct Auth URL if not pre-constructed let authUrl = providerConfig.authUrl; if (!authUrl.includes('client_id=')) { authUrl = `${authUrl}?client_id=${providerConfig.clientId}&redirect_uri=${encodeURIComponent(providerConfig.redirectUri)}&response_type=code&scope=${encodeURIComponent(providerConfig.scope)}&state=${encodeURIComponent(state)}`; } else if (!authUrl.includes('state=')) { authUrl = `${authUrl}&state=${encodeURIComponent(state)}`; } // 4. Open popup window const popup = window.open( authUrl, 'sso-oauth-popup', 'width=600,height=700,status=yes,toolbar=no,menubar=no,location=yes' ); if (!popup) { return { success: false, message: 'Popup blocked by browser. Please enable popups for this site.' }; } // 5. Promise wrapper listening for postMessage or window closure return await new Promise((resolve) => { let checkClosedInterval: any; const messageListener = (event: MessageEvent) => { if (event.origin !== window.location.origin) return; const callbackData = event.data; if (callbackData && callbackData.type === 'sso-oauth-callback') { window.removeEventListener('message', messageListener); clearInterval(checkClosedInterval); resolve({ success: true, code: callbackData.code, stateObj: callbackData.stateObj || stateObj, redirectUri: providerConfig.redirectUri }); } }; window.addEventListener('message', messageListener); checkClosedInterval = setInterval(() => { if (popup.closed) { clearInterval(checkClosedInterval); window.removeEventListener('message', messageListener); resolve({ success: false, message: 'Authentication cancelled (window closed).' }); } }, 500); }); } catch (error) { const emsg = extractErrorMsg(error); console.error(`${lc} ${emsg}`); return { success: false, message: emsg }; } } /** * Checks if the current window was opened as an OAuth popup (`window.opener` exists) * and has received an authorization code in the URL query string (`?code=...`). * If so, posts `sso-oauth-callback` to the parent window and closes itself. */ export function checkAndNotifyPopupOpener(): boolean { if (typeof window === 'undefined' || !window.opener) { return false; } const url = new URL(window.location.href); const code = url.searchParams.get('code'); if (!code) { return false; } const rawState = url.searchParams.get('state'); let stateObj: any; if (rawState) { try { stateObj = JSON.parse(atob(rawState)); } catch { } } window.opener.postMessage({ type: 'sso-oauth-callback', code, stateObj }, window.location.origin); window.close(); return true; }