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>
504 lines
18 KiB
TypeScript
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,
|
|
});
|