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>
This commit is contained in:
Innei
2025-11-30 14:03:05 +08:00
parent 2a7336cd6b
commit 019ee50121
21 changed files with 384 additions and 67 deletions

View File

@@ -2,7 +2,7 @@
title: Quick Start title: Quick Start
description: Get your gallery running in about 5 minutes. description: Get your gallery running in about 5 minutes.
createdAt: 2025-11-14T22:20:00+08:00 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 order: 2
--- ---
@@ -111,3 +111,4 @@ Deploy to Vercel or any Node.js host. See [Vercel Deployment](/deployment/vercel

View File

@@ -2,7 +2,7 @@
title: SaaS Architecture title: SaaS Architecture
description: Tenant model, domain routing, OAuth flow, and data injection paths. description: Tenant model, domain routing, OAuth flow, and data injection paths.
createdAt: 2025-11-23T20:20:00+08:00 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 order: 36
--- ---
@@ -30,8 +30,8 @@ order: 36
## OAuth gateway flow ## OAuth gateway flow
- Providers redirect to `https://auth.<baseDomain>/api/auth/callback/{provider}?tenantSlug=<slug>`. - Providers redirect to the fixed `https://auth.<baseDomain>/api/auth/callback/{provider}`.
- Gateway rewrites to `https://<slug>.<baseDomain>/api/auth/callback/{provider}` preserving query params. - Tenant slug is wrapped into the OAuth `state` so the gateway can restore the inner Better Auth state and forward to `https://<slug>.<baseDomain>/api/auth/callback/{provider}`.
- Keeps provider config simple (single callback URL) while maintaining per-tenant sessions. - Keeps provider config simple (single callback URL) while maintaining per-tenant sessions.
## Data path ## Data path

View File

@@ -2,7 +2,7 @@
title: B2 (Backblaze B2) title: B2 (Backblaze B2)
description: Configure Backblaze B2 storage for cost-effective cloud storage. description: Configure Backblaze B2 storage for cost-effective cloud storage.
createdAt: 2025-11-14T22:10:00+08:00 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 order: 33
--- ---
@@ -86,13 +86,12 @@ Compare with AWS S3 to see which fits your usage pattern better.
## Troubleshooting ## Troubleshooting
**Authentication errors:** **Authentication errors:**
- Verify `B2_KEY_ID` and `B2_KEY` are correct - Verify `B2_KEY_ID` and `B2_KEY` are correct
- Check that the application key has read permissions - Check that the application key has read permissions
- Ensure the bucket ID and name match your B2 bucket - Ensure the bucket ID and name match your B2 bucket
**Rate limiting:** **Rate limiting:**
- B2 has generous rate limits, but very high concurrency may still hit limits - B2 has generous rate limits, but very high concurrency may still hit limits
- Reduce concurrency if needed - Reduce concurrency if needed

View File

@@ -2,7 +2,7 @@
title: Eagle Storage title: Eagle Storage
description: Publish directly from an Eagle 4 library with filtering support. description: Publish directly from an Eagle 4 library with filtering support.
createdAt: 2025-11-14T22:10:00+08:00 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 order: 36
--- ---
@@ -165,3 +165,4 @@ This creates tags in the manifest based on folder structure, useful for organizi

View File

@@ -2,7 +2,7 @@
title: GitHub Storage title: GitHub Storage
description: Use a GitHub repository as photo storage for simple deployments. description: Use a GitHub repository as photo storage for simple deployments.
createdAt: 2025-11-14T22:10:00+08:00 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 order: 34
--- ---
@@ -100,3 +100,4 @@ For private repositories:

View File

@@ -2,7 +2,7 @@
title: Storage Providers title: Storage Providers
description: Choose a storage provider for your photo collection. description: Choose a storage provider for your photo collection.
createdAt: 2025-11-14T22:40:00+08:00 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 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. See each provider's documentation for specific configuration options.

View File

@@ -2,7 +2,7 @@
title: Local Storage title: Local Storage
description: Use local file system paths for development and self-hosting. description: Use local file system paths for development and self-hosting.
createdAt: 2025-11-14T22:10:00+08:00 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 order: 35
--- ---
@@ -134,3 +134,4 @@ If you want to serve original photos:

View File

@@ -2,7 +2,7 @@
title: S3 / S3-Compatible title: S3 / S3-Compatible
description: Configure S3 or S3-compatible storage for your photo collection. description: Configure S3 or S3-compatible storage for your photo collection.
createdAt: 2025-11-14T22:10:00+08:00 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 order: 32
--- ---
@@ -121,3 +121,4 @@ This prevents processing temporary or system files.

View File

@@ -1,6 +1,8 @@
import { TextDecoder } from 'node:util' import { TextDecoder } from 'node:util'
import { decodeGatewayState, encodeGatewayState } from '@afilmory/be-utils'
import { authUsers } from '@afilmory/db' import { authUsers } from '@afilmory/db'
import { env } from '@afilmory/env'
import { Body, ContextParam, Controller, Get, HttpContext, Post } from '@afilmory/framework' import { Body, ContextParam, Controller, Get, HttpContext, Post } from '@afilmory/framework'
import { freshSessionMiddleware } from 'better-auth/api' import { freshSessionMiddleware } from 'better-auth/api'
import { DbAccessor } from 'core/database/database.provider' import { DbAccessor } from 'core/database/database.provider'
@@ -78,6 +80,7 @@ type SocialSignInRequest = {
errorCallbackURL?: string errorCallbackURL?: string
newUserCallbackURL?: string newUserCallbackURL?: string
disableRedirect?: boolean disableRedirect?: boolean
additionalData?: Record<string, unknown>
} }
type LinkSocialAccountRequest = { type LinkSocialAccountRequest = {
@@ -85,6 +88,7 @@ type LinkSocialAccountRequest = {
callbackURL?: string callbackURL?: string
errorCallbackURL?: string errorCallbackURL?: string
disableRedirect?: boolean disableRedirect?: boolean
additionalData?: Record<string, unknown>
} }
type UnlinkSocialAccountRequest = { type UnlinkSocialAccountRequest = {
@@ -110,6 +114,7 @@ export class AuthController {
private readonly registration: AuthRegistrationService, private readonly registration: AuthRegistrationService,
private readonly tenantService: TenantService, private readonly tenantService: TenantService,
) {} ) {}
private readonly gatewayStateSecret = env.AUTH_GATEWAY_STATE_SECRET ?? env.CONFIG_ENCRYPTION_KEY
@AllowPlaceholderTenant() @AllowPlaceholderTenant()
@Get('/session') @Get('/session')
@@ -183,6 +188,17 @@ export class AuthController {
@Post('/social/link') @Post('/social/link')
@Roles(RoleBit.ADMIN) @Roles(RoleBit.ADMIN)
async linkSocialAccount(@ContextParam() context: Context, @Body() body: LinkSocialAccountRequest) { 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() const provider = body?.provider?.trim()
if (!provider) { if (!provider) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth 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 errorCallbackURL = this.normalizeCallbackUrl(body?.errorCallbackURL)
const auth = await this.auth.getAuth() const auth = await this.auth.getAuth()
const tenantSlug = getTenantContext()?.requestedSlug ?? null
const response = await auth.api.linkSocialAccount({ const response = await auth.api.linkSocialAccount({
headers, headers,
body: { body: {
@@ -206,11 +224,15 @@ export class AuthController {
disableRedirect: body?.disableRedirect ?? true, disableRedirect: body?.disableRedirect ?? true,
...(callbackURL ? { callbackURL } : {}), ...(callbackURL ? { callbackURL } : {}),
...(errorCallbackURL ? { errorCallbackURL } : {}), ...(errorCallbackURL ? { errorCallbackURL } : {}),
additionalData: {
...body?.additionalData,
tenantSlug,
},
}, },
asResponse: true, asResponse: true,
}) })
return response return await this.rewriteOAuthState(response, tenantSlug)
} }
@Post('/social/unlink') @Post('/social/unlink')
@@ -299,6 +321,17 @@ export class AuthController {
@AllowPlaceholderTenant() @AllowPlaceholderTenant()
@Post('/social') @Post('/social')
async signInSocial(@ContextParam() context: Context, @Body() body: SocialSignInRequest) { 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() const provider = body?.provider?.trim()
if (!provider) { if (!provider) {
throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' }) throw new BizException(ErrorCode.COMMON_BAD_REQUEST, { message: '缺少 OAuth Provider 参数' })
@@ -306,6 +339,7 @@ export class AuthController {
const { headers } = context.req.raw const { headers } = context.req.raw
const tenantContext = getTenantContext() const tenantContext = getTenantContext()
const tenantSlug = tenantContext?.requestedSlug ?? tenantContext?.tenant?.slug ?? null
// Only allow auto sign-up on real tenants (not placeholder) // Only allow auto sign-up on real tenants (not placeholder)
// On placeholder tenant, users must explicitly register first // On placeholder tenant, users must explicitly register first
@@ -318,12 +352,16 @@ export class AuthController {
...body, ...body,
provider, provider,
requestSignUp: shouldAllowSignUp, requestSignUp: shouldAllowSignUp,
additionalData: {
...body.additionalData,
tenantSlug,
},
}, },
headers, headers,
asResponse: true, asResponse: true,
}) })
return response return await this.rewriteOAuthState(response, tenantSlug)
} }
@SkipTenantGuard() @SkipTenantGuard()
@@ -387,11 +425,32 @@ export class AuthController {
@SkipTenantGuard() @SkipTenantGuard()
@Get('/callback/*') @Get('/callback/*')
async callback(@ContextParam() context: Context) { async callback(@ContextParam() context: Context) {
const query = context.req.query()
const { tenantSlug } = query
const reqUrl = new URL(context.req.url) 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) { if (tenantSlug) {
reqUrl.hostname = `${tenantSlug}.${reqUrl.hostname}` reqUrl.hostname = `${tenantSlug}.${reqUrl.hostname}`
reqUrl.searchParams.delete('tenantSlug') reqUrl.searchParams.delete('tenantSlug')
@@ -399,6 +458,10 @@ export class AuthController {
return context.redirect(reqUrl.toString(), 302) return context.redirect(reqUrl.toString(), 302)
} }
if (didRewriteState || didRewriteHost) {
return context.redirect(reqUrl.toString(), 302)
}
return await this.auth.handler(context) return await this.auth.handler(context)
} }
@@ -516,4 +579,101 @@ export class AuthController {
headers, 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<Response> {
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<string, unknown>
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
}
}
} }

View File

@@ -159,7 +159,7 @@ export class AuthProvider implements OnModuleInit {
return entries.reduce<Record<string, { clientId: string; clientSecret: string; redirectURI?: string }>>( return entries.reduce<Record<string, { clientId: string; clientSecret: string; redirectURI?: string }>>(
(acc, [key, value]) => { (acc, [key, value]) => {
const redirectUri = this.buildRedirectUri(tenantSlug, key, oauthGatewayUrl) const redirectUri = this.buildRedirectUri(key, oauthGatewayUrl)
acc[key] = { acc[key] = {
clientId: value.clientId, clientId: value.clientId,
clientSecret: value.clientSecret, clientSecret: value.clientSecret,
@@ -171,15 +171,11 @@ export class AuthProvider implements OnModuleInit {
) )
} }
private buildRedirectUri( private buildRedirectUri(provider: keyof SocialProvidersConfig, oauthGatewayUrl: string | null): string | null {
tenantSlug: string | null,
provider: keyof SocialProvidersConfig,
oauthGatewayUrl: string | null,
): string | null {
const basePath = `/api/auth/callback/${provider}` const basePath = `/api/auth/callback/${provider}`
if (oauthGatewayUrl) { if (oauthGatewayUrl) {
return this.buildGatewayRedirectUri(oauthGatewayUrl, basePath, tenantSlug) return this.buildGatewayRedirectUri(oauthGatewayUrl, basePath)
} }
logger.error( logger.error(
['[AuthProvider] OAuth 网关地址未配置,无法为第三方登录生成回调 URL。', `provider=${String(provider)}`].join(' '), ['[AuthProvider] OAuth 网关地址未配置,无法为第三方登录生成回调 URL。', `provider=${String(provider)}`].join(' '),
@@ -187,14 +183,9 @@ export class AuthProvider implements OnModuleInit {
return null return null
} }
private buildGatewayRedirectUri(gatewayBaseUrl: string, basePath: string, tenantSlug: string | null): string { private buildGatewayRedirectUri(gatewayBaseUrl: string, basePath: string): string {
const normalizedBase = gatewayBaseUrl.replace(/\/+$/, '') const normalizedBase = gatewayBaseUrl.replace(/\/+$/, '')
const searchParams = new URLSearchParams() return `${normalizedBase}${basePath}`
if (tenantSlug) {
searchParams.set('tenantSlug', tenantSlug)
}
const query = searchParams.toString()
return `${normalizedBase}${basePath}${query ? `?${query}` : ''}`
} }
private async buildTrustedOrigins(): Promise<string[]> { private async buildTrustedOrigins(): Promise<string[]> {

View File

@@ -1,3 +1,5 @@
import { decodeGatewayState } from '@afilmory/be-utils'
import { env } from '@afilmory/env'
import { HttpContext } from '@afilmory/framework' import { HttpContext } from '@afilmory/framework'
import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils' import { DEFAULT_BASE_DOMAIN, isTenantSlugReserved } from '@afilmory/utils'
import { BizException, ErrorCode } from 'core/errors' import { BizException, ErrorCode } from 'core/errors'
@@ -28,6 +30,7 @@ export interface TenantResolutionOptions {
@injectable() @injectable()
export class TenantContextResolver { export class TenantContextResolver {
private readonly log = logger.extend('TenantResolver') private readonly log = logger.extend('TenantResolver')
private readonly gatewayStateSecret = env.AUTH_GATEWAY_STATE_SECRET ?? env.CONFIG_ENCRYPTION_KEY
constructor( constructor(
private readonly tenantService: TenantService, 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) { if (!derivedSlug) {
derivedSlug = host ? (extractTenantSlugFromHost(host, baseDomain) ?? undefined) : undefined 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)) { if (!derivedSlug && this.isRootTenantPath(context.req.path)) {
derivedSlug = ROOT_TENANT_SLUG derivedSlug = ROOT_TENANT_SLUG
} }

View File

@@ -6,10 +6,9 @@ subdomain.
## How It Works ## How It Works
1. Better Auth (running inside `be/apps/core`) builds provider redirect URLs using the tenant slug. 1. Better Auth (running inside `be/apps/core`) wraps the OAuth `state` with the tenant slug.
2. Instead of sending the provider back to the tenant domain, the redirect URL is set to 2. Providers always redirect to the fixed URL `https://auth.afilmory.art/api/auth/callback/{provider}`.
`https://auth.afilmory.art/api/auth/callback/{provider}?tenantSlug=<slug>`. 3. The gateway unwraps `state`, restores the inner Better Auth state, and issues a 302 redirect to
3. The gateway receives the provider callback, validates the slug/host, and issues a 302 redirect to
`https://<slug>.afilmory.art/api/auth/callback/{provider}` (preserving `code`, `state`, etc.). `https://<slug>.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 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 ## Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
| --------------------------------- | -------------------- | ----------------------------------------------------------------- | | --------------------------------- | -------------------- | --------------------------------------------------------------------------------------- |
| `AUTH_GATEWAY_HOST` | `0.0.0.0` | Interface to bind. | | `AUTH_GATEWAY_HOST` | `0.0.0.0` | Interface to bind. |
| `AUTH_GATEWAY_PORT` | `8790` | Port to listen on. | | `AUTH_GATEWAY_PORT` | `8790` | Port to listen on. |
| `AUTH_GATEWAY_BASE_DOMAIN` | `afilmory.art` | Root domain used when constructing tenant hosts. | | `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_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_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_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_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 ## Callback Contract
@@ -42,16 +42,16 @@ The service starts on `http://0.0.0.0:8790` by default.
Query parameters: Query parameters:
- `tenantSlug` (preferred) or `tenant` — tenant slug to route to. Required unless `targetHost` is - `state` — wrapped gateway state containing the tenant slug and inner Better Auth state.
provided or you want to hit the root domain. - `tenantSlug` (legacy) or `tenant` — optional slug fallback when `state` is not wrapped.
- `targetHost` — explicit host override (opt-in via `ALLOW_CUSTOM_HOST`). - `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: Example redirect produced by the gateway:
``` ```
https://auth.afilmory.art/api/auth/callback/github?tenantSlug=innei&code=...&state=... https://auth.afilmory.art/api/auth/callback/github?code=...&state=<wrapped>
⮕ 302 → https://innei.afilmory.art/api/auth/callback/github?code=...&state=... ⮕ 302 → https://innei.afilmory.art/api/auth/callback/github?code=...&state=<inner>
``` ```
This service is intentionally stateless so it can be deployed behind a simple load balancer. This service is intentionally stateless so it can be deployed behind a simple load balancer.

View File

@@ -9,7 +9,7 @@
"start": "node dist/main.js" "start": "node dist/main.js"
}, },
"dependencies": { "dependencies": {
"@afilmory/utils": "workspace:*", "@afilmory/be-utils": "workspace:*",
"@hono/node-server": "^1.13.5", "@hono/node-server": "^1.13.5",
"hono": "^4.10.7", "hono": "^4.10.7",
"zod": "catalog:" "zod": "catalog:"

View File

@@ -37,6 +37,11 @@ const envSchema = z.object({
.min(1) .min(1)
.regex(/^[a-z0-9-]+$/i) .regex(/^[a-z0-9-]+$/i)
.default('root'), .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({ const parsed = envSchema.parse({
@@ -47,6 +52,7 @@ const parsed = envSchema.parse({
CALLBACK_BASE_PATH: process.env.AUTH_GATEWAY_CALLBACK_BASE_PATH, CALLBACK_BASE_PATH: process.env.AUTH_GATEWAY_CALLBACK_BASE_PATH,
ALLOW_CUSTOM_HOST: process.env.AUTH_GATEWAY_ALLOW_CUSTOM_HOST, ALLOW_CUSTOM_HOST: process.env.AUTH_GATEWAY_ALLOW_CUSTOM_HOST,
ROOT_SLUG: process.env.AUTH_GATEWAY_ROOT_SLUG, ROOT_SLUG: process.env.AUTH_GATEWAY_ROOT_SLUG,
STATE_SECRET: process.env.AUTH_GATEWAY_STATE_SECRET ?? process.env.CONFIG_ENCRYPTION_KEY,
}) })
export const gatewayConfig = { export const gatewayConfig = {
@@ -57,6 +63,7 @@ export const gatewayConfig = {
callbackBasePath: parsed.CALLBACK_BASE_PATH, callbackBasePath: parsed.CALLBACK_BASE_PATH,
allowCustomHost: Boolean(parsed.ALLOW_CUSTOM_HOST), allowCustomHost: Boolean(parsed.ALLOW_CUSTOM_HOST),
rootSlug: parsed.ROOT_SLUG.toLowerCase(), rootSlug: parsed.ROOT_SLUG.toLowerCase(),
stateSecret: parsed.STATE_SECRET,
} as const } as const
export type GatewayConfig = typeof gatewayConfig export type GatewayConfig = typeof gatewayConfig

View File

@@ -1,3 +1,4 @@
import { decodeGatewayState } from '@afilmory/be-utils'
import { serve } from '@hono/node-server' import { serve } from '@hono/node-server'
import { Hono } from 'hono' import { Hono } from 'hono'
@@ -25,8 +26,30 @@ callbackRouter.all('/:provider', (c) => {
const requestUrl = new URL(c.req.url) const requestUrl = new URL(c.req.url)
const tenantSlugParam = requestUrl.searchParams.get('tenantSlug') ?? requestUrl.searchParams.get('tenant') const tenantSlugParam = requestUrl.searchParams.get('tenantSlug') ?? requestUrl.searchParams.get('tenant')
const explicitHostParam = requestUrl.searchParams.get('targetHost') const explicitHostParam = requestUrl.searchParams.get('targetHost')
const tenantSlug = sanitizeTenantSlug(tenantSlugParam) const stateParam = requestUrl.searchParams.get('state')
const explicitHost = sanitizeExplicitHost(explicitHostParam) 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('tenant')
requestUrl.searchParams.delete('tenantSlug') requestUrl.searchParams.delete('tenantSlug')
@@ -36,6 +59,10 @@ callbackRouter.all('/:provider', (c) => {
return c.json({ error: 'invalid_tenant', message: 'Tenant slug is invalid.' }, 400) 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) { if (explicitHostParam && !explicitHost) {
return c.json({ error: 'invalid_host', message: 'Target host is invalid.' }, 400) return c.json({ error: 'invalid_host', message: 'Target host is invalid.' }, 400)
} }

View File

@@ -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'; -- DELETE FROM drizzle.__drizzle_migrations WHERE id = 7 AND hash = 'wrong_hash_here';
*/ */

View File

@@ -22,6 +22,7 @@ export const env = createEnv({
GITHUB_CLIENT_SECRET: z.string().optional(), GITHUB_CLIENT_SECRET: z.string().optional(),
CONFIG_ENCRYPTION_KEY: z.string().min(1), CONFIG_ENCRYPTION_KEY: z.string().min(1),
AUTH_GATEWAY_STATE_SECRET: z.string().min(1).optional(),
// Payment // Payment
CREEM_API_KEY: z.string().min(1), CREEM_API_KEY: z.string().min(1),

View File

@@ -1 +1,2 @@
export * from './json' export * from './json'
export * from './oauth-state'

View File

@@ -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
}
}

View File

@@ -19,7 +19,8 @@ This document describes how tenant resolution, Better Auth instances, and dashbo
1. `HttpContext.tenant.requestedSlug` 1. `HttpContext.tenant.requestedSlug`
2. `HttpContext.tenant.slug` 2. `HttpContext.tenant.slug`
3. Derived from the host (when the context slug is still the placeholder). 3. Derived from the host (when the context slug is still the placeholder).
- Redirect URIs are always built as `<OAuthGateway>/api/auth/callback/:provider?tenantSlug=...`. - 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. - 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 & Gateway

9
pnpm-lock.yaml generated
View File

@@ -1276,9 +1276,9 @@ importers:
be/apps/oauth-gateway: be/apps/oauth-gateway:
dependencies: dependencies:
'@afilmory/utils': '@afilmory/be-utils':
specifier: workspace:* specifier: workspace:*
version: link:../../../packages/utils version: link:../../packages/utils
'@hono/node-server': '@hono/node-server':
specifier: ^1.13.5 specifier: ^1.13.5
version: 1.19.6(hono@4.10.7) 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) version: 0.16.7(synckit@0.11.11)(typescript@5.9.3)
unplugin-dts: unplugin-dts:
specifier: 1.0.0-beta.6 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: vite:
specifier: 7.2.4 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) 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 magic-string-ast: 1.0.3
unplugin: 2.3.10 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: dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.53.3) '@rollup/pluginutils': 5.3.0(rollup@4.53.3)
'@volar/typescript': 2.4.23 '@volar/typescript': 2.4.23
@@ -24547,7 +24547,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@microsoft/api-extractor': 7.52.13(@types/node@24.10.1) '@microsoft/api-extractor': 7.52.13(@types/node@24.10.1)
esbuild: 0.25.12 esbuild: 0.25.12
rolldown: 1.0.0-beta.51
rollup: 4.53.3 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) 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: transitivePeerDependencies: