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
41 changes: 41 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
110 changes: 110 additions & 0 deletions app/UserPanelClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { useEffect, useState } from "react";
import Image from "next/image";

type MeUnauthed = { authenticated: false };

type MeAuthed = {
authenticated: true;
user: {
sub?: string;
email?: string;
name?: string;
picture?: string;
};
};

type MeResponse = MeUnauthed | MeAuthed;

export default function UserPanelClient() {
const [data, setData] = useState<MeResponse | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
(async () => {
try {
const r = await fetch("/api/me");
if (!r.ok) throw new Error(`Request failed: ${r.status}`);
const json = (await r.json()) as MeResponse;
setData(json);
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error");
}
})();
}, []);

if (error) {
return (
<div className="rounded-xl2 border border-red-200 bg-red-50 p-4">
<p className="text-sm font-medium text-red-700">Something went wrong</p>
<p className="mt-1 text-sm text-red-600">{error}</p>
</div>
);
}

if (!data) {
return (
<div className="rounded-xl2 border border-ui-border bg-white p-4">
<p className="text-sm text-ui-muted">Loading your session…</p>
</div>
);
}

if (!data.authenticated) {
return (
<div className="rounded-xl2 border border-ui-border bg-white p-4">
<p className="text-sm text-ui-muted">Not signed in yet.</p>
</div>
);
}

const displayName = data.user.name || "Signed in user";
const displayEmail = data.user.email || "No email claim";

return (
<div className="space-y-4">
<div className="rounded-xl2 border border-ui-border bg-white p-4 md:p-5">
<div className="flex items-center gap-4">
<div className="h-14 w-14 overflow-hidden rounded-full border border-ui-border bg-ui-surface">
{data.user.picture ? (
<Image
src={data.user.picture}
alt="Profile photo"
width={56}
height={56}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-sm font-semibold text-ui-muted">
U
</div>
)}
</div>

<div className="min-w-0">
<p className="text-sm text-ui-muted">Signed in as</p>
<p className="truncate text-base font-semibold text-ui-text">{displayName}</p>
<p className="truncate text-sm text-ui-muted">{displayEmail}</p>
</div>
</div>

<div className="mt-4 flex flex-wrap gap-2">
<a
href="/api/auth/login?switch_account=1"
className="inline-flex items-center justify-center rounded-pill border border-ui-border bg-white px-4 py-2 text-sm font-medium text-ui-text hover:bg-neutral-50"
>
Sign in with another account
</a>

<a
href="/api/auth/logout"
className="inline-flex items-center justify-center rounded-pill bg-black px-4 py-2 text-sm font-medium text-white hover:opacity-90"
>
Sign out
</a>
</div>
</div>
</div>
);
}
150 changes: 150 additions & 0 deletions app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
export const runtime = "nodejs";

import { NextRequest, NextResponse } from "next/server";
import { buildAuthSetupMessage, getAuthConfig } from "@/lib/auth";

type TokenResponse = {
access_token: string;
id_token: string;
refresh_token?: string;
expires_in?: number;
error?: string;
error_description?: string;
};

export async function GET(req: NextRequest) {
const url = req.nextUrl;

const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const errorDesc = url.searchParams.get("error_description");

if (error) {
return NextResponse.redirect(
new URL(`/auth/error?message=${encodeURIComponent(errorDesc || error)}`, url)
);
}
if (!code || !state) {
return NextResponse.redirect(
new URL(`/auth/error?message=${encodeURIComponent("Missing code/state")}`, url)
);
}

const auth = getAuthConfig(req);

if (!auth.isConfigured) {
return NextResponse.redirect(
new URL(`/auth/error?message=${encodeURIComponent(buildAuthSetupMessage())}`, url)
);
}

const expectedState = req.cookies.get("oauth_state")?.value;
const verifier = req.cookies.get("pkce_verifier")?.value;

// Tie this callback to the original login attempt (CSRF + PKCE protection).
if (!expectedState || !verifier || expectedState !== state) {
return NextResponse.redirect(
new URL(`/auth/error?message=${encodeURIComponent("Invalid state/PKCE")}`, url)
);
}

const body = new URLSearchParams({
grant_type: "authorization_code",
client_id: auth.clientId,
client_secret: auth.clientSecret,
redirect_uri: auth.callbackUrl,
code,
code_verifier: verifier,
});
let tokenJson: TokenResponse | null = null;
let tokenStatus = 500;

try {
const tokenResp = await fetch(auth.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});

tokenStatus = tokenResp.status;

// Parse defensively since some provider failures may not return strict JSON.
const tokenText = await tokenResp.text();
try {
tokenJson = JSON.parse(tokenText);
} catch {}

if (!tokenResp.ok) {
const msg =
tokenJson?.error_description ||
tokenJson?.error ||
`Token exchange failed (${tokenResp.status})`;
return NextResponse.redirect(new URL(`/auth/error?message=${encodeURIComponent(msg)}`, url));
}
} catch {
return NextResponse.redirect(
new URL(
`/auth/error?message=${encodeURIComponent("Could not reach the Google token endpoint.")}`,
url
)
);
}

if (!tokenJson?.access_token || !tokenJson.id_token) {
return NextResponse.redirect(
new URL(
`/auth/error?message=${encodeURIComponent(
`Malformed token response${tokenStatus ? ` (${tokenStatus})` : ""}`
)}`,
url
)
);
}

const secure = process.env.NODE_ENV === "production";

const res = NextResponse.redirect(new URL(auth.postLoginRedirect, url));

res.cookies.set("access_token", tokenJson.access_token, {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge: tokenJson.expires_in ?? 3600,
});

res.cookies.set("id_token", tokenJson.id_token, {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge: tokenJson.expires_in ?? 3600,
});

if (tokenJson.refresh_token) {
res.cookies.set("refresh_token", tokenJson.refresh_token, {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
});
}

res.cookies.set("oauth_state", "", {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge: 0,
});
res.cookies.set("pkce_verifier", "", {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge: 0,
});

return res;
}
14 changes: 14 additions & 0 deletions app/api/auth/error/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { redirect } from "next/navigation";

export default async function LegacyAuthErrorPage({
searchParams,
}: {
searchParams: Promise<{ message?: string }>;
}) {
const params = await searchParams;
const destination = params.message
? `/auth/error?message=${encodeURIComponent(params.message)}`
: "/auth/error";

redirect(destination);
}
72 changes: 72 additions & 0 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { buildAuthSetupMessage, getAuthConfig } from "@/lib/auth";

function base64url(buf: Buffer) {
return buf
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}

function sha256(input: string) {
return crypto.createHash("sha256").update(input).digest();
}

export async function GET(req: NextRequest) {
const url = req.nextUrl;
const auth = getAuthConfig(req);

if (!auth.isConfigured) {
return NextResponse.redirect(
new URL(`/auth/error?message=${encodeURIComponent(buildAuthSetupMessage())}`, url)
);
}

const intent = url.searchParams.get("intent");
const switchAccount = url.searchParams.get("switch_account") === "1";

// Google account selection is the closest match for signup and account switching.
const prompt = switchAccount || intent === "signup" ? "select_account" : null;

// Short-lived values stored in cookies and validated in the callback route.
const state = base64url(crypto.randomBytes(16));
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(sha256(verifier));

const authorizeUrl = new URL(auth.authorizationEndpoint);
authorizeUrl.searchParams.set("client_id", auth.clientId);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("redirect_uri", auth.callbackUrl);
authorizeUrl.searchParams.set("scope", "openid email profile");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("code_challenge", challenge);
authorizeUrl.searchParams.set("code_challenge_method", "S256");
authorizeUrl.searchParams.set("include_granted_scopes", "true");

if (prompt) {
authorizeUrl.searchParams.set("prompt", prompt);
}

const res = NextResponse.redirect(authorizeUrl);
const secure = process.env.NODE_ENV === "production";

res.cookies.set("oauth_state", state, {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge: 10 * 60,
});

res.cookies.set("pkce_verifier", verifier, {
httpOnly: true,
sameSite: "lax",
secure,
path: "/",
maxAge: 10 * 60,
});

return res;
}
Loading