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
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Discord Bot Configuration (Required)
DISCORD_TOKEN=your_discord_bot_token_here
CLIENT_ID=your_discord_client_id_here
CLIENT_SECRET=your_discord_client_secret_here

# Environment Configuration
NODE_ENV=production
Expand Down Expand Up @@ -139,12 +140,15 @@ SEARCH_RETRY_DELAY=5000
# Enable web application for feature toggle management
# WEBAPP_ENABLED=false
# WEBAPP_PORT=3000
# WEBAPP_REDIRECT_URI=http://localhost:3000/auth/callback
# WEBAPP_FRONTEND_URL=http://localhost:5173
# WEBAPP_BACKEND_URL=http://localhost:3000
# WEBAPP_REDIRECT_URI=http://localhost:3000/api/auth/callback
# DEVELOPER_USER_IDS=your_discord_user_id
#
# Production / Cloudflare Tunnel
# WEBAPP_FRONTEND_URL=https://lucky.lucassantana.tech
# WEBAPP_REDIRECT_URI=https://lucky.lucassantana.tech/api/auth/callback
# WEBAPP_BACKEND_URL=https://lucky-api.lucassantana.tech
# WEBAPP_REDIRECT_URI=https://lucky-api.lucassantana.tech/api/auth/callback
# WEBAPP_SESSION_SECRET=generate-a-random-64-char-string
# POSTGRES_PASSWORD=change-me-in-production
# NGINX_PORT=8080
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- OAuth authorize/callback now canonicalize to the API-domain callback in production via `WEBAPP_BACKEND_URL`, preventing Discord `redirect_uri inválido` mismatches
- Added `/auth/callback` compatibility alias and callback-path normalization so legacy `/auth/callback` values still resolve to `/api/auth/callback`
- Backend server now enables `trust proxy` in production so secure session cookies are correctly issued behind nginx/Cloudflare
- Backend CORS now accepts configured origins plus `*.lucassantana.tech` and `*.luk-homeserver.com.br` hosts for dashboard/API split-domain setups
- Backend auth/Last.fm redirect targets now use the primary frontend origin when `WEBAPP_FRONTEND_URL` contains multiple comma-separated domains
- Frontend API client now auto-resolves hosted API base to `lucky-api.lucassantana.tech` or `api.luk-homeserver.com.br` when `VITE_API_BASE_URL` is not set
- Backend OAuth session persistence now uses a connect-redis v9 compatibility adapter for ioredis clients, preventing callback save failures
- Frontend API inference now uses same-origin `/api` for `*.lucassantana.tech` to keep OAuth/session requests on one browser origin
- Nginx now normalizes `X-Forwarded-Proto` from edge headers (defaulting to `https`) so secure dashboard session cookies are emitted behind Cloudflare Tunnel
- Deploy smoke check now falls back to `/api/health` when `/api/health/auth-config` is unavailable
- Vercel routing no longer rewrites `/api/*` back to the same Lucky host, preventing `508 INFINITE_LOOP` on OAuth login
- Frontend API base URL now supports `VITE_API_BASE_URL` for hosted deployments that use a separate backend origin
Expand Down Expand Up @@ -48,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Added `WEBAPP_BACKEND_URL` env propagation in Docker compose stacks and updated OAuth setup docs/examples to use API-domain callback URLs in production
- Added root npm deploy shortcuts: `npm run deploy:remote` and `npm run deploy:homelab`
- `scripts/deploy-remote.sh` now targets workflow file `deploy.yml` and waits for the dispatch run more reliably
- `scripts/deploy-remote.sh` now always prints failed GitHub Actions logs before exiting
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ When `WEBAPP_FRONTEND_URL` includes multiple origins, use comma-separated values
(example: `https://lucky.lucassantana.tech,https://lukbot.vercel.app`); backend CORS
accepts all configured entries while OAuth/Last.fm redirects use the first origin.
Set `WEBAPP_REDIRECT_URI` to the exact Discord OAuth callback URL registered in the
Discord Developer Portal (example:
`https://lucky-api.lucassantana.tech/api/auth/callback`).
Discord Developer Portal (example: `https://lucky-api.lucassantana.tech/api/auth/callback`).
Set `WEBAPP_BACKEND_URL` to your public backend/API origin so production OAuth always
builds callback URLs on the API domain.

## Environment Variables

Expand All @@ -153,7 +154,8 @@ See `.env.example` for all available options. Key variables:
| `REDIS_HOST` | No | Redis host (default: localhost) |
| `WEBAPP_ENABLED` | No | Enable web dashboard (default: false) |
| `WEBAPP_SESSION_SECRET` | No | Session encryption key |
| `WEBAPP_REDIRECT_URI` | No | Explicit Discord OAuth callback URL (for split domains use API host, e.g. `https://lucky-api.lucassantana.tech/api/auth/callback`) |
| `WEBAPP_REDIRECT_URI` | No | Explicit Discord OAuth callback URL (must match Discord app settings) |
| `WEBAPP_BACKEND_URL` | No | Public backend/API origin used to canonicalize OAuth callbacks in production |
| `CLIENT_SECRET` | No | Discord OAuth secret (for dashboard) |
| `SENTRY_DSN` | No | Error tracking |

Expand Down
3 changes: 3 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ services:
- WEBAPP_ENABLED=${WEBAPP_ENABLED:-false}
- WEBAPP_PORT=${WEBAPP_PORT:-3000}
- WEBAPP_REDIRECT_URI=${WEBAPP_REDIRECT_URI:-}
- WEBAPP_FRONTEND_URL=${WEBAPP_FRONTEND_URL:-}
- WEBAPP_BACKEND_URL=${WEBAPP_BACKEND_URL:-}
- WEBAPP_SESSION_SECRET=${WEBAPP_SESSION_SECRET:-}
- CLIENT_SECRET=${CLIENT_SECRET:-}
- LASTFM_API_KEY=${LASTFM_API_KEY:-}
- LASTFM_API_SECRET=${LASTFM_API_SECRET:-}
- LASTFM_SESSION_KEY=${LASTFM_SESSION_KEY:-}
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ services:
- LASTFM_API_SECRET=${LASTFM_API_SECRET:-}
- LASTFM_LINK_SECRET=${LASTFM_LINK_SECRET:-}
- WEBAPP_FRONTEND_URL=${WEBAPP_FRONTEND_URL:-}
- WEBAPP_BACKEND_URL=${WEBAPP_BACKEND_URL:-}
networks:
- lucky-network
logging:
Expand Down
9 changes: 6 additions & 3 deletions docs/CLOUDFLARE_TUNNEL_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,14 @@ WEBAPP_PORT=3000

# Public URL of the web app (Cloudflare Tunnel / custom domain)
WEBAPP_FRONTEND_URL=https://app.yourdomain.com
WEBAPP_REDIRECT_URI=https://app.yourdomain.com/api/auth/callback
WEBAPP_BACKEND_URL=https://api.yourdomain.com
WEBAPP_REDIRECT_URI=https://api.yourdomain.com/api/auth/callback
```

Discord OAuth2 redirect URI must match exactly:

1. In [Discord Developer Portal](https://discord.com/developers/applications) → your app → **OAuth2** → **Redirects**, add:
`https://app.yourdomain.com/api/auth/callback`
`https://api.yourdomain.com/api/auth/callback`
2. Use the same value for `WEBAPP_REDIRECT_URI`.

See [WEBAPP_SETUP.md](WEBAPP_SETUP.md) and [DISCORD_OAUTH2_SETUP.md](DISCORD_OAUTH2_SETUP.md) for full OAuth and session configuration.
Expand Down Expand Up @@ -222,7 +223,9 @@ Important: `localhost` in a container means the container itself. If `cloudflare
1. Add domain to Cloudflare and switch nameservers.
2. Install `cloudflared` and create a tunnel (dashboard or CLI).
3. Point a hostname (e.g. `app.yourdomain.com`) to the tunnel and set ingress to `http://localhost:WEBAPP_PORT`.
4. Set `WEBAPP_FRONTEND_URL` and `WEBAPP_REDIRECT_URI` to `https://app.yourdomain.com` (and callback path).
4. Set `WEBAPP_FRONTEND_URL=https://app.yourdomain.com`,
`WEBAPP_BACKEND_URL=https://api.yourdomain.com`, and
`WEBAPP_REDIRECT_URI=https://api.yourdomain.com/api/auth/callback`.
5. Add the same callback URL in the Discord Developer Portal.

After that, the bot frontend is served over HTTPS at your custom domain via Cloudflare Tunnel and DNS.
10 changes: 8 additions & 2 deletions docs/WEBAPP_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ WEBAPP_REDIRECT_URI=http://localhost:3000/api/auth/callback
WEBAPP_ENABLED=true
WEBAPP_PORT=3000
WEBAPP_FRONTEND_URL=http://localhost:5173
WEBAPP_BACKEND_URL=http://localhost:3000
WEBAPP_SESSION_SECRET=your_random_session_secret_here

# Developer Access
Expand All @@ -77,12 +78,14 @@ VITE_API_BASE_URL=https://api.yourdomain.com/api

`WEBAPP_FRONTEND_URL` also supports comma-separated origins for CORS
(for example `https://lucky.lucassantana.tech,https://app.lucassantana.tech`).

### Environment Variable Descriptions

- **CLIENT_ID**: Your Discord application's Client ID from the [Discord Developer Portal](https://discord.com/developers/applications)
- **CLIENT_SECRET**: Your Discord application's Client Secret (keep this secure!)
- **WEBAPP_REDIRECT_URI**: The callback URL registered in Discord OAuth2 settings. Must match exactly.
- **WEBAPP_FRONTEND_URL**: The frontend application URL (used for OAuth redirects after authentication)
- **WEBAPP_BACKEND_URL**: Public backend/API origin used to canonicalize OAuth callback URLs in production
- **WEBAPP_SESSION_SECRET**: A random secret string for signing session cookies (use a strong random value)
- **DEVELOPER_USER_IDS**: Comma-separated list of Discord user IDs with developer access

Expand All @@ -98,7 +101,7 @@ VITE_API_BASE_URL=https://api.yourdomain.com/api

1. In the "Redirects" section, add your callback URL:
- Development: `http://localhost:3000/api/auth/callback`
- Production: `https://yourdomain.com/api/auth/callback`
- Production: `https://api.yourdomain.com/api/auth/callback`
2. Save changes

### Step 3: Get Credentials
Expand Down Expand Up @@ -193,6 +196,7 @@ The web interface supports two types of feature toggles:

- `GET /api/auth/discord` - Initiate Discord OAuth flow (redirects to Discord)
- `GET /api/auth/callback` - OAuth callback handler (redirects to frontend)
- `GET /auth/callback` - Compatibility alias for OAuth callback handler
- `GET /api/auth/logout` - Logout user
- `GET /api/auth/status` - Check authentication status
- `GET /api/auth/user` - Get current user info
Expand Down Expand Up @@ -237,7 +241,8 @@ The Express server automatically serves the built frontend in production mode (`
For production, ensure:

- `WEBAPP_FRONTEND_URL` points to your production frontend URL
- `WEBAPP_REDIRECT_URI` matches your production callback URL
- `WEBAPP_BACKEND_URL` points to your production backend/API URL
- `WEBAPP_REDIRECT_URI` matches your production callback URL on the API domain
- `WEBAPP_SESSION_SECRET` is a strong random value
- `NODE_ENV=production` for secure cookies

Expand Down Expand Up @@ -270,6 +275,7 @@ If OAuth isn't working:

- Verify `CLIENT_ID` and `CLIENT_SECRET` are correct
- Check `WEBAPP_REDIRECT_URI` matches Discord OAuth settings exactly
- Check `WEBAPP_BACKEND_URL` matches the backend/API public origin
- Ensure redirect URI is whitelisted in Discord Developer Portal
- Check browser console for CORS errors
- Verify `WEBAPP_FRONTEND_URL` is set correctly
Expand Down
5 changes: 3 additions & 2 deletions nginx/nginx.conf
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
resolver 127.0.0.11 valid=10s ipv6=off;

map $http_x_forwarded_proto $proxy_x_forwarded_proto {
default $http_x_forwarded_proto;
'' $scheme;
"~*https" https;
"~*http" http;
default https;
}

server {
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ const isProduction = process.env.NODE_ENV === 'production'
export function startWebApp(): void {
const app = express()

if (isProduction) {
app.set('trust proxy', 1)
}

setupMiddleware(app)
setupRoutes(app)

Expand Down
137 changes: 135 additions & 2 deletions packages/backend/tests/integration/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,62 @@ describe('Auth Routes Integration', () => {
}
})

test('should emit secure session cookie for https forwarded requests', async () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'

try {
const localApp = express()
setupSessionMiddleware(localApp)
setupAuthRoutes(localApp)
localApp.use(errorHandler)

const response = await request(localApp)
.get('/api/auth/discord')
.set('x-forwarded-proto', 'https')
.set('x-forwarded-host', 'lucky.lucassantana.tech')
.expect(302)

const cookies = response.headers['set-cookie'] as
| string[]
| undefined

expect(cookies).toBeDefined()
expect(
cookies?.some(
(cookie) =>
cookie.includes('sessionId=') &&
cookie.includes('Secure') &&
cookie.includes('SameSite=Lax'),
),
).toBe(true)
} finally {
process.env.NODE_ENV = originalNodeEnv
}
})

test('should not emit secure session cookie for http forwarded requests', async () => {
const originalNodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = 'production'

try {
const localApp = express()
setupSessionMiddleware(localApp)
setupAuthRoutes(localApp)
localApp.use(errorHandler)

const response = await request(localApp)
.get('/api/auth/discord')
.set('x-forwarded-proto', 'http')
.set('x-forwarded-host', 'lucky.lucassantana.tech')
.expect(302)

expect(response.headers['set-cookie']).toBeUndefined()
} finally {
process.env.NODE_ENV = originalNodeEnv
}
})

test('should return 500 when CLIENT_ID is missing', async () => {
const originalClientId = process.env.CLIENT_ID
delete process.env.CLIENT_ID
Expand All @@ -139,6 +195,52 @@ describe('Auth Routes Integration', () => {
process.env.CLIENT_ID = originalClientId
}
})

test('should enforce canonical callback and secure cookie in production', async () => {
const originalNodeEnv = process.env.NODE_ENV
const originalRedirectUri = process.env.WEBAPP_REDIRECT_URI
const originalBackendUrl = process.env.WEBAPP_BACKEND_URL

process.env.NODE_ENV = 'production'
process.env.WEBAPP_REDIRECT_URI =
'https://lucky.lucassantana.tech/api/auth/callback'
process.env.WEBAPP_BACKEND_URL =
'https://lucky-api.lucassantana.tech'

const productionApp = express()
productionApp.set('trust proxy', 1)
setupSessionMiddleware(productionApp)
setupAuthRoutes(productionApp)
productionApp.use(errorHandler)

const response = await request(productionApp)
.get('/api/auth/discord')
.set('x-forwarded-proto', 'https')
.expect(302)

expect(response.headers.location).toContain(
encodeURIComponent(
'https://lucky-api.lucassantana.tech/api/auth/callback',
),
)

const cookies = response.headers['set-cookie'] ?? []
expect(cookies.join(';')).toContain('Secure')
expect(cookies.join(';')).toContain('HttpOnly')
expect(cookies.join(';')).toContain('SameSite=Lax')

process.env.NODE_ENV = originalNodeEnv
if (originalRedirectUri) {
process.env.WEBAPP_REDIRECT_URI = originalRedirectUri
} else {
delete process.env.WEBAPP_REDIRECT_URI
}
if (originalBackendUrl) {
process.env.WEBAPP_BACKEND_URL = originalBackendUrl
} else {
delete process.env.WEBAPP_BACKEND_URL
}
})
})

describe('GET /api/auth/callback', () => {
Expand All @@ -155,7 +257,9 @@ describe('Auth Routes Integration', () => {
.set('x-forwarded-host', 'lucky.lucassantana.tech')
.expect(302)

expect(getDiscordOAuthMock().exchangeCodeForToken).toHaveBeenCalledWith(
expect(
getDiscordOAuthMock().exchangeCodeForToken,
).toHaveBeenCalledWith(
MOCK_AUTH_CODE,
'https://lucky.lucassantana.tech/api/auth/callback',
)
Expand All @@ -176,7 +280,9 @@ describe('Auth Routes Integration', () => {
.set('Cookie', ['sessionId=callback_session_id'])
.expect(302)

expect(response.headers.location).toContain('authenticated=true')
expect(response.headers.location).toContain(
'authenticated=true',
)
expect(
getDiscordOAuthMock().exchangeCodeForToken,
).toHaveBeenCalledWith(
Expand Down Expand Up @@ -224,6 +330,33 @@ describe('Auth Routes Integration', () => {

expect(response.headers.location).toContain('error=session_failed')
})

test('should handle callback alias route', async () => {
const mockDiscordOAuth = discordOAuthService as jest.Mocked<
typeof discordOAuthService
>
mockDiscordOAuth.exchangeCodeForToken.mockResolvedValue(
MOCK_TOKEN_RESPONSE,
)
mockDiscordOAuth.getUserInfo.mockResolvedValue(MOCK_DISCORD_USER)

const mockSessionService = sessionService as jest.Mocked<
typeof sessionService
>
mockSessionService.setSession.mockResolvedValue()

const response = await request(app)
.get('/auth/callback')
.query({ code: MOCK_AUTH_CODE })
.set('Cookie', ['sessionId=callback_session_id'])
.expect(302)

expect(response.headers.location).toContain('authenticated=true')
expect(mockDiscordOAuth.exchangeCodeForToken).toHaveBeenCalledWith(
MOCK_AUTH_CODE,
expect.stringContaining('/api/auth/callback'),
)
})
})

describe('GET /api/auth/logout', () => {
Expand Down
Loading