ubiquitous-invention/apps/web/lib/auth.ts
Randall Stillwell 58f92f3898 feat(security): in-process rate limit for sign-in and invite creation
Algorithm: a fixed-window token bucket implemented as a pure function in
`@tasks/shared` (`consumeTokenBucket`) plus a thin `apps/web` wrapper that
holds per-key state in a module-scoped `Map`. No Redis, no external deps —
horizontally-scaled deploys will need a Redis-backed swap behind the same
`rateLimit()` signature; called out in the JSDoc as a follow-up. The pure
core is unit-tested in `packages/shared` (6 new vitest cases covering
allow/deny, window reset, key isolation, monotonic retryAfterMs, denied-
flood pegging, and option validation); the wrapper is intentionally not
tested here because apps/web has no vitest harness yet.

Wire-ins (the two narrow surfaces called out in the v1 spec):

  1. Credentials `authorize` in `apps/web/lib/auth.ts`: 5 attempts per
     IP per 60s. IP comes from `next/headers` (x-forwarded-for first
     entry, then x-real-ip); when headers() throws or returns nothing we
     fall back to keying on "unknown" in prod and skipping the limiter
     entirely in dev so a local test loop doesn't lock itself out. On a
     trip we `console.warn` and return null — the standard Auth.js
     "auth failed" signal — without consulting the DB.

  2. `invites.create` in `apps/web/server/routers/invites.ts`: 10
     invite-creates per inviter per hour. Keyed by inviter id (not
     workspace) so a multi-workspace admin can't multiply their
     allowance. On trip we throw TRPCError TOO_MANY_REQUESTS with a
     retry-after seconds count baked into the message.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 13:26:19 -05:00

504 lines
18 KiB
TypeScript

import NextAuth from "next-auth";
import type { Account, DefaultSession, NextAuthConfig, Profile } from "next-auth";
import Authentik from "next-auth/providers/authentik";
import Credentials from "next-auth/providers/credentials";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import { rateLimit } from "@/server/lib/rate-limit";
const SIGNIN_RATE_LIMIT = { limit: 5, windowMs: 60_000 } as const;
/**
* Best-effort IP extraction for the Credentials `authorize` callback.
* Auth.js v5 doesn't hand us the request, so we reach for the Next.js
* `headers()` helper — which works inside the route-handler context that
* `/api/auth/[...nextauth]` runs in, but may throw in other contexts
* (e.g. server actions during sign-in testing). On throw we return
* `null` and the caller decides whether to skip the limiter (dev) or
* lock the address as `"unknown"` (prod, so a header-stripping proxy
* doesn't accidentally disable the guardrail entirely).
*/
async function resolveSignInIp(): Promise<string | null> {
try {
const { headers } = await import("next/headers");
const h = await headers();
const fwd = h.get("x-forwarded-for");
if (fwd) {
const first = fwd.split(",")[0]?.trim();
if (first) return first;
}
return h.get("x-real-ip") ?? null;
} catch {
return null;
}
}
/**
* Session strategy is JWT (no `@auth/drizzle-adapter`). The callbacks below
* are the manual replacement for what an adapter would normally do:
*
* 1. Credentials sign-in resolves to a `users.id` directly inside `authorize`
* because the row already exists (Credentials never creates users).
*
* 2. OAuth sign-in resolves via the linked `accounts` row when one exists
* (`(provider, provider_account_id)` is the canonical "this OAuth
* identity belongs to this user" record). When it doesn't, we fall back
* to looking up the OAuth-claimed email in `user_email_identities` and
* either link to that existing user, or mint a fresh `users` + primary
* identity row. The `accounts` row is then written so subsequent
* sign-ins use the fast path.
*
* Security model for OAuth:
* - Trust the provider's `email_verified` claim, but only after we've
* resolved it correctly per provider (Google/Authentik expose it on the
* OIDC profile; GitHub does not — we hit `/user/emails` with the access
* token and read the `verified` flag on the matching entry).
* - An unverified email never produces a verified identity row, which
* means the partial-unique constraint on `verified_at IS NOT NULL` can
* never be tricked into resolving the wrong human.
* - The "Bob claims alice's email via GitHub" attack is foreclosed two
* ways: (a) GitHub would not return `verified: true` for an email Bob
* doesn't actually own, and (b) if a verified identity already exists
* for the email under another user with an existing OAuth account, we
* reject the sign-in rather than silently re-linking.
*
* `db` is loaded dynamically inside callbacks so this module stays importable
* from edge contexts (middleware, etc.); the actual SQL only runs on the
* Node route handler.
*/
type Sql = (t: TemplateStringsArray, ...v: unknown[]) => Promise<unknown[]>;
async function getSql(): Promise<Sql> {
const { db } = await import("@tasks/database/client");
return (db as { $client: Sql }).$client;
}
/**
* Per-provider resolution of `email_verified`. The provider's profile object
* is the source of truth for Google and Authentik (both expose the claim on
* the OIDC profile directly); GitHub does not, so we make a single REST call
* to `/user/emails` and read the `verified` flag on the entry matching the
* primary email.
*
* Authentik caveat: since the 2025.10 release, `email_verified` defaults to
* `false` on the OIDC ID token unless an authentik admin has added a custom
* property mapping that derives it. We honor whatever the provider says —
* if it's false, the identity row gets `verified_at = null` and the user
* will be prompted to verify via another provider (or, eventually, the
* manual-verification flow) before they can accept invites to that email.
*/
async function resolveOAuthEmailVerified(args: {
provider: string;
profile: Profile | undefined;
accessToken: string | null | undefined;
fallbackEmail: string;
}): Promise<boolean> {
if (args.provider === "google" || args.provider === "authentik") {
return Boolean(args.profile?.email_verified);
}
if (args.provider === "github" && args.accessToken) {
try {
const res = await fetch("https://api.github.com/user/emails", {
headers: {
Authorization: `Bearer ${args.accessToken}`,
Accept: "application/vnd.github+json",
"User-Agent": "echodo-auth",
},
});
if (!res.ok) return false;
const emails = (await res.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
}>;
const target = args.fallbackEmail.toLowerCase();
const match = emails.find((e) => e.email.toLowerCase() === target);
return Boolean(match?.verified);
} catch (e) {
console.warn("[auth] failed to fetch github /user/emails:", e);
return false;
}
}
return false;
}
/**
* Result of resolving an OAuth sign-in to a `users.id`.
*
* - `userId`: the canonical id the JWT should carry. Sign-in proceeds.
* - `conflict: "cross_user_email"`: a verified identity for this email
* already belongs to *another* user who has at least one OAuth account
* already linked. We refuse to silently re-link — the original owner has
* primacy. Sign-in is aborted; the user sees the NextAuth error page.
*/
type OAuthResolution =
| { userId: string }
| { conflict: "cross_user_email" };
async function resolveOAuthUser(args: {
account: Account;
email: string;
emailVerified: boolean;
name: string | null;
image: string | null;
}): Promise<OAuthResolution> {
const sql = await getSql();
const provider = args.account.provider;
const providerAccountId = args.account.providerAccountId;
const emailLower = args.email.trim().toLowerCase();
if (!emailLower || !providerAccountId) {
return { conflict: "cross_user_email" };
}
// Fast path: (provider, providerAccountId) is the canonical record of "this
// OAuth identity belongs to this user." If we've seen them before, return
// immediately and bump last_used_at on the matching identity row (if any).
const linkedAccount = (await sql`
SELECT user_id
FROM accounts
WHERE provider = ${provider} AND provider_account_id = ${providerAccountId}
LIMIT 1
`) as { user_id: string }[];
if (linkedAccount[0]) {
const userId = linkedAccount[0].user_id;
await sql`
UPDATE user_email_identities
SET last_used_at = now()
WHERE user_id = ${userId} AND email = ${emailLower}
`;
return { userId };
}
// New (provider, providerAccountId). Resolve which user this OAuth identity
// should attach to.
const verifiedClaim = (await sql`
SELECT user_id
FROM user_email_identities
WHERE email = ${emailLower} AND verified_at IS NOT NULL
LIMIT 1
`) as { user_id: string }[];
let userId: string;
if (verifiedClaim[0]) {
// The email is verified for an existing user. Two sub-cases:
// (a) They have no other OAuth accounts → this is their first OAuth
// sign-in for an email they previously used via Credentials. Link.
// (b) They have an OAuth account already → an OAuth account on a
// different provider is claiming an email another verified user
// already owns. Refuse.
const ownerHasOAuthAccount = (await sql`
SELECT 1
FROM accounts
WHERE user_id = ${verifiedClaim[0].user_id} AND provider != 'credentials'
LIMIT 1
`) as unknown[];
if (ownerHasOAuthAccount.length > 0) {
return { conflict: "cross_user_email" };
}
userId = verifiedClaim[0].user_id;
} else {
// No verified identity for this email. Check the legacy `users.email`
// column (covers Credentials users whose primary identity is in the
// identities table as well, but defensive against any drift).
const legacy = (await sql`
SELECT id FROM users WHERE lower(email) = ${emailLower} LIMIT 1
`) as { id: string }[];
if (legacy[0]) {
userId = legacy[0].id;
} else {
const created = (await sql`
INSERT INTO users (email, name, avatar_url)
VALUES (${emailLower}, ${args.name}, ${args.image})
RETURNING id
`) as { id: string }[];
userId = created[0]!.id;
await sql`
INSERT INTO user_email_identities
(user_id, email, verified_at, source, created_at, last_used_at)
VALUES
(${userId}, ${emailLower}, now(), 'primary', now(), now())
ON CONFLICT (user_id, email) DO NOTHING
`;
}
}
// Write the accounts row so subsequent sign-ins take the fast path.
await sql`
INSERT INTO accounts (
user_id, type, provider, provider_account_id,
access_token, refresh_token, expires_at, token_type, scope,
id_token, session_state
) VALUES (
${userId}, ${args.account.type}, ${provider}, ${providerAccountId},
${args.account.access_token ?? null}, ${args.account.refresh_token ?? null},
${typeof args.account.expires_at === "number" ? args.account.expires_at : null},
${args.account.token_type ?? null}, ${args.account.scope ?? null},
${args.account.id_token ?? null}, ${args.account.session_state ?? null}
)
ON CONFLICT (provider, provider_account_id) DO NOTHING
`;
// Write the oauth identity row only if the provider verified the email.
// An unverified claim leaves the identities table alone — the user still
// signs in (we know they control the OAuth account), they just can't yet
// accept invites sent to that address until they verify it.
if (args.emailVerified) {
await sql`
INSERT INTO user_email_identities
(user_id, email, verified_at, source, created_at, last_used_at)
VALUES
(${userId}, ${emailLower}, now(), ${`oauth:${provider}`}, now(), now())
ON CONFLICT (user_id, email) DO UPDATE SET
verified_at = COALESCE(user_email_identities.verified_at, EXCLUDED.verified_at),
last_used_at = EXCLUDED.last_used_at
`;
}
return { userId };
}
function slugifyForWorkspace(seed: string): string {
const cleaned = seed
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 50);
return cleaned || "workspace";
}
/**
* Idempotent: if the user already owns or is a member of any workspace, no-op.
* Otherwise mint a personal workspace and add them as `owner`. Runs on every
* first sign-in (credentials and OAuth alike) so freshly-created OAuth users
* don't land in the app with no tenant scope and an unusable session.
*
* Slug collisions are handled by retrying with a random 6-char suffix; we cap
* attempts so a misbehaving DB can't lock the sign-in flow.
*/
async function ensureUserHasWorkspace(args: {
userId: string;
displayName: string | null;
email: string;
}): Promise<void> {
const sql = await getSql();
const existing = (await sql`
SELECT 1
FROM workspaces w
LEFT JOIN workspace_members m
ON m.workspace_id = w.id AND m.user_id = ${args.userId}
WHERE w.owner_user_id = ${args.userId} OR m.user_id = ${args.userId}
LIMIT 1
`) as unknown[];
if (existing.length > 0) return;
const trimmedName = args.displayName?.trim() ?? "";
const seed = trimmedName || args.email.split("@")[0] || "workspace";
const baseSlug = slugifyForWorkspace(seed);
const workspaceName = trimmedName ? `${trimmedName}'s workspace` : "My workspace";
let workspaceId: string | null = null;
let candidate = baseSlug;
for (let attempt = 0; attempt < 5 && !workspaceId; attempt += 1) {
const inserted = (await sql`
INSERT INTO workspaces (slug, name, owner_user_id)
VALUES (${candidate}, ${workspaceName}, ${args.userId})
ON CONFLICT (slug) DO NOTHING
RETURNING id
`) as { id: string }[];
if (inserted[0]) {
workspaceId = inserted[0].id;
break;
}
candidate = `${baseSlug}-${Math.random().toString(36).slice(2, 8)}`;
}
if (!workspaceId) {
console.warn("[auth] Failed to provision workspace for user", args.userId);
return;
}
await sql`
INSERT INTO workspace_members (workspace_id, user_id, role)
VALUES (${workspaceId}, ${args.userId}, 'owner')
ON CONFLICT (workspace_id, user_id) DO NOTHING
`;
}
declare module "next-auth" {
interface Session {
user: {
id: string;
} & DefaultSession["user"];
}
}
const providers: NextAuthConfig["providers"] = [
Credentials({
name: "Email",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email as string | undefined;
const password = credentials?.password as string | undefined;
if (!email?.trim() || !password) return null;
// Rate-limit by IP before doing any DB work. 5/minute is well above
// the rate a human can plausibly fat-finger a password but tight
// enough that an automated guesser hits the wall before it can
// burn through a meaningful dictionary slice. In dev we skip the
// limiter when we can't resolve an IP so a header-less local test
// run doesn't lock itself out.
const ip = await resolveSignInIp();
const limiterKey = `signin:${ip ?? "unknown"}`;
const skipLimiter = ip === null && process.env.NODE_ENV === "development";
if (!skipLimiter) {
const rl = rateLimit({
key: limiterKey,
limit: SIGNIN_RATE_LIMIT.limit,
windowMs: SIGNIN_RATE_LIMIT.windowMs,
});
if (!rl.allowed) {
console.warn(
"[auth] credentials sign-in rate-limited ip=%s retryAfterMs=%d",
ip ?? "unknown",
rl.retryAfterMs,
);
return null;
}
}
const devPassword = process.env.AUTH_DEV_PASSWORD;
if (!devPassword) {
console.warn("[auth] AUTH_DEV_PASSWORD is not set; credentials sign-in disabled.");
return null;
}
if (password !== devPassword) return null;
const sql = await getSql();
const rows = (await sql`
SELECT id, email, name, avatar_url AS "avatarUrl"
FROM users
WHERE lower(email) = lower(${email.trim()})
LIMIT 1
`) as { id: string; email: string; name: string | null; avatarUrl: string | null }[];
const user = rows[0];
if (!user) return null;
return {
id: user.id,
email: user.email,
name: user.name ?? undefined,
image: user.avatarUrl ?? undefined,
};
},
}),
];
if (process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET) {
providers.push(
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
);
}
if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
providers.push(
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
);
}
if (
process.env.AUTH_AUTHENTIK_ID &&
process.env.AUTH_AUTHENTIK_SECRET &&
process.env.AUTH_AUTHENTIK_ISSUER
) {
providers.push(
Authentik({
clientId: process.env.AUTH_AUTHENTIK_ID,
clientSecret: process.env.AUTH_AUTHENTIK_SECRET,
issuer: process.env.AUTH_AUTHENTIK_ISSUER,
}),
);
}
export const { handlers, auth, signIn, signOut } = NextAuth({
session: { strategy: "jwt" },
pages: {
signIn: "/sign-in",
},
providers,
callbacks: {
async signIn({ user, account }) {
// OAuth providers must give us an email so we can map to a `users` row.
if (account && account.provider !== "credentials" && !user?.email) {
return false;
}
return true;
},
// The Auth.js v5 jwt callback receives `profile` on the initial sign-in
// call (when `account && user` are present). We use it to read provider-
// specific claims like Google/Authentik's `email_verified`. For GitHub
// we fall back to a REST call inside `resolveOAuthEmailVerified`.
async jwt({ token, user, account, profile }) {
if (account && user) {
let dbId: string | null = null;
if (account.provider === "credentials") {
dbId = user.id ?? null;
} else if (user.email) {
const emailVerified = await resolveOAuthEmailVerified({
provider: account.provider,
profile,
accessToken: account.access_token ?? null,
fallbackEmail: user.email,
});
const resolution = await resolveOAuthUser({
account,
email: user.email,
emailVerified,
name: user.name ?? null,
image: user.image ?? null,
});
if ("conflict" in resolution) {
console.warn(
"[auth] OAuth sign-in refused: email %s already verified for another user (provider=%s)",
user.email,
account.provider,
);
// Returning a token with no `id` field denies the session — the
// session callback below leaves `session.user.id` unset, which
// protectedProcedure / workspaceProcedure both reject.
return token;
}
dbId = resolution.userId;
}
if (dbId) {
token.id = dbId;
await ensureUserHasWorkspace({
userId: dbId,
displayName: user.name ?? null,
email: user.email ?? "",
});
}
}
return token;
},
async session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string;
}
return session;
},
},
trustHost: true,
});