feat: auto enroll into org as student and oauth improvements
This commit is contained in:
+15
-3
@@ -12,6 +12,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
||||
import { config as emailAndPassword } from './auth/email-password';
|
||||
import { getUserOrgRolesMap } from './queries/organization/organization';
|
||||
import { loginLink } from './auth/plugins/login-link';
|
||||
import { oAuthProxy } from 'better-auth/plugins/oauth-proxy';
|
||||
import { resolveTrustedBrowserOrigin } from './utils';
|
||||
import { sso } from '@better-auth/sso';
|
||||
import { syncUserWithProfile } from './auth/hooks/sync-user';
|
||||
@@ -77,9 +78,12 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id });
|
||||
await createProfileHook(user);
|
||||
after: async (user, ctx) => {
|
||||
console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id, ctx });
|
||||
const request =
|
||||
(ctx as { context?: { request?: Request }; request?: Request } | undefined)?.context?.request ??
|
||||
(ctx as { request?: Request } | undefined)?.request;
|
||||
await createProfileHook(user, request);
|
||||
}
|
||||
},
|
||||
update: {
|
||||
@@ -109,6 +113,14 @@ export const auth: ReturnType<typeof betterAuth> = betterAuth({
|
||||
// OIDC providers are registered dynamically per organization
|
||||
// via the admin API (auth.api.registerSSOProvider)
|
||||
}),
|
||||
// Lets OAuth/SSO callbacks land on the canonical production URL
|
||||
// (api.classroomio.com) while still completing the flow on the tenant
|
||||
// host the user signed in from (<org>.classroomio.school or a BYOD
|
||||
// domain). Without this, the state cookie set on the tenant host isn't
|
||||
// sent to the api host on the Google redirect → state_security_mismatch.
|
||||
oAuthProxy({
|
||||
productionURL: CONSTANTS.BASE_URL
|
||||
}),
|
||||
loginLink(),
|
||||
tokenExchange(),
|
||||
// Attaches the user's org memberships ({ [orgId]: roleId }) to the session
|
||||
|
||||
@@ -6,6 +6,7 @@ import { User } from 'better-auth';
|
||||
import { db } from '@db/drizzle';
|
||||
import { getOrganizationCount } from '@db/queries/organization';
|
||||
import { ssoProvisioningHook } from './sso-provisioning';
|
||||
import { tenantProvisioningHook } from './tenant-provisioning';
|
||||
|
||||
/**
|
||||
* True if the user has any non-credential account (OAuth/SSO). Those providers verify email.
|
||||
@@ -36,8 +37,8 @@ async function isSelfHostedFirstSignup(): Promise<boolean> {
|
||||
*
|
||||
* Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org).
|
||||
*/
|
||||
export const createProfileHook = async (user: User) => {
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id });
|
||||
export const createProfileHook = async (user: User, request?: Request) => {
|
||||
console.log('[auth] createProfileHook: running', { userId: user.id, request });
|
||||
|
||||
const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1);
|
||||
console.debug('existingProfile', existingProfile);
|
||||
@@ -81,8 +82,12 @@ export const createProfileHook = async (user: User) => {
|
||||
});
|
||||
console.debug('newProfile', newProfile);
|
||||
|
||||
// Run SSO provisioning hook for JIT org membership
|
||||
// Run SSO provisioning hook for JIT org membership (email-domain match)
|
||||
await ssoProvisioningHook(user);
|
||||
|
||||
// Run tenant provisioning hook for signups on a tenant subdomain
|
||||
// (signupGuard has already enforced disableSignup / inviteOnly)
|
||||
await tenantProvisioningHook(user, request);
|
||||
} catch (error) {
|
||||
console.error('Error creating profile for user:', error);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { User } from 'better-auth';
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN, blockedSubdomain } from '@cio/utils/constants';
|
||||
import { getOrganizationByCustomDomain, getOrganizationBySiteName } from '@db/queries/organization';
|
||||
|
||||
import { ensureOrgMembership } from './sso-provisioning';
|
||||
|
||||
/**
|
||||
* Subdomains under the brand/tenant root that are NOT a tenant org.
|
||||
* Reuses the dashboard's `blockedSubdomain` list so the two stay in sync.
|
||||
*/
|
||||
const RESERVED_SUBDOMAINS = new Set<string>(blockedSubdomain);
|
||||
|
||||
/**
|
||||
* Resolve the org a signup happened on, given the inbound request's host.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Subdomain under TENANT_ROOT_DOMAIN (e.g. ciodevs.classroomio.school) → siteName lookup
|
||||
* 2. Subdomain under BRAND_ROOT_DOMAIN (e.g. ciodevs.classroomio.com) → siteName lookup
|
||||
* 3. Anything else (e.g. learn.acme.com) → verified-custom-domain lookup
|
||||
*
|
||||
* Returns null for the apex, reserved subdomains (app/api/www/…), and unverified custom domains.
|
||||
*/
|
||||
async function resolveOrgIdFromHost(host: string): Promise<string | null> {
|
||||
const hostname = host.split(':')[0]?.toLowerCase();
|
||||
if (!hostname) return null;
|
||||
|
||||
const tenantSuffix = `.${TENANT_ROOT_DOMAIN}`;
|
||||
const brandSuffix = `.${BRAND_ROOT_DOMAIN}`;
|
||||
|
||||
if (hostname.endsWith(tenantSuffix) || hostname.endsWith(brandSuffix)) {
|
||||
const suffix = hostname.endsWith(tenantSuffix) ? tenantSuffix : brandSuffix;
|
||||
const subdomain = hostname.slice(0, -suffix.length);
|
||||
|
||||
// Apex (no subdomain) or multi-level subdomain we don't recognise
|
||||
if (!subdomain || subdomain.includes('.')) return null;
|
||||
if (RESERVED_SUBDOMAINS.has(subdomain)) return null;
|
||||
|
||||
const org = await getOrganizationBySiteName(subdomain);
|
||||
return org?.id ?? null;
|
||||
}
|
||||
|
||||
const org = await getOrganizationByCustomDomain(hostname);
|
||||
return org?.id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-enroll a signup into the org they signed up on.
|
||||
*
|
||||
* Runs after `ssoProvisioningHook` so SSO-domain matches (which carry policy-driven
|
||||
* role mapping) win. `ensureOrgMembership` is idempotent and invite-aware, so if SSO
|
||||
* already inserted a row this is a no-op.
|
||||
*
|
||||
* The `signupGuard` middleware (apps/api/src/middlewares/signup-guard.ts) has already
|
||||
* rejected `disableSignup` orgs and `inviteOnly` orgs without a valid invite by the
|
||||
* time this runs — so any signup that reaches the hook is allowed to join.
|
||||
*
|
||||
* Org resolution order:
|
||||
* 1. `cio-org-id` request header (set by the dashboard on every API call)
|
||||
* 2. Host header: tenant subdomain, brand subdomain, or verified custom domain.
|
||||
* Prefers `x-forwarded-host` (set by the Cloudflare Worker proxy) over the
|
||||
* raw `Host`, which behind the proxy is the upstream service name and not
|
||||
* the tenant host the user actually visited.
|
||||
*
|
||||
* TODO(oauth): OAuth/SSO signups lose both the header and the original host —
|
||||
* the callback lands on `api.classroomio.com/api/auth/callback/<provider>`, so
|
||||
* neither path here can resolve the tenant. To fix, encode the org id in
|
||||
* Better Auth's OAuth `state` (or `additionalParams`) at signup initiation and
|
||||
* pull it out of `request` here. Until then, OAuth signups on a tenant site
|
||||
* will not be auto-enrolled and must be invited manually.
|
||||
*/
|
||||
export const tenantProvisioningHook = async (user: User, request?: Request) => {
|
||||
if (!user.email || !request) return;
|
||||
|
||||
const headerOrgId = request.headers.get('cio-org-id');
|
||||
if (headerOrgId) {
|
||||
await ensureOrgMembership(user.id, user.email, headerOrgId);
|
||||
return;
|
||||
}
|
||||
|
||||
const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host');
|
||||
if (!host) return;
|
||||
|
||||
const orgId = await resolveOrgIdFromHost(host);
|
||||
if (!orgId) return;
|
||||
|
||||
await ensureOrgMembership(user.id, user.email, orgId);
|
||||
};
|
||||
@@ -197,6 +197,26 @@ export const getOrganizationBySiteName = async (siteName: string): Promise<TOrga
|
||||
return organization || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an organization by its verified custom domain.
|
||||
* Only matches rows where isCustomDomainVerified is true so we never auto-enroll
|
||||
* a signup into an org just because someone pointed DNS at us.
|
||||
*/
|
||||
export const getOrganizationByCustomDomain = async (customDomain: string): Promise<TOrganization | null> => {
|
||||
const [organization] = await db
|
||||
.select()
|
||||
.from(schema.organization)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.organization.customDomain, customDomain.toLowerCase()),
|
||||
eq(schema.organization.isCustomDomainVerified, true)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return organization || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an organization by ID
|
||||
* @param id Organization ID
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
import { getVerifiedCustomDomainHostnames } from '../../queries/organization/organization';
|
||||
|
||||
const CLASSROOMIO_ROOT = 'classroomio.com';
|
||||
const FIRST_PARTY_ROOTS: readonly string[] = [BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN];
|
||||
|
||||
/** Lowercase hostnames with verified custom domains ( warmed at API boot + updated on domain routes ). */
|
||||
const verifiedCustomDomainHostnames = new Set<string>();
|
||||
@@ -25,7 +26,7 @@ function originMatchesStaticEntry(origin: string, entry: string): boolean {
|
||||
function isClassroomioHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase();
|
||||
|
||||
return host === CLASSROOMIO_ROOT || host.endsWith(`.${CLASSROOMIO_ROOT}`);
|
||||
return FIRST_PARTY_ROOTS.some((root) => host === root || host.endsWith(`.${root}`));
|
||||
}
|
||||
|
||||
export function trustCustomDomainHostname(hostname: string): void {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||
import {
|
||||
getWidgetThemeSerifStack,
|
||||
type TWidgetConfig,
|
||||
@@ -62,7 +63,7 @@ export function formatLessonsLine(course: TWidgetPayloadCourse): string {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Hostname used in UTM (custom domain or `{siteName}.classroomio.com`). */
|
||||
/** Hostname used in UTM (custom domain or `{siteName}.${TENANT_ROOT_DOMAIN}`). */
|
||||
export function getOrgDomainForUtm(organization: TWidgetPayload['organization']): string {
|
||||
const custom = organization.customDomain?.trim();
|
||||
if (custom) {
|
||||
@@ -76,7 +77,7 @@ export function getOrgDomainForUtm(organization: TWidgetPayload['organization'])
|
||||
return custom.replace(/^\/+/, '').split('/')[0] || organization.siteName;
|
||||
}
|
||||
const site = organization.siteName.trim();
|
||||
return site ? `${site}.classroomio.com` : 'classroomio.com';
|
||||
return site ? `${site}.${TENANT_ROOT_DOMAIN}` : BRAND_ROOT_DOMAIN;
|
||||
}
|
||||
|
||||
/** Marketing homepage link with widget + org attribution for analytics. */
|
||||
@@ -89,5 +90,5 @@ export function buildPoweredByMarketingUrl(payload: TWidgetPayload): string {
|
||||
utm_content: payload.widgetId,
|
||||
utm_term: orgDomain
|
||||
});
|
||||
return `https://classroomio.com/?${params.toString()}`;
|
||||
return `https://${BRAND_ROOT_DOMAIN}/?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* The apex that hosts every free-tier tenant site as `<orgSiteName>.<TENANT_ROOT_DOMAIN>`.
|
||||
* The `apps/cio-tenant-router` Cloudflare Worker terminates traffic for this zone and forwards
|
||||
* to the dashboard service. Marketing apex (classroomio.com) is intentionally separate.
|
||||
*/
|
||||
export const TENANT_ROOT_DOMAIN = 'classroomio.school';
|
||||
|
||||
/** The marketing / admin / api zone. */
|
||||
export const BRAND_ROOT_DOMAIN = 'classroomio.com';
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './roles';
|
||||
export * from './org';
|
||||
export * from './content';
|
||||
export * from './domains';
|
||||
export * from './error-codes';
|
||||
|
||||
Reference in New Issue
Block a user