Skip to content

Commit 228d89d

Browse files
jensensclaude
andauthored
feat: Add webmail auth proxy for proper login redirect (#6)
* feat: Add webmail auth proxy for proper login redirect When accessing /webmail without being logged in, Traefik's ForwardAuth returns 403 Forbidden instead of redirecting to the login page. This is because ForwardAuth passes through the auth endpoint's error response directly. This commit adds a dedicated nginx auth proxy that: - Sits between Traefik and webmail service - Checks authentication via admin's /internal/auth/user endpoint - Redirects to /sso/login on auth failure (instead of 403) - Passes X-User/X-User-Token headers on success for SSO New constructs: - WebmailAuthProxyConfigMap: nginx.conf template with auth_request - WebmailAuthProxyConstruct: Deployment and Service Modified: - TraefikIngressConstruct: Added optional webmailAuthProxyService prop - MailuChart: Creates and wires auth proxy when ingress is enabled 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: sort imports alphabetically in mailu-chart 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e2aeee6 commit 228d89d

File tree

5 files changed

+512
-13
lines changed

5 files changed

+512
-13
lines changed

src/constructs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export { WebdavConstruct, WebdavConstructProps } from './webdav-construct';
1717

1818
// Supporting components
1919
export { DovecotSubmissionConstruct, DovecotSubmissionConstructProps } from './dovecot-submission-construct';
20+
export { WebmailAuthProxyConstruct, WebmailAuthProxyConstructProps } from './webmail-auth-proxy-construct';
21+
export { WebmailAuthProxyConfigMap, WebmailAuthProxyConfigMapProps } from './webmail-auth-proxy-configmap';
2022

2123
// Ingress components (optional)
2224
export { TraefikIngressConstruct, TraefikIngressConstructProps } from './traefik-ingress-construct';

src/constructs/traefik-ingress-construct.ts

Lines changed: 181 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,20 @@ export interface TraefikIngressConstructProps {
6969
* @default false
7070
*/
7171
enableSmtp?: boolean;
72+
73+
/**
74+
* Reference to the webmail auth proxy service (handles auth + redirect)
75+
* When provided, webmail ingress will route through this proxy instead of
76+
* using ForwardAuth middleware, enabling proper redirect on auth failure.
77+
*/
78+
webmailAuthProxyService?: kplus.Service;
7279
}
7380

7481
export class TraefikIngressConstruct extends Construct {
7582
public readonly httpIngress: k8s.KubeIngress;
83+
public readonly webmailIngress: k8s.KubeIngress;
7684
public readonly antispamIngress: k8s.KubeIngress;
85+
public readonly ssoPhpIngress: k8s.KubeIngress;
7786
public readonly tcpRoutes: traefik.IngressRouteTcp[];
7887

7988
constructor(scope: Construct, id: string, props: TraefikIngressConstructProps) {
@@ -131,6 +140,8 @@ export class TraefikIngressConstruct extends Construct {
131140
},
132141
},
133142
},
143+
// SSO path (handled by Flask at /sso/login)
144+
// Note: /sso.php routes are handled by separate ssoPhpIngress with redirect middleware
134145
{
135146
path: '/sso',
136147
pathType: 'Prefix',
@@ -167,19 +178,7 @@ export class TraefikIngressConstruct extends Construct {
167178
},
168179
},
169180
},
170-
// Webmail path
171-
{
172-
path: '/webmail',
173-
pathType: 'Prefix',
174-
backend: {
175-
service: {
176-
name: props.webmailService.name,
177-
port: {
178-
number: 80,
179-
},
180-
},
181-
},
182-
},
181+
// NOTE: /webmail path is handled by separate webmailIngress with ForwardAuth middleware
183182
// Health check endpoint on front service
184183
{
185184
path: '/health',
@@ -241,6 +240,41 @@ export class TraefikIngressConstruct extends Construct {
241240
},
242241
});
243242

243+
// Create ForwardAuth Middleware for webmail authentication
244+
// This middleware checks if user is authenticated and passes user credentials to webmail
245+
// The auth endpoint returns X-User and X-User-Token headers that Roundcube uses for authentication
246+
new traefik.Middleware(this, 'webmail-auth-middleware', {
247+
metadata: {
248+
name: 'mailu-webmail-auth',
249+
namespace: props.namespace,
250+
},
251+
spec: {
252+
forwardAuth: {
253+
// Point to user auth endpoint that verifies session and returns user info headers
254+
address: `http://${props.adminService.name}.${props.namespace}.svc.cluster.local:8080/internal/auth/user`,
255+
// Pass through X-User and X-User-Token headers from auth response to webmail
256+
// The Roundcube plugin (patched) expects these headers for authentication
257+
authResponseHeaders: ['X-User', 'X-User-Token'],
258+
// Don't trust X-Forwarded-* headers from the authentication request
259+
trustForwardHeader: false,
260+
},
261+
},
262+
});
263+
264+
// Create StripPrefix Middleware to remove /webmail before forwarding to webmail service
265+
// Webmail nginx expects requests at root /, but ingress sends /webmail/*
266+
new traefik.Middleware(this, 'webmail-strip-prefix', {
267+
metadata: {
268+
name: 'mailu-webmail-strip-prefix',
269+
namespace: props.namespace,
270+
},
271+
spec: {
272+
stripPrefix: {
273+
prefixes: ['/webmail'],
274+
},
275+
},
276+
});
277+
244278
// Create StripPrefix Middleware to remove /admin/antispam before forwarding to Rspamd
245279
// Rspamd's web interface is at the root path /, not /admin/antispam/
246280
new traefik.Middleware(this, 'antispam-strip-prefix', {
@@ -255,6 +289,140 @@ export class TraefikIngressConstruct extends Construct {
255289
},
256290
});
257291

292+
// Create RedirectRegex Middleware to redirect sso.php to /sso/login
293+
// Roundcube's mailu plugin expects sso.php but Flask serves /sso/login
294+
new traefik.Middleware(this, 'sso-php-redirect', {
295+
metadata: {
296+
name: 'mailu-sso-php-redirect',
297+
namespace: props.namespace,
298+
},
299+
spec: {
300+
redirectRegex: {
301+
regex: '^https?://([^/]+)(/webmail)?/sso\\.php(.*)$',
302+
replacement: 'https://${1}/sso/login${3}',
303+
permanent: false,
304+
},
305+
},
306+
});
307+
308+
// Separate Ingress for sso.php redirect compatibility
309+
// Roundcube's mailu plugin redirects to sso.php but Flask serves /sso/login
310+
// This ingress catches sso.php requests and redirects to /sso/login
311+
this.ssoPhpIngress = new k8s.KubeIngress(this, 'sso-php-ingress', {
312+
metadata: {
313+
name: 'mailu-sso-php',
314+
namespace: props.namespace,
315+
annotations: {
316+
'cert-manager.io/cluster-issuer': certIssuer,
317+
// Apply redirect middleware to rewrite sso.php to /sso/login
318+
'traefik.ingress.kubernetes.io/router.middlewares': `${props.namespace}-mailu-sso-php-redirect@kubernetescrd`,
319+
// Higher priority to ensure this ingress is checked before mailu-webmail ingress
320+
'traefik.ingress.kubernetes.io/router.priority': '100',
321+
},
322+
},
323+
spec: {
324+
ingressClassName: 'traefik',
325+
tls: [
326+
{
327+
hosts: [props.hostname],
328+
secretName: 'mailu-tls',
329+
},
330+
],
331+
rules: [
332+
{
333+
host: props.hostname,
334+
http: {
335+
paths: [
336+
{
337+
path: '/sso.php',
338+
pathType: 'Exact',
339+
backend: {
340+
service: {
341+
name: props.adminService.name,
342+
port: {
343+
number: 8080,
344+
},
345+
},
346+
},
347+
},
348+
{
349+
path: '/webmail/sso.php',
350+
pathType: 'Exact',
351+
backend: {
352+
service: {
353+
name: props.adminService.name,
354+
port: {
355+
number: 8080,
356+
},
357+
},
358+
},
359+
},
360+
],
361+
},
362+
},
363+
],
364+
},
365+
});
366+
367+
// Separate Ingress for webmail with authentication
368+
// When auth proxy is provided, route through it (handles auth + redirect on failure)
369+
// Otherwise, use ForwardAuth middleware (returns 403 on auth failure - legacy behavior)
370+
// NOTE: We do NOT strip /webmail prefix - the webmail nginx is configured (via WEB_WEBMAIL env)
371+
// to handle requests at /webmail and generate correct asset paths
372+
const useAuthProxy = !!props.webmailAuthProxyService;
373+
const webmailBackendService = useAuthProxy
374+
? props.webmailAuthProxyService!
375+
: props.webmailService;
376+
377+
// Build annotations - only include ForwardAuth when not using auth proxy
378+
const webmailIngressAnnotations: Record<string, string> = {
379+
// Use cert-manager to provision Let's Encrypt certificate
380+
'cert-manager.io/cluster-issuer': certIssuer,
381+
};
382+
if (!useAuthProxy) {
383+
// Apply ForwardAuth middleware when not using auth proxy (legacy behavior)
384+
webmailIngressAnnotations['traefik.ingress.kubernetes.io/router.middlewares'] =
385+
`${props.namespace}-mailu-webmail-auth@kubernetescrd`;
386+
}
387+
388+
this.webmailIngress = new k8s.KubeIngress(this, 'webmail-auth-ingress', {
389+
metadata: {
390+
name: 'mailu-webmail-auth',
391+
namespace: props.namespace,
392+
annotations: webmailIngressAnnotations,
393+
},
394+
spec: {
395+
ingressClassName: 'traefik',
396+
tls: [
397+
{
398+
hosts: [props.hostname],
399+
secretName: 'mailu-tls',
400+
},
401+
],
402+
rules: [
403+
{
404+
host: props.hostname,
405+
http: {
406+
paths: [
407+
{
408+
path: '/webmail',
409+
pathType: 'Prefix',
410+
backend: {
411+
service: {
412+
name: webmailBackendService.name,
413+
port: {
414+
number: 80,
415+
},
416+
},
417+
},
418+
},
419+
],
420+
},
421+
},
422+
],
423+
},
424+
});
425+
258426
// Separate Ingress for Rspamd antispam web UI with ForwardAuth middleware
259427
// This proxies /admin/antispam/* to Rspamd's web interface on port 11334
260428
// and ensures only authenticated global admins can access it

0 commit comments

Comments
 (0)