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
@@ -11,3 +11,59 @@ app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- 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
kind: ConfigMap
metadata:
@@ -7,21 +9,26 @@ metadata:
data:
config.yaml: |
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:
sessionLifetime: {{ .Values.tokens.sessionLifetime }}
cookies:
domain: {{ .Values.cookieDomain | quote }}
portals:
- name: {{ .Values.portal.name | quote }}
{{- range $portals }}
- name: {{ .name | quote }}
providers:
- pocketID:
endpoint: {{ .Values.pocketID.endpoint | quote }}
clientID: {{ .Values.pocketID.clientID | quote }}
clientSecretFile: "/var/run/secrets/traefik-forward-auth/client-secret"
endpoint: {{ .endpoint | quote }}
clientID: {{ .clientID | quote }}
clientSecretFile: "{{ include "tfa.secretDir" $ }}/{{ .name }}/client-secret"
{{- end }}
{{- with .Values.extraConfig }}
{{ . | nindent 4 }}
{{- end }}
+12 -5
View File
@@ -1,3 +1,4 @@
{{- $portals := include "tfa.portals" . | fromYamlArray -}}
apiVersion: apps/v1
kind: Deployment
metadata:
@@ -43,18 +44,24 @@ spec:
- name: config
mountPath: /etc/traefik-forward-auth
readOnly: true
- name: client-secret
mountPath: /var/run/secrets/traefik-forward-auth
{{- range $portals }}
# 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
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: config
configMap:
name: {{ .Release.Name }}-config
- name: client-secret
{{- range $portals }}
- name: client-secret-{{ .name }}
secret:
secretName: {{ required "existingSecret is required" .Values.existingSecret }}
secretName: {{ .existingSecret }}
items:
- key: client-secret
- key: {{ .secretKey }}
path: client-secret
{{- end }}
+13 -5
View File
@@ -1,16 +1,24 @@
{{- if .Values.middleware.enabled }}
{{- $portals := include "tfa.portals" . | fromYamlArray -}}
{{- range $portals }}
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
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:
{{- include "tfa.labels" . | nindent 4 }}
{{- include "tfa.labels" $ | nindent 4 }}
spec:
forwardAuth:
address: "http://{{ .Release.Name }}.{{ .Release.Namespace }}.svc.cluster.local/portals/{{ .Values.portal.name }}"
trustForwardHeader: {{ .Values.middleware.trustForwardHeader }}
{{- with .Values.middleware.authResponseHeaders }}
# The path selects the portal, and therefore which OAuth2 client — which is
# how access is separated between domains.
address: "http://{{ $.Release.Name }}.{{ $.Release.Namespace }}.svc.cluster.local/portals/{{ .name }}"
trustForwardHeader: {{ $.Values.middleware.trustForwardHeader }}
{{- with $.Values.middleware.authResponseHeaders }}
authResponseHeaders:
{{- toYaml . | nindent 6 }}
{{- end }}
{{- end }}
{{- end }}