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 pnpm@10.6.1 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';

// 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');
}
// 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,
});
}

// 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);
}
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);
}

// 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
});

// 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);
}

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);
}
// 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