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
|
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
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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:"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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(),
|
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),
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './json'
|
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`
|
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
9
pnpm-lock.yaml
generated
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user