src/lib/google.ts
Rama main · réplica de lectura del repositorio original.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import type { User } from "@prisma/client";
import { db } from "@/lib/db";
import { CLAIMED, ensureDefaultConnection, isClaimed } from "@/lib/auth";
import { getSettings, googleIsConfigured, type InstanceSettings } from "@/lib/settings";
import { recordSystemEvent } from "@/lib/data/system";
import { GOOGLE_DATA_SCOPES, type GoogleGrant } from "@/lib/accounts/google";
/**
* Sign in with Google.
*
* The authorization-code flow, written out rather than pulled from a library,
* for the same reason the MCP transport is hand-rolled: it is about ninety
* lines, and a dependency here would own the login page of every instance.
*
* Two things are worth knowing before changing anything in this file.
*
* **The ID token's signature is not verified, and does not need to be.** It
* arrives in the body of a direct TLS POST that this server makes to Google's
* token endpoint, authenticated with the client secret — not through the
* browser. Google's own guidance is that a token collected that way can be
* used without validating the signature, because TLS already proves who sent
* it. The claims that guard against *misconfiguration* rather than forgery —
* issuer, audience, expiry, nonce — are still checked below.
*
* **`email_verified` is the whole security model for matching.** An account
* here is found by email address, so an unverified Google email would let
* anyone who can create a Google account walk into the matching account on
* this instance. It is refused, loudly.
*/
const AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
const ISSUERS = ["https://accounts.google.com", "accounts.google.com"];
/** Where Google sends the browser back to. Registered in the Cloud console. */
export function googleRedirectUri(baseUrl: string) {
return `${baseUrl.replace(/\/$/, "")}/api/auth/google/callback`;
}
// ---------------------------------------------------------------------------
// The state cookie
// ---------------------------------------------------------------------------
/**
* State and nonce live in one signed, httpOnly cookie rather than a row in the
* database. Nothing about a sign-in that is half-finished is worth persisting,
* and keeping it out of the database means a login still works across a
* restart, a replica or a redeploy — the same reason the MCP transport holds
* no session state.
*
* Signed with the client secret, which is the one instance-wide secret both
* ends of this flow already have to agree on.
*/
export type GoogleState = {
state: string;
nonce: string;
next: string;
/**
* Set when the flow was started from Settings by somebody already signed in,
* who is attaching Google to the account they are already in. It lives in
* the signed cookie rather than a query parameter precisely so the callback
* cannot be talked into linking by a crafted URL.
*/
link?: boolean;
/**
* Set when the flow was started from Settings → Connections by somebody signed
* in, asking for read access to their Gmail and Calendar rather than a
* sign-in. Same cookie, same reason: the callback trusts only what it
* signed.
*/
data?: boolean;
};
export const GOOGLE_STATE_COOKIE = "hired_google_oauth";
function sign(payload: string, secret: string) {
return createHmac("sha256", secret).update(payload).digest("base64url");
}
export function packState(value: GoogleState, secret: string) {
const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
return `${payload}.${sign(payload, secret)}`;
}
export function unpackState(cookie: string | undefined, secret: string): GoogleState | null {
if (!cookie) return null;
const [payload, mac] = cookie.split(".");
if (!payload || !mac) return null;
const expected = Buffer.from(sign(payload, secret));
const actual = Buffer.from(mac);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
try {
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString());
if (typeof parsed?.state !== "string" || typeof parsed?.nonce !== "string") return null;
return {
state: parsed.state,
nonce: parsed.nonce,
next: safeNext(parsed.next),
link: parsed.link === true,
data: parsed.data === true,
};
} catch {
return null;
}
}
/**
* Where to land after signing in. Anything but a path on this instance is
* dropped — an open redirect on a login callback is how a phishing page
* borrows your domain, in the moment right after the visitor watched a real
* sign-in succeed.
*
* The backslash case is the one worth naming, because the obvious check misses
* it: WHATWG URL resolution treats `\` as `/` for http(s), so `/\evil.com`
* begins with a single slash, passes "starts with / but not //", and resolves
* to `https://evil.com/`. Control characters are stripped by parsers before
* resolution, so they smuggle the same thing past the same check.
*/
export function safeNext(value: string | null | undefined) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/";
if (/[\\\x00-\x1f\x7f]/.test(value)) return "/";
return value;
}
export function newStateValues(next: string | null, link = false, data = false): GoogleState {
return {
state: randomBytes(16).toString("hex"),
nonce: randomBytes(16).toString("hex"),
next: safeNext(next),
link,
data,
};
}
/**
* Attach a Google identity to the account already signed in.
*
* This is the safe direction of the link that step 2 of `resolveGoogleAccount`
* refuses to make on its own: the person has proved they hold this account by
* being in it, and Google has proved they hold the address. Doing both makes
* the address proven, which is what lets them use the button next time.
*/
export async function linkGoogleToUser(
userId: string,
identity: GoogleIdentity,
): Promise<{ ok: true } | { ok: false; reason: GoogleRefusal }> {
if (!identity.sub) return { ok: false, reason: "no_identity" };
if (!identity.emailVerified) return { ok: false, reason: "unverified" };
const taken = await db.user.findUnique({ where: { googleId: identity.sub } });
if (taken && taken.id !== userId) return { ok: false, reason: "linked_elsewhere" };
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return { ok: false, reason: "failed" };
// Only vouch for the address when it is the one they already hold here.
// Linking a Google account under a different address is fine — people have
// several — but it says nothing about the address on this account.
const proves = user.email === identity.email;
await db.user.update({
where: { id: userId },
data: {
googleId: identity.sub,
...(proves && !user.emailProvenAt ? { emailProvenAt: new Date() } : {}),
},
});
return { ok: true };
}
/** Detach Google, refusing to leave an account with no way back in. */
export async function unlinkGoogleFromUser(
userId: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return { ok: false, error: "No such account." };
if (!user.googleId) return { ok: true };
if (!user.passwordHash) {
return {
ok: false,
error: "Set a password first, or you would have no way to sign in.",
};
}
await db.user.update({ where: { id: userId }, data: { googleId: null } });
return { ok: true };
}
// ---------------------------------------------------------------------------
// The protocol
// ---------------------------------------------------------------------------
export function googleAuthUrl(input: {
settings: InstanceSettings;
redirectUri: string;
state: GoogleState;
}) {
const params = new URLSearchParams({
client_id: input.settings.googleClientId,
redirect_uri: input.redirectUri,
response_type: "code",
scope: input.state.data
? `openid email ${GOOGLE_DATA_SCOPES.mail} ${GOOGLE_DATA_SCOPES.calendar}`
: "openid email profile",
state: input.state.state,
nonce: input.state.nonce,
// Ask every time rather than reusing whatever account the browser is
// already signed in to. On a shared machine, silently landing in someone
// else's workspace is the worst possible outcome here.
prompt: "select_account",
});
if (input.state.data) {
// A refresh token is only issued with offline access, and only on a
// consent screen the person actually sees — so both are forced. Without
// `consent`, a second connect from the same Google account comes back
// with no refresh token at all and nothing to store.
params.set("access_type", "offline");
params.set("prompt", "consent select_account");
params.set("include_granted_scopes", "true");
}
return `${AUTH_ENDPOINT}?${params.toString()}`;
}
export type GoogleIdentity = {
sub: string;
email: string;
emailVerified: boolean;
name: string;
picture: string;
/**
* The tokens that came back with the identity. Only the Settings → Connections
* flow asks for anything worth keeping; for a sign-in the refresh token is
* empty and the access token is thrown away with the response.
*/
grant: GoogleGrant;
};
/** Read a JWT payload without verifying it. See the note at the top. */
function decodeSegment(token: string) {
const segment = token.split(".")[1];
if (!segment) throw new Error("Google returned a token this app could not read.");
return JSON.parse(Buffer.from(segment, "base64url").toString()) as Record<string, unknown>;
}
export async function exchangeCode(input: {
settings: InstanceSettings;
code: string;
redirectUri: string;
nonce: string;
}): Promise<GoogleIdentity> {
const response = await fetch(TOKEN_ENDPOINT, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
code: input.code,
client_id: input.settings.googleClientId,
client_secret: input.settings.googleClientSecret,
redirect_uri: input.redirectUri,
grant_type: "authorization_code",
}),
});
const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
if (!response.ok) {
// Google's own words are far more useful than anything this app could
// invent — redirect_uri_mismatch names the exact misconfiguration.
const detail = [body.error, body.error_description].filter(Boolean).join(": ");
throw new Error(detail || `Google refused the sign-in (HTTP ${response.status}).`);
}
const idToken = typeof body.id_token === "string" ? body.id_token : "";
if (!idToken) throw new Error("Google did not return an identity token.");
const claims = decodeSegment(idToken);
const issuer = String(claims.iss ?? "");
const audience = String(claims.aud ?? "");
const expiry = Number(claims.exp ?? 0);
if (!ISSUERS.includes(issuer)) throw new Error("That identity token did not come from Google.");
if (audience !== input.settings.googleClientId) {
throw new Error("That identity token was issued for a different application.");
}
if (!expiry || expiry * 1000 < Date.now()) throw new Error("That sign-in took too long. Try again.");
if (String(claims.nonce ?? "") !== input.nonce) {
throw new Error("That sign-in could not be matched to the request that started it.");
}
const email = String(claims.email ?? "").trim().toLowerCase();
if (!email) throw new Error("That Google account has no email address on it.");
const expiresIn = typeof body.expires_in === "number" ? body.expires_in : 3600;
return {
sub: String(claims.sub ?? ""),
email,
// Google sends this as a boolean, but has historically sent the string
// "true" as well. Anything else is treated as unverified.
emailVerified: claims.email_verified === true || claims.email_verified === "true",
name: String(claims.name ?? ""),
picture: String(claims.picture ?? ""),
grant: {
accessToken: typeof body.access_token === "string" ? body.access_token : "",
refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : "",
expiresAt: new Date(Date.now() + expiresIn * 1000),
scopes: typeof body.scope === "string" ? body.scope.split(/\s+/).filter(Boolean) : [],
},
};
}
// ---------------------------------------------------------------------------
// Who is allowed in
// ---------------------------------------------------------------------------
/**
* Why a sign-in was refused, as a code rather than a sentence.
*
* The sentence is looked up on the sign-in page. It travels as a code because
* the callback's only way to say anything is `?error=` on a redirect, and a
* callback that echoes arbitrary text back onto its own sign-in page is a
* phishing primitive: "Your workspace is locked, email it-desk@… with your
* recovery phrase", rendered in a styled box on the real domain, above the
* real password form.
*/
export type GoogleRefusal =
| "unverified"
| "no_identity"
| "not_set_up"
| "suspended"
| "unpaid"
| "linked_elsewhere"
| "unproven_email"
| "invite_only"
| "domain"
| "expired_state"
| "cancelled"
| "no_refresh_token"
| "no_scopes"
| "failed";
const REFUSALS: GoogleRefusal[] = [
"unverified",
"no_identity",
"not_set_up",
"suspended",
"unpaid",
"linked_elsewhere",
"unproven_email",
"invite_only",
"domain",
"expired_state",
"cancelled",
"no_refresh_token",
"no_scopes",
"failed",
];
export function isGoogleRefusal(value: string): value is GoogleRefusal {
return (REFUSALS as string[]).includes(value);
}
export type GoogleOutcome =
| { ok: true; user: User; created: boolean }
| { ok: false; reason: GoogleRefusal; detail?: string };
/**
* The sentence a visitor reads. `detail` is only ever filled in from settings
* this instance owns — never from anything Google or a query string said.
*/
export function refusalMessage(reason: GoogleRefusal, detail = "") {
switch (reason) {
case "unverified":
return "That Google account's email address is not verified, so it can't be matched to an account here.";
case "no_identity":
return "Google did not identify that account.";
case "not_set_up":
return "This instance has not been set up yet.";
case "suspended":
return "That account has been suspended. Ask an admin to turn it back on.";
case "unpaid":
return "That account is suspended because its subscription lapsed. Paying again turns it back on.";
case "linked_elsewhere":
return "That address already belongs to an account linked to a different Google account. An admin can help if the address changed hands.";
case "unproven_email":
return "There is already an account with that address here, and this instance can't confirm it belongs to you. Sign in with your password, then connect Google from Settings.";
case "invite_only":
return "Accounts here are invite-only. Ask an admin for an invitation.";
case "domain":
return detail ? `Sign-up here is limited to ${detail}.` : "That address can't sign up here.";
case "expired_state":
return "That sign-in link has expired. Try again.";
case "cancelled":
return "Sign-in was cancelled.";
case "no_refresh_token":
return "The provider did not hand over a lasting token. Remove Hired under your account's third-party app access, then connect again.";
case "no_scopes":
return "Neither mail nor calendar was allowed on the consent screen, so there is nothing to connect. Try again and tick at least one.";
default:
return "Google sign-in didn't work. Check Admin → Health for the reason.";
}
}
function domainAllowed(email: string, allowed: string) {
const list = allowed
.split(",")
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ""))
.filter(Boolean);
if (list.length === 0) return true;
const domain = email.split("@")[1] ?? "";
return list.includes(domain);
}
/**
* Turn a verified Google identity into a session, or a reason why not.
*
* The order matters and is the whole policy:
*
* 1. Known Google id → that account, whatever their email is today.
* 2. Proven email match → link Google to the account they already have.
* 3. Open invitation → accept it; no password ever gets chosen.
* 4. Sign-up allowed → a new member account.
* 5. Otherwise → refused.
*
* Steps 2 and 3 are why an admin does not have to do anything for existing
* people: the invitation they already sent works with the Google button.
*
* **"Proven" in step 2 is doing real work, and skipping it is an account
* takeover.** Matching on the address alone would mean trusting a field the
* account holder can type into. Anyone here can change their own email to any
* address nobody is using; if that were enough to match, a member could set
* theirs to a colleague's address and the colleague's first Google sign-in
* would drop them into the member's workspace — career history, resumes, pipeline —
* while the member kept their password and could still read all of it.
* `emailProvenAt` records the instance actually having had a reason to believe
* the address: an admin addressed an invitation to it, the owner claimed the
* instance with it, or Google itself handed it over verified. Typing it into
* Settings clears it.
*
* Step 2 also refuses when the account is already bound to a *different*
* Google identity, rather than rebinding it. Workspace admins reassign
* addresses when somebody leaves, and silently rebinding would hand the new
* holder of alex@corp.com everything the previous Alex ever wrote.
*/
export async function resolveGoogleAccount(identity: GoogleIdentity): Promise<GoogleOutcome> {
const settings = await getSettings();
if (!identity.sub) return { ok: false, reason: "no_identity" };
if (!identity.emailVerified) {
return { ok: false, reason: "unverified" };
}
// Nobody may claim an unclaimed instance this way. Setup is a deliberate act
// with the owner's own password, and it happens before any of this exists.
if ((await db.user.count({ where: CLAIMED })) === 0) {
return { ok: false, reason: "not_set_up" };
}
// 1. Seen before.
const byGoogle = await db.user.findUnique({ where: { googleId: identity.sub } });
if (byGoogle) {
if (!byGoogle.isActive) return { ok: false, reason: suspended(byGoogle) };
return { ok: true, user: await touch(byGoogle, identity), created: false };
}
// 2. Already has an account under this address — link the two, but only when
// the address was established by somebody other than its holder.
const byEmail = await db.user.findUnique({ where: { email: identity.email } });
if (byEmail && isClaimed(byEmail)) {
if (!byEmail.isActive) return { ok: false, reason: suspended(byEmail) };
if (byEmail.googleId && byEmail.googleId !== identity.sub) {
return { ok: false, reason: "linked_elsewhere" };
}
if (!byEmail.emailProvenAt) {
return { ok: false, reason: "unproven_email" };
}
const linked = await touch(byEmail, identity);
await recordSystemEvent({
level: "INFO",
source: "google.signin",
message: "Linked a Google account to an existing member",
userEmail: identity.email,
});
return { ok: true, user: linked, created: false };
}
// 3. Invited but never accepted — the invitation is the permission.
const invite = await db.invite.findFirst({
where: { email: identity.email, acceptedAt: null, expiresAt: { gt: new Date() } },
orderBy: { createdAt: "desc" },
});
if (invite) {
const user = await db.$transaction(async (tx) => {
const data = {
name: identity.name,
googleId: identity.sub,
role: invite.role,
isActive: true,
// Google verified it and an admin addressed an invitation to it.
emailProvenAt: new Date(),
invitedById: invite.invitedById,
lastLoginAt: new Date(),
...(invite.stripeCustomerId ? { stripeCustomerId: invite.stripeCustomerId } : {}),
};
const created = byEmail
? await tx.user.update({ where: { id: byEmail.id }, data })
: await tx.user.create({ data: { ...data, email: identity.email, passwordHash: "" } });
await tx.invite.update({ where: { id: invite.id }, data: { acceptedAt: new Date() } });
return created;
});
await ensureDefaultConnection(user.id);
return { ok: true, user, created: true };
}
// 4. Nobody invited them. Only an admin's explicit choice lets them in.
if (!settings.googleAllowSignup) {
return { ok: false, reason: "invite_only" };
}
if (!domainAllowed(identity.email, settings.googleAllowedDomains)) {
return { ok: false, reason: "domain", detail: settings.googleAllowedDomains };
}
// A new member, never an admin: role is something an admin grants on
// purpose, and no sign-in method should be able to hand it out.
//
// `role` is set on the update branch too, not just the create. Reaching here
// with an existing row means it was unclaimed — today only the placeholder
// from the multi-user migration, which is a SUPER_ADMIN — and adopting it
// without saying so would inherit that role. The placeholder's address is on
// a .local domain that Google can never verify, so this is unreachable
// rather than load-bearing; it is written down because the next unclaimed
// row this app learns to create might not be.
const user = byEmail
? await db.user.update({
where: { id: byEmail.id },
data: {
name: identity.name,
googleId: identity.sub,
role: "MEMBER",
isActive: true,
emailProvenAt: new Date(),
lastLoginAt: new Date(),
},
})
: await db.user.create({
data: {
email: identity.email,
name: identity.name,
passwordHash: "",
googleId: identity.sub,
role: "MEMBER",
emailProvenAt: new Date(),
lastLoginAt: new Date(),
},
});
await ensureDefaultConnection(user.id);
await recordSystemEvent({
level: "INFO",
source: "google.signin",
message: "New account created through Google sign-up",
userEmail: identity.email,
});
return { ok: true, user, created: true };
}
function suspended(user: User): GoogleRefusal {
return user.stripeCustomerId ? "unpaid" : "suspended";
}
/** Attach the Google id, and take a name from Google only if we have none. */
async function touch(user: User, identity: GoogleIdentity) {
return db.user.update({
where: { id: user.id },
data: {
googleId: identity.sub,
...(user.name ? {} : { name: identity.name }),
},
});
}
export { googleIsConfigured };