Files
api/src/lib/auth.ts
T
Flea Flicker 7e329ff72f fix(gro-1866): add session-from-auth portal endpoint and role scope
Adds POST /api/portal/session-from-auth which bridges a valid Better Auth
customer session (from SSO login) to a portal impersonation session, so
real SSO customers can access the client portal.

The endpoint is registered before the validatePortalSession catch-all so it
is not subject to that middleware. It validates the Better Auth session
from request cookies, looks up the client by email, creates an active
impersonation session, and returns { sessionId, clientId, clientName }.

Also adds "role" to the genericOAuth scopes so Authentik propagates the
role claim into Better Auth user objects (GRO-1862 root cause fix).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-28 15:00:15 +00:00

317 lines
11 KiB
TypeScript

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { genericOAuth } from "better-auth/plugins";
import { getDb, authProviderConfig, eq } from "@groombook/db";
import { decryptSecret } from "@groombook/db";
import { sendEmail } from "../services/email.js";
const BETTER_AUTH_SECRET = process.env.BETTER_AUTH_SECRET;
const BETTER_AUTH_URL = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
// Auth instance — initialized lazily via initAuth()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let authInstance: any = null;
let authInitPromise: Promise<void> | null = null;
/** Returns the current auth instance. Throws if not yet initialized. */
export function getAuth() {
if (!authInstance) {
throw new Error(
"Auth not initialized. Call initAuth() at startup before handling requests."
);
}
return authInstance;
}
/** Returns a promise that resolves when auth is initialized. */
export function getAuthPromise() {
return authInitPromise;
}
/** Returns which OAuth/social providers are configured via env vars. */
export function getActiveProviders(): string[] {
const providers: string[] = [];
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
providers.push("google");
}
if (process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET) {
providers.push("github");
}
if (process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID && process.env.OIDC_CLIENT_SECRET) {
providers.push("authentik");
}
return providers;
}
/**
* Re-initializes the Better-Auth instance after auth config changes.
*
* Clears both authInstance and authInitPromise, then calls initAuth() to
* re-read config from DB and build a fresh Better-Auth instance.
* Sessions are DB-backed and survive the re-init.
*/
export async function reinitAuth(): Promise<void> {
authInstance = null;
authInitPromise = null;
await initAuth();
console.log("[auth] Re-initialized auth instance after config change");
}
/**
* Initializes the Better-Auth instance.
*
* Config resolution chain:
* 1. Query auth_provider_config table for an enabled provider
* 2. If DB config exists → use it (decrypt clientSecret)
* 3. If no DB config → fall back to OIDC_* env vars
* 4. If neither → auth is unconfigured (getAuth() returns null, AUTH_DISABLED implied)
*
* Idempotent — subsequent calls return immediately after initialization completes.
*/
export async function initAuth(): Promise<void> {
if (authInstance) return; // Already initialized
if (authInitPromise) {
await authInitPromise;
return;
}
authInitPromise = (async () => {
// Guard: require BETTER_AUTH_SECRET unless explicitly in dev/demo mode
if (!BETTER_AUTH_SECRET && process.env.AUTH_DISABLED !== "true") {
throw new Error(
"[FATAL] BETTER_AUTH_SECRET environment variable is required when auth is enabled"
);
}
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
// config so auth.handler exists (middleware bypasses it anyway)
if (process.env.AUTH_DISABLED === "true") {
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
authInstance = betterAuth({
database: drizzleAdapter(getDb(), { provider: "pg" }),
secret: BETTER_AUTH_SECRET!,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
storage: "memory",
customRules: {
"/get-session": false,
},
},
plugins: [
genericOAuth({
config: [
{
providerId: "authentik",
clientId: "placeholder",
clientSecret: "placeholder",
discoveryUrl: undefined,
scopes: ["openid", "profile", "email"],
},
],
}),
],
session: {
expiresIn: 60 * 60 * 24 * 7,
updateAge: 60 * 60 * 24,
cookieCache: { enabled: false },
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
return;
}
// Step 1: Try to load config from DB
const db = getDb();
const [dbConfig] = await db
.select()
.from(authProviderConfig)
.where(eq(authProviderConfig.enabled, true))
.limit(1);
let providerConfig: {
providerId: string;
clientId: string;
clientSecret: string;
issuerUrl: string;
internalBaseUrl?: string;
scopes: string;
};
if (dbConfig) {
// Step 2: Use DB config (decrypt clientSecret)
const decryptedSecret = decryptSecret(dbConfig.clientSecret);
providerConfig = {
providerId: dbConfig.providerId,
clientId: dbConfig.clientId,
clientSecret: decryptedSecret,
issuerUrl: dbConfig.issuerUrl,
internalBaseUrl: dbConfig.internalBaseUrl ?? undefined,
scopes: dbConfig.scopes,
};
console.log("[auth] Using DB config for provider:", dbConfig.providerId);
} else {
// Step 3: Fall back to env vars
const oidcIssuer = process.env.OIDC_ISSUER;
const oidcClientId = process.env.OIDC_CLIENT_ID;
const oidcClientSecret = process.env.OIDC_CLIENT_SECRET;
if (!oidcIssuer || !oidcClientId || !oidcClientSecret) {
// Step 4: Neither DB config nor env vars — auth is unconfigured
console.warn(
"[auth] No auth provider configured. Set up auth_provider_config in DB or OIDC_* env vars."
);
return; // authInstance stays null — AUTH_DISABLED mode
}
providerConfig = {
providerId: "authentik",
clientId: oidcClientId,
clientSecret: oidcClientSecret,
issuerUrl: oidcIssuer,
internalBaseUrl: process.env.OIDC_INTERNAL_BASE,
scopes: "openid profile email role",
};
console.log("[auth] Using env var config (no DB config found)");
}
const hasGoogle = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
const hasGitHub = !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET);
const issuerUrlObj = new URL(providerConfig.issuerUrl);
const issuerHostname = issuerUrlObj.hostname;
const discoveryUrlStr = `${providerConfig.issuerUrl}/.well-known/openid-configuration`;
let oidcConfig: Record<string, string> = {};
try {
const discoveryRes = await fetch(discoveryUrlStr, {
signal: AbortSignal.timeout(5000),
});
if (discoveryRes.ok) {
const discovery = await discoveryRes.json() as {
authorization_endpoint?: string;
token_endpoint?: string;
userinfo_endpoint?: string;
};
const replaceHost = (url: string, newHost: string) => {
try {
const parsed = new URL(url);
const newParsed = new URL(newHost);
return `${newParsed.origin}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
};
const authzUrl = discovery.authorization_endpoint;
const tokenUrl = discovery.token_endpoint;
const userInfoUrl = discovery.userinfo_endpoint;
if (authzUrl && tokenUrl && userInfoUrl) {
const authzUrlObj = new URL(authzUrl);
// Only validate authorizationUrl hostname against issuer — token/userinfo
// may legitimately use internal hostnames (OIDC_INTERNAL_BASE) for server-to-server calls.
if (authzUrlObj.hostname !== issuerHostname) {
throw new Error(
`[FATAL] OIDC discovery URL hostname mismatch: expected '${issuerHostname}' but got '${authzUrlObj.hostname}'. This may indicate a man-in-the-middle attack.`
);
}
oidcConfig = {
authorizationUrl: authzUrl,
tokenUrl: providerConfig.internalBaseUrl
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
: tokenUrl,
userInfoUrl: providerConfig.internalBaseUrl
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
: userInfoUrl,
};
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
} else {
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
}
} else {
console.warn(`[auth] OIDC discovery failed (${discoveryRes.status}), using discoveryUrl only`);
}
} catch (err) {
console.warn(`[auth] OIDC discovery fetch failed: ${err}, using discoveryUrl only`);
}
// Build Better-Auth instance using resolved config
authInstance = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
secret: BETTER_AUTH_SECRET,
baseURL: BETTER_AUTH_URL,
rateLimit: {
enabled: true,
max: 100,
window: 10,
storage: "memory",
customRules: {
"/get-session": false,
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["authentik"],
},
storeStateStrategy: "cookie" as const,
},
emailAndPassword: {
enabled: true,
emailVerification: {
sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
await sendEmail({
to: user.email,
subject: "Verify your GroomBook email",
text: `Click the link to verify your email: ${url}`,
html: `<p>Click the link to verify your email:</p><a href="${url}">${url}</a>`,
});
},
},
},
plugins: [
genericOAuth({
config: [
{
providerId: providerConfig.providerId,
clientId: providerConfig.clientId,
clientSecret: providerConfig.clientSecret,
discoveryUrl: discoveryUrlStr,
...(Object.keys(oidcConfig).length > 0 ? oidcConfig : {}),
scopes: providerConfig.scopes.split(" ").filter(Boolean),
},
],
}),
],
socialProviders: {
...(hasGoogle ? {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
} : {}),
...(hasGitHub ? {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
} : {}),
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
});
})();
await authInitPromise;
}