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
description: Forward auth for Traefik (ItalyPaleAle/traefik-forward-auth) with Pocket ID provider
type: application
version: 0.1.6
version: 0.2.0
appVersion: "4.14.1"
annotations:
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/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 }}
+33 -6
View File
@@ -4,18 +4,45 @@ image:
# Overrides the image tag; defaults to Chart.AppVersion
tag: ""
# TFA server hostname (used for OIDC redirects). E.g. "auth.example.com"
hostname: ""
# Domains served by this instance. One entry per registrable domain.
#
# `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.
# Must be a parent of `hostname` (e.g. "example.com" for "auth.example.com").
# Single-domain shorthand, used when `domains` is empty. Equivalent to one
# `domains` entry of {domain: cookieDomain, authHost: hostname}.
hostname: ""
cookieDomain: ""
tokens:
sessionLifetime: 24h
# Pocket ID OIDC configuration. `clientID` is a public identifier;
# `clientSecret` is read from `existingSecret` (key: `client-secret`).
# Portals. Each is one OAuth2 client, selected by the middleware address
# (/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:
endpoint: ""
clientID: ""