Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bot `/lastfm link` now prioritizes absolute `WEBAPP_BACKEND_URL` for connect
URL host generation (fallback: `WEBAPP_REDIRECT_URI` origin), preventing
stale legacy domains from appearing in user-facing link embeds (PR #163)
- `/api/health/auth-config` now accepts forwarded request-origin fallback when
`WEBAPP_BACKEND_URL` is unset, preventing false degraded deploy-gate failures
while keeping OAuth callback path and origin validation active
- Guild list/dashboard metrics now return nullable live values from bot/API
enrichment (no forced `0` fallback when metrics are unavailable)
- Sidebar profile identity now resolves as `nick > global_name > username`
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ Set `WEBAPP_EXPECTED_CLIENT_ID` to the production Discord app id to make
Set `WEBAPP_BACKEND_URL` to your public backend/API origin when you expose API routes
through a dedicated host. Use an absolute URL (for example,
`https://lucky-api.lucassantana.tech`).
When `WEBAPP_BACKEND_URL` is temporarily missing, `/api/health/auth-config`
now validates OAuth origin against the current request origin fallback so deploy
smoke checks can confirm live routing contract correctly.
Bot `/lastfm link` URLs prioritize `WEBAPP_BACKEND_URL` and fall back to the
origin of `WEBAPP_REDIRECT_URI` when backend URL is not set.

Expand Down
33 changes: 33 additions & 0 deletions packages/backend/src/routes/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ import { buildAuthConfigHealth } from '../utils/authHealth'

const DEFAULT_PRODUCTION_CLIENT_ID = '962198089161134131'

const getForwardedHeader = (
req: Request,
headerName: string,
): string | undefined => {
const value = req.headers[headerName]
if (!value) return undefined
const raw = Array.isArray(value) ? value[0] : value
return raw.split(',')[0].trim() || undefined
}

const resolveRequestOrigin = (req: Request): string | undefined => {
const forwardedProtocol =
req.get('x-forwarded-proto')?.split(',')[0].trim() ||
getForwardedHeader(req, 'x-forwarded-proto')
const forwardedHost =
req.get('x-forwarded-host')?.split(',')[0].trim() ||
getForwardedHeader(req, 'x-forwarded-host')
const protocol = forwardedProtocol ?? req.protocol ?? 'http'
const host = forwardedHost ?? req.get('host') ?? ''

if (!host) {
return undefined
}

try {
return new URL(`${protocol}://${host}`).origin
} catch {
return undefined
}
}

export function setupHealthRoutes(app: Express): void {
app.get('/api/health', (_req: Request, res: Response) => {
res.json({
Expand All @@ -28,6 +59,7 @@ export function setupHealthRoutes(app: Express): void {

app.get('/api/health/auth-config', (req: Request, res: Response) => {
const redirectUri = getOAuthRedirectUri(req)
const requestOrigin = resolveRequestOrigin(req)
const frontendOrigins = getFrontendOrigins()
const backendOrigins = (process.env.WEBAPP_BACKEND_URL ?? '')
.split(',')
Expand All @@ -49,6 +81,7 @@ export function setupHealthRoutes(app: Express): void {
redirectUri,
frontendOrigins,
backendOrigins,
requestOrigin,
sessionSecretConfigured,
redisHealthy,
expectedClientId,
Expand Down
29 changes: 27 additions & 2 deletions packages/backend/src/utils/authHealth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
redirectUri: string
frontendOrigins: string[]
backendOrigins?: string[]
requestOrigin?: string
sessionSecretConfigured: boolean
redisHealthy: boolean
expectedClientId?: string
Expand Down Expand Up @@ -41,6 +42,16 @@
return new Set(normalizedOrigins)
}

const normalizeOrigin = (value?: string): string => {
if (!value) return ''

try {
return new URL(value).origin
} catch {
return ''
}
}

export function buildAuthorizeUrlPreview(
clientId: string,
redirectUri: string,
Expand All @@ -59,11 +70,12 @@
return `${DISCORD_AUTHORIZE_BASE}?${query.toString().replace(/\+/g, '%20')}`
}

export function buildAuthConfigHealth({

Check warning on line 73 in packages/backend/src/utils/authHealth.ts

View workflow job for this annotation

GitHub Actions / Quality Gates

Function 'buildAuthConfigHealth' has a complexity of 21. Maximum allowed is 15
clientId,
redirectUri,
frontendOrigins,
backendOrigins = [],
requestOrigin,
sessionSecretConfigured,
redisHealthy,
expectedClientId,
Expand Down Expand Up @@ -94,7 +106,13 @@
warnings.push('Redis is not healthy for shared services')
}

if (frontendOrigins.length === 0 && backendOrigins.length === 0) {
const normalizedRequestOrigin = normalizeOrigin(requestOrigin)

if (
frontendOrigins.length === 0 &&
backendOrigins.length === 0 &&
normalizedRequestOrigin.length === 0
) {
warnings.push(
'No WEBAPP_FRONTEND_URL or WEBAPP_BACKEND_URL origins configured',
)
Expand All @@ -107,11 +125,18 @@
warnings.push('OAuth callback path should be /api/auth/callback')
}

if (frontendOrigins.length > 0 || backendOrigins.length > 0) {
if (
frontendOrigins.length > 0 ||
backendOrigins.length > 0 ||
normalizedRequestOrigin.length > 0
) {
const configuredOrigins = new Set([
...getConfiguredFrontendOrigins(frontendOrigins),
...getConfiguredFrontendOrigins(backendOrigins),
])
if (normalizedRequestOrigin.length > 0) {
configuredOrigins.add(normalizedRequestOrigin)
}

if (!configuredOrigins.has(parsedRedirectUri.origin)) {
warnings.push(
Expand Down
22 changes: 22 additions & 0 deletions packages/backend/tests/integration/routes/health.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,5 +197,27 @@ describe('Health Routes Integration', () => {
'CLIENT_ID does not match expected production app id (expected-client-id)',
)
})

test('should return ok when backend origin comes from forwarded request headers', async () => {
mockRedis.isHealthy.mockReturnValue(true)
process.env.NODE_ENV = 'production'
delete process.env.WEBAPP_BACKEND_URL
process.env.WEBAPP_EXPECTED_CLIENT_ID = 'test-client-id'
process.env.WEBAPP_REDIRECT_URI =
'https://lucky-api.lucassantana.tech/api/auth/callback'

const response = await request(app)
.get('/api/health/auth-config')
.set('x-forwarded-proto', 'https')
.set('x-forwarded-host', 'lucky-api.lucassantana.tech')
.set('Host', 'lucky-api.lucassantana.tech')
.expect(200)

expect(response.body.warnings).toEqual([])
expect(response.body.status).toBe('ok')
expect(response.body.auth.redirectUri).toBe(
'https://lucky-api.lucassantana.tech/api/auth/callback',
)
})
})
})
16 changes: 16 additions & 0 deletions packages/backend/tests/unit/utils/authHealth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ describe('authHealth utils', () => {
expect(response.warnings).toEqual([])
})

test('returns ok when redirect uri origin matches request origin fallback', () => {
const response = buildAuthConfigHealth({
clientId: '962198089161134131',
redirectUri:
'https://lucky-api.lucassantana.tech/api/auth/callback',
frontendOrigins: ['https://lucky.lucassantana.tech'],
backendOrigins: [],
requestOrigin: 'https://lucky-api.lucassantana.tech',
sessionSecretConfigured: true,
redisHealthy: true,
})

expect(response.status).toBe('ok')
expect(response.warnings).toEqual([])
})

test('returns degraded when callback path is not the API callback path', () => {
const response = buildAuthConfigHealth({
clientId: '962198089161134131',
Expand Down
Loading