Browser Auth Without a Backend: The GINEXYS Architecture
TLDR: GINEXYS does auth without a traditional backend. Here is the exact architecture: three surfaces, one session model, Cloudflare Functions as the edge layer, and a VS Code extension handshake that encrypts tokens in transit using AES-GCM derived from a per-session HMAC key.
The Constraint
No persistent server. Auth runs on Cloudflare Pages Functions — which are stateless edge workers — plus Supabase for identity. This eliminates server-side session stores. Every session decision has to be either stateless (JWT verification) or delegated to an external store (Supabase, KV).
Three Auth Surfaces
Surface 1: Browser modal (ginexys-auth.js)
The primary sign-in path is a self-contained modal loaded on any page. It does two things:
- Email/password sign-in via
sb.auth.signInWithPassword(). - OAuth via
sb.auth.signInWithOAuth()withskipBrowserRedirect: true— the OAuth URL is opened in a new tab, not a redirect. This preserves the current tool state.
POST /api/auth/session { action: 'set', access_token, expires_in } to write an HttpOnly cookie. It then removes the Supabase localStorage keys — those were only the handoff channel.
// After OAuth completes in the callback tab:
async function _handleOAuthReturn() {
if (!_oauthPending) return;
_oauthPending = false;
var token = _getStoredToken(); // reads Supabase localStorage
if (!token) return;
// Evict from localStorage immediately — cookie is the real session
for (var i = localStorage.length - 1; i >= 0; i--) {
var k = localStorage.key(i);
if (k && k.startsWith('sb-') && k.endsWith('-auth-token')) localStorage.removeItem(k);
}
await _onSuccess(token, null);
}
Surface 2: Standalone login page (auth/login.html)
Used for direct navigation and VS Code handshake. Stores tokens in sessionStorage only (not localStorage) and strips the refresh_token before storing:
const _secureStorage = {
setItem: (k, v) => {
try {
const parsed = JSON.parse(v);
if (parsed?.refresh_token) delete parsed.refresh_token;
sessionStorage.setItem(k, JSON.stringify(parsed));
} catch {
sessionStorage.setItem(k, v);
}
},
// ...
};
No refresh_token in storage means the session is tab-scoped. When the tab closes, the token is gone. The HttpOnly cookie is the durable session. This is intentional: a stolen access_token expires in one hour; a stolen refresh_token never expires.
Surface 3: VS Code extension (AuthProvider.ts)
The extension cannot open a browser redirect (it would lose state). Instead it uses a polling handshake:
- Extension generates
state(UUID) andsecret(32 random bytes, hex-encoded). - Extension POSTs to
ginexys.com/auth/vscodewithAuthorization: Bearer <secret>. This creates a row invscode_auth_sessionswith the state and secret, expiring in 5 minutes. - Extension opens
ginexys.com/auth/login.html?state=<state>&from=vscodein the browser. - User signs in.
callback.htmlencrypts the access token with AES-GCM and writes it to the session row. - Extension polls
ginexys.com/auth/vscode-poll?state=<state>withAuthorization: Bearer <secret>every 5 seconds. - On delivery, the edge function decrypts the token, returns it, and deletes the row.
HMAC-SHA256(SERVICE_ROLE_KEY, state). The derived key is never stored. The state is the key derivation input, so the row cannot be decrypted without knowing both the service role key (server-only) and the state (URL-public, not secret). The token in storage is always encrypted at rest.
// AuthProvider.ts — token stored in OS keychain, not filesystem
class TokenStore {
private readonly SECRET_KEY = 'ginexys-extension-token';
async save(token: string): Promise<void> { await this.context.secrets.store(this.SECRET_KEY, token); } }
context.secrets maps to the OS keychain per platform: macOS Keychain, Windows Credential Manager, Linux SecretService.
The Session Model
All three surfaces converge on the same HttpOnly cookie gx_session. JavaScript cannot read it. /api/me reads it as a fallback when no Authorization header is present.
The cookie is set by /api/auth/session:
const SECURE_FLAGS = 'HttpOnly; Secure; SameSite=Lax; Path=/';
const maxAge = Math.min(parseInt(expires_in) || 3600, 3600); // cap at 1 hour
return new Response(JSON.stringify({ ok: true }), {
headers: {
'Set-Cookie': gx_session=${token}; ${SECURE_FLAGS}; Max-Age=${maxAge},
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
},
});
On logout, the same endpoint is called with action: 'clear', which sets Max-Age=0 to immediately expire the cookie. This is the server-side session invalidation — without it, logout is cosmetic.
Cross-Tab Sync
When OAuth completes in a popup tab, the parent tab needs to know. Two mechanisms run in parallel:
Primary — BroadcastChannel:
// callback.html (the popup) const bc = new BroadcastChannel('gx:auth'); bc.postMessage({ type: 'gx:auth-complete' }); bc.close();
Fallback — visibilitychange:
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') _handleOAuthReturn(); });
BroadcastChannel fires immediately regardless of focus. visibilitychange only fires when the tab regains focus — it handles the case where the popup closes before broadcasting (race condition on slow connections).
The PKCE Invariant
Every Supabase client init includes flowType: 'pkce'. This makes Supabase use the PKCE flow (RFC 7636) for all OAuth. The authorization server verifies the code_challenge/code_verifier pair before issuing tokens. An intercepted authorization code is worthless without the verifier, which never leaves the browser.
What This Cannot Do
No server-side refresh. Tokens expire after 1 hour and the user re-authenticates. Acceptable for a developer tool — unacceptable for a consumer product with background sync. The correct fix is a refresh endpoint that reads the HttpOnly cookie and issues a new access token, but that requires storing the refresh token server-side (not in the browser), which requires a persistent store.
That is the tradeoff: pure-edge auth is simpler to operate but weaker on session longevity. For a tools platform where sessions are short and intentional, it is the right call.