Make /auth work for both nginx and traefik

This commit is contained in:
2025-08-08 13:45:44 +10:00
parent 1aba3b3947
commit 9ece5628c4
10 changed files with 440 additions and 14 deletions
+1
View File
@@ -59,3 +59,4 @@ dev-*
web/test.html
*.log
test*.yaml
+63 -5
View File
@@ -1,6 +1,6 @@
# 🔐 Passkey Auth for Kubernetes Nginx Ingress
# 🔐 Passkey Auth for Kubernetes Ingress Controllers
A WebAuthn-based passkey authentication provider that integrates ingress controllers, currently support Kubernetes nginx Ingress controller. Provides secure, passwordless authentication using passkeys (FIDO2/WebAuthn) as an auth backend for nginx ingress.
A WebAuthn-based passkey authentication provider that integrates with ingress controllers. Currently supports Kubernetes Nginx Ingress Controller and Traefik Ingress Controller. Provides secure, passwordless authentication using passkeys (FIDO2/WebAuthn) as an auth backend.
## TLDR;
@@ -17,7 +17,7 @@ I use it for signing into my home lab apps.
## ✨ Features
- **Passwordless Authentication**: Uses WebAuthn/FIDO2 passkeys for secure authentication
- **Nginx Ingress Integration**: Works as auth backend using nginx `auth_request` directive
- **Ingress Controller Integration**: Works as auth backend for Nginx Ingress (`auth_request`) and Traefik Ingress (`ForwardAuth`)
- **User Management**: An simple Admin interface for managing users and approval status
- **Kubernetes Native**: Designed for Kubernetes deployment with persistent storage
@@ -63,15 +63,17 @@ go run main.go
### Setup Your App's Ingress
#### Nginx Ingress Controller
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: your-app-ingress
annotations:
nginx.ingress.kubernetes.io/auth-url: "https://your-passkey-auth.com/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://your-passkey-auth.com/?redirect=https%3A%2F%2F$host$request_uri"
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-Email"
spec:
rules:
- host: your-app.com
@@ -86,6 +88,47 @@ spec:
number: 80
```
#### Traefik Ingress Controller
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: your-app-ingress
annotations:
traefik.ingress.kubernetes.io/router.middlewares: default-passkey-auth@kubernetescrd
spec:
rules:
- host: your-app.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: your-app-service
port:
number: 80
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: passkey-auth
spec:
forwardAuth:
address: https://your-passkey-auth.com/auth
authRequestHeaders:
- "X-Forwarded-Method"
- "X-Forwarded-Proto"
- "X-Forwarded-Host"
- "X-Forwarded-Uri"
- "X-Forwarded-For"
authResponseHeaders:
- "X-Auth-User"
- "X-Auth-Email"
authResponseHeadersRegex: "^X-"
```
## 👥 User Management
@@ -118,6 +161,21 @@ go run main.go
# Access at http://localhost:8080
```
### Auth Endpoint Behavior
The `/auth` endpoint automatically adapts to work with both Nginx and Traefik ingress controllers:
**For Nginx auth_request:**
- Authenticated users: Returns `200 OK` with user headers
- Unauthenticated users: Returns `401 Unauthorized`
**For Traefik ForwardAuth:**
- Authenticated users: Returns `200 OK` with user headers
- Unauthenticated users (with redirect param): Returns `302 Found` with `Location` header pointing to login page
- Unauthenticated users (without redirect param): Returns `401 Unauthorized` (fallback for Nginx)
The endpoint detects the ingress controller type by checking for query parameters like `rd` or `redirect` that Traefik typically includes.
### Key API Endpoints
| Endpoint | Method | Description |
@@ -126,7 +184,7 @@ go run main.go
| `/api/register/finish` | POST | Complete passkey registration |
| `/api/login/begin` | POST | Start passkey authentication |
| `/api/login/finish` | POST | Complete passkey authentication |
| `/auth` | GET | Nginx auth check endpoint |
| `/auth` | GET | Auth check endpoint for ingress controllers |
| `/api/users` | GET/POST | List/create users |
| `/health` | GET | Health check |
+3 -2
View File
@@ -1,8 +1,8 @@
apiVersion: v2
name: passkey-auth
description: A WebAuthn-based passkey authentication provider for Kubernetes Nginx Ingress
description: A WebAuthn-based passkey authentication provider for Kubernetes ingress controllers (Nginx & Traefik)
type: application
version: 0.1.1
version: 0.1.2
appVersion: "main"
home: https://github.com/wahyd4/passkey-auth
sources:
@@ -15,6 +15,7 @@ keywords:
- webauthn
- passkey
- nginx-ingress
- traefik-ingress
- security
annotations:
category: Security
+46 -2
View File
@@ -1,6 +1,6 @@
# Passkey Auth Helm Chart
A Helm chart for deploying Passkey Auth, a WebAuthn-based passkey authentication provider that integrates with Kubernetes Nginx Ingress controller.
A Helm chart for deploying Passkey Auth, a WebAuthn-based passkey authentication provider that integrates with Kubernetes ingress controllers (Nginx Ingress Controller and Traefik Ingress Controller).
## Overview
@@ -18,7 +18,7 @@ helm upgrade --install my-passkey-auth -n home-apps -f my-values.yaml passkey-a
- Kubernetes 1.19+
- Helm 3.0+
- Nginx Ingress Controller
- Ingress Controller (Nginx Ingress Controller or Traefik Ingress Controller)
- StorageClass for persistent volumes
## Installation
@@ -85,6 +85,8 @@ ingress:
## Setup Authentication for Your Services
### Nginx Ingress Controller
Add these annotations to your ingress resources to protect them with passkey authentication:
```yaml
@@ -100,6 +102,40 @@ spec:
# ... your ingress spec
```
### Traefik Ingress Controller
For Traefik, create a ForwardAuth middleware and reference it in your ingress:
```yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: passkey-auth
namespace: your-app-namespace
spec:
forwardAuth:
address: https://auth.example.com/auth
authRequestHeaders:
- "X-Forwarded-Method"
- "X-Forwarded-Proto"
- "X-Forwarded-Host"
- "X-Forwarded-Uri"
- "X-Forwarded-For"
authResponseHeaders:
- "X-Auth-User"
- "X-Auth-Email"
authResponseHeadersRegex: "^X-"
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-protected-app
annotations:
traefik.ingress.kubernetes.io/router.middlewares: your-app-namespace-passkey-auth@kubernetescrd
spec:
# ... your ingress spec
```
## Advanced Configuration
### Custom Storage
@@ -256,3 +292,11 @@ secrets:
| `nodeSelector` | Node labels for pod assignment | `{}` |
| `tolerations` | Tolerations for pod assignment | `[]` |
| `affinity` | Affinity for pod assignment | `{}` |
### Traefik configuration
| Name | Description | Value |
| ------------------------------ | --------------------------------------------------------------- | ------- |
| `traefik.enabled` | Enable Traefik-specific features | `false` |
| `traefik.middleware.create` | Create Traefik ForwardAuth middleware | `true` |
| `traefik.middleware.name` | Custom name for middleware (defaults to chart fullname) | `""` |
@@ -0,0 +1,55 @@
# Example values for Traefik deployment
# Copy this file and modify for your environment
config:
webauthn:
rpId: "auth.example.com"
rpOrigins:
- "https://auth.example.com"
cors:
allowedOrigins:
- "https://*.example.com"
auth:
cookieDomain: ".example.com"
allowedEmails:
- "admin@example.com"
secrets:
sessionSecret: "your-secure-random-secret-min-32-chars"
ingress:
enabled: true
className: "traefik"
annotations:
kubernetes.io/tls-acme: "true"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
hosts:
- host: auth.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: passkey-auth-tls
hosts:
- auth.example.com
# Enable Traefik middleware creation
traefik:
enabled: true
middleware:
create: true
name: "passkey-auth" # Optional: custom name
persistence:
enabled: true
size: 2Gi
resources:
limits:
cpu: 400m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
@@ -0,0 +1,27 @@
{{- if and .Values.traefik.enabled .Values.traefik.middleware.create }}
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: {{ default (include "passkey-auth.fullname" .) .Values.traefik.middleware.name }}-forwardauth
namespace: {{ .Release.Namespace }}
labels:
{{- include "passkey-auth.labels" . | nindent 4 }}
spec:
forwardAuth:
address: http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/auth
authRequestHeaders:
- X-Forwarded-Method
- X-Forwarded-Proto
- X-Forwarded-Host
- X-Forwarded-Uri
- X-Forwarded-For
- Cookie
- Authorization
authResponseHeaders:
- X-Auth-User
- X-Auth-Email
authResponseHeadersRegex: "^X-"
authResponseHeaders:
- Location
authResponseHeadersRegex: ^X-|^Location$
{{- end }}
+7
View File
@@ -166,3 +166,10 @@ volumeMounts: []
# Additional volumes
volumes: []
# Traefik-specific configuration
traefik:
enabled: false
middleware:
create: true
name: "" # If empty, will use chart fullname
+40 -4
View File
@@ -392,17 +392,18 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]string{"status": "success"})
}
// AuthCheck implements the nginx auth_request protocol
// AuthCheck implements auth backend for both nginx auth_request and Traefik ForwardAuth
func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
// Debug logging
logrus.Debugf("AuthCheck request from %s", r.RemoteAddr)
logrus.Debugf("AuthCheck headers: %+v", r.Header)
logrus.Debugf("AuthCheck cookies: %+v", r.Cookies())
logrus.Debugf("AuthCheck query params: %+v", r.URL.Query())
session, err := h.store.Get(r, "auth-session")
if err != nil {
logrus.Errorf("Failed to get auth session: %v", err)
w.WriteHeader(http.StatusUnauthorized)
h.handleUnauthenticated(w, r)
return
}
@@ -411,8 +412,8 @@ func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
logrus.Debugf("Session values: %+v", session.Values)
if !ok || !authenticated {
logrus.Debugf("User not authenticated, returning 401")
w.WriteHeader(http.StatusUnauthorized)
logrus.Debugf("User not authenticated")
h.handleUnauthenticated(w, r)
return
}
@@ -428,6 +429,41 @@ func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
// handleUnauthenticated handles unauthenticated requests for both Nginx and Traefik
func (h *Handlers) handleUnauthenticated(w http.ResponseWriter, r *http.Request) {
// Check for redirect parameter (Traefik ForwardAuth typically includes this)
redirectURL := r.URL.Query().Get("rd")
if redirectURL == "" {
// Also check for other common redirect parameter names
redirectURL = r.URL.Query().Get("redirect")
}
// If redirect parameter is present, return 302 redirect (Traefik ForwardAuth)
if redirectURL != "" {
logrus.Debugf("Redirect parameter found (%s), returning 302 redirect for Traefik", redirectURL)
// Construct login URL with redirect parameter
loginURL := "/login.html?redirect=" + redirectURL
// If we have a host header, construct a full URL
if host := r.Header.Get("Host"); host != "" {
scheme := "http"
if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil {
scheme = "https"
}
loginURL = scheme + "://" + host + loginURL
}
w.Header().Set("Location", loginURL)
w.WriteHeader(http.StatusFound) // 302
return
}
// No redirect parameter, return 401 (Nginx auth_request)
logrus.Debugf("No redirect parameter, returning 401 for Nginx auth_request")
w.WriteHeader(http.StatusUnauthorized)
}
// GetAuthStatus returns the current authentication status
func (h *Handlers) GetAuthStatus(w http.ResponseWriter, r *http.Request) {
session, err := h.store.Get(r, "auth-session")
+195
View File
@@ -0,0 +1,195 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"passkey-auth/internal/auth"
"passkey-auth/internal/config"
"passkey-auth/internal/database"
)
func TestAuthCheck(t *testing.T) {
// Setup test config
cfg := &config.Config{
Auth: config.AuthConfig{
SessionSecret: "test-secret",
},
WebAuthn: config.WebAuthnConfig{
RPDisplayName: "Test Passkey Auth",
RPID: "localhost",
RPOrigins: []string{"http://localhost:8080"},
},
}
// Setup test database
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer db.Close()
// Setup test WebAuthn (mock)
webAuthn, err := auth.NewWebAuthn(cfg)
if err != nil {
t.Fatalf("Failed to create test WebAuthn: %v", err)
}
// Create handlers
h := New(db, webAuthn, cfg)
tests := []struct {
name string
queryParams string
authenticated bool
expectedStatus int
expectedHeader string
}{
{
name: "Unauthenticated without redirect param (Nginx)",
queryParams: "",
authenticated: false,
expectedStatus: http.StatusUnauthorized,
expectedHeader: "",
},
{
name: "Unauthenticated with rd param (Traefik)",
queryParams: "rd=https://example.com/protected",
authenticated: false,
expectedStatus: http.StatusFound,
expectedHeader: "/login.html?redirect=https://example.com/protected",
},
{
name: "Unauthenticated with redirect param (Traefik)",
queryParams: "redirect=https://example.com/protected",
authenticated: false,
expectedStatus: http.StatusFound,
expectedHeader: "/login.html?redirect=https://example.com/protected",
},
{
name: "Authenticated with redirect param",
queryParams: "rd=https://example.com/protected",
authenticated: true,
expectedStatus: http.StatusOK,
expectedHeader: "",
},
{
name: "Authenticated without redirect param",
queryParams: "",
authenticated: true,
expectedStatus: http.StatusOK,
expectedHeader: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request
req, err := http.NewRequest("GET", "/auth?"+tt.queryParams, nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Mock session if authenticated
if tt.authenticated {
// Create a session cookie for testing
session, _ := h.store.Get(req, "auth-session")
session.Values["authenticated"] = true
session.Values["user_id"] = 1
session.Values["user_email"] = "test@example.com"
// Create a response recorder to capture the session cookie
w := httptest.NewRecorder()
session.Save(req, w)
// Extract the cookie and add it to the request
for _, cookie := range w.Result().Cookies() {
if cookie.Name == "auth-session" {
req.AddCookie(cookie)
break
}
}
}
// Create response recorder
w := httptest.NewRecorder()
// Call the handler
h.AuthCheck(w, req)
// Check status code
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
// Check Location header for redirects
if tt.expectedStatus == http.StatusFound {
location := w.Header().Get("Location")
if location != tt.expectedHeader {
t.Errorf("Expected Location header %s, got %s", tt.expectedHeader, location)
}
}
// Check auth headers for authenticated requests
if tt.authenticated && tt.expectedStatus == http.StatusOK {
userHeader := w.Header().Get("X-Auth-User")
if userHeader == "" {
t.Error("Expected X-Auth-User header for authenticated request")
}
}
})
}
}
func TestAuthCheckWithHost(t *testing.T) {
// Setup test config
cfg := &config.Config{
Auth: config.AuthConfig{
SessionSecret: "test-secret",
},
WebAuthn: config.WebAuthnConfig{
RPDisplayName: "Test Passkey Auth",
RPID: "localhost",
RPOrigins: []string{"http://localhost:8080"},
},
}
// Setup test database
db, err := database.New(":memory:")
if err != nil {
t.Fatalf("Failed to create test database: %v", err)
}
defer db.Close()
// Setup test WebAuthn
webAuthn, err := auth.NewWebAuthn(cfg)
if err != nil {
t.Fatalf("Failed to create test WebAuthn: %v", err)
}
// Create handlers
h := New(db, webAuthn, cfg)
// Test with Host header
req, err := http.NewRequest("GET", "/auth?rd=https://example.com/protected", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
req.Header.Set("Host", "auth.example.com")
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
h.AuthCheck(w, req)
// Should return 302 with full URL
if w.Code != http.StatusFound {
t.Errorf("Expected status %d, got %d", http.StatusFound, w.Code)
}
expectedLocation := "https://auth.example.com/login.html?redirect=https://example.com/protected"
location := w.Header().Get("Location")
if location != expectedLocation {
t.Errorf("Expected Location header %s, got %s", expectedLocation, location)
}
}
+3 -1
View File
@@ -61,7 +61,9 @@ func main() {
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
api.HandleFunc("/users/{id}", h.DeleteUser).Methods("DELETE")
// Nginx auth backend endpoint
// Auth backend endpoint for ingress controllers (Nginx auth_request & Traefik ForwardAuth)
// - For Nginx: Returns 200 for authenticated, 401 for unauthenticated
// - For Traefik: Returns 200 for authenticated, 302 redirect for unauthenticated (when rd param present)
router.HandleFunc("/auth", h.AuthCheck).Methods("GET", "HEAD")
// Health check