-
-
Notifications
You must be signed in to change notification settings - Fork 4k
Feature: Add generic SSO support via trusted reverse proxy headers #1047
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Implements trusted-headers SSO pattern that works with any reverse proxy: - Configurable header mapping via environment variables - Supports Traefik, Nginx, Caddy, oauth2-proxy, Authelia, Keycloak, etc. - Optional shared secret validation for security - Graceful fallback to normal authentication - JWT enrichment with organization context - Standardized Bearer token authorization Security features: - Opt-in only with ENABLE_SSO=true and SSO_TRUST_PROXY=true - Header validation and sanitization - Development-only logging (no secrets exposed) - Network isolation required (documented separately) Configuration options: - SSO_HEADER_EMAIL: Email header name (default: remote-email) - SSO_HEADER_NAME: Display name header (default: remote-name) - SSO_HEADER_USER: Username header (default: remote-user) - SSO_HEADER_GROUPS: Groups header (default: remote-groups) - SSO_SHARED_SECRET: Optional shared secret for validation - SSO_DEFAULT_ORG_STRATEGY: Organization selection (default: first-active) - SSO_FORCE_ORG_ID: Force specific organization ID
|
@Alexbeav is attempting to deploy a commit to the Listinai Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR introduces Single Sign-On (SSO) support for reverse proxy authentication in the Postiz backend. The implementation allows trusted reverse proxies (like Traefik, Nginx, Caddy, oauth2-proxy) to authenticate users by passing headers with user information.
Key changes:
- Adds SSO authentication logic via trusted reverse proxy headers
- Implements configurable header names and organization selection strategies
- Includes optional shared secret validation for additional security
| res.cookie('auth', jwt, { | ||
| path: '/', | ||
| domain: cookieDomain, | ||
| ...(!process.env.NOT_SECURED | ||
| ? { | ||
| secure: true, | ||
| httpOnly: true, | ||
| sameSite: 'lax', | ||
| } | ||
| : {}), | ||
| maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days | ||
| }); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The SSO cookie uses sameSite: 'lax' while all other auth cookies in the codebase use sameSite: 'none' (see auth.controller.ts and removeAuth function in this same file). This inconsistency could cause issues with cross-site authentication. For consistency and to prevent potential authentication problems, change sameSite: 'lax' to sameSite: 'none' to match the existing pattern.
| // Security: validate shared secret if configured | ||
| if (sharedSecret && req.headers[secretHeader] !== sharedSecret) { | ||
| if (process.env.NODE_ENV !== 'production') { | ||
| console.warn('[SSO] Invalid or missing shared secret header, falling back to normal auth'); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code uses console.warn for logging. According to the project's coding guidelines, logging should use Sentry's logger. Import Sentry with import * as Sentry from \"@sentry/nextjs\", enable logs with Sentry.init({ enableLogs: true }), and use const { logger } = Sentry. Replace this with logger.warn('Rate limit reached for endpoint', { endpoint: '/sso', ssoEnabled: true }).
| console.log('[SSO] Trusted headers detected:', { | ||
| email: ssoEmail, | ||
| name: ssoName, | ||
| user: ssoUser, | ||
| groups: ssoGroups, | ||
| }); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code uses console.log for logging. According to the project's coding guidelines, logging should use Sentry's logger. Replace this with logger.debug(logger.fmtSSO trusted headers detected for: ${ssoEmail}, { ssoName, ssoUser, ssoGroups }).
|
|
||
| if (!organizations || !selectedOrg) { | ||
| if (process.env.NODE_ENV !== 'production') { | ||
| console.error('[SSO] No organization found for user:', lookupEmail); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code uses console.error for logging. According to the project's coding guidelines, logging should use Sentry's logger. Replace this with logger.error('Failed to process SSO authentication', { lookupEmail, reason: 'No organization found' }).
| const cookieDomain = getCookieUrlFromDomain(process.env.FRONTEND_URL!); | ||
|
|
||
| if (process.env.NODE_ENV !== 'production') { | ||
| console.log('[SSO] Setting auth cookie for user:', lookupEmail, 'org:', selectedOrg.id); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code uses console.log for logging. According to the project's coding guidelines, logging should use Sentry's logger. Replace this with logger.info('SSO auth cookie set', { lookupEmail, orgId: selectedOrg.id }).
| return next(); | ||
| } else { | ||
| if (process.env.NODE_ENV !== 'production') { | ||
| console.log('[SSO] User not found or not activated, continuing to normal auth'); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code uses console.log for logging. According to the project's coding guidelines, logging should use Sentry's logger. Replace this with logger.info('SSO user lookup failed', { reason: 'User not found or not activated' }).
| } | ||
| } catch (err) { | ||
| if (process.env.NODE_ENV !== 'production') { | ||
| console.error('[SSO] Error during SSO processing:', err); |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code uses console.error for logging. According to the project's coding guidelines, logging should use Sentry's logger. Replace this with logger.error('Failed to process SSO authentication', { error: err }).
| selectedOrg = organizations[0]; | ||
| } | ||
|
|
||
| if (!organizations || !selectedOrg) { |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The condition !organizations will always evaluate to false because organizations is the result of filtering an array and will never be null/undefined (it will be an empty array at worst). The correct check should be if (!organizations.length || !selectedOrg) to properly detect when no organizations exist.
| if (!organizations || !selectedOrg) { | |
| if (!organizations.length || !selectedOrg) { |
| const ssoName = req.headers[nameHeader] as string | undefined; | ||
| const ssoUser = req.headers[userHeader] as string | undefined; | ||
| const ssoGroups = req.headers[groupsHeader] as string | undefined; | ||
|
|
||
| if (process.env.NODE_ENV !== 'production') { | ||
| console.log('[SSO] Trusted headers detected:', { | ||
| email: ssoEmail, | ||
| name: ssoName, | ||
| user: ssoUser, | ||
| groups: ssoGroups, |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The variables ssoName and ssoGroups are extracted from headers but are never used in the SSO authentication logic (only in debug logging). Consider removing these extractions or documenting why they're reserved for future use to avoid confusion.
| const ssoName = req.headers[nameHeader] as string | undefined; | |
| const ssoUser = req.headers[userHeader] as string | undefined; | |
| const ssoGroups = req.headers[groupsHeader] as string | undefined; | |
| if (process.env.NODE_ENV !== 'production') { | |
| console.log('[SSO] Trusted headers detected:', { | |
| email: ssoEmail, | |
| name: ssoName, | |
| user: ssoUser, | |
| groups: ssoGroups, | |
| const ssoUser = req.headers[userHeader] as string | undefined; | |
| if (process.env.NODE_ENV !== 'production') { | |
| console.log('[SSO] Trusted headers detected:', { | |
| email: ssoEmail, | |
| user: ssoUser, |
| const enableSSO = process.env.ENABLE_SSO === 'true'; | ||
| const trustProxy = process.env.SSO_TRUST_PROXY === 'true'; | ||
| const ssoMode = process.env.SSO_MODE || 'trusted-headers'; |
Copilot
AI
Nov 3, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new SSO-related environment variables (ENABLE_SSO, SSO_TRUST_PROXY, SSO_MODE, SSO_HEADER_EMAIL, SSO_HEADER_NAME, SSO_HEADER_USER, SSO_HEADER_GROUPS, SSO_SHARED_SECRET, SSO_SECRET_HEADER, SSO_DEFAULT_ORG_STRATEGY, SSO_FORCE_ORG_ID) are not documented in the .env.example file. Per the coding guidelines, the .env.example file should be kept updated with new environment variables.
- Add SSO middleware to backend auth controller - Bypass frontend auth checks when SSO enabled - Support trusted headers mode (Remote-Email, Remote-User, Remote-Name) - Integrate with Traefik + Authelia v4.38 ForwardAuth - Add comprehensive documentation in claude.md - Configure for *.dev.test domains with LAN access SSO Flow: 1. User → Traefik → Authelia (auth) → Postiz 2. Authelia sets Remote-* headers 3. Backend SSO middleware processes headers and sets auth cookie 4. Frontend bypasses auth checks when SSO enabled Test Credentials: - Username: testuser - Password: password - Email: [email protected] Status: 95% complete - needs user creation in Postiz database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Fixed bug where `if (!organizations)` would not catch empty arrays, allowing users with zero organizations to pass validation incorrectly. Changes: - Line 105 (SSO flow): Added `organizations.length === 0` check - Line 226 (JWT flow): Added `organizations.length === 0 || !setOrg` check This ensures proper rejection (403) when: - User has no active organization memberships - Organization lookup returns empty array - Selected org is undefined after resolution Implements "no auto-provisioning" policy - users must be pre-existing members of at least one active organization. 🤖 Generated with Claude Code Co-Authored-By: Claude <[email protected]>
Fixed critical bugs preventing SSO authentication: - Added getUserByEmailAnyProvider() to support SSO users with non-LOCAL providers - Fixed org selection guard to properly check for empty arrays (organizations.length === 0) - Updated SSO middleware to use provider-agnostic user lookup - Added debug endpoint for testing SSO authentication flow This resolves the infinite refresh loop during SSO login. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Previously, when a user authenticated via Authelia but didn't exist in Postiz, the middleware would log an error but fall through to normal auth flow. This caused an infinite redirect loop: 1. SSO headers present but user not found 2. Falls through to normal auth 3. No auth cookie exists 4. Postiz redirects to /auth 5. Traefik intercepts and sends to Authelia 6. Authelia has valid session, sends headers again 7. Loop continues Now explicitly throws HttpForbiddenException with descriptive message when SSO headers are present but user doesn't exist. The catch block also re-throws HttpForbiddenException to ensure it propagates properly and doesn't fall through. This prevents the loop without implementing auto-provisioning. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
HttpForbiddenException constructor doesn't accept parameters. Removed the custom error message parameter from the throw statement. The exception will still properly break the redirect loop by: 1. Returning 401 status 2. Clearing auth cookies via exception filter 3. Logging the error with user email in non-production 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
✨ Feature: Add generic SSO support via trusted reverse proxy headers
Problem
Many organizations deploy Postiz behind reverse proxies (Traefik, Nginx, Caddy) that handle authentication. Currently, these users must:
Solution
Implement trusted-headers SSO pattern that works with any reverse proxy:
Remote-Email,X-Forwarded-User)Security Model
SSO_TRUST_PROXY=trueImplementation Details
New Environment Variables
Supported Reverse Proxies
Changes
apps/backend/src/services/auth/auth.middleware.ts(new SSO logic)Testing
Breaking Changes
None - feature is opt-in and disabled by default
Migration Path
Existing Postiz installations are unaffected. To enable SSO:
ENABLE_SSO=trueandSSO_TRUST_PROXY=trueto.envFuture Enhancements
Community Feedback
This implementation was:
Examples in the Wild
This pattern is used by: