223 lines
6.4 KiB
Markdown
223 lines
6.4 KiB
Markdown
# 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: ultradesu/rsauth2-proxy:0.1.0
|
|
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 .
|
|
```
|
|
|
|
## 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
|
|
|
|
WTFPL
|