/* Supabase social OAuth with PKCE. Provider secrets stay in Supabase, never in this file. */ const SUPABASE_URL = 'https://yohoshkditpgjjbrgtli.supabase.co'; const KEY = 'sb_publishable_se8BfFiSt6gUJngDdf0m1w_kE9anEYZ'; const FLOW_KEY = 'capgain.oauth.flow.v1'; const SESSION_KEY = 'capgain.oauth.session.v1'; const providers = { google: 'Google', apple: 'Apple', x: 'X', facebook: 'Facebook' }; const CG = window.CG; if (!CG?.auth) throw new Error('CAPGAIN authentication is unavailable'); const emailAuth = CG.auth; const listeners = []; let social = read(SESSION_KEY); let providerStatus = null; function read(key) { try { return JSON.parse(sessionStorage.getItem(key) || 'null'); } catch (_) { return null; } } function save(value) { if (value) sessionStorage.setItem(SESSION_KEY, JSON.stringify(value)); else sessionStorage.removeItem(SESSION_KEY); } function random(bytes = 32) { const raw = crypto.getRandomValues(new Uint8Array(bytes)); return [...raw].map(x => x.toString(16).padStart(2, '0')).join(''); } function base64url(bytes) { return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } async function sha256(value) { return base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)))); } function mask(value) { const email = String(value || ''); const at = email.indexOf('@'); return at > 0 ? `${email.slice(0, 2)}•••${email.slice(at)}` : 'Signed in'; } function emit() { listeners.forEach(fn => { try { fn(current()); } catch (_) {} }); try { CG.account?.syncButton?.(); } catch (_) {} } async function request(path, payload, token) { const response = await fetch(SUPABASE_URL + path, { method: 'POST', headers: { 'content-type': 'application/json', apikey: KEY, ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: JSON.stringify(payload) }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.msg || data.error_description || data.message || 'Sign-in could not be completed'); return data; } function accept(payload, provider) { if (!payload.access_token || !payload.refresh_token || !payload.user?.id) throw new Error('Invalid sign-in response'); social = { accessToken: payload.access_token, refreshToken: payload.refresh_token, userId: payload.user.id, emailMasked: mask(payload.user.email), provider, expiresAt: Date.now() + Number(payload.expires_in || 3600) * 1000 }; save(social); emit(); } async function refresh() { if (!social?.refreshToken) return; try { accept(await request('/auth/v1/token?grant_type=refresh_token', { refresh_token: social.refreshToken }), social.provider); } catch (_) { social = null; save(null); emit(); } } function current() { if (social && Date.now() < social.expiresAt) return { emailMasked: social.emailMasked, mobileMasked: social.emailMasked, method: social.provider, provider: social.provider, userId: social.userId, expiresAt: social.expiresAt }; return emailAuth.current(); } async function begin(provider) { if (!providers[provider]) return; if (!providerStatus?.[provider === 'x' ? 'twitter' : provider]) { CG.toast?.(`${providers[provider]} sign-in is being set up. Use email sign-in for now.`, 'warn'); return; } if (!crypto?.subtle || location.protocol !== 'https:') { CG.toast?.('Secure HTTPS is required for social sign-in.', 'warn'); return; } const verifier = random(48), nonce = random(16); const redirect = new URL(location.pathname, location.origin); redirect.searchParams.set('cg_oauth', nonce); sessionStorage.setItem(FLOW_KEY, JSON.stringify({ verifier, nonce, provider, at: Date.now() })); const target = new URL(SUPABASE_URL + '/auth/v1/authorize'); target.searchParams.set('provider', provider); target.searchParams.set('redirect_to', redirect.href); target.searchParams.set('code_challenge', await sha256(verifier)); target.searchParams.set('code_challenge_method', 's256'); location.assign(target.href); } async function complete() { const url = new URL(location.href), code = url.searchParams.get('code'), nonce = url.searchParams.get('cg_oauth'); const error = url.searchParams.get('error_description') || url.searchParams.get('error'); if (!code && !error) return; const flow = read(FLOW_KEY); sessionStorage.removeItem(FLOW_KEY); url.searchParams.delete('code'); url.searchParams.delete('cg_oauth'); url.searchParams.delete('error'); url.searchParams.delete('error_description'); history.replaceState({}, '', url.pathname + url.search + url.hash); if (error) { CG.toast?.('Sign-in was cancelled or unavailable. Check your provider setup.', 'warn'); return; } if (!flow || !nonce || flow.nonce !== nonce || Date.now() - flow.at > 10 * 60 * 1000) { CG.toast?.('Sign-in verification expired. Please try again.', 'warn'); return; } try { const data = await request('/auth/v1/token?grant_type=pkce', { auth_code: code, code_verifier: flow.verifier }); accept(data, flow.provider); CG.toast?.(`Signed in with ${providers[flow.provider]}`, 'good'); } catch (_) { CG.toast?.('Sign-in could not be completed. Check the provider configuration.', 'warn'); } } function addButtons() { const form = document.querySelector('.cg-email-auth'); if (!form) return; if (form.querySelector('.cg-social-auth')) { updateButtons(form); return; } const group = document.createElement('div'); group.className = 'cg-social-auth'; group.innerHTML = '

Or continue with

' + Object.entries(providers).map(([key, label]) => ``).join(''); group.addEventListener('click', event => { const provider = event.target.closest('[data-social]')?.dataset.social; if (provider) begin(provider); }); form.prepend(group); updateButtons(form); } function updateButtons(form) { form.querySelectorAll('.cg-social-auth [data-social]').forEach(button => { const key = button.dataset.social; const enabled = !!providerStatus?.[key === 'x' ? 'twitter' : key]; const title = enabled ? `Continue with ${providers[key]}` : `${providers[key]} sign-in is being set up`; const label = enabled ? providers[key] : `${providers[key]} · setup pending`; if (button.disabled !== !enabled) button.disabled = !enabled; if (button.title !== title) button.title = title; if (button.textContent !== label) button.textContent = label; }); } const auth = { ...emailAuth, provider: () => social ? `supabase-${social.provider}` : emailAuth.provider(), providerLabel: () => social ? `${providers[social.provider]} sign-in` : emailAuth.providerLabel(), current, isSignedIn: () => !!current(), openSignIn: options => { if (current()) { options?.onDone?.(current()); return; } emailAuth.openSignIn(options); queueMicrotask(addButtons); }, requireSignIn: (reason, done) => { if (current()) { done?.(current()); return true; } auth.openSignIn({ reason, onDone: done }); return false; }, signOut: async () => { if (social) { const old = social; social = null; save(null); emit(); try { await request('/auth/v1/logout', {}, old.accessToken); } catch (_) {} } emailAuth.signOut(); }, on: fn => { listeners.push(fn); emailAuth.on(fn); }, beginSocial: begin, }; CG.auth = auth; new MutationObserver(addButtons).observe(document.body, { childList: true, subtree: true }); fetch(SUPABASE_URL + '/auth/v1/settings', { headers: { apikey: KEY } }) .then(response => response.ok ? response.json() : null) .then(settings => { providerStatus = settings?.external || null; addButtons(); }) .catch(() => { providerStatus = null; addButtons(); }); complete(); if (social && Date.now() >= social.expiresAt - 60000) refresh(); setInterval(() => { if (social && Date.now() >= social.expiresAt - 60000) refresh(); }, 60000);