Skip to content
Open
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
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM node:22-alpine
ARG NEXT_PUBLIC_VERSION
ENV NEXT_PUBLIC_VERSION=$NEXT_PUBLIC_VERSION
RUN apk add --no-cache g++ make py3-pip bash nginx
RUN adduser -D -g 'www' www
RUN mkdir /www
RUN chown -R www:www /var/lib/nginx
RUN chown -R www:www /www


RUN npm --no-update-notifier --no-fund --global install [email protected] pm2

WORKDIR /app

COPY . /app
COPY var/docker/nginx.conf /etc/nginx/nginx.conf

RUN pnpm install
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm run build

CMD ["sh", "-c", "nginx && pnpm run pm2"]
16 changes: 16 additions & 0 deletions apps/backend/src/api/routes/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,20 @@ export class AuthController {
login: true,
});
}

@Get('/sso-init')
async ssoInit(
@Query('redirect') redirect: string,
@Res({ passthrough: false }) response: Response
) {
// This endpoint is protected by the auth middleware, which will:
// 1. Process SSO headers if present
// 2. Set the auth cookie
// 3. Attach user/org to request
//
// If we reach here, authentication succeeded
// Redirect back to the original destination
const redirectUrl = redirect || '/';
return response.redirect(redirectUrl);
}
}
53 changes: 53 additions & 0 deletions apps/backend/src/api/routes/debug.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';

/**
* Debug endpoints for testing SSO middleware behavior
* IMPORTANT: Only enable in development/test environments
*/
@Controller('debug')
export class DebugController {
@Get('auth')
debugAuth(@Req() req: Request) {
// Only allow in non-production environments
if (process.env.NODE_ENV === 'production') {
return { error: 'Debug endpoints disabled in production' };
}

return {
user: (req as any).user ? {
id: (req as any).user.id,
email: (req as any).user.email,
name: (req as any).user.name,
activated: (req as any).user.activated,
} : null,
org: (req as any).org ? {
id: (req as any).org.id,
name: (req as any).org.name,
} : null,
headers: {
'remote-email': req.headers['remote-email'],
'remote-user': req.headers['remote-user'],
'remote-name': req.headers['remote-name'],
'remote-groups': req.headers['remote-groups'],
'cookie': req.headers.cookie ? '[present]' : '[absent]',
'authorization': req.headers.authorization ? '[present]' : '[absent]',
},
sso: {
enabled: process.env.ENABLE_SSO === 'true',
trustProxy: process.env.SSO_TRUST_PROXY === 'true',
mode: process.env.SSO_MODE,
sharedSecretConfigured: !!process.env.SSO_SHARED_SECRET,
},
};
}

@Get('health')
health() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
env: process.env.NODE_ENV,
};
}
}
152 changes: 151 additions & 1 deletion apps/backend/src/services/auth/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,159 @@ export class AuthMiddleware implements NestMiddleware {
private _userService: UsersService
) {}
async use(req: Request, res: Response, next: NextFunction) {
// Check if user already has valid auth cookie first
const existingAuth = req.headers.auth || req.cookies.auth;

// TRUSTED REVERSE PROXY SSO
// Supports any reverse proxy that can set trusted headers (Traefik, Nginx, Caddy, oauth2-proxy, etc.)
const enableSSO = process.env.ENABLE_SSO === 'true';
const trustProxy = process.env.SSO_TRUST_PROXY === 'true';
const ssoMode = process.env.SSO_MODE || 'trusted-headers';
Comment on lines +39 to +41
Copy link

Copilot AI Nov 3, 2025

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.

Copilot uses AI. Check for mistakes.

// Only process SSO if explicitly enabled AND proxy is trusted
if (enableSSO && trustProxy && ssoMode === 'trusted-headers' && !existingAuth) {
// Configurable header names (default to Authelia/ForwardAuth standard)
const emailHeader = (process.env.SSO_HEADER_EMAIL || 'remote-email').toLowerCase();
const nameHeader = (process.env.SSO_HEADER_NAME || 'remote-name').toLowerCase();
const userHeader = (process.env.SSO_HEADER_USER || 'remote-user').toLowerCase();
const groupsHeader = (process.env.SSO_HEADER_GROUPS || 'remote-groups').toLowerCase();

// Optional shared secret validation
const sharedSecret = process.env.SSO_SHARED_SECRET;
const secretHeader = (process.env.SSO_SECRET_HEADER || 'x-sso-secret').toLowerCase();

// 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');
Copy link

Copilot AI Nov 3, 2025

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 }).

Copilot generated this review using guidance from repository custom instructions.
}
// Fall through to normal auth
} else {
// Extract SSO headers
const ssoEmail = req.headers[emailHeader] as string | undefined;
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,
Comment on lines +64 to +73
Copy link

Copilot AI Nov 3, 2025

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.

Suggested change
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,

Copilot uses AI. Check for mistakes.
});
Comment on lines +69 to +74
Copy link

Copilot AI Nov 3, 2025

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 }).

Copilot generated this review using guidance from repository custom instructions.
}

// Process SSO if we have at least email or username
if (ssoEmail || ssoUser) {
const lookupEmail = ssoEmail || `${ssoUser}@sso.local`;

try {
// Use provider-agnostic lookup for SSO users
let user = await this._userService.getUserByEmailAnyProvider(lookupEmail);

if (user && user.activated) {
// Load organization context
delete user.password;
const orgHeader = req.cookies.showorg || req.headers.showorg;
const organizations = (
await this._organizationService.getOrgsByUserId(user.id)
).filter((f) => !f.users[0].disabled);

// Organization selection strategy
const orgStrategy = process.env.SSO_DEFAULT_ORG_STRATEGY || 'first-active';
const forceOrgId = process.env.SSO_FORCE_ORG_ID;

let selectedOrg;
if (forceOrgId) {
selectedOrg = organizations.find((org) => org.id === forceOrgId);
} else if (orgHeader) {
selectedOrg = organizations.find((org) => org.id === orgHeader);
} else if (orgStrategy === 'first-active') {
selectedOrg = organizations[0];
}

if (!organizations || organizations.length === 0 || !selectedOrg) {
if (process.env.NODE_ENV !== 'production') {
console.error('[SSO] No organization found for user:', lookupEmail);
Copy link

Copilot AI Nov 3, 2025

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' }).

Copilot generated this review using guidance from repository custom instructions.
}
throw new HttpForbiddenException();
}

// Ensure org has API key
if (!selectedOrg.apiKey) {
await this._organizationService.updateApiKey(selectedOrg.id);
}

// Enrich JWT payload with org context
const jwtPayload = { ...user, orgId: selectedOrg.id };
const jwt = AuthService.signJWT(jwtPayload);
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);
Copy link

Copilot AI Nov 3, 2025

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 }).

Copilot generated this review using guidance from repository custom instructions.
}

// Set secure cookie
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
});
Comment on lines +128 to +139
Copy link

Copilot AI Nov 3, 2025

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.

Copilot uses AI. Check for mistakes.

// Set request context
// @ts-ignore
req.user = user;
// @ts-ignore
req.org = selectedOrg;

// Standardize authorization header for downstream middleware
delete req.headers.authorization;
req.headers.authorization = `Bearer ${jwt}`;
req.headers.auth = jwt;
req.cookies.auth = jwt;

if (process.env.NODE_ENV !== 'production') {
console.log('[SSO] Request authenticated with org:', selectedOrg.id);
Copy link

Copilot AI Nov 3, 2025

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 request authenticated', { orgId: selectedOrg.id }).

Copilot generated this review using guidance from repository custom instructions.
}

return next();
} else {
// User authenticated by Authelia but doesn't exist in Postiz
// Return error instead of falling through to prevent redirect loop
if (process.env.NODE_ENV !== 'production') {
console.error('[SSO] User authenticated by Authelia but not found in Postiz:', lookupEmail);
}
throw new HttpForbiddenException();
}
} catch (err) {
// Re-throw HttpForbiddenException to prevent fallback to normal auth
if (err instanceof HttpForbiddenException) {
throw err;
}

if (process.env.NODE_ENV !== 'production') {
console.error('[SSO] Error during SSO processing:', err);
Copy link

Copilot AI Nov 3, 2025

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 }).

Copilot generated this review using guidance from repository custom instructions.
}
// Graceful fallback: continue to normal auth flow for other errors
}
}
}
}

// Standard Postiz authentication flow
const auth = req.headers.auth || req.cookies.auth;
if (!auth) {
throw new HttpForbiddenException();
}

try {
let user = AuthService.verifyJWT(auth) as User | null;
const orgHeader = req.cookies.showorg || req.headers.showorg;
Expand All @@ -47,6 +196,7 @@ export class AuthMiddleware implements NestMiddleware {
throw new HttpForbiddenException();
}

// Handle impersonation (superadmin feature)
const impersonate = req.cookies.impersonate || req.headers.impersonate;
if (user?.isSuperAdmin && impersonate) {
const loadImpersonate = await this._organizationService.getUserOrg(
Expand Down Expand Up @@ -82,7 +232,7 @@ export class AuthMiddleware implements NestMiddleware {
const setOrg =
organization.find((org) => org.id === orgHeader) || organization[0];

if (!organization) {
if (!organization || organization.length === 0 || !setOrg) {
throw new HttpForbiddenException();
}

Expand Down
7 changes: 6 additions & 1 deletion apps/frontend/src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ export async function middleware(request: NextRequest) {

const org = nextUrl.searchParams.get('org');
const url = new URL(nextUrl).search;
if (nextUrl.href.indexOf('/auth') === -1 && !authCookie) {

// SSO INTEGRATION: When SSO is enabled, disable frontend auth checks
// Let the reverse proxy (Traefik + Authelia) and backend middleware handle authentication
const enableSSO = process.env.ENABLE_SSO === 'true';

if (nextUrl.href.indexOf('/auth') === -1 && !authCookie && !enableSSO) {
const providers = ['google', 'settings'];
const findIndex = providers.find((p) => nextUrl.href.indexOf(p) > -1);
const additional = !findIndex
Expand Down
Loading