From 019ee501210a8cb77f0ebf88ee4983a965b09800 Mon Sep 17 00:00:00 2001 From: Innei Date: Sun, 30 Nov 2025 14:03:05 +0800 Subject: [PATCH] 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 --- .../contents/getting-started/quick-start.mdx | 3 +- apps/docs/contents/saas/architecture.mdx | 6 +- apps/docs/contents/storage/providers/b2.mdx | 7 +- .../docs/contents/storage/providers/eagle.mdx | 3 +- .../contents/storage/providers/github.mdx | 3 +- .../docs/contents/storage/providers/index.mdx | 3 +- .../docs/contents/storage/providers/local.mdx | 3 +- apps/docs/contents/storage/providers/s3.mdx | 3 +- .../modules/platform/auth/auth.controller.ts | 170 +++++++++++++++++- .../modules/platform/auth/auth.provider.ts | 19 +- .../tenant/tenant-context-resolver.service.ts | 28 ++- be/apps/oauth-gateway/README.md | 36 ++-- be/apps/oauth-gateway/package.json | 2 +- be/apps/oauth-gateway/src/config.ts | 7 + be/apps/oauth-gateway/src/index.ts | 31 +++- be/packages/db/scripts/fix-migration-8.sql | 1 + be/packages/env/src/index.ts | 1 + be/packages/utils/src/index.ts | 1 + be/packages/utils/src/oauth-state.ts | 112 ++++++++++++ docs/backend/tenant-flow.md | 3 +- pnpm-lock.yaml | 9 +- 21 files changed, 384 insertions(+), 67 deletions(-) create mode 100644 be/packages/utils/src/oauth-state.ts diff --git a/apps/docs/contents/getting-started/quick-start.mdx b/apps/docs/contents/getting-started/quick-start.mdx index e29a0e7d..5cc3560d 100644 --- a/apps/docs/contents/getting-started/quick-start.mdx +++ b/apps/docs/contents/getting-started/quick-start.mdx @@ -2,7 +2,7 @@ title: Quick Start description: Get your gallery running in about 5 minutes. createdAt: 2025-11-14T22:20:00+08:00 -lastModified: 2025-11-25T17:23:59+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 2 --- @@ -111,3 +111,4 @@ Deploy to Vercel or any Node.js host. See [Vercel Deployment](/deployment/vercel + diff --git a/apps/docs/contents/saas/architecture.mdx b/apps/docs/contents/saas/architecture.mdx index 486f34c0..b4b3e30b 100644 --- a/apps/docs/contents/saas/architecture.mdx +++ b/apps/docs/contents/saas/architecture.mdx @@ -2,7 +2,7 @@ title: SaaS Architecture description: Tenant model, domain routing, OAuth flow, and data injection paths. createdAt: 2025-11-23T20:20:00+08:00 -lastModified: 2025-11-23T20:44:02+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 36 --- @@ -30,8 +30,8 @@ order: 36 ## OAuth gateway flow -- Providers redirect to `https://auth./api/auth/callback/{provider}?tenantSlug=`. -- Gateway rewrites to `https://./api/auth/callback/{provider}` preserving query params. +- Providers redirect to the fixed `https://auth./api/auth/callback/{provider}`. +- Tenant slug is wrapped into the OAuth `state` so the gateway can restore the inner Better Auth state and forward to `https://./api/auth/callback/{provider}`. - Keeps provider config simple (single callback URL) while maintaining per-tenant sessions. ## Data path diff --git a/apps/docs/contents/storage/providers/b2.mdx b/apps/docs/contents/storage/providers/b2.mdx index 1e9d2201..778c847b 100644 --- a/apps/docs/contents/storage/providers/b2.mdx +++ b/apps/docs/contents/storage/providers/b2.mdx @@ -2,7 +2,7 @@ title: B2 (Backblaze B2) description: Configure Backblaze B2 storage for cost-effective cloud storage. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-25T17:23:59+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 33 --- @@ -86,13 +86,12 @@ Compare with AWS S3 to see which fits your usage pattern better. ## Troubleshooting **Authentication errors:** + - Verify `B2_KEY_ID` and `B2_KEY` are correct - Check that the application key has read permissions - Ensure the bucket ID and name match your B2 bucket **Rate limiting:** + - B2 has generous rate limits, but very high concurrency may still hit limits - Reduce concurrency if needed - - - diff --git a/apps/docs/contents/storage/providers/eagle.mdx b/apps/docs/contents/storage/providers/eagle.mdx index a93499f3..71ccbf50 100644 --- a/apps/docs/contents/storage/providers/eagle.mdx +++ b/apps/docs/contents/storage/providers/eagle.mdx @@ -2,7 +2,7 @@ title: Eagle Storage description: Publish directly from an Eagle 4 library with filtering support. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-25T17:23:59+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 36 --- @@ -165,3 +165,4 @@ This creates tags in the manifest based on folder structure, useful for organizi + diff --git a/apps/docs/contents/storage/providers/github.mdx b/apps/docs/contents/storage/providers/github.mdx index 927d54a7..1cf41381 100644 --- a/apps/docs/contents/storage/providers/github.mdx +++ b/apps/docs/contents/storage/providers/github.mdx @@ -2,7 +2,7 @@ title: GitHub Storage description: Use a GitHub repository as photo storage for simple deployments. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-25T17:23:59+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 34 --- @@ -100,3 +100,4 @@ For private repositories: + diff --git a/apps/docs/contents/storage/providers/index.mdx b/apps/docs/contents/storage/providers/index.mdx index 24874f85..d06faca6 100644 --- a/apps/docs/contents/storage/providers/index.mdx +++ b/apps/docs/contents/storage/providers/index.mdx @@ -2,7 +2,7 @@ title: Storage Providers description: Choose a storage provider for your photo collection. createdAt: 2025-11-14T22:40:00+08:00 -lastModified: 2025-11-25T17:23:59+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 30 --- @@ -111,3 +111,4 @@ Credentials and sensitive information should be stored in `.env` and referenced See each provider's documentation for specific configuration options. + diff --git a/apps/docs/contents/storage/providers/local.mdx b/apps/docs/contents/storage/providers/local.mdx index 7ec684e5..47880367 100644 --- a/apps/docs/contents/storage/providers/local.mdx +++ b/apps/docs/contents/storage/providers/local.mdx @@ -2,7 +2,7 @@ title: Local Storage description: Use local file system paths for development and self-hosting. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-25T17:23:59+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 35 --- @@ -134,3 +134,4 @@ If you want to serve original photos: + diff --git a/apps/docs/contents/storage/providers/s3.mdx b/apps/docs/contents/storage/providers/s3.mdx index f4046ffc..f76a392c 100644 --- a/apps/docs/contents/storage/providers/s3.mdx +++ b/apps/docs/contents/storage/providers/s3.mdx @@ -2,7 +2,7 @@ title: S3 / S3-Compatible description: Configure S3 or S3-compatible storage for your photo collection. createdAt: 2025-11-14T22:10:00+08:00 -lastModified: 2025-11-25T17:23:59+08:00 +lastModified: 2025-11-30T14:03:05+08:00 order: 32 --- @@ -121,3 +121,4 @@ This prevents processing temporary or system files. + diff --git a/be/apps/core/src/modules/platform/auth/auth.controller.ts b/be/apps/core/src/modules/platform/auth/auth.controller.ts index 20bf98e6..24afed51 100644 --- a/be/apps/core/src/modules/platform/auth/auth.controller.ts +++ b/be/apps/core/src/modules/platform/auth/auth.controller.ts @@ -1,6 +1,8 @@ import { TextDecoder } from 'node:util' +import { decodeGatewayState, encodeGatewayState } from '@afilmory/be-utils' import { authUsers } from '@afilmory/db' +import { env } from '@afilmory/env' import { Body, ContextParam, Controller, Get, HttpContext, Post } from '@afilmory/framework' import { freshSessionMiddleware } from 'better-auth/api' import { DbAccessor } from 'core/database/database.provider' @@ -78,6 +80,7 @@ type SocialSignInRequest = { errorCallbackURL?: string newUserCallbackURL?: string disableRedirect?: boolean + additionalData?: Record } type LinkSocialAccountRequest = { @@ -85,6 +88,7 @@ type LinkSocialAccountRequest = { callbackURL?: string errorCallbackURL?: string disableRedirect?: boolean + additionalData?: Record } type UnlinkSocialAccountRequest = { @@ -110,6 +114,7 @@ export class AuthController { private readonly registration: AuthRegistrationService, private readonly tenantService: TenantService, ) {} + private readonly gatewayStateSecret = env.AUTH_GATEWAY_STATE_SECRET ?? env.CONFIG_ENCRYPTION_KEY @AllowPlaceholderTenant() @Get('/session') @@ -183,6 +188,17 @@ export class AuthController { @Post('/social/link') @Roles(RoleBit.ADMIN) async linkSocialAccount(@ContextParam() context: Context, @Body() body: LinkSocialAccountRequest) { + return await this.handleLinkSocialAccount(context, body) + } + + // Compatibility for Better Auth client default path + @Post('/link-social') + @Roles(RoleBit.ADMIN) + async linkSocialAccountCompat(@ContextParam() context: Context, @Body() body: LinkSocialAccountRequest) { + return await this.handleLinkSocialAccount(context, body) + } + + private async handleLinkSocialAccount(context: Context, body: LinkSocialAccountRequest) { const provider = body?.provider?.trim() if (!provider) { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' }) @@ -198,6 +214,8 @@ export class AuthController { const errorCallbackURL = this.normalizeCallbackUrl(body?.errorCallbackURL) const auth = await this.auth.getAuth() + const tenantSlug = getTenantContext()?.requestedSlug ?? null + const response = await auth.api.linkSocialAccount({ headers, body: { @@ -206,11 +224,15 @@ export class AuthController { disableRedirect: body?.disableRedirect ?? true, ...(callbackURL ? { callbackURL } : {}), ...(errorCallbackURL ? { errorCallbackURL } : {}), + additionalData: { + ...body?.additionalData, + tenantSlug, + }, }, asResponse: true, }) - return response + return await this.rewriteOAuthState(response, tenantSlug) } @Post('/social/unlink') @@ -299,6 +321,17 @@ export class AuthController { @AllowPlaceholderTenant() @Post('/social') async signInSocial(@ContextParam() context: Context, @Body() body: SocialSignInRequest) { + return await this.handleSocialSignIn(context, body) + } + + // Compatibility for Better Auth client default path + @AllowPlaceholderTenant() + @Post('/sign-in/social') + async signInSocialCompat(@ContextParam() context: Context, @Body() body: SocialSignInRequest) { + return await this.handleSocialSignIn(context, body) + } + + private async handleSocialSignIn(context: Context, body: SocialSignInRequest) { const provider = body?.provider?.trim() if (!provider) { throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' }) @@ -306,6 +339,7 @@ export class AuthController { const { headers } = context.req.raw const tenantContext = getTenantContext() + const tenantSlug = tenantContext?.requestedSlug ?? tenantContext?.tenant?.slug ?? null // Only allow auto sign-up on real tenants (not placeholder) // On placeholder tenant, users must explicitly register first @@ -318,12 +352,16 @@ export class AuthController { ...body, provider, requestSignUp: shouldAllowSignUp, + additionalData: { + ...body.additionalData, + tenantSlug, + }, }, headers, asResponse: true, }) - return response + return await this.rewriteOAuthState(response, tenantSlug) } @SkipTenantGuard() @@ -387,11 +425,32 @@ export class AuthController { @SkipTenantGuard() @Get('/callback/*') async callback(@ContextParam() context: Context) { - const query = context.req.query() - const { tenantSlug } = query - const reqUrl = new URL(context.req.url) + let didRewriteState = false + let didRewriteHost = false + const wrappedState = reqUrl.searchParams.get('state') + let tenantSlugFromState: string | null = null + if (this.gatewayStateSecret && wrappedState) { + const decoded = decodeGatewayState(wrappedState, { secret: this.gatewayStateSecret }) + if (decoded?.innerState) { + reqUrl.searchParams.set('gatewayState', wrappedState) + reqUrl.searchParams.set('state', decoded.innerState) + didRewriteState = decoded.innerState !== wrappedState + tenantSlugFromState = decoded.tenantSlug ?? null + } + } + + if (tenantSlugFromState) { + const { hostname } = reqUrl + if (!hostname.startsWith(`${tenantSlugFromState}.`)) { + reqUrl.hostname = `${tenantSlugFromState}.${hostname}` + didRewriteHost = true + } + } + + const tenantSlug = reqUrl.searchParams.get('tenantSlug') + if (tenantSlug) { reqUrl.hostname = `${tenantSlug}.${reqUrl.hostname}` reqUrl.searchParams.delete('tenantSlug') @@ -399,6 +458,10 @@ export class AuthController { return context.redirect(reqUrl.toString(), 302) } + if (didRewriteState || didRewriteHost) { + return context.redirect(reqUrl.toString(), 302) + } + return await this.auth.handler(context) } @@ -516,4 +579,101 @@ export class AuthController { headers, }) } + + /** + * Wraps the Better Auth `state` parameter with tenant metadata so the OAuth gateway + * can route callbacks without dynamic redirect URIs. Preserves cookies/headers from + * the upstream Better Auth response. + */ + private async rewriteOAuthState(response: Response, tenantSlug: string | null): Promise { + if (!this.gatewayStateSecret) { + return response + } + + const location = response.headers.get('location') + if (location) { + const wrappedLocation = this.wrapGatewayState(location, tenantSlug) + if (wrappedLocation !== location) { + const headers = new Headers() + response.headers.forEach((value, key) => { + headers.append(key, value) + }) + headers.set('location', wrappedLocation) + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }) + } + } + + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) { + return response + } + + const clone = response.clone() + let payload: unknown + try { + payload = await clone.json() + } catch { + return response + } + + if (!payload || typeof payload !== 'object') { + return response + } + + const payloadRecord = payload as Record + const url = typeof payloadRecord.url === 'string' ? payloadRecord.url : null + if (!url) { + return response + } + + const wrappedUrl = this.wrapGatewayState(url, tenantSlug) + if (wrappedUrl === url) { + return response + } + + const headers = new Headers() + response.headers.forEach((value, key) => { + headers.append(key, value) + }) + headers.set('content-type', 'application/json; charset=utf-8') + + const nextPayload = { + ...payloadRecord, + url: wrappedUrl, + } + + return new Response(JSON.stringify(nextPayload), { + status: response.status, + statusText: response.statusText, + headers, + }) + } + + private wrapGatewayState(url: string, tenantSlug: string | null): string { + if (!this.gatewayStateSecret) { + return url + } + + try { + const parsed = new URL(url) + const state = parsed.searchParams.get('state') + if (!state) { + return url + } + + const wrapped = encodeGatewayState({ + secret: this.gatewayStateSecret, + tenantSlug, + innerState: state, + }) + parsed.searchParams.set('state', wrapped) + return parsed.toString() + } catch { + return url + } + } } diff --git a/be/apps/core/src/modules/platform/auth/auth.provider.ts b/be/apps/core/src/modules/platform/auth/auth.provider.ts index a8574439..f6cbe595 100644 --- a/be/apps/core/src/modules/platform/auth/auth.provider.ts +++ b/be/apps/core/src/modules/platform/auth/auth.provider.ts @@ -159,7 +159,7 @@ export class AuthProvider implements OnModuleInit { return entries.reduce>( (acc, [key, value]) => { - const redirectUri = this.buildRedirectUri(tenantSlug, key, oauthGatewayUrl) + const redirectUri = this.buildRedirectUri(key, oauthGatewayUrl) acc[key] = { clientId: value.clientId, clientSecret: value.clientSecret, @@ -171,15 +171,11 @@ export class AuthProvider implements OnModuleInit { ) } - private buildRedirectUri( - tenantSlug: string | null, - provider: keyof SocialProvidersConfig, - oauthGatewayUrl: string | null, - ): string | null { + private buildRedirectUri(provider: keyof SocialProvidersConfig, oauthGatewayUrl: string | null): string | null { const basePath = `/api/auth/callback/${provider}` if (oauthGatewayUrl) { - return this.buildGatewayRedirectUri(oauthGatewayUrl, basePath, tenantSlug) + return this.buildGatewayRedirectUri(oauthGatewayUrl, basePath) } logger.error( ['[AuthProvider] OAuth 网关地址未配置,无法为第三方登录生成回调 URL。', `provider=${String(provider)}`].join(' '), @@ -187,14 +183,9 @@ export class AuthProvider implements OnModuleInit { return null } - private buildGatewayRedirectUri(gatewayBaseUrl: string, basePath: string, tenantSlug: string | null): string { + private buildGatewayRedirectUri(gatewayBaseUrl: string, basePath: string): string { const normalizedBase = gatewayBaseUrl.replace(/\/+$/, '') - const searchParams = new URLSearchParams() - if (tenantSlug) { - searchParams.set('tenantSlug', tenantSlug) - } - const query = searchParams.toString() - return `${normalizedBase}${basePath}${query ? `?${query}` : ''}` + return `${normalizedBase}${basePath}` } private async buildTrustedOrigins(): Promise { diff --git a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts index d81833dd..bbe76cc6 100644 --- a/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts +++ b/be/apps/core/src/modules/platform/tenant/tenant-context-resolver.service.ts @@ -1,3 +1,5 @@ +import { decodeGatewayState } from '@afilmory/be-utils' +import { env } from '@afilmory/env' import { HttpContext } from '@afilmory/framework' import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils' import { BizException, ErrorCode } from 'core/errors' @@ -28,6 +30,7 @@ export interface TenantResolutionOptions { @injectable() export class TenantContextResolver { private readonly log = logger.extend('TenantResolver') + private readonly gatewayStateSecret = env.AUTH_GATEWAY_STATE_SECRET ?? env.CONFIG_ENCRYPTION_KEY constructor( private readonly tenantService: TenantService, @@ -72,17 +75,26 @@ export class TenantContextResolver { } } - if (!derivedSlug) { - // Allow resolving tenant from query param for OAuth callbacks (Gateway flow) - const querySlug = context.req.query('tenantSlug') - if (querySlug && context.req.path.startsWith('/api/auth/callback/')) { - derivedSlug = querySlug - } - } - if (!derivedSlug) { derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined } + if ( + !derivedSlug && + this.gatewayStateSecret && + context.req.path.startsWith('/api/auth/callback/') && + context.req.query + ) { + const gatewayState = context.req.query('gatewayState') + const state = context.req.query('state') + const decoded = + decodeGatewayState(gatewayState, { secret: this.gatewayStateSecret }) || + decodeGatewayState(state, { secret: this.gatewayStateSecret }) + + if (decoded?.tenantSlug) { + derivedSlug = decoded.tenantSlug + this.log.verbose('Resolved tenant from gateway state during OAuth callback', { slug: derivedSlug }) + } + } if (!derivedSlug && this.isRootTenantPath(context.req.path)) { derivedSlug = ROOT_TENANT_SLUG } diff --git a/be/apps/oauth-gateway/README.md b/be/apps/oauth-gateway/README.md index 4c33f500..9de2b29d 100644 --- a/be/apps/oauth-gateway/README.md +++ b/be/apps/oauth-gateway/README.md @@ -6,10 +6,9 @@ subdomain. ## How It Works -1. Better Auth (running inside `be/apps/core`) builds provider redirect URLs using the tenant slug. -2. Instead of sending the provider back to the tenant domain, the redirect URL is set to - `https://auth.afilmory.art/api/auth/callback/{provider}?tenantSlug=`. -3. The gateway receives the provider callback, validates the slug/host, and issues a 302 redirect to +1. Better Auth (running inside `be/apps/core`) wraps the OAuth `state` with the tenant slug. +2. Providers always redirect to the fixed URL `https://auth.afilmory.art/api/auth/callback/{provider}`. +3. The gateway unwraps `state`, restores the inner Better Auth state, and issues a 302 redirect to `https://.afilmory.art/api/auth/callback/{provider}` (preserving `code`, `state`, etc.). Because the gateway only rewrites the callback target, it does **not** interact with provider APIs or @@ -26,15 +25,16 @@ The service starts on `http://0.0.0.0:8790` by default. ## Environment Variables -| Variable | Default | Description | -| --------------------------------- | -------------------- | ----------------------------------------------------------------- | -| `AUTH_GATEWAY_HOST` | `0.0.0.0` | Interface to bind. | -| `AUTH_GATEWAY_PORT` | `8790` | Port to listen on. | -| `AUTH_GATEWAY_BASE_DOMAIN` | `afilmory.art` | Root domain used when constructing tenant hosts. | -| `AUTH_GATEWAY_CALLBACK_BASE_PATH` | `/api/auth/callback` | Base path that the providers call. | -| `AUTH_GATEWAY_FORCE_HTTPS` | `true` | Forces redirects to `https` unless the host looks like localhost. | -| `AUTH_GATEWAY_ALLOW_CUSTOM_HOST` | `false` | Allow requests to pass an explicit `targetHost` query parameter. | -| `AUTH_GATEWAY_ROOT_SLUG` | `root` | Slug treated as the apex (no subdomain). | +| Variable | Default | Description | +| --------------------------------- | -------------------- | --------------------------------------------------------------------------------------- | +| `AUTH_GATEWAY_HOST` | `0.0.0.0` | Interface to bind. | +| `AUTH_GATEWAY_PORT` | `8790` | Port to listen on. | +| `AUTH_GATEWAY_BASE_DOMAIN` | `afilmory.art` | Root domain used when constructing tenant hosts. | +| `AUTH_GATEWAY_CALLBACK_BASE_PATH` | `/api/auth/callback` | Base path that the providers call. | +| `AUTH_GATEWAY_FORCE_HTTPS` | `true` | Forces redirects to `https` unless the host looks like localhost. | +| `AUTH_GATEWAY_ALLOW_CUSTOM_HOST` | `false` | Allow requests to pass an explicit `targetHost` query parameter. | +| `AUTH_GATEWAY_ROOT_SLUG` | `root` | Slug treated as the apex (no subdomain). | +| `AUTH_GATEWAY_STATE_SECRET` | _none_ (required) | HMAC secret for decoding wrapped `state` values (fallbacks to `CONFIG_ENCRYPTION_KEY`). | ## Callback Contract @@ -42,16 +42,16 @@ The service starts on `http://0.0.0.0:8790` by default. Query parameters: -- `tenantSlug` (preferred) or `tenant` — tenant slug to route to. Required unless `targetHost` is - provided or you want to hit the root domain. +- `state` — wrapped gateway state containing the tenant slug and inner Better Auth state. +- `tenantSlug` (legacy) or `tenant` — optional slug fallback when `state` is not wrapped. - `targetHost` — explicit host override (opt-in via `ALLOW_CUSTOM_HOST`). -- All other query parameters (`code`, `state`, etc.) are forwarded verbatim. +- All other query parameters (`code`, etc.) are forwarded verbatim. Example redirect produced by the gateway: ``` -https://auth.afilmory.art/api/auth/callback/github?tenantSlug=innei&code=...&state=... - ⮕ 302 → https://innei.afilmory.art/api/auth/callback/github?code=...&state=... +https://auth.afilmory.art/api/auth/callback/github?code=...&state= + ⮕ 302 → https://innei.afilmory.art/api/auth/callback/github?code=...&state= ``` This service is intentionally stateless so it can be deployed behind a simple load balancer. diff --git a/be/apps/oauth-gateway/package.json b/be/apps/oauth-gateway/package.json index 56df2b64..7e6b1663 100644 --- a/be/apps/oauth-gateway/package.json +++ b/be/apps/oauth-gateway/package.json @@ -9,7 +9,7 @@ "start": "node dist/main.js" }, "dependencies": { - "@afilmory/utils": "workspace:*", + "@afilmory/be-utils": "workspace:*", "@hono/node-server": "^1.13.5", "hono": "^4.10.7", "zod": "catalog:" diff --git a/be/apps/oauth-gateway/src/config.ts b/be/apps/oauth-gateway/src/config.ts index 5b3fe747..29c0a56c 100644 --- a/be/apps/oauth-gateway/src/config.ts +++ b/be/apps/oauth-gateway/src/config.ts @@ -37,6 +37,11 @@ const envSchema = z.object({ .min(1) .regex(/^[a-z0-9-]+$/i) .default('root'), + STATE_SECRET: z + .string() + .trim() + .min(1, { message: 'AUTH_GATEWAY_STATE_SECRET or CONFIG_ENCRYPTION_KEY is required for state decoding.' }) + .default(process.env.AUTH_GATEWAY_STATE_SECRET ?? process.env.CONFIG_ENCRYPTION_KEY ?? ''), }) const parsed = envSchema.parse({ @@ -47,6 +52,7 @@ const parsed = envSchema.parse({ CALLBACK_BASE_PATH: process.env.AUTH_GATEWAY_CALLBACK_BASE_PATH, ALLOW_CUSTOM_HOST: process.env.AUTH_GATEWAY_ALLOW_CUSTOM_HOST, ROOT_SLUG: process.env.AUTH_GATEWAY_ROOT_SLUG, + STATE_SECRET: process.env.AUTH_GATEWAY_STATE_SECRET ?? process.env.CONFIG_ENCRYPTION_KEY, }) export const gatewayConfig = { @@ -57,6 +63,7 @@ export const gatewayConfig = { callbackBasePath: parsed.CALLBACK_BASE_PATH, allowCustomHost: Boolean(parsed.ALLOW_CUSTOM_HOST), rootSlug: parsed.ROOT_SLUG.toLowerCase(), + stateSecret: parsed.STATE_SECRET, } as const export type GatewayConfig = typeof gatewayConfig diff --git a/be/apps/oauth-gateway/src/index.ts b/be/apps/oauth-gateway/src/index.ts index f1fadb6c..2af1b83d 100644 --- a/be/apps/oauth-gateway/src/index.ts +++ b/be/apps/oauth-gateway/src/index.ts @@ -1,3 +1,4 @@ +import { decodeGatewayState } from '@afilmory/be-utils' import { serve } from '@hono/node-server' import { Hono } from 'hono' @@ -25,8 +26,30 @@ callbackRouter.all('/:provider', (c) => { const requestUrl = new URL(c.req.url) const tenantSlugParam = requestUrl.searchParams.get('tenantSlug') ?? requestUrl.searchParams.get('tenant') const explicitHostParam = requestUrl.searchParams.get('targetHost') - const tenantSlug = sanitizeTenantSlug(tenantSlugParam) - const explicitHost = sanitizeExplicitHost(explicitHostParam) + const stateParam = requestUrl.searchParams.get('state') + const originalStateParam = stateParam + + const decodedState = + gatewayConfig.stateSecret && stateParam + ? decodeGatewayState(stateParam, { secret: gatewayConfig.stateSecret }) + : null + + if (stateParam && gatewayConfig.stateSecret && !decodedState) { + return c.json({ error: 'invalid_state', message: 'OAuth state is invalid or expired.' }, 400) + } + + if (decodedState?.innerState) { + requestUrl.searchParams.set('state', decodedState.innerState) + } + + if (decodedState && originalStateParam) { + requestUrl.searchParams.set('gatewayState', originalStateParam) + } + + const tenantSlugFromState = decodedState?.tenantSlug ?? null + const tenantSlug = sanitizeTenantSlug(tenantSlugParam ?? tenantSlugFromState ?? undefined) + const explicitHostFromState = sanitizeExplicitHost(decodedState?.targetHost) + const explicitHost = sanitizeExplicitHost(explicitHostParam) ?? explicitHostFromState requestUrl.searchParams.delete('tenant') requestUrl.searchParams.delete('tenantSlug') @@ -36,6 +59,10 @@ callbackRouter.all('/:provider', (c) => { return c.json({ error: 'invalid_tenant', message: 'Tenant slug is invalid.' }, 400) } + if (decodedState?.tenantSlug && !tenantSlug) { + return c.json({ error: 'invalid_tenant', message: 'Tenant slug in state is invalid.' }, 400) + } + if (explicitHostParam && !explicitHost) { return c.json({ error: 'invalid_host', message: 'Target host is invalid.' }, 400) } diff --git a/be/packages/db/scripts/fix-migration-8.sql b/be/packages/db/scripts/fix-migration-8.sql index 9ee3d150..738e3f9d 100644 --- a/be/packages/db/scripts/fix-migration-8.sql +++ b/be/packages/db/scripts/fix-migration-8.sql @@ -44,3 +44,4 @@ SELECT * FROM drizzle.__drizzle_migrations WHERE id = 7; -- DELETE FROM drizzle.__drizzle_migrations WHERE id = 7 AND hash = 'wrong_hash_here'; */ + diff --git a/be/packages/env/src/index.ts b/be/packages/env/src/index.ts index f5e85416..6b2b7fbe 100644 --- a/be/packages/env/src/index.ts +++ b/be/packages/env/src/index.ts @@ -22,6 +22,7 @@ export const env = createEnv({ GITHUB_CLIENT_SECRET: z.string().optional(), CONFIG_ENCRYPTION_KEY: z.string().min(1), + AUTH_GATEWAY_STATE_SECRET: z.string().min(1).optional(), // Payment CREEM_API_KEY: z.string().min(1), diff --git a/be/packages/utils/src/index.ts b/be/packages/utils/src/index.ts index d0415704..81ed012d 100644 --- a/be/packages/utils/src/index.ts +++ b/be/packages/utils/src/index.ts @@ -1 +1,2 @@ export * from './json' +export * from './oauth-state' diff --git a/be/packages/utils/src/oauth-state.ts b/be/packages/utils/src/oauth-state.ts new file mode 100644 index 00000000..675c1334 --- /dev/null +++ b/be/packages/utils/src/oauth-state.ts @@ -0,0 +1,112 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' + +export interface GatewayStatePayload { + innerState: string + tenantSlug: string | null + targetHost?: string | null + issuedAt: number + expiresAt: number +} + +export interface EncodeGatewayStateOptions { + secret: string + tenantSlug: string | null + innerState: string + targetHost?: string | null + ttlMs?: number +} + +export interface DecodeGatewayStateOptions { + secret: string + clockToleranceMs?: number +} + +const DEFAULT_TTL_MS = 10 * 60 * 1000 +const DEFAULT_CLOCK_TOLERANCE_MS = 30 * 1000 + +function base64UrlEncode(input: string): string { + return Buffer.from(input, 'utf8').toString('base64url') +} + +function base64UrlDecode(input: string): string { + return Buffer.from(input, 'base64url').toString('utf8') +} + +function signPayload(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('base64url') +} + +function safeCompare(a: string, b: string): boolean { + const aBuf = Buffer.from(a, 'utf8') + const bBuf = Buffer.from(b, 'utf8') + if (aBuf.length !== bBuf.length) { + return false + } + return timingSafeEqual(aBuf, bBuf) +} + +/** + * Wraps the Better Auth state with tenant metadata so the OAuth gateway can + * route the callback without hard-coding tenant slugs in redirect URIs. + */ +export function encodeGatewayState(options: EncodeGatewayStateOptions): string { + const { secret, tenantSlug, innerState, targetHost, ttlMs = DEFAULT_TTL_MS } = options + const now = Date.now() + const payload: GatewayStatePayload = { + innerState, + tenantSlug, + targetHost: targetHost ?? null, + issuedAt: now, + expiresAt: now + ttlMs, + } + + const encodedPayload = base64UrlEncode(JSON.stringify(payload)) + const signature = signPayload(encodedPayload, secret) + return `${encodedPayload}.${signature}` +} + +/** + * Verifies and unwraps the gateway state. Returns null if the token is missing, + * malformed, expired, or fails signature verification. + */ +export function decodeGatewayState( + token: string | null | undefined, + options: DecodeGatewayStateOptions, +): GatewayStatePayload | null { + if (!token) { + return null + } + + const parts = token.split('.') + if (parts.length !== 2) { + return null + } + + const [encodedPayload, signature] = parts + const expectedSignature = signPayload(encodedPayload, options.secret) + if (!safeCompare(expectedSignature, signature)) { + return null + } + + try { + const parsed = JSON.parse(base64UrlDecode(encodedPayload)) as GatewayStatePayload + if ( + !parsed || + typeof parsed.innerState !== 'string' || + typeof parsed.issuedAt !== 'number' || + typeof parsed.expiresAt !== 'number' + ) { + return null + } + + const tolerance = options.clockToleranceMs ?? DEFAULT_CLOCK_TOLERANCE_MS + const now = Date.now() + if (parsed.expiresAt + tolerance < now) { + return null + } + + return parsed + } catch { + return null + } +} diff --git a/docs/backend/tenant-flow.md b/docs/backend/tenant-flow.md index 98d2dad5..1cb87cfb 100644 --- a/docs/backend/tenant-flow.md +++ b/docs/backend/tenant-flow.md @@ -19,7 +19,8 @@ This document describes how tenant resolution, Better Auth instances, and dashbo 1. `HttpContext.tenant.requestedSlug` 2. `HttpContext.tenant.slug` 3. Derived from the host (when the context slug is still the placeholder). -- Redirect URIs are always built as `/api/auth/callback/:provider?tenantSlug=...`. +- Redirect URIs are fixed to `/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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 811c419b..a10bf26c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1276,9 +1276,9 @@ importers: be/apps/oauth-gateway: dependencies: - '@afilmory/utils': + '@afilmory/be-utils': specifier: workspace:* - version: link:../../../packages/utils + version: link:../../packages/utils '@hono/node-server': specifier: ^1.13.5 version: 1.19.6(hono@4.10.7) @@ -1605,7 +1605,7 @@ importers: version: 0.16.7(synckit@0.11.11)(typescript@5.9.3) unplugin-dts: specifier: 1.0.0-beta.6 - version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.51)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) + version: 1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)) vite: specifier: 7.2.4 version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) @@ -24533,7 +24533,7 @@ snapshots: magic-string-ast: 1.0.3 unplugin: 2.3.10 - unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rolldown@1.0.0-beta.51)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): + unplugin-dts@1.0.0-beta.6(@microsoft/api-extractor@7.52.13(@types/node@24.10.1))(esbuild@0.25.12)(rollup@4.53.3)(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.53.3) '@volar/typescript': 2.4.23 @@ -24547,7 +24547,6 @@ snapshots: optionalDependencies: '@microsoft/api-extractor': 7.52.13(@types/node@24.10.1) esbuild: 0.25.12 - rolldown: 1.0.0-beta.51 rollup: 4.53.3 vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: