feat(traefik-forward-auth): serve several domains and portals from one instance

A session cookie scoped to one registrable domain is never sent to a host under
another, so an app on a second domain could not be protected by this instance at
all. It failed with a 500 naming the cause: "return URL host does not match any
configured cookie domain".

`domains` is now a list of {domain, authHost}, matching the upstream
`server.domains` schema. The chart previously emitted `server.hostname` and
`cookies.domain`, which are not in 4.14.1's documented options — a deprecated
form that still worked. The single-domain values remain as shorthand and fold
into one entry, so an existing release renders the same protection it did before.

`portals` is a list too. A portal is one OAuth2 client, chosen by the middleware
address, so two portals means two Pocket ID applications — which is how access
can be separated per domain rather than merely shared across them. Each portal
gets its own Middleware and its own mounted secret, under
/var/run/secrets/traefik-forward-auth/<portal>/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
sha
2026-08-18 00:10:40 +03:00
co-authored by Claude Opus 5
parent 308b164894
commit cf2aebf298
6 changed files with 130 additions and 25 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ apiVersion: v2
name: traefik-forward-auth name: traefik-forward-auth
description: Forward auth for Traefik (ItalyPaleAle/traefik-forward-auth) with Pocket ID provider description: Forward auth for Traefik (ItalyPaleAle/traefik-forward-auth) with Pocket ID provider
type: application type: application
version: 0.1.6 version: 0.2.0
appVersion: "4.14.1" appVersion: "4.14.1"
annotations: annotations:
version-source: github-release:ItalyPaleAle/traefik-forward-auth version-source: github-release:ItalyPaleAle/traefik-forward-auth
@@ -11,3 +11,59 @@ app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }} app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end -}} {{- end -}}
{{/*
The portals, normalised to one shape.
A portal is a set of identity providers with its own OAuth2 client, and the
middleware address is what selects it so two portals means two clients, and
therefore two Pocket ID applications that can be granted to different people.
`portals` is the general form. The older single-portal values (`portal.name`,
`pocketID`, `existingSecret`, `middleware.name`) still work and are folded into
the same shape here, so an existing release keeps rendering exactly as before.
*/}}
{{- define "tfa.portals" -}}
{{- if .Values.portals -}}
{{- range .Values.portals }}
- name: {{ .name | quote }}
endpoint: {{ .pocketID.endpoint | quote }}
clientID: {{ .pocketID.clientID | quote }}
existingSecret: {{ required "each portal needs an existingSecret" .existingSecret | quote }}
secretKey: {{ .secretKey | default "client-secret" | quote }}
middlewareName: {{ .middlewareName | default (printf "%s-%s" $.Release.Name .name) | quote }}
{{- end }}
{{- else -}}
- name: {{ .Values.portal.name | quote }}
endpoint: {{ .Values.pocketID.endpoint | quote }}
clientID: {{ .Values.pocketID.clientID | quote }}
existingSecret: {{ required "existingSecret is required" .Values.existingSecret | quote }}
secretKey: "client-secret"
middlewareName: {{ include "tfa.middlewareName" . | quote }}
{{- end -}}
{{- end -}}
{{/*
The domains served, normalised.
`domain` is the cookie's scope and `authHost` is where this service is reachable
for it. A browser will not send a cookie scoped to one registrable domain to a
host under another, so an app on a second domain needs its own entry here — not
merely a second client.
*/}}
{{- define "tfa.domains" -}}
{{- if .Values.domains -}}
{{- range .Values.domains }}
- domain: {{ .domain | quote }}
authHost: {{ .authHost | default .domain | quote }}
{{- end }}
{{- else -}}
- domain: {{ required "cookieDomain is required when `domains` is not set" .Values.cookieDomain | quote }}
authHost: {{ required "hostname is required when `domains` is not set" .Values.hostname | quote }}
{{- end -}}
{{- end -}}
{{/* Where a portal's client secret is mounted. */}}
{{- define "tfa.secretDir" -}}
/var/run/secrets/traefik-forward-auth
{{- end -}}
+15 -8
View File
@@ -1,3 +1,5 @@
{{- $portals := include "tfa.portals" . | fromYamlArray -}}
{{- $domains := include "tfa.domains" . | fromYamlArray -}}
apiVersion: v1 apiVersion: v1
kind: ConfigMap kind: ConfigMap
metadata: metadata:
@@ -7,21 +9,26 @@ metadata:
data: data:
config.yaml: | config.yaml: |
server: server:
hostname: {{ .Values.hostname | quote }} # One entry per domain served. `domain` scopes the session cookie and
# `authHost` is where this service is reachable for that domain.
domains:
{{- range $domains }}
- domain: {{ .domain | quote }}
authHost: {{ .authHost | quote }}
{{- end }}
tokens: tokens:
sessionLifetime: {{ .Values.tokens.sessionLifetime }} sessionLifetime: {{ .Values.tokens.sessionLifetime }}
cookies:
domain: {{ .Values.cookieDomain | quote }}
portals: portals:
- name: {{ .Values.portal.name | quote }} {{- range $portals }}
- name: {{ .name | quote }}
providers: providers:
- pocketID: - pocketID:
endpoint: {{ .Values.pocketID.endpoint | quote }} endpoint: {{ .endpoint | quote }}
clientID: {{ .Values.pocketID.clientID | quote }} clientID: {{ .clientID | quote }}
clientSecretFile: "/var/run/secrets/traefik-forward-auth/client-secret" clientSecretFile: "{{ include "tfa.secretDir" $ }}/{{ .name }}/client-secret"
{{- end }}
{{- with .Values.extraConfig }} {{- with .Values.extraConfig }}
{{ . | nindent 4 }} {{ . | nindent 4 }}
{{- end }} {{- end }}
+12 -5
View File
@@ -1,3 +1,4 @@
{{- $portals := include "tfa.portals" . | fromYamlArray -}}
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -43,18 +44,24 @@ spec:
- name: config - name: config
mountPath: /etc/traefik-forward-auth mountPath: /etc/traefik-forward-auth
readOnly: true readOnly: true
- name: client-secret {{- range $portals }}
mountPath: /var/run/secrets/traefik-forward-auth # One directory per portal: each has its own OAuth2 client, so each
# has its own secret.
- name: client-secret-{{ .name }}
mountPath: "{{ include "tfa.secretDir" $ }}/{{ .name }}"
readOnly: true readOnly: true
{{- end }}
resources: resources:
{{- toYaml .Values.resources | nindent 12 }} {{- toYaml .Values.resources | nindent 12 }}
volumes: volumes:
- name: config - name: config
configMap: configMap:
name: {{ .Release.Name }}-config name: {{ .Release.Name }}-config
- name: client-secret {{- range $portals }}
- name: client-secret-{{ .name }}
secret: secret:
secretName: {{ required "existingSecret is required" .Values.existingSecret }} secretName: {{ .existingSecret }}
items: items:
- key: client-secret - key: {{ .secretKey }}
path: client-secret path: client-secret
{{- end }}
+13 -5
View File
@@ -1,16 +1,24 @@
{{- if .Values.middleware.enabled }} {{- if .Values.middleware.enabled }}
{{- $portals := include "tfa.portals" . | fromYamlArray -}}
{{- range $portals }}
---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: Middleware kind: Middleware
metadata: metadata:
name: {{ include "tfa.middlewareName" . }} # Referenced by apps as <namespace>-<name>@kubernetescrd. Renaming one detaches
# protection from every ingress that names it.
name: {{ .middlewareName }}
labels: labels:
{{- include "tfa.labels" . | nindent 4 }} {{- include "tfa.labels" $ | nindent 4 }}
spec: spec:
forwardAuth: forwardAuth:
address: "http://{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local/portals/{{ .Values.portal.name }}" # The path selects the portal, and therefore which OAuth2 client — which is
trustForwardHeader: {{ .Values.middleware.trustForwardHeader }} # how access is separated between domains.
{{- with .Values.middleware.authResponseHeaders }} address: "http://{{ $.Release.Name }}.{{ $.Release.Namespace }}.svc.cluster.local/portals/{{ .name }}"
trustForwardHeader: {{ $.Values.middleware.trustForwardHeader }}
{{- with $.Values.middleware.authResponseHeaders }}
authResponseHeaders: authResponseHeaders:
{{- toYaml . | nindent 6 }} {{- toYaml . | nindent 6 }}
{{- end }} {{- end }}
{{- end }} {{- end }}
{{- end }}
+33 -6
View File
@@ -4,18 +4,45 @@ image:
# Overrides the image tag; defaults to Chart.AppVersion # Overrides the image tag; defaults to Chart.AppVersion
tag: "" tag: ""
# TFA server hostname (used for OIDC redirects). E.g. "auth.example.com" # Domains served by this instance. One entry per registrable domain.
hostname: "" #
# `domain` scopes the session cookie; `authHost` is where this service is
# reachable for that domain, and must be it or a sub-domain of it.
#
# A browser will not send a cookie scoped to one registrable domain to a host
# under another, so an app on a second domain needs its own entry here. A second
# OAuth2 client alone does not solve it.
domains: []
# - domain: example.com
# authHost: auth.example.com
# - domain: example.org
# authHost: auth.example.org
# Cookie domain — scope at which the session cookie is valid. # Single-domain shorthand, used when `domains` is empty. Equivalent to one
# Must be a parent of `hostname` (e.g. "example.com" for "auth.example.com"). # `domains` entry of {domain: cookieDomain, authHost: hostname}.
hostname: ""
cookieDomain: "" cookieDomain: ""
tokens: tokens:
sessionLifetime: 24h sessionLifetime: 24h
# Pocket ID OIDC configuration. `clientID` is a public identifier; # Portals. Each is one OAuth2 client, selected by the middleware address
# `clientSecret` is read from `existingSecret` (key: `client-secret`). # (/portals/<name>) — which is how access is separated: grant one Pocket ID
# application to one group of people and the other to another, and each portal
# admits only its own.
#
# Each portal mounts its secret at
# /var/run/secrets/traefik-forward-auth/<name>/client-secret.
portals: []
# - name: main
# middlewareName: pocket-id-auth # default: <release>-<name>
# existingSecret: tfa-main-secret # must define key `client-secret`
# secretKey: client-secret # optional, if the key differs
# pocketID:
# endpoint: https://id.example.com
# clientID: "..."
# Single-portal shorthand, used when `portals` is empty.
pocketID: pocketID:
endpoint: "" endpoint: ""
clientID: "" clientID: ""