feat: implement OAuth handoff mechanism with Redis for secure token management and payload storage
This commit is contained in:
+51
-4
@@ -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=…<huge>`, 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}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
: '';
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user