# Authentication Architecture
This page explains how authentication works across all cluster services —
from the outer Cloudflare layer through to per-service role assignment.
## Three-layer auth model
Every request to a cluster service passes through up to three authentication
layers. Not all services use all three layers.
```{mermaid}
flowchart TB
subgraph L1["Layer 1 — Cloudflare Access"]
CF[Cloudflare Access
email allowlist]
end
subgraph L2["Layer 2 — Ingress auth"]
DEX[Dex OIDC
native provider]
OAP[oauth2-proxy
GitHub gateway]
end
subgraph L3["Layer 3 — App RBAC"]
RBAC[Per-service roles
admin / viewer / user]
end
CF --> DEX
CF --> OAP
DEX --> RBAC
OAP --> RBAC
```
**Layer 1 — Cloudflare Access** gates tunnel-exposed services at the edge.
Users must authenticate with an email on the allowlist before traffic
reaches the cluster. LAN-only services skip this layer entirely.
**Layer 2 — Ingress auth** verifies identity at the cluster boundary.
Services either use Dex (ArgoCD's built-in OIDC provider with a GitHub
connector) or oauth2-proxy (a lightweight reverse-proxy that redirects to
GitHub directly).
**Layer 3 — App RBAC** maps the authenticated identity to a role inside
the application. Emails in the `admin_emails` list in `values.yaml`
receive admin privileges; all other authenticated users get a read-only
or viewer role. Entry is gated by Cloudflare Access (email-based OTP),
so there is no separate viewer list in the repo.
## Ingress architecture
Layer 2 authentication is enforced at **ingress-nginx**, the single
entry point for every request that reaches the cluster. Understanding
where ingress-nginx sits clarifies *where* Dex and oauth2-proxy plug in
and *why* the auth-url subrequest pattern works.
```{mermaid}
flowchart TB
subgraph Internet
CF[Cloudflare Edge]
end
subgraph LAN["Local Network"]
CLIENT[LAN Client]
end
subgraph Cluster["K3s Cluster"]
ING[ingress-nginx
LoadBalancer on workers]
SVC1[echo service]
SVC2[grafana service]
SVC3[argocd service]
CFPOD[cloudflared pod]
end
CF -->|"tunnel"| CFPOD
CFPOD -->|"HTTP"| ING
CLIENT -->|"DNS → worker IP"| ING
ING --> SVC1 & SVC2 & SVC3
```
Tunnel-routed requests arrive at the `cloudflared` pod via an outbound
tunnel and are forwarded over plain HTTP to ingress-nginx. LAN clients
hit ingress-nginx directly over the worker node IPs. In both cases
ingress-nginx is where OAuth subrequests are issued — either to the
cluster-wide oauth2-proxy or, indirectly, to a service that authenticates
itself against Dex.
### NGINX Ingress (not Traefik)
K3s ships Traefik as its default ingress controller, but this project
disables it (`--disable=traefik`) and deploys **ingress-nginx** instead.
Reasons:
- More widely documented in the Kubernetes ecosystem
- Better support for TLS passthrough (needed for ArgoCD)
- More straightforward configuration model
- Mature `auth-url` / `auth-signin` annotation support, which is what
oauth2-proxy relies on for the subrequest flow described later in this
page
### LoadBalancer on workers
The ingress-nginx controller runs on **worker nodes** — in multi-node
clusters the control plane carries a `NoSchedule` taint. DNS entries for
all services must therefore point to worker node IPs, not the control
plane. For single-node clusters, DNS points to that single node.
For round-robin across workers:
```
*.example.com A 192.168.1.82
*.example.com A 192.168.1.83
*.example.com A 192.168.1.84
```
A single worker IP also works — kube-proxy routes traffic to the ingress
pod regardless of which worker receives the connection.
See {doc}`networking` for TLS certificate issuance, Cloudflare tunnel
details, and ArgoCD's TLS termination pattern.
## Auth method summary
| Service | Layer 1 (Cloudflare) | Layer 2 (Ingress) | Layer 3 (App RBAC) |
|---------|---------------------|-------------------|-------------------|
| ArgoCD | Cloudflare Access | Dex (native) | email → `role:admin` / `role:readonly` |
| argocd-monitor | Cloudflare Access | Dex (oauth2-proxy sidecar) | Inherits ArgoCD RBAC |
| Grafana | Cloudflare Access | Dex (`generic_oauth`) | email → `Admin` / `Viewer` |
| Open WebUI | Cloudflare Access | Dex (native OIDC) | email → admin / user |
| Headlamp | Cloudflare Access | oauth2-proxy + ServiceAccount token | — |
| Supabase Studio | Cloudflare Access | oauth2-proxy | Dashboard password |
| Echo | Cloudflare Access | None | None (public test service) |
| Open Brain MCP | Cloudflare Access | OAuth 2.1 (GitHub) | x-brain-key |
| Supabase API | Bypass (no Access) | x-brain-key | — |
## Dex as shared OIDC provider
ArgoCD ships with [Dex](https://dexidp.io/), a federated OIDC provider.
Rather than deploying a separate identity provider, all OIDC-capable
services share ArgoCD's Dex instance. Dex connects to GitHub as its
upstream identity source and issues tokens to four registered static
clients.
```{mermaid}
flowchart LR
GH[GitHub OAuth App]
subgraph Dex["ArgoCD Dex Server"]
CON[GitHub connector]
CON --> C1[argo-cd]
CON --> C2[argocd-monitor]
CON --> C3[grafana]
CON --> C4[open-webui]
end
GH --> CON
C1 --> ArgoCD
C2 --> argocd-monitor
C3 --> Grafana
C4 --> Open-WebUI
```
All four clients authenticate through a single GitHub OAuth App whose
callback URL points to `https://argocd./api/dex/callback`.
Each client has its own `client_secret` stored in the `argocd-dex-secret`
SealedSecret.
### Why Dex?
Full-featured identity providers like Authentik or Keycloak need ~2 GB of
RAM — too heavy for a small ARM cluster. Dex is a lightweight OIDC
federation layer that adds negligible overhead because it runs inside the
existing ArgoCD server pod.
## Per-service auth flows
### ArgoCD — native Dex login
ArgoCD has first-class Dex integration. The built-in admin account is
disabled; all users log in via GitHub through Dex.
:::{note}
ArgoCD runs with `server.insecure: true` so that TLS is terminated at
nginx rather than inside the pod. This allows it to be routed through the
Cloudflare tunnel and protected by Cloudflare Access like every other
service.
:::
```{mermaid}
sequenceDiagram
actor User
participant ArgoCD
participant Dex
participant GitHub
User->>ArgoCD: Visit argocd.
ArgoCD->>Dex: Redirect to /api/dex/auth
Dex->>GitHub: Redirect to GitHub OAuth
GitHub->>Dex: Auth code + user info
Dex->>ArgoCD: ID token (email scope)
ArgoCD->>ArgoCD: Map email → role:admin or role:readonly
ArgoCD->>User: Logged in
```
RBAC is configured in `argocd-rbac-cm.yml`. The `scopes` field is set to
`[email]`, and policy rules map specific emails to `role:admin`. Everyone
else gets `role:readonly` (can view applications and logs but not modify).
### argocd-monitor — Dex cross-client auth
argocd-monitor is a dashboard that queries the ArgoCD API. It runs its own
oauth2-proxy **sidecar** (separate from the cluster-wide oauth2-proxy) that
authenticates against Dex.
```{mermaid}
sequenceDiagram
actor User
participant Sidecar as oauth2-proxy sidecar
participant Dex
participant GitHub
participant API as ArgoCD API
User->>Sidecar: Visit argocd-monitor.
Sidecar->>Dex: Auth request (scope: audience:server:client_id:argo-cd)
Dex->>GitHub: Redirect to GitHub OAuth
GitHub->>Dex: Auth code + user info
Dex->>Sidecar: ID token with argo-cd audience
Sidecar->>API: Forward request with token
API->>API: Validate token (argo-cd audience accepted)
API->>User: Dashboard data
```
The cross-client flow works because the `argo-cd` static client lists
`argocd-monitor` in its `trustedPeers`. This lets Dex issue tokens with
the `argo-cd` audience to the `argocd-monitor` client, so the ArgoCD API
accepts them.
### Grafana — generic OAuth via Dex
Grafana uses its built-in `auth.generic_oauth` provider pointed at the Dex
endpoints. Password login is disabled — the login page shows only a
"Sign in with GitHub (via Dex)" button.
The `role_attribute_path` JMESPath expression grants `Admin` to emails in
the `admin_emails` list and `Viewer` to everyone else. The client secret
is injected from the `grafana-oauth-secret` SealedSecret.
### Open WebUI — native OIDC via Dex
Open WebUI uses its built-in OIDC support via environment variables. The
`OPENID_PROVIDER_URL` points to the Dex discovery endpoint. Password login
is disabled — the login page shows only an OAuth button.
The `OAUTH_ADMIN_EMAIL` variable (populated from `admin_emails`) controls
who gets the admin role. Everyone else gets the `user` role. The client
secret comes from the `open-webui-oauth-secret` SealedSecret.
:::{important}
The discovery URL must be the full path including
`.well-known/openid-configuration` — Open WebUI's OIDC library does not
follow the 301 redirect from `/api/dex` to `/api/dex/`.
:::
### Headlamp — oauth2-proxy + ServiceAccount token
Headlamp is protected by the cluster-wide oauth2-proxy (admin-only,
same as Supabase Studio). After authenticating via GitHub,
users paste a Kubernetes ServiceAccount token to access the dashboard.
The token is generated with `kubectl create token headlamp -n headlamp`.
### oauth2-proxy services — Headlamp and Supabase Studio
Services without native OIDC support use the cluster-wide oauth2-proxy.
This is a separate authentication path that goes directly to GitHub (not
through Dex). Only emails in the `admin_emails` list can authenticate —
viewer users cannot access these services.
```{mermaid}
sequenceDiagram
actor User
participant NGINX as ingress-nginx
participant OAP as oauth2-proxy
participant GitHub
participant Svc as Backend service
User->>NGINX: Visit supabase-studio.
NGINX->>OAP: Auth subrequest
OAP-->>NGINX: 401 (not authenticated)
NGINX->>User: Redirect to oauth2.
User->>OAP: /oauth2/start
OAP->>GitHub: Redirect to GitHub OAuth
GitHub->>OAP: Auth code + user info
OAP->>OAP: Check email against admin_emails
OAP->>User: Set session cookie
User->>NGINX: Retry original request (with cookie)
NGINX->>OAP: Auth subrequest
OAP-->>NGINX: 202 + X-Auth-Request-Email header
NGINX->>Svc: Forward request
Svc->>User: Response
```
The nginx ingress uses `auth-url` and `auth-signin` annotations to
delegate authentication to oauth2-proxy. Only emails in the
`admin_emails` list are permitted — viewer users and unauthenticated
visitors get a 403 after GitHub login.
:::{important}
The `auth-url` must use the cluster-internal service address
(`oauth2-proxy.oauth2-proxy.svc.cluster.local`), not the external domain.
The external domain resolves via Cloudflare to an IPv6 address that is
unreachable from inside the cluster, causing intermittent 500 errors.
:::
## Full cluster auth map
```{mermaid}
flowchart TB
Internet((Internet))
LAN((LAN))
subgraph Cloudflare["Cloudflare Edge"]
CFA[Cloudflare Access
email allowlist]
CFT[Cloudflare Tunnel]
end
subgraph Cluster["K3s Cluster"]
NGINX[ingress-nginx]
subgraph DexAuth["Dex OIDC (native)"]
ArgoCD
Monitor[argocd-monitor]
Grafana
OpenWebUI[Open WebUI]
end
Headlamp
subgraph ProxyAuth["oauth2-proxy (admin only)"]
Headlamp
Supabase[Supabase Studio]
end
Echo
OAP[oauth2-proxy pod]
DexPod[Dex
inside ArgoCD]
end
GH[GitHub OAuth]
Internet --> CFA --> CFT --> NGINX
LAN --> NGINX
NGINX --> ArgoCD
NGINX --> Monitor
NGINX --> Grafana
NGINX --> OpenWebUI
NGINX --> Headlamp
NGINX --> Supabase
NGINX --> Echo
ArgoCD <-.-> DexPod
Monitor <-.-> DexPod
Grafana <-.-> DexPod
OpenWebUI <-.-> DexPod
Headlamp <-.-> OAP
Supabase <-.-> OAP
DexPod <-.-> GH
OAP <-.-> GH
```
Solid lines show request flow; dashed lines show authentication redirects.
All services are exposed via the Cloudflare tunnel and pass through
Cloudflare Access (email allowlist) before reaching the cluster.
## Managing access
Access is controlled at two layers:
- **Cloudflare Access** — email-based OTP gate managed in the Cloudflare
Zero Trust dashboard. This decides who can reach the cluster at all.
- **`admin_emails`** in `kubernetes-services/values.yaml` — grants admin
privileges. Everyone else who passes Cloudflare Access and authenticates
via Dex/GitHub gets a viewer/read-only role by default.
```yaml
admin_emails:
- alice@example.com # full admin access everywhere
```
**Admin emails** are consumed in four places:
| Template / Config | Effect |
|-------------------|--------|
| `oauth2-proxy.yaml` | Email allowlist — only admins can access Supabase Studio and Headlamp |
| `grafana.yaml` | `role_attribute_path` — admin emails get `Admin`, others get `Viewer` |
| `open-webui.yaml` | `OAUTH_ADMIN_EMAIL` — admin emails get admin role |
| `argocd-rbac-cm.yml` | `g, , role:admin` — admin emails get ArgoCD admin |
All other authenticated users receive read-only roles: ArgoCD
`role:readonly`, Grafana `Viewer`, Open WebUI `user`. They cannot
access oauth2-proxy-gated services (Headlamp, Supabase Studio).
:::{important}
`admin_emails` must be kept in sync in two places:
- `kubernetes-services/values.yaml` (for Helm-rendered templates)
- `group_vars/all.yml` (for Ansible-rendered ArgoCD RBAC)
After changing `admin_emails` in `group_vars/all.yml`, re-run
`ansible-playbook pb_all.yml --tags cluster` to update ArgoCD RBAC.
:::
## SealedSecrets for authentication
All OAuth client secrets are encrypted as SealedSecrets and committed
to Git:
| SealedSecret | Location | Contents |
|-------------|----------|----------|
| `argocd-dex-secret` | `additions/argocd/` | GitHub connector credentials + all 5 Dex client secrets |
| `grafana-oauth-secret` | `additions/grafana/` | Grafana's Dex client secret |
| `open-webui-oauth-secret` | `additions/open-webui/` | Open WebUI's Dex client secret |
| `argocd-monitor-oauth-secret` | `additions/argocd-monitor/` | argocd-monitor's Dex client secret + cookie secret |
| `oauth2-proxy-credentials` | `additions/oauth2-proxy/` | GitHub OAuth App credentials + cookie secret |
Re-sealing any of these secrets requires restarting the affected pods
(environment variables from `secretKeyRef` are read at startup, not
watched).
## Design rationale
**Why two auth paths (Dex + oauth2-proxy)?** Dex provides proper OIDC
tokens with scopes and claims, enabling fine-grained RBAC (admin vs
viewer). Services with native OIDC support (ArgoCD, Grafana, Open WebUI) use
Dex for authentication, which allows both admin and viewer users to log
in with differentiated roles. Services without native OIDC (Supabase
Studio) use oauth2-proxy as a binary admin-only gate. Headlamp
uses ServiceAccount token auth for simplicity.
**Why is oauth2-proxy admin-only?** oauth2-proxy has no concept of roles —
it either allows or denies an email. Since Supabase Studio has no
app-level RBAC, giving viewer users access would grant them full
admin capabilities. Restricting oauth2-proxy to `admin_emails` ensures
only trusted operators can reach these destructive admin tools.
**Why not a standalone Dex deployment?** Running Dex inside ArgoCD avoids
deploying another pod and reuses ArgoCD's existing GitHub connector
configuration. The trade-off is that Dex configuration lives in ArgoCD's
ConfigMap rather than a standalone Helm chart.