feat(gotify): put the Gotify UI behind ZITADEL single sign-on

Gotify has no OIDC support, so it cannot validate a ZITADEL token itself. Put
oauth2-proxy in front of it and let nginx ask the proxy for every request.

The Gotify REST API keeps its own client-token check. /message, /application,
/client, /user, /version, /stream and /health are skipped by the proxy, so
Forgejo webhooks, the harness, poimen-sender and the Android client keep working
without a browser session. Only the web UI requires SSO.

The /oauth2 path needs its own ingress: the auth_request annotation would
otherwise also guard /oauth2/start, and the login entry point would demand a
login first.

The client id and secret come from project role provisioning:
`zitadel-provision.py apply` now creates the `gotify` OIDC app, and the secret is
SOPS-encrypted under k8s/argocd/secrets.

Gotify's own login remains behind the proxy. ZITADEL decides who reaches the
service, Gotify decides what that person may do inside it.
This commit was merged in pull request #72.
This commit is contained in:
2026-10-27 12:06:00 -07:00
parent cc7c30ee20
commit 03cebb0aeb
8 changed files with 178 additions and 0 deletions
+6
View File
@@ -9,6 +9,12 @@ metadata:
# WebSocket support for Gotify client connections (/stream endpoint)
# nginx-ingress handles Upgrade/Connection headers natively with http/1.1
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
# Single sign-on. Gotify has no OIDC, so every request is handed to
# oauth2-proxy for an allow or deny decision. oauth2-proxy skips the token
# API paths itself, so /message keeps Gotify's own X-Gotify-Key check.
nginx.ingress.kubernetes.io/auth-url: "http://gotify-oauth2-proxy.notifications.svc.cluster.local:4180/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://gotify.riotpiao.com/oauth2/start?rd=$scheme://$host$request_uri"
nginx.ingress.kubernetes.io/auth-response-headers: "x-auth-request-user,x-auth-request-email"
spec:
ingressClassName: nginx
rules:
+3
View File
@@ -5,6 +5,9 @@ resources:
- db.yaml
- deployment.yaml
- service.yaml
- oauth2-proxy-deployment.yaml
- oauth2-proxy-service.yaml
- oauth2-proxy-ingress.yaml
- ingress.yaml
- igotify-pvc.yaml
- igotify-deployment.yaml
@@ -0,0 +1,82 @@
# oauth2-proxy in front of Gotify. Gotify has no OIDC support of its own, so it
# cannot validate a ZITADEL token. This proxy is the front door: nginx asks it
# for an auth decision on every request (auth_request), and it answers from a
# ZITADEL session cookie.
#
# The Gotify REST API keeps its own client-token auth (X-Gotify-Key) and must
# stay reachable without a browser session, or every push sender breaks:
# Forgejo webhooks, the harness, poimen-sender and the ntfy-style relays all post
# to /message. skip-auth-route leaves those paths to Gotify's own token check.
#
# Gotify's own login still exists behind the proxy. ZITADEL decides who may
# reach the service; Gotify decides what that person may do inside it.
apiVersion: apps/v1
kind: Deployment
metadata:
name: gotify-oauth2-proxy
namespace: notifications
labels:
app: gotify-oauth2-proxy
spec:
replicas: 1
selector:
matchLabels:
app: gotify-oauth2-proxy
template:
metadata:
labels:
app: gotify-oauth2-proxy
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://zitadel.riotpiao.com
- --email-domain=*
- --upstream=static://202
- --reverse-proxy=true
- --set-xauthrequest=true
- --cookie-secure=true
- --cookie-expire=12h
- --cookie-refresh=1h
- --skip-provider-button=true
- --redirect-url=https://gotify.riotpiao.com/oauth2/callback
- --http-address=0.0.0.0:4180
# Browser paths keep SSO. API paths fall through to Gotify's own
# client-token check, so existing senders and the Android client
# keep working without a browser session.
- --skip-auth-route=^/(message|application|client|user|version|stream|health)(/.*)?$
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef: {name: gotify-oauth2-proxy, key: client-id}
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef: {name: gotify-oauth2-proxy, key: client-secret}
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef: {name: gotify-oauth2-proxy, key: cookie-secret}
ports:
- name: http
containerPort: 4180
readinessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 15
periodSeconds: 20
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 128Mi}
+22
View File
@@ -0,0 +1,22 @@
# The proxy needs its own ingress path. nginx auth_request also guards
# /oauth2/start and /oauth2/callback, which would loop: the login entry point
# would itself require a login. A longer path match sends /oauth2 to the proxy
# directly, and the prefix rule on the gotify ingress handles everything else.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: gotify-oauth2
namespace: notifications
spec:
ingressClassName: nginx
rules:
- host: gotify.riotpiao.com
http:
paths:
- path: /oauth2
pathType: Prefix
backend:
service:
name: gotify-oauth2-proxy
port:
number: 4180
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: gotify-oauth2-proxy
namespace: notifications
labels:
app: gotify-oauth2-proxy
spec:
type: ClusterIP
selector:
app: gotify-oauth2-proxy
ports:
- name: http
port: 4180
targetPort: http