Add Google OAuth2 flow

Redirect to Google consent screen, exchange code for token,
fetch userinfo, derive username from email, create KV session.

Refs #11
This commit is contained in:
marauder-actual
2026-06-12 09:09:14 +02:00
parent 47d2d00d77
commit 01c4a6d399
+129
View File
@@ -0,0 +1,129 @@
import { Hono } from "hono";
import type { Env } from "../types";
import { createSession, setSessionCookie, type Session } from "./session";
const google = new Hono<{ Bindings: Env }>();
// ─── Google OAuth endpoints ─────────────────────────────────────
interface GoogleTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
id_token?: string;
}
interface GoogleUser {
sub: string;
name: string;
email: string;
email_verified: boolean;
picture: string;
}
// Step 1: Redirect to Google authorization
google.get("/auth/google", (c) => {
const clientId = c.env.GOOGLE_CLIENT_ID;
if (!clientId) {
return c.json({ error: "Google OAuth not configured" }, 500);
}
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: `${new URL(c.req.url).origin}/auth/google/callback`,
response_type: "code",
scope: "openid email profile",
access_type: "offline",
state: crypto.randomUUID(),
});
return c.redirect(
`https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`,
);
});
// Step 2: Handle callback, exchange code for token, create session
google.get("/auth/google/callback", async (c) => {
const code = c.req.query("code");
if (!code) {
return c.redirect("/?error=no_code");
}
const clientId = c.env.GOOGLE_CLIENT_ID;
const clientSecret = c.env.GOOGLE_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return c.json({ error: "Google OAuth not configured" }, 500);
}
// Exchange code for access token
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: `${new URL(c.req.url).origin}/auth/google/callback`,
grant_type: "authorization_code",
}),
});
if (!tokenRes.ok) {
return c.redirect("/?error=token_exchange_failed");
}
const tokenData = (await tokenRes.json()) as GoogleTokenResponse;
if (!tokenData.access_token) {
return c.redirect("/?error=no_access_token");
}
// Fetch user profile from userinfo endpoint
const userRes = await fetch(
"https://www.googleapis.com/oauth2/v3/userinfo",
{
headers: { Authorization: `Bearer ${tokenData.access_token}` },
},
);
if (!userRes.ok) {
return c.redirect("/?error=user_fetch_failed");
}
const user = (await userRes.json()) as GoogleUser;
if (!user.email) {
return c.redirect("/?error=no_email");
}
// Derive a username from email prefix
const usernameBase = user.email
.split("@")[0]
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.slice(0, 20);
const username = usernameBase.length >= 3
? usernameBase
: usernameBase.padEnd(3, "0");
// Create session
const session: Session = {
username,
email: user.email,
avatar_url: user.picture,
provider: "google",
created_at: new Date().toISOString(),
};
const sessionId = await createSession(c.env.INSTANCES, session);
const cookie = await setSessionCookie(sessionId, c.env.SESSION_SECRET);
return new Response(null, {
status: 302,
headers: {
Location: "/dashboard",
"Set-Cookie": cookie,
},
});
});
export { google };