feat: support custom domain in TRUSTED_ORIGIN

This commit is contained in:
Rotimi Best
2026-05-19 09:59:56 +01:00
parent 86ebe9ef62
commit 917a71ccce
12 changed files with 189 additions and 8 deletions
+4
View File
@@ -25,6 +25,10 @@
"types": "./dist/auth.d.ts",
"default": "./dist/auth.js"
},
"./utils": {
"types": "./dist/utils/index.d.ts",
"default": "./dist/utils/index.js"
},
"./queries": {
"types": "./dist/queries/index.d.ts",
"default": "./dist/queries/index.js"
+12 -1
View File
@@ -1,4 +1,5 @@
import * as CONSTANTS from './constants';
import { resolveTrustedBrowserOrigin } from './utils';
import * as schema from '@db/schema';
import { admin, anonymous } from 'better-auth/plugins';
@@ -43,7 +44,17 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
prompt: 'select_account consent'
}
},
trustedOrigins: CONSTANTS.TRUSTED_ORIGINS,
trustedOrigins: (request) => {
const origins = [...CONSTANTS.TRUSTED_ORIGINS];
const originHeader = request?.headers.get('origin');
const resolved = resolveTrustedBrowserOrigin(originHeader, CONSTANTS.TRUSTED_ORIGINS);
if (resolved && !origins.includes(resolved)) {
origins.push(resolved);
}
return origins;
},
advanced: {
cookiePrefix: 'classroomio',
crossSubDomainCookies: {
+1 -1
View File
@@ -6,7 +6,7 @@ const {
const DEFAULT_TRUSTED_ORIGINS = [
'http://localhost:5173',
'http://*.classroomio.*',
'https://*.classroomio.com',
// Common OIDC IdP discovery origins (SSO registration fetches .well-known/openid-configuration)
'https://accounts.google.com',
'https://account.google.com', // common typo for Google
@@ -8,7 +8,7 @@ import type {
TOrganization,
TOrganizationPlan
} from '@db/types';
import { and, asc, count, desc, eq, ilike, inArray, or, sql } from 'drizzle-orm';
import { and, asc, count, desc, eq, ilike, inArray, isNotNull, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db, type DbOrTxClient } from '@db/drizzle';
@@ -18,6 +18,26 @@ export function getOrgIdBySiteName(siteName: string) {
return db.select().from(schema.organization).where(eq(schema.organization.siteName, siteName)).limit(1);
}
/**
* Hostnames (lowercase) for organizations with a verified custom domain.
* Used by the API to warm an in-memory CORS / trusted-origin registry at startup.
*/
export async function getVerifiedCustomDomainHostnames(): Promise<string[]> {
try {
const rows = await db
.select({ customDomain: schema.organization.customDomain })
.from(schema.organization)
.where(and(eq(schema.organization.isCustomDomainVerified, true), isNotNull(schema.organization.customDomain)));
return [...new Set(rows.map((row) => row.customDomain?.trim().toLowerCase()).filter(Boolean) as string[])];
} catch (error) {
console.error('getVerifiedCustomDomainHostnames error:', error);
throw new Error(
`Failed to load verified custom domains: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
const result = await db
.select({
@@ -0,0 +1,94 @@
import { getVerifiedCustomDomainHostnames } from '../../queries/organization/organization';
const CLASSROOMIO_ROOT = 'classroomio.com';
/** Lowercase hostnames with verified custom domains ( warmed at API boot + updated on domain routes ). */
const verifiedCustomDomainHostnames = new Set<string>();
function originMatchesStaticEntry(origin: string, entry: string): boolean {
const trimmedEntry = entry.trim();
if (!trimmedEntry.includes('*')) {
return origin === trimmedEntry;
}
const regex = new RegExp(
`^${trimmedEntry
.split('*')
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
.join('.*')}$`
);
return regex.test(origin);
}
function isClassroomioHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return host === CLASSROOMIO_ROOT || host.endsWith(`.${CLASSROOMIO_ROOT}`);
}
export function trustCustomDomainHostname(hostname: string): void {
const normalized = hostname.trim().toLowerCase();
if (normalized) {
verifiedCustomDomainHostnames.add(normalized);
}
}
export function untrustCustomDomainHostname(hostname: string): void {
verifiedCustomDomainHostnames.delete(hostname.trim().toLowerCase());
}
export async function preloadVerifiedCustomDomainOrigins(): Promise<void> {
const hostnames = await getVerifiedCustomDomainHostnames();
verifiedCustomDomainHostnames.clear();
for (const hostname of hostnames) {
verifiedCustomDomainHostnames.add(hostname);
}
}
/**
* Resolves whether a browser `Origin` header value is allowed for CORS / Better Auth.
* `staticTrustedOriginEntries` may include exact origins or `*` patterns (e.g. https://*.classroomio.com).
*/
export function resolveTrustedBrowserOrigin(
origin: string | undefined | null,
staticTrustedOriginEntries: readonly string[]
): string | undefined {
if (!origin || origin === 'null') {
return undefined;
}
let parsed: URL;
try {
parsed = new URL(origin);
} catch {
return undefined;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return undefined;
}
for (const entry of staticTrustedOriginEntries) {
if (originMatchesStaticEntry(origin, entry)) {
return origin;
}
}
const hostname = parsed.hostname;
if (isClassroomioHost(hostname)) {
return origin;
}
if (verifiedCustomDomainHostnames.has(hostname.toLowerCase())) {
return origin;
}
return undefined;
}
+1
View File
@@ -0,0 +1 @@
export * from './custom-domain/trusted-origin';