diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..5c215b9 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,20 @@ +# York Factory OAuth (Doorkeeper) — draft preview mode +# Run `bin/rails db:seed` in york_factory to create the TradingPost OAuth +# application and print the client_id / client_secret. +# +# Local development values: +YF_OAUTH_URL=http://localhost:3000 +YF_OAUTH_CLIENT_ID=your-client-id +YF_OAUTH_CLIENT_SECRET=your-client-secret +YF_OAUTH_CALLBACK_URL=http://localhost:5050/api/auth/callback +# +# Production values (set in the buildcanada.com deployment env): +# YF_OAUTH_URL=https://auth.buildcanada.com +# YF_OAUTH_CLIENT_ID= +# YF_OAUTH_CLIENT_SECRET= +# YF_OAUTH_CALLBACK_URL=https://www.buildcanada.com/api/auth/callback + +# York Factory API base URL (data API — memos, posts, etc.) +# Local: http://localhost:3000/api/v1 +# Production: https://yorkfactory.buildcanada.com/api/v1 +YORK_FACTORY_API_URL=http://localhost:3000/api/v1 diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts new file mode 100644 index 0000000..8e6569d --- /dev/null +++ b/src/app/api/auth/callback/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + oauthConfig, + safeRedirectPath, + setSessionCookies, + type TokenResponse, +} from "@/lib/oauth"; + +export async function GET(request: NextRequest) { + const { url, callbackUrl, clientId, clientSecret } = oauthConfig(); + const { searchParams } = request.nextUrl; + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + + const siteUrl = new URL(request.url).origin; + + if (error) { + return NextResponse.redirect(`${siteUrl}/?preview_error=oauth_denied`); + } + + if (!code || !state) { + return NextResponse.redirect(`${siteUrl}/?preview_error=invalid_callback`); + } + + const storedState = request.cookies.get("oauth_state")?.value; + const redirectTo = safeRedirectPath( + request.cookies.get("oauth_redirect")?.value, + ); + + if (!storedState || state !== storedState) { + return NextResponse.redirect(`${siteUrl}/?preview_error=state_mismatch`); + } + + const tokenRes = await fetch(`${url}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: callbackUrl, + client_id: clientId, + client_secret: clientSecret, + }).toString(), + cache: "no-store", + }); + + if (!tokenRes.ok) { + return NextResponse.redirect( + `${siteUrl}/?preview_error=token_exchange_failed`, + ); + } + + const tokenData = (await tokenRes.json()) as TokenResponse; + + const response = NextResponse.redirect(`${siteUrl}${redirectTo}`); + response.cookies.delete("oauth_state"); + response.cookies.delete("oauth_redirect"); + + // Store the access token (+ refresh token for silent renewal). Identity and + // admin status are resolved live from /me when needed (see lib/auth.ts) — + // never baked into a cookie that could go stale. + setSessionCookies(response, tokenData); + + return response; +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..09c2273 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,42 @@ +import { randomBytes } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { oauthConfig, safeRedirectPath } from "@/lib/oauth"; + +export async function GET(request: NextRequest) { + const { url, callbackUrl, clientId } = oauthConfig(); + + if (!clientId) { + return NextResponse.json({ error: "OAuth not configured" }, { status: 503 }); + } + + const state = randomBytes(16).toString("hex"); + const redirectTo = safeRedirectPath( + request.nextUrl.searchParams.get("redirect"), + ); + + const authorizeUrl = new URL(`${url}/oauth/authorize`); + authorizeUrl.searchParams.set("client_id", clientId); + authorizeUrl.searchParams.set("redirect_uri", callbackUrl); + authorizeUrl.searchParams.set("response_type", "code"); + authorizeUrl.searchParams.set("state", state); + + const isSecure = process.env.NODE_ENV === "production"; + const response = NextResponse.redirect(authorizeUrl.toString()); + + response.cookies.set("oauth_state", state, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + maxAge: 60 * 10, + path: "/", + }); + response.cookies.set("oauth_redirect", redirectTo, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + maxAge: 60 * 10, + path: "/", + }); + + return response; +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..5be087b --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + ACCESS_TOKEN_COOKIE, + REFRESH_TOKEN_COOKIE, + clearSessionCookies, + oauthConfig, +} from "@/lib/oauth"; + +export async function POST(request: NextRequest) { + const { url, clientId, clientSecret } = oauthConfig(); + const siteUrl = new URL(request.url).origin; + + // CSRF guard: a same-origin form POST sends an Origin header matching our own + // origin. Reject anything cross-site so logout can't be forced from another + // site. (Logout is POST-only for the same reason — no GET vector.) + const origin = request.headers.get("origin"); + if (origin && origin !== siteUrl) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Revoke both tokens server-side so logout isn't just a client-side cookie + // drop — a leaked token shouldn't outlive the session. + if (clientId && clientSecret) { + const tokens = [ + request.cookies.get(ACCESS_TOKEN_COOKIE)?.value, + request.cookies.get(REFRESH_TOKEN_COOKIE)?.value, + ].filter(Boolean) as string[]; + + await Promise.all( + tokens.map((token) => + fetch(`${url}/oauth/revoke`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + token, + client_id: clientId, + client_secret: clientSecret, + }).toString(), + cache: "no-store", + }).catch(() => {}), + ), + ); + } + + // 303 See Other so the browser issues a GET to /memos after the POST. + const response = NextResponse.redirect(`${siteUrl}/memos`, 303); + clearSessionCookies(response); + + return response; +} diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..9024276 --- /dev/null +++ b/src/app/api/auth/me/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { getCurrentUser } from "@/lib/auth"; + +// Surfaces the signed-in user's identity to the browser for PostHog identify. +// The access token stays httpOnly on the server; only non-sensitive identity +// fields cross to the client. Returns { user: null } when not signed in. +export async function GET() { + const user = await getCurrentUser(); + return NextResponse.json( + { user }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d52d8d3..5406e6a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,6 +7,7 @@ import ScrollToTop from "@/components/ScrollToTop"; import ThemeShell from "@/components/ThemeShell"; import { Toaster } from "sonner"; import { SubscribeModal } from "@/components/subscribe"; +import { IdentifyUser } from "@/components/auth/IdentifyUser"; const GA_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID; @@ -73,6 +74,7 @@ export default function RootLayout({ + ); diff --git a/src/app/memos/[slug]/DraftPreviewBanner.tsx b/src/app/memos/[slug]/DraftPreviewBanner.tsx new file mode 100644 index 0000000..65f2d41 --- /dev/null +++ b/src/app/memos/[slug]/DraftPreviewBanner.tsx @@ -0,0 +1,47 @@ +type BannerState = "viewing-draft" | "draft-not-found"; + +interface DraftPreviewBannerProps { + state: BannerState; + slug: string; +} + +// Logout is a POST form (not a link) so it can't be triggered cross-site via +// /. Combined with the route's same-origin check, this prevents CSRF +// force-logout. display:contents keeps the form transparent to the flex layout +// so the button behaves like the link it replaces. +function ExitPreviewButton({ + className, + label, +}: { + className: string; + label: string; +}) { + return ( +
+ +
+ ); +} + +export function DraftPreviewBanner({ state }: DraftPreviewBannerProps) { + if (state === "viewing-draft") { + return ( +
+ DRAFT — not yet published + +
+ ); + } + + return ( +
+ No draft found for this slug. + +
+ ); +} diff --git a/src/app/memos/[slug]/page.tsx b/src/app/memos/[slug]/page.tsx index 25f96c5..2a02bdb 100644 --- a/src/app/memos/[slug]/page.tsx +++ b/src/app/memos/[slug]/page.tsx @@ -10,6 +10,20 @@ import { buildGraph } from "@/lib/schemas/graph"; import { generateArticleSchema } from "@/lib/schemas/generators/article"; import { generateBreadcrumbSchema } from "@/lib/schemas/generators/breadcrumb"; import { generateOrganizationSchema } from "@/lib/schemas/generators/organization"; +import { DraftPreviewBanner } from "./DraftPreviewBanner"; +import { setAccessToken } from "@/lib/auth-token"; +import { getCurrentUser, getAccessTokenCookie } from "@/lib/auth"; + +// Draft preview is gated on the signed-in user actually being an admin (live +// from /me), never on a baked cookie. When they are, we hand apiFetch the +// access token so it fetches drafts; otherwise the request store stays empty +// and only published content is returned. +async function resolveAccessToken(): Promise { + const user = await getCurrentUser(); + const token = user?.admin ? await getAccessTokenCookie() : undefined; + setAccessToken(token); + return token; +} export async function generateStaticParams() { try { @@ -26,6 +40,8 @@ export async function generateMetadata({ params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; + // Prime the request-scoped token so draft metadata resolves for admins. + await resolveAccessToken(); let memo; try { memo = await fetchMemo(slug); @@ -64,11 +80,27 @@ export default async function MemoDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; + + const accessToken = await resolveAccessToken(); + let memo; try { memo = await fetchMemo(slug); } catch { - notFound(); + if (!accessToken) notFound(); + + return ( +
+
+

404

+

Memo not found

+

+ This memo doesn't exist or hasn't been published yet. +

+ +
+
+ ); } if (memo.slug !== slug) { @@ -141,6 +173,9 @@ export default async function MemoDetailPage({ return (
+ {accessToken && ( + + )}