Files
afilmory/docs/backend/tenant-flow.md
Innei 019ee50121 feat: implement OAuth state management for multi-tenancy support
- Introduced encoding and decoding of OAuth state to include tenant metadata, allowing the gateway to route callbacks without hard-coded tenant slugs.
- Updated the AuthController to handle social account linking and sign-in with compatibility for legacy paths.
- Refactored redirect URI construction to simplify tenant slug handling.
- Enhanced documentation to reflect changes in the OAuth flow and state management.

Signed-off-by: Innei <tukon479@gmail.com>
2025-11-30 14:03:05 +08:00

4.0 KiB

Tenant & OAuth Flow

This document describes how tenant resolution, Better Auth instances, and dashboard redirects tie together across the platform.

Request Bootstrap

  1. RequestContextMiddleware runs first on every request.
    • Calls TenantContextResolver.resolve() to populate HttpContext.tenant.
    • Calls AuthProvider.getAuth() so downstream handlers reuse the tenant-aware Better Auth instance.
  2. TenantContextResolver inspects x-forwarded-host, origin, and host headers.
    • Extracts a slug via tenant-host.utils.ts.
    • Loads the tenant aggregate; when a subdomain hits /auth or /api/auth for the first time, it auto-provisions a real tenant record with status = "pending" so auth flows have a fully-qualified tenant id.
    • Always stores the original requestedSlug so downstream services know which workspace was requested.

Auth Provider

  • AuthProvider caches Better Auth instances by protocol://host::slug::settings-hash.
  • The slug priority is:
    1. HttpContext.tenant.requestedSlug
    2. HttpContext.tenant.slug
    3. Derived from the host (when the context slug is still the placeholder).
  • Redirect URIs are fixed to <OAuthGateway>/api/auth/callback/:provider (Google-friendly).
  • Tenant routing is encoded into the OAuth state value (HMAC wrapped) so the gateway can forward callbacks to the right tenant without dynamic redirect URIs.
  • Because the requested slug participates in the cache key, the same Better Auth instance handles both the /auth/social request and the gateway callback, preserving OAuth state.

System Settings & Gateway

  • System settings only manage:
    • Registration flags
    • Base domain
    • OAuth gateway URL
    • Provider credentials
  • No per-provider redirect URIs are stored; every provider points to the centralized gateway.
  • /auth/social/providers reflects the enabled providers for the UI.

Session Payload

GET /auth/session returns:

{
  user: BetterAuthUser,
  session: BetterAuthSession,
  tenant: {
    id: string,
    slug: string | null,      // Effective slug (requested slug if present, otherwise actual)
    isPlaceholder: boolean
  } | null
}
  • When a tenant is still provisioning (status = "pending"), tenant.slug still holds the requested subdomain, isPlaceholder is true, and the dashboard stays on the onboarding surface.
  • Consumers simply check tenant.isPlaceholder to know whether they are in onboarding.

Dashboard Behavior

  • Welcome flow (/platform/welcome):
    • Locks the slug input to window.location.hostname via getTenantSlugFromHost.
    • Shows TenantMissing or TenantRestricted pages, but relies on backend redirects for actual auth.
  • Hooks (usePageRedirect):
    • If tenant is null or isPlaceholder, stay on welcome routes.
    • If tenant.slug exists and differs from the current host, sign out placeholder cookies and window.location.replace(buildTenantUrl(tenant.slug)).
    • Superadmin routes are gated separately.

OAuth Happy Path

  1. User opens https://slug.example.com (maybe not provisioned yet).
  2. Resolver sets requestedSlug = "slug", but tenant aggregate may still be the placeholder.
  3. User clicks “Sign in with GitHub” → /auth/social uses requestedSlug and redirects via the OAuth gateway.
  4. Gateway forwards the callback to https://slug.example.com/api/auth/callback/github.
  5. Resolver again sets requestedSlug = "slug"; Better Auth instance cache hits, so state matches.
  6. /auth/session returns { tenant: { slug: "slug", isPlaceholder: true } } while the workspace is pending → dashboard stays on welcome, no cross-subdomain jump.
  7. Once the onboarding API marks the tenant active, future sessions have isPlaceholder: false, and usePageRedirect ensures we land on the actual workspace subdomain.

Key Guarantees

  • Only a single tenant.slug crosses the API boundary; there are no ambiguous fields.
  • Placeholder detection is a boolean (isPlaceholder).
  • Better Auth instances survive OAuth handshakes regardless of tenant provisioning state.