mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 14:44:48 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.<baseDomain>/api/auth/callback/{provider}?tenantSlug=<slug>`.
|
||||
- Gateway rewrites to `https://<slug>.<baseDomain>/api/auth/callback/{provider}` preserving query params.
|
||||
- Providers redirect to the fixed `https://auth.<baseDomain>/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://<slug>.<baseDomain>/api/auth/callback/{provider}`.
|
||||
- Keeps provider config simple (single callback URL) while maintaining per-tenant sessions.
|
||||
|
||||
## Data path
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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<string, unknown>
|
||||
}
|
||||
|
||||
type LinkSocialAccountRequest = {
|
||||
@@ -85,6 +88,7 @@ type LinkSocialAccountRequest = {
|
||||
callbackURL?: string
|
||||
errorCallbackURL?: string
|
||||
disableRedirect?: boolean
|
||||
additionalData?: Record<string, unknown>
|
||||
}
|
||||
|
||||
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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export class AuthProvider implements OnModuleInit {
|
||||
|
||||
return entries.reduce<Record<string, { clientId: string; clientSecret: string; redirectURI?: string }>>(
|
||||
(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<string[]> {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=<slug>`.
|
||||
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://<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
|
||||
@@ -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=<wrapped>
|
||||
⮕ 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.
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
*/
|
||||
|
||||
|
||||
|
||||
1
be/packages/env/src/index.ts
vendored
1
be/packages/env/src/index.ts
vendored
@@ -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),
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './json'
|
||||
export * from './oauth-state'
|
||||
|
||||
112
be/packages/utils/src/oauth-state.ts
Normal file
112
be/packages/utils/src/oauth-state.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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 `<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.
|
||||
|
||||
## System Settings & Gateway
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user