mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
Resolve the client IP from the TCP peer plus trusted-proxy X-Forwarded-For hops (TRUSTED_PROXIES) and hand it to Better Auth in an internal header, so spoofed forwarding headers can no longer rotate sign-in rate-limit buckets. The Expo authorization proxy now only redirects to the OIDC provider's authorization endpoint. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
|
import { expo } from "@better-auth/expo";
|
|
import { APIError, createAuthMiddleware } from "better-auth/api";
|
|
import { setCookieCache } from "better-auth/cookies";
|
|
import { type BetterAuthOptions, betterAuth } from "better-auth/minimal";
|
|
import { admin, genericOAuth, type UserWithRole } from "better-auth/plugins";
|
|
|
|
import { claimInitialAdmin, isRegistrationOpen } from "@sofa/core/settings";
|
|
import { db } from "@sofa/db/client";
|
|
import * as schema from "@sofa/db/schema";
|
|
import { createLogger } from "@sofa/logger";
|
|
|
|
import {
|
|
CLIENT_IP_HEADER,
|
|
getOidcDiscoveryURL,
|
|
getOidcRedirectURI,
|
|
isOidcAutoRegisterEnabled,
|
|
isOidcConfigured,
|
|
isPasswordLoginDisabled,
|
|
} from "./config";
|
|
|
|
const authLog = createLogger("auth");
|
|
|
|
export const auth = betterAuth({
|
|
trustedOrigins: ["sofa://"],
|
|
logger: {
|
|
// Suppress unset secret/low entropy warnings during build
|
|
disabled: process.env.NEXT_PHASE === "phase-production-build",
|
|
level: "debug",
|
|
log: (level, message, ...args) => {
|
|
const fn = authLog[level as keyof typeof authLog];
|
|
if (fn) fn(message, ...args);
|
|
},
|
|
},
|
|
database: drizzleAdapter(db, {
|
|
provider: "sqlite",
|
|
// `db` is created without a schema, so the adapter can't discover tables
|
|
// via `db._.fullSchema` and must be given them explicitly.
|
|
schema: {
|
|
user: schema.user,
|
|
session: schema.session,
|
|
account: schema.account,
|
|
verification: schema.verification,
|
|
},
|
|
}),
|
|
emailAndPassword: {
|
|
enabled: !isPasswordLoginDisabled(),
|
|
},
|
|
account: {
|
|
accountLinking: {
|
|
enabled: true,
|
|
trustedProviders: ["oidc"],
|
|
},
|
|
},
|
|
session: {
|
|
cookieCache: {
|
|
enabled: true,
|
|
maxAge: 5 * 60,
|
|
strategy: "jwt",
|
|
},
|
|
},
|
|
plugins: [
|
|
admin(),
|
|
...(isOidcConfigured()
|
|
? [
|
|
genericOAuth({
|
|
config: [
|
|
{
|
|
providerId: "oidc",
|
|
clientId: process.env.OIDC_CLIENT_ID ?? "",
|
|
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
|
|
discoveryUrl: getOidcDiscoveryURL() ?? "",
|
|
redirectURI: getOidcRedirectURI(),
|
|
scopes: ["openid", "email", "profile"],
|
|
pkce: true,
|
|
disableImplicitSignUp: !isOidcAutoRegisterEnabled(),
|
|
mapProfileToUser: (profile) => ({
|
|
name:
|
|
profile.name ||
|
|
(typeof profile.preferred_username === "string"
|
|
? profile.preferred_username
|
|
: undefined) ||
|
|
profile.email ||
|
|
undefined,
|
|
}),
|
|
},
|
|
],
|
|
}),
|
|
]
|
|
: []),
|
|
expo(),
|
|
],
|
|
advanced: {
|
|
database: {
|
|
generateId: () => Bun.randomUUIDv7(),
|
|
},
|
|
// apps/server resolves the client IP (TCP peer + TRUSTED_PROXIES hops) and passes it in this
|
|
// header; see apps/server/src/client-ip.ts. Don't set `trustedProxies` here: Better Auth would
|
|
// then discard resolved LAN addresses, because they fall inside the trusted ranges.
|
|
ipAddress: {
|
|
ipAddressHeaders: [CLIENT_IP_HEADER],
|
|
},
|
|
},
|
|
hooks: {
|
|
before: createAuthMiddleware(async (ctx) => {
|
|
// Block email/password sign-up when registration is closed.
|
|
// This is endpoint-level so it doesn't affect OIDC user creation
|
|
// (which is gated by the genericOAuth plugin's disableImplicitSignUp).
|
|
if (ctx.path === "/sign-up/email") {
|
|
const open = isRegistrationOpen();
|
|
if (!open) {
|
|
throw new APIError("FORBIDDEN", {
|
|
message: "Registration is currently closed",
|
|
code: "REGISTRATION_CLOSED",
|
|
});
|
|
}
|
|
}
|
|
}),
|
|
after: createAuthMiddleware(async (ctx) => {
|
|
// The first user is promoted to admin by a create.after database hook, which
|
|
// Better Auth runs after the sign-up transaction commits — i.e. after the
|
|
// session cookie (including its cached user) was already written with
|
|
// role "user". Re-read the user and refresh the cache cookie if it changed.
|
|
const newSession = ctx.context.newSession;
|
|
if (!newSession) return;
|
|
try {
|
|
const fresh = (await ctx.context.internalAdapter.findUserById(
|
|
newSession.user.id,
|
|
)) as UserWithRole | null;
|
|
const cachedRole = (newSession.user as UserWithRole).role;
|
|
if (!fresh || fresh.role === cachedRole) return;
|
|
const user = { ...newSession.user, role: fresh.role };
|
|
await setCookieCache(ctx, { session: newSession.session, user }, false);
|
|
} catch (err) {
|
|
// Never turn a successful sign-in into an error over a cache refresh.
|
|
authLog.warn("Failed to refresh session cookie cache:", err);
|
|
}
|
|
}),
|
|
},
|
|
databaseHooks: {
|
|
user: {
|
|
create: {
|
|
after: async (user) => {
|
|
// Promote exactly one bootstrap user to admin, even if multiple
|
|
// sign-ups race during the first-run window.
|
|
claimInitialAdmin(user.id);
|
|
},
|
|
},
|
|
},
|
|
},
|
|
} satisfies BetterAuthOptions);
|
|
|
|
export type Session = typeof auth.$Infer.Session;
|