diff --git a/src/betterAuth.ts b/src/betterAuth.ts index 5012c542ac..5326c8bc71 100644 --- a/src/betterAuth.ts +++ b/src/betterAuth.ts @@ -298,6 +298,9 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => { basePath: '/auth', secret: process.env.BETTER_AUTH_SECRET ?? '', trustedOrigins, + onAPIError: { + errorURL: `${process.env.COMMENTS_PREFIX}/callback`, + }, secondaryStorage: { get: (key) => singleRedisClient.get(`ba:${key}`), set: (key, value, ttl) => diff --git a/src/routes/betterAuth.ts b/src/routes/betterAuth.ts index a838204b1c..d0ac98b407 100644 --- a/src/routes/betterAuth.ts +++ b/src/routes/betterAuth.ts @@ -119,6 +119,57 @@ export const logoutBetterAuth = async ( } }; +const isOAuthCallbackPath = (url: string): boolean => + /\/auth\/callback\//.test(url); + +const rewriteOAuthErrorRedirect = ( + request: FastifyRequest, + response: Response, +): string | undefined => { + if (!isOAuthCallbackPath(request.url)) { + return undefined; + } + + if (response.status < 300 || response.status >= 400) { + return undefined; + } + + const location = response.headers.get('location'); + if (!location) { + return undefined; + } + + const webappCallback = `${process.env.COMMENTS_PREFIX}/callback`; + if (!webappCallback) { + return undefined; + } + + if (location.startsWith(webappCallback)) { + return undefined; + } + + let redirectUrl: URL; + try { + redirectUrl = new URL(location, `${request.protocol}://${request.host}`); + } catch { + return undefined; + } + + const hasError = + redirectUrl.searchParams.has('error') || + redirectUrl.searchParams.get('state') === 'state_not_found'; + if (!hasError) { + return undefined; + } + + const callbackUrl = new URL(webappCallback); + redirectUrl.searchParams.forEach((value, key) => { + callbackUrl.searchParams.set(key, value); + }); + + return callbackUrl.toString(); +}; + const betterAuthRoute = async (fastify: FastifyInstance): Promise => { // Apple sends OAuth callbacks as application/x-www-form-urlencoded POSTs. // Fastify does not parse this content type by default, so collect the raw @@ -142,6 +193,18 @@ const betterAuthRoute = async (fastify: FastifyInstance): Promise => { reply, body, }); + + const rewrittenUrl = rewriteOAuthErrorRedirect(request, response); + if (rewrittenUrl) { + request.log.warn( + { originalLocation: response.headers.get('location') }, + 'OAuth callback error redirect rewritten to webapp', + ); + reply.header('location', rewrittenUrl); + reply.status(302); + return reply.send(); + } + return sendBetterAuthResponse(reply, response); } catch (error) { return sendBetterAuthError( diff --git a/src/routes/redirects.ts b/src/routes/redirects.ts index 7687dcfb30..528891ee59 100644 --- a/src/routes/redirects.ts +++ b/src/routes/redirects.ts @@ -171,9 +171,29 @@ const redirectToProfileImage = async ( export default async function (fastify: FastifyInstance): Promise { const con = await createOrGetConnection(); - fastify.get('/', (req, res) => - res.redirect('https://r.daily.dev/api-redirect'), - ); + fastify.get('/', (req, res) => { + // Some OAuth providers (e.g. GitHub) redirect cancellations to the + // API root instead of the BetterAuth callback URL. Forward the error + // params to the webapp callback so the popup can close cleanly. + const { error, state } = req.query as Record; + if (error && state) { + const webappOrigin = process.env.COMMENTS_PREFIX; + if (webappOrigin) { + req.log.warn( + { error, state }, + 'OAuth error redirected to API root, forwarding to webapp callback', + ); + const callbackUrl = new URL(`${webappOrigin}/callback`); + const params = new URLSearchParams(req.url.split('?')[1] ?? ''); + params.forEach((value, key) => { + callbackUrl.searchParams.set(key, value); + }); + return res.redirect(callbackUrl.toString()); + } + } + + return res.redirect('https://r.daily.dev/api-redirect'); + }); fastify.get('/landing', (req, res) => res.redirect('https://daily.dev')); fastify.get('/tos', (req, res) => res.redirect('https://daily.dev/tos')); fastify.get('/privacy', (req, res) =>