feat: support custom domain in TRUSTED_ORIGIN
This commit is contained in:
+3
-2
@@ -1,6 +1,7 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
|
||||||
import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants';
|
import { API_SERVER_URL } from '@api/constants';
|
||||||
|
import { resolveTrustedBrowserOriginForCors } from '@api/utils/origins';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
import { Hono } from '@api/utils/hono';
|
||||||
import { accountRouter } from '@api/routes/account';
|
import { accountRouter } from '@api/routes/account';
|
||||||
@@ -44,7 +45,7 @@ export const app = new Hono()
|
|||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
cors({
|
cors({
|
||||||
origin: TRUSTED_ORIGINS,
|
origin: (origin) => resolveTrustedBrowserOriginForCors(origin),
|
||||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
|
allowHeaders: ['Content-Type', 'Authorization', 'Cio-org-id'],
|
||||||
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
|
exposeHeaders: ['Content-Length', 'X-Kuma-Revision'],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const API_PORT = env.PORT ? parseInt(env.PORT) : 3002;
|
|||||||
export const API_SERVER_URL = env.PUBLIC_SERVER_URL || `http://localhost:${API_PORT}`;
|
export const API_SERVER_URL = env.PUBLIC_SERVER_URL || `http://localhost:${API_PORT}`;
|
||||||
export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
|
export const TRUSTED_ORIGINS = env.TRUSTED_ORIGINS
|
||||||
? env.TRUSTED_ORIGINS.split(',')
|
? env.TRUSTED_ORIGINS.split(',')
|
||||||
: ['http://localhost:5173', 'http://localhost:5180', 'http://*.classroomio.*'];
|
: ['http://localhost:5173', 'http://localhost:5180', 'https://*.classroomio.com'];
|
||||||
|
|
||||||
const {
|
const {
|
||||||
CLOUDFLARE_BUCKET_DOMAIN,
|
CLOUDFLARE_BUCKET_DOMAIN,
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import 'dotenv/config';
|
|||||||
|
|
||||||
import { API_PORT } from '@api/constants';
|
import { API_PORT } from '@api/constants';
|
||||||
import { app } from '@api/app';
|
import { app } from '@api/app';
|
||||||
import { env } from '@api/config/env';
|
|
||||||
import { configureOpenAPI } from '@api/utils/openapi';
|
import { configureOpenAPI } from '@api/utils/openapi';
|
||||||
import { connectRedis } from '@api/utils/redis/redis';
|
import { connectRedis } from '@api/utils/redis/redis';
|
||||||
|
import { env } from '@api/config/env';
|
||||||
|
import { preloadVerifiedCustomDomainOriginsRegistry } from '@api/utils/origins';
|
||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { showRoutes } from 'hono/dev';
|
import { showRoutes } from 'hono/dev';
|
||||||
|
|
||||||
@@ -15,6 +16,10 @@ async function startServer() {
|
|||||||
// Connect to Redis (non-blocking: API starts even if Redis fails)
|
// Connect to Redis (non-blocking: API starts even if Redis fails)
|
||||||
await connectRedis();
|
await connectRedis();
|
||||||
|
|
||||||
|
preloadVerifiedCustomDomainOriginsRegistry().then(() => {
|
||||||
|
console.log('Verified custom domain origins preloaded');
|
||||||
|
});
|
||||||
|
|
||||||
serve({ fetch: app.fetch, port: API_PORT });
|
serve({ fetch: app.fetch, port: API_PORT });
|
||||||
|
|
||||||
if (env.NODE_ENV !== 'production') {
|
if (env.NODE_ENV !== 'production') {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
getOrgIdBySiteName,
|
getOrgIdBySiteName,
|
||||||
getOrganizationAudience,
|
getOrganizationAudience,
|
||||||
getOrganizationAudienceMember,
|
getOrganizationAudienceMember,
|
||||||
|
getOrganizationById,
|
||||||
getOrganizationBySiteName,
|
getOrganizationBySiteName,
|
||||||
getOrganizationTeam,
|
getOrganizationTeam,
|
||||||
getOrganizations,
|
getOrganizations,
|
||||||
@@ -42,6 +43,7 @@ import { createOrganizationWithOwner } from '@api/services/onboarding';
|
|||||||
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
import { deriveAudienceMemberStatus } from '@api/utils/audience-member-status';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
|
import { inviteTeamMembers as inviteTeamMembersSecure } from './organization/invite';
|
||||||
|
import { trustCustomDomainHostname, untrustCustomDomainHostname } from '@cio/db/utils';
|
||||||
|
|
||||||
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
|
const PUBLIC_ORG_LANDING_PAGE_COURSE_LIMIT = 4;
|
||||||
|
|
||||||
@@ -572,10 +574,32 @@ export async function updateOrg(orgId: string, data: Partial<TOrganization>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let previousCustomDomainHostname: string | undefined;
|
||||||
|
|
||||||
|
if (data.customDomain !== undefined || data.isCustomDomainVerified !== undefined) {
|
||||||
|
const previousOrg = await getOrganizationById(orgId);
|
||||||
|
const oldHost = previousOrg?.customDomain?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (oldHost) {
|
||||||
|
previousCustomDomainHostname = oldHost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const organization = await updateOrganization(orgId, data);
|
const organization = await updateOrganization(orgId, data);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
throw new AppError('Organization not found', ErrorCodes.ORGANIZATION_NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previousCustomDomainHostname) {
|
||||||
|
untrustCustomDomainHostname(previousCustomDomainHostname);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCustomDomainHostname = organization.customDomain?.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (newCustomDomainHostname && organization.isCustomDomainVerified) {
|
||||||
|
trustCustomDomainHostname(newCustomDomainHostname);
|
||||||
|
}
|
||||||
|
|
||||||
return organization;
|
return organization;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
preloadVerifiedCustomDomainOrigins,
|
||||||
|
resolveTrustedBrowserOrigin,
|
||||||
|
trustCustomDomainHostname,
|
||||||
|
untrustCustomDomainHostname
|
||||||
|
} from '@cio/db/utils';
|
||||||
|
|
||||||
|
import { TRUSTED_ORIGINS } from '@api/constants';
|
||||||
|
|
||||||
|
const staticTrustedOriginEntries = TRUSTED_ORIGINS.map((entry) => entry.trim()).filter(Boolean) as readonly string[];
|
||||||
|
|
||||||
|
export async function preloadVerifiedCustomDomainOriginsRegistry(): Promise<void> {
|
||||||
|
await preloadVerifiedCustomDomainOrigins();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTrustedBrowserOriginForCors(origin: string): string | undefined {
|
||||||
|
console.log('check origin', origin);
|
||||||
|
return resolveTrustedBrowserOrigin(origin, staticTrustedOriginEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { trustCustomDomainHostname, untrustCustomDomainHostname };
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Particle cloud — same component used in the footer at lower intensity -->
|
<!-- Particle cloud — same component used in the footer at lower intensity -->
|
||||||
<ParticleField class="absolute inset-0 z-[2] h-full w-full" />
|
<ParticleField class="pointer-events-none absolute inset-0 z-[2] hidden h-full w-full md:block" />
|
||||||
|
|
||||||
<!-- Bottom fade to next section -->
|
<!-- Bottom fade to next section -->
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
"types": "./dist/auth.d.ts",
|
"types": "./dist/auth.d.ts",
|
||||||
"default": "./dist/auth.js"
|
"default": "./dist/auth.js"
|
||||||
},
|
},
|
||||||
|
"./utils": {
|
||||||
|
"types": "./dist/utils/index.d.ts",
|
||||||
|
"default": "./dist/utils/index.js"
|
||||||
|
},
|
||||||
"./queries": {
|
"./queries": {
|
||||||
"types": "./dist/queries/index.d.ts",
|
"types": "./dist/queries/index.d.ts",
|
||||||
"default": "./dist/queries/index.js"
|
"default": "./dist/queries/index.js"
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,5 @@
|
|||||||
import * as CONSTANTS from './constants';
|
import * as CONSTANTS from './constants';
|
||||||
|
import { resolveTrustedBrowserOrigin } from './utils';
|
||||||
import * as schema from '@db/schema';
|
import * as schema from '@db/schema';
|
||||||
|
|
||||||
import { admin, anonymous } from 'better-auth/plugins';
|
import { admin, anonymous } from 'better-auth/plugins';
|
||||||
@@ -43,7 +44,17 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
|||||||
prompt: 'select_account consent'
|
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: {
|
advanced: {
|
||||||
cookiePrefix: 'classroomio',
|
cookiePrefix: 'classroomio',
|
||||||
crossSubDomainCookies: {
|
crossSubDomainCookies: {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const {
|
|||||||
|
|
||||||
const DEFAULT_TRUSTED_ORIGINS = [
|
const DEFAULT_TRUSTED_ORIGINS = [
|
||||||
'http://localhost:5173',
|
'http://localhost:5173',
|
||||||
'http://*.classroomio.*',
|
'https://*.classroomio.com',
|
||||||
// Common OIDC IdP discovery origins (SSO registration fetches .well-known/openid-configuration)
|
// Common OIDC IdP discovery origins (SSO registration fetches .well-known/openid-configuration)
|
||||||
'https://accounts.google.com',
|
'https://accounts.google.com',
|
||||||
'https://account.google.com', // common typo for Google
|
'https://account.google.com', // common typo for Google
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
TOrganization,
|
TOrganization,
|
||||||
TOrganizationPlan
|
TOrganizationPlan
|
||||||
} from '@db/types';
|
} 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 { ROLE } from '@cio/utils/constants';
|
||||||
import { db, type DbOrTxClient } from '@db/drizzle';
|
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);
|
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[]> => {
|
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.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