feat: support custom domain in TRUSTED_ORIGIN
This commit is contained in:
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './custom-domain/trusted-origin';
|
||||
Reference in New Issue
Block a user