From a4bd49eb85d6b0cf37a438d6a291c0b5c63f44ff Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 20 May 2026 14:32:39 +0100 Subject: [PATCH] feat: implement OAuth handoff mechanism with Redis for secure token management and payload storage --- apps/api/src/app.ts | 55 +++++++++++++++++++++-- apps/api/src/utils/redis/oauth-handoff.ts | 27 +++++++++++ apps/dashboard/src/lib/utils/store/org.ts | 16 +------ 3 files changed, 80 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/utils/redis/oauth-handoff.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 736c47ae..be00cd9d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,6 +1,8 @@ import 'dotenv/config'; +import { consumeHandoffPayload, storeHandoffPayload } from '@api/utils/redis/oauth-handoff'; import { isPublicCorsPath, sessionCors } from '@api/middlewares/cors'; +import { randomBytes } from 'crypto'; import { API_SERVER_URL } from '@api/constants'; import { Hono } from '@api/utils/hono'; @@ -101,20 +103,65 @@ export const app = new Hono() request = new Request(url, request); } + // Inbound: if the browser is hitting `/oauth-proxy-callback?token=…` + // (our short handoff token), swap the token back to the original + // encrypted `cookies=…` payload before handing the request to Better + // Auth — the oauth-proxy plugin expects the cookies query param. + { + const inUrl = new URL(request.url); + if (inUrl.pathname.endsWith('/oauth-proxy-callback')) { + const token = inUrl.searchParams.get('token'); + if (token && !inUrl.searchParams.has('cookies')) { + const cookies = await consumeHandoffPayload(token); + if (cookies) { + inUrl.searchParams.set('cookies', cookies); + inUrl.searchParams.delete('token'); + request = new Request(inUrl, request); + } + } + } + } + const response = await auth.handler(request); + // Outbound: if the OAuth callback redirected to + // `/oauth-proxy-callback?cookies=…`, stash the cookies payload + // in Redis under a short token and rewrite the Location header so the + // browser only carries the token across hosts. The companion swap on + // the inbound side restores the cookies before Better Auth's plugin + // tries to decrypt them. Without this, the encrypted cookies blob + // pushes the Location header past Render's response size ceiling and + // the redirect silently never reaches the browser. + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get('location'); + if (location && location.includes('/oauth-proxy-callback') && location.includes('cookies=')) { + try { + const outUrl = new URL(location); + const cookies = outUrl.searchParams.get('cookies'); + if (cookies && cookies.length > 500) { + const token = randomBytes(16).toString('hex'); + await storeHandoffPayload(token, cookies); + outUrl.searchParams.delete('cookies'); + outUrl.searchParams.set('token', token); + const headers = new Headers(response.headers); + headers.set('location', outUrl.toString()); + return new Response(response.body, { status: response.status, headers }); + } + } catch (error) { + console.error('[auth-handler] failed to swap cookies → token, falling back to inline cookies:', error); + } + } + } + if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location') ?? ''; - const setCookies = response.headers.get('set-cookie') ?? ''; console.log( '[auth-handler]', c.req.method, c.req.path, '→', response.status, - `location.length=${location.length}`, - `setCookie.length=${setCookies.length}`, - `location[0..200]=${location.slice(0, 200)}` + `location.length=${location.length}` ); } diff --git a/apps/api/src/utils/redis/oauth-handoff.ts b/apps/api/src/utils/redis/oauth-handoff.ts new file mode 100644 index 00000000..3ea220f8 --- /dev/null +++ b/apps/api/src/utils/redis/oauth-handoff.ts @@ -0,0 +1,27 @@ +import { logRedisUnavailableOnce, redis } from './redis'; + +const KEY = (token: string) => `oauth-handoff:${token}`; +const TTL_SECONDS = 120; // OAuth callbacks usually finish in < 2s; 2 minutes gives plenty of slack. + +export async function storeHandoffPayload(token: string, payload: string): Promise { + try { + await redis.set(KEY(token), payload, { EX: TTL_SECONDS }); + } catch (error) { + logRedisUnavailableOnce('Redis SET oauth-handoff failed', error); + throw new Error('Failed to store OAuth handoff payload'); + } +} + +export async function consumeHandoffPayload(token: string): Promise { + try { + const value = await redis.get(KEY(token)); + if (value) { + // Single-use: delete on read so the token can't be replayed. + await redis.del(KEY(token)); + } + return value; + } catch (error) { + logRedisUnavailableOnce('Redis GET oauth-handoff failed', error); + return null; + } +} diff --git a/apps/dashboard/src/lib/utils/store/org.ts b/apps/dashboard/src/lib/utils/store/org.ts index 5ec07808..39eec5ed 100644 --- a/apps/dashboard/src/lib/utils/store/org.ts +++ b/apps/dashboard/src/lib/utils/store/org.ts @@ -6,7 +6,7 @@ import type { AccountOrg } from '$features/app/types'; import type { OrgTeamMember } from '../types/org'; import { PLAN } from '@cio/utils/plans'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; -import { ROLE } from '@cio/utils/constants'; +import { ROLE, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { STEPS } from '../constants/quiz'; import type { Writable } from 'svelte/store'; @@ -81,24 +81,12 @@ export const currentOrgDomain = derived(currentOrg, ($currentOrg) => { const browserOrigin = dev && browser && window.location.origin; - // Get the root domain from window.location - let rootDomain = ''; - if (browser && typeof window !== 'undefined') { - const host = window.location.hostname; - const parts = host.split('.'); - if (parts.length >= 2) { - rootDomain = parts.slice(-2).join('.'); - } else { - rootDomain = host; - } - } - return browserOrigin ? browserOrigin : $currentOrg.customDomain && $currentOrg.isCustomDomainVerified ? `https://${$currentOrg.customDomain}` : $currentOrg.siteName - ? `https://${$currentOrg.siteName}.${rootDomain}` + ? `https://${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}` : ''; });