OAuth (mock provider)

End-to-end OAuth flow against a mock provider that lives on the same site. App ↔ IdP ↔ callback all wired through the same bext routes — copy the shape into a real app and swap the IdP URL for Google, GitHub, or your own. CSRF is enforced via a one-shot oauth_state cookie compared at callback.

Tip
The <code>oauth_state</code> cookie must be <code>HttpOnly</code> and <code>SameSite=Lax</code>: HttpOnly prevents JS from reading it (resists XSS), Lax prevents it from being sent on cross-site sub-resource requests. Without both, the CSRF check is ineffective.

Not signed in

src/app/examples/oauth-mock/page.tsxTSX
// Three pieces:
//
//  1. /examples/oauth-mock          — the app. Login button mints a
//                                     CSRF state cookie + 303s to the
//                                     IdP with redirect_uri + state.
//
//  2. /examples/mock-idp-login      — the IdP's "consent" UI. User
//                                     picks an identity; IdP 303s
//                                     back to redirect_uri with
//                                     ?code=...&state=...
//
//  3. /examples/oauth-mock/callback — exchanges the code at /api/mock-idp
//                                     (real apps POST server→server),
//                                     verifies state matches the
//                                     cookie (CSRF), sets a session
//                                     cookie, redirects to the app.

// Login action mints state, sets cookie, redirects to IdP.
export async function action({ request }) {
  const state = randomState();
  return new Response(null, { status: 303, headers: {
    Location: `/examples/mock-idp-login?redirect_uri=${cb}&state=${state}`,
    "set-cookie": "oauth_state=" + state + "; HttpOnly; SameSite=Lax",
  }});
}

// Callback exchanges code, verifies state, sets session.
export async function loader({ request }) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  if (cookies.oauth_state !== url.searchParams.get("state")) throw fail("state_mismatch");
  const id = await (await fetch("/api/mock-idp?code=" + code)).json();
  const headers = new Headers({ Location: "/examples/oauth-mock" });
  headers.append("Set-Cookie", "oauth_session=" + JSON.stringify(id) + "; HttpOnly");
  headers.append("Set-Cookie", "oauth_state=; Max-Age=0");
  throw new Response(null, { status: 303, headers });
}