This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
# rsauth2-proxy
|
||||
|
||||
Auth proxy for [Traefik ForwardAuth](https://doc.traefik.io/traefik/middlewares/http/forwardauth/) with Keycloak OIDC. Single instance protects all services in a cluster. Replaces oauth2-proxy.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Browser → Traefik → ForwardAuth (/auth) → rsauth2-proxy
|
||||
├── no session → 302 to Keycloak login
|
||||
├── valid session → 200 + user headers
|
||||
└── expired session → token refresh → 302 back
|
||||
```
|
||||
|
||||
Traefik calls `/auth` for every request to a protected service. The proxy checks the encrypted session cookie, verifies group membership against the route config, and returns 200 (allow), 403 (deny), or 302 (login required).
|
||||
|
||||
Sessions are stored entirely in an AES-256-GCM encrypted cookie. No server-side state. Any number of replicas work without coordination.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `AUTH_PROXY_OIDC_ISSUER` | yes | | OIDC issuer URL |
|
||||
| `AUTH_PROXY_CLIENT_ID` | yes | | Keycloak client ID |
|
||||
| `AUTH_PROXY_CLIENT_SECRET` | yes | | Keycloak client secret |
|
||||
| `AUTH_PROXY_COOKIE_SECRET` | yes | | AES-256 key, 32 bytes, base64-encoded |
|
||||
| `AUTH_PROXY_COOKIE_DOMAIN` | yes | | Cookie domain (e.g. `.example.com`) |
|
||||
| `AUTH_PROXY_CALLBACK_URL` | yes | | Full callback URL (e.g. `https://auth.example.com/callback`) |
|
||||
| `AUTH_PROXY_LISTEN` | no | `0.0.0.0:8080` | Listen address |
|
||||
| `AUTH_PROXY_ROUTES_FILE` | no | `/config/routes.yaml` | Path to routes config |
|
||||
| `AUTH_PROXY_LOG_LEVEL` | no | `info` | Log level (`debug`, `info`, `warn`, `error`) |
|
||||
|
||||
Generate a cookie secret:
|
||||
|
||||
```sh
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
## Routes file
|
||||
|
||||
Defines which hosts are protected and which groups have access.
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
grafana.example.com:
|
||||
allowed_groups: ["admins", "developers"]
|
||||
|
||||
wiki.example.com:
|
||||
allowed_groups: []
|
||||
# Empty list = any authenticated user
|
||||
|
||||
secret.example.com:
|
||||
allowed_groups: ["admins"]
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Host in routes, `allowed_groups` empty — any authenticated user is allowed
|
||||
- Host in routes, `allowed_groups` set — user must be in at least one listed group
|
||||
- Host not in routes — denied (403)
|
||||
|
||||
The file is polled every 5 seconds and reloaded on change. This works reliably with Kubernetes ConfigMap volume mounts.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `GET /auth` | ForwardAuth endpoint (called by Traefik) |
|
||||
| `GET /callback` | OIDC callback (receives authorization code from Keycloak) |
|
||||
| `GET /refresh` | Token refresh (transparent redirect when session expires) |
|
||||
| `GET /sign_out` | Logout (clears cookie, redirects to Keycloak end_session) |
|
||||
| `GET /health` | Health check (returns 200) |
|
||||
|
||||
## Keycloak setup
|
||||
|
||||
The proxy reads user groups from the `groups` claim in the ID token. Keycloak does not include this by default. Add a group membership mapper to the client:
|
||||
|
||||
```hcl
|
||||
resource "keycloak_openid_group_membership_protocol_mapper" "groups" {
|
||||
realm_id = keycloak_realm.main.id
|
||||
client_id = keycloak_openid_client.auth_proxy.id
|
||||
name = "groups"
|
||||
claim_name = "groups"
|
||||
full_path = false
|
||||
}
|
||||
```
|
||||
|
||||
Or manually: Client Scopes → your client → Mappers → Add mapper → "Group Membership", claim name `groups`, full path off.
|
||||
|
||||
## Kubernetes deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: auth-proxy
|
||||
namespace: auth-proxy
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: auth-proxy
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: auth-proxy
|
||||
spec:
|
||||
containers:
|
||||
- name: auth-proxy
|
||||
image: ghcr.io/your-org/rsauth2-proxy:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: auth-proxy-creds
|
||||
volumeMounts:
|
||||
- name: routes
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
volumes:
|
||||
- name: routes
|
||||
configMap:
|
||||
name: auth-proxy-routes
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: auth-proxy
|
||||
namespace: auth-proxy
|
||||
spec:
|
||||
selector:
|
||||
app: auth-proxy
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
```
|
||||
|
||||
### Traefik ForwardAuth middleware
|
||||
|
||||
Create in each namespace that has protected services:
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: Middleware
|
||||
metadata:
|
||||
name: auth-proxy
|
||||
spec:
|
||||
forwardAuth:
|
||||
address: http://auth-proxy.auth-proxy.svc:80/auth
|
||||
trustForwardHeader: true
|
||||
authResponseHeaders:
|
||||
- X-Auth-Request-User
|
||||
- X-Auth-Request-Email
|
||||
- X-Auth-Request-Groups
|
||||
```
|
||||
|
||||
### Ingress for auth-proxy itself
|
||||
|
||||
The `/callback`, `/refresh`, and `/sign_out` endpoints must be reachable by browsers:
|
||||
|
||||
```yaml
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: auth-proxy
|
||||
namespace: auth-proxy
|
||||
spec:
|
||||
entryPoints:
|
||||
- websecure
|
||||
routes:
|
||||
- match: Host(`auth.example.com`) && (Path(`/callback`) || Path(`/refresh`) || Path(`/sign_out`))
|
||||
kind: Rule
|
||||
services:
|
||||
- name: auth-proxy
|
||||
port: 80
|
||||
tls:
|
||||
secretName: auth-proxy-tls
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```sh
|
||||
docker build -t rsauth2-proxy .
|
||||
```
|
||||
|
||||
Produces a static musl binary in a `FROM scratch` image (~10MB).
|
||||
|
||||
## Security properties
|
||||
|
||||
- **Encrypted cookies** — AES-256-GCM, not just signed. Cookie contents cannot be read or tampered with without the key.
|
||||
- **PKCE (S256)** — protects the authorization code exchange against interception.
|
||||
- **Stateless PKCE** — the PKCE verifier is encrypted inside the `state` parameter. No server-side storage needed.
|
||||
- **No open redirect** — the redirect URL after login is encrypted in `state`, not taken from user input.
|
||||
- **Deny by default** — any host not listed in routes gets 403.
|
||||
- **JWT validation** — ID tokens are verified against Keycloak's JWKS (keys refreshed hourly).
|
||||
- **Cookie flags** — `HttpOnly`, `Secure`, `SameSite=Lax`.
|
||||
|
||||
## Response headers
|
||||
|
||||
On successful authentication, the following headers are set on the request forwarded to the upstream service:
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `X-Auth-Request-User` | `preferred_username` from the ID token |
|
||||
| `X-Auth-Request-Email` | `email` from the ID token |
|
||||
| `X-Auth-Request-Groups` | Comma-separated list of groups |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Reference in New Issue
Block a user