Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -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=<from york_factory db:seed>
# YF_OAUTH_CLIENT_SECRET=<from york_factory db:seed>
# 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
66 changes: 66 additions & 0 deletions src/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
42 changes: 42 additions & 0 deletions src/app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
50 changes: 50 additions & 0 deletions src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -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;
}
13 changes: 13 additions & 0 deletions src/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
);
}
2 changes: 2 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -73,6 +74,7 @@ export default function RootLayout({
</ThemeShell>
<Toaster position="bottom-right" />
<SubscribeModal />
<IdentifyUser />
</body>
</html>
);
Expand Down
47 changes: 47 additions & 0 deletions src/app/memos/[slug]/DraftPreviewBanner.tsx
Original file line number Diff line number Diff line change
@@ -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
// <img>/<a>. 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 (
<form action="/api/auth/logout" method="post" className="contents">
<button
type="submit"
className={`${className} [font:inherit] text-inherit cursor-pointer border-0 bg-transparent p-0`}
>
{label}
</button>
</form>
);
}

export function DraftPreviewBanner({ state }: DraftPreviewBannerProps) {
if (state === "viewing-draft") {
return (
<div className="w-full bg-amber-500 text-white text-sm px-4 py-2 flex items-center justify-between">
<span className="font-medium">DRAFT — not yet published</span>
<ExitPreviewButton className="underline" label="Exit preview" />
</div>
);
}

return (
<div className="w-full border border-amber-400 bg-amber-50 text-amber-900 text-sm px-4 py-3 flex items-center gap-2">
<span>No draft found for this slug.</span>
<ExitPreviewButton className="underline ml-auto" label="Exit preview mode" />
</div>
);
}
37 changes: 36 additions & 1 deletion src/app/memos/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
const user = await getCurrentUser();
const token = user?.admin ? await getAccessTokenCookie() : undefined;
setAccessToken(token);
return token;
}

export async function generateStaticParams() {
try {
Expand All @@ -26,6 +40,8 @@ export async function generateMetadata({
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
// Prime the request-scoped token so draft metadata resolves for admins.
await resolveAccessToken();
let memo;
try {
memo = await fetchMemo(slug);
Expand Down Expand Up @@ -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 (
<div className="mx-[10px] my-[10px] border border-border-light bg-bg">
<div className="max-w-[720px] mx-auto px-[5vw] md:px-[10vw] py-24 flex flex-col items-center gap-8 text-center">
<p className="type-label text-text-secondary">404</p>
<h1 className="type-title">Memo not found</h1>
<p className="type-body text-text-secondary">
This memo doesn&apos;t exist or hasn&apos;t been published yet.
</p>
<DraftPreviewBanner state="draft-not-found" slug={slug} />
</div>
</div>
);
}

if (memo.slug !== slug) {
Expand Down Expand Up @@ -141,6 +173,9 @@ export default async function MemoDetailPage({

return (
<div className="mx-[10px] my-[10px] border border-border-light bg-bg">
{accessToken && (
<DraftPreviewBanner state="viewing-draft" slug={slug} />
)}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
Expand Down
53 changes: 53 additions & 0 deletions src/components/auth/IdentifyUser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import { useEffect } from "react";
import posthog from "posthog-js";

// Sentinel recording which user (by email) we've already identified this
// browser session, so identify fires once per session rather than once per
// page load.
const SESSION_KEY = "yf_identified";

// Bridges the httpOnly session to PostHog: asks the server who the user is
// (token never leaves the server) and identifies them in PostHog on login,
// resets on logout. Rendered once in the root layout; returns no UI.
export function IdentifyUser() {
useEffect(() => {
let cancelled = false;

(async () => {
try {
const res = await fetch("/api/auth/me", { cache: "no-store" });
if (!res.ok || cancelled) return;

const { user } = await res.json();
if (cancelled) return;

if (user) {
if (sessionStorage.getItem(SESSION_KEY) !== user.email) {
posthog.identify(user.email, {
email: user.email,
name: user.name,
role: user.role,
is_admin: user.admin,
});
sessionStorage.setItem(SESSION_KEY, user.email);
}
} else if (sessionStorage.getItem(SESSION_KEY)) {
// Was identified, now signed out → reset so the next anonymous
// visitor isn't conflated with the previous user.
posthog.reset();
sessionStorage.removeItem(SESSION_KEY);
}
} catch {
// Best-effort: analytics identification must never break the page.
}
})();

return () => {
cancelled = true;
};
}, []);

return null;
}
Loading
Loading