trails/apps/journal/app/routes/api.sync.callback.$provider.ts
Ullrich Schäfer 6f366293c9
journal: OAuth connect→callback→resume lifecycle as one module
The PKCE verifier cookie, state encoding, redirect-URI construction,
and code exchange were coordinated across three route handlers that
each knew part of the protocol. Two latent bugs lived in the gaps: a
push-initiated re-authorization skipped PKCE entirely (harmless today
only because the sole pusher, Wahoo, is not a PKCE provider), and the
push-resume callback path never cleared the spent verifier cookie.

oauth-flow.server.ts now owns the lifecycle: initiateOAuthFlow builds
the provider redirect (state + verifier cookie, on every entry path),
and completeOAuthFlow consumes the callback — state decode, verifier
recovery, code exchange, connection linking — returning a
discriminated result the route maps to redirects. The three routes are
thin adapters; the next OAuth provider reuses the flow, not the
pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:44:04 +02:00

56 lines
2.4 KiB
TypeScript

import { redirect, data } from "react-router";
import type { Route } from "./+types/api.sync.callback.$provider";
import { requireSessionUser } from "~/lib/auth/session.server";
import { getManifest } from "~/lib/connected-services";
import {
completeOAuthFlow,
clearPkceCookieHeader,
} from "~/lib/connected-services/oauth-flow.server";
import { pushRouteToProvider } from "~/lib/connected-services/push-action.server";
export async function loader({ params, request }: Route.LoaderArgs) {
const user = await requireSessionUser(request);
const manifest = getManifest(params.provider);
if (!manifest || !manifest.exchangeCode) {
return data({ error: "Unknown provider" }, { status: 404 });
}
const result = await completeOAuthFlow(manifest, request, user.id);
const state = result.state;
const fallbackReturn = state.returnTo ?? "/settings";
// Spent (or irrelevant) verifier — clear it on every outcome.
const headers = { "Set-Cookie": clearPkceCookieHeader() };
switch (result.status) {
case "denied":
// User declined at the provider. Back to the originating page
// with a notice instead of looping them through OAuth again.
return redirect(`${fallbackReturn}?push=needs_permission`, { headers });
case "missing_code":
return data({ error: "Missing authorization code" }, { status: 400 });
case "missing_verifier":
return redirect(`${fallbackReturn}?error=sync_failed`, { headers });
case "error":
return redirect(`${fallbackReturn}?error=${result.code}`, { headers });
case "linked":
break;
}
// Resume an interrupted push now that the connection has the scope.
if (state.pushAfter?.routeId) {
const outcome = await pushRouteToProvider({
userId: user.id,
providerId: manifest.id,
routeId: state.pushAfter.routeId,
});
const target = state.returnTo ?? `/routes/${state.pushAfter.routeId}`;
if (outcome.status === "success") return redirect(`${target}?push=success`, { headers });
if (outcome.status === "scope_missing") return redirect(`${target}?push=needs_permission`, { headers });
if (outcome.status === "needs_relink") return redirect(`${target}?push=needs_permission`, { headers });
if (outcome.status === "error") return redirect(`${target}?push=error&code=${outcome.code}`, { headers });
return redirect(`${target}?push=${outcome.status}`, { headers });
}
return redirect(state.returnTo ?? "/settings", { headers });
}