Have a seperate endpoint for traefik auth

This commit is contained in:
2025-08-08 14:34:36 +10:00
parent 01a625d8fd
commit 4e4ee1fda8
6 changed files with 349 additions and 201 deletions
+9 -11
View File
@@ -163,18 +163,17 @@ go run main.go
### Auth Endpoint Behavior
The `/auth` endpoint automatically adapts to work with both Nginx and Traefik ingress controllers:
The auth service provides separate endpoints for different ingress controllers:
**For Nginx auth_request:**
- Authenticated users: Returns `200 OK` with user headers
**Nginx auth_request (`/auth/nginx`):**
- Authenticated users: Returns `200 OK` with user headers (`X-Auth-User`, `X-Auth-User-ID`)
- 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)
**Traefik ForwardAuth (`/auth/traefik`):**
- Authenticated users: Returns `200 OK` with user headers (`X-Auth-User`, `X-Auth-User-ID`)
- Unauthenticated users: Returns `302 Found` with `Location` header pointing to login page
The endpoint detects the ingress controller type by checking for query parameters like `rd` or `redirect` that Traefik typically includes.
The Traefik endpoint automatically reconstructs the original URL from forwarded headers (`X-Forwarded-Host`, `X-Forwarded-Uri`, `X-Forwarded-Proto`) to provide proper redirect functionality.
### Key API Endpoints
@@ -184,11 +183,10 @@ The endpoint detects the ingress controller type by checking for query parameter
| `/api/register/finish` | POST | Complete passkey registration |
| `/api/login/begin` | POST | Start passkey authentication |
| `/api/login/finish` | POST | Complete passkey authentication |
| `/auth` | GET | Auth check endpoint for ingress controllers |
| `/auth/nginx` | GET | Auth check endpoint for Nginx auth_request |
| `/auth/traefik` | GET | Auth check endpoint for Traefik ForwardAuth |
| `/api/users` | GET/POST | List/create users |
| `/health` | GET | Health check |
## 📄 License
Apache License 2.0
+5 -5
View File
@@ -95,9 +95,9 @@ kind: Ingress
metadata:
name: my-protected-app
annotations:
nginx.ingress.kubernetes.io/auth-url: "https://auth.example.com/auth"
nginx.ingress.kubernetes.io/auth-url: "https://auth.example.com/auth/nginx"
nginx.ingress.kubernetes.io/auth-signin: "https://auth.example.com/login?rd=$scheme://$http_host$request_uri"
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-Email"
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
spec:
# ... your ingress spec
```
@@ -114,7 +114,7 @@ metadata:
namespace: your-app-namespace
spec:
forwardAuth:
address: https://auth.example.com/auth
address: https://auth.example.com/auth/traefik
authRequestHeaders:
- "X-Forwarded-Method"
- "X-Forwarded-Proto"
@@ -123,8 +123,8 @@ spec:
- "X-Forwarded-For"
authResponseHeaders:
- "X-Auth-User"
- "X-Auth-Email"
authResponseHeadersRegex: "^X-"
- "X-Auth-User-ID"
authResponseHeadersRegex: "^X-|^Location$"
---
apiVersion: networking.k8s.io/v1
kind: Ingress
@@ -8,7 +8,7 @@ metadata:
{{- include "passkey-auth.labels" . | nindent 4 }}
spec:
forwardAuth:
address: http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/auth
address: http{{ if .Values.ingress.tls }}s{{ end }}://{{ (index .Values.ingress.hosts 0).host }}/auth/traefik
authRequestHeaders:
- X-Forwarded-Method
- X-Forwarded-Proto
+103 -42
View File
@@ -392,32 +392,29 @@ func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
h.writeJSON(w, map[string]string{"status": "success"})
}
// 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())
// AuthCheckNginx implements nginx auth_request protocol
func (h *Handlers) AuthCheckNginx(w http.ResponseWriter, r *http.Request) {
logrus.Debugf("Nginx AuthCheck request from %s", r.RemoteAddr)
logrus.Debugf("Nginx AuthCheck headers: %+v", r.Header)
logrus.Debugf("Nginx AuthCheck cookies: %+v", r.Cookies())
session, err := h.store.Get(r, "auth-session")
if err != nil {
logrus.Errorf("Failed to get auth session: %v", err)
h.handleUnauthenticated(w, r)
w.WriteHeader(http.StatusUnauthorized)
return
}
authenticated, ok := session.Values["authenticated"].(bool)
logrus.Debugf("Session authenticated: %v, ok: %v", authenticated, ok)
logrus.Debugf("Session values: %+v", session.Values)
if !ok || !authenticated {
logrus.Debugf("User not authenticated")
h.handleUnauthenticated(w, r)
logrus.Debugf("User not authenticated, returning 401 for Nginx")
w.WriteHeader(http.StatusUnauthorized)
return
}
// Optional: Add user info to response headers
// Add user info to response headers for nginx
if userID, ok := session.Values["user_id"].(int); ok {
w.Header().Set("X-Auth-User-ID", strconv.Itoa(userID))
}
@@ -425,43 +422,107 @@ func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Auth-User", userEmail)
}
logrus.Debugf("User authenticated, returning 200")
logrus.Debugf("User authenticated, returning 200 for Nginx")
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")
}
// AuthCheckTraefik implements Traefik ForwardAuth protocol
func (h *Handlers) AuthCheckTraefik(w http.ResponseWriter, r *http.Request) {
logrus.Debugf("Traefik AuthCheck request from %s", r.RemoteAddr)
logrus.Debugf("Traefik AuthCheck headers: %+v", r.Header)
logrus.Debugf("Traefik AuthCheck cookies: %+v", r.Cookies())
// 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
session, err := h.store.Get(r, "auth-session")
if err != nil {
logrus.Errorf("Failed to get auth session: %v", err)
h.handleTraefikUnauthenticated(w, r)
return
}
// No redirect parameter, return 401 (Nginx auth_request)
logrus.Debugf("No redirect parameter, returning 401 for Nginx auth_request")
w.WriteHeader(http.StatusUnauthorized)
authenticated, ok := session.Values["authenticated"].(bool)
logrus.Debugf("Session authenticated: %v, ok: %v", authenticated, ok)
if !ok || !authenticated {
logrus.Debugf("User not authenticated")
h.handleTraefikUnauthenticated(w, r)
return
}
// Add user info to response headers for Traefik
if userID, ok := session.Values["user_id"].(int); ok {
w.Header().Set("X-Auth-User-ID", strconv.Itoa(userID))
}
if userEmail, ok := session.Values["user_email"].(string); ok {
w.Header().Set("X-Auth-User", userEmail)
}
logrus.Debugf("User authenticated, returning 200 for Traefik")
w.WriteHeader(http.StatusOK)
}
// handleTraefikUnauthenticated handles unauthenticated requests for Traefik ForwardAuth
func (h *Handlers) handleTraefikUnauthenticated(w http.ResponseWriter, r *http.Request) {
// Construct the original URL from Traefik headers
redirectURL := h.constructOriginalURL(r)
logrus.Debugf("Constructed redirect URL from headers: %s", redirectURL)
// Construct login URL with redirect parameter
loginURL := "/login.html"
if redirectURL != "" {
loginURL += "?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
}
logrus.Debugf("Returning 302 redirect to: %s", loginURL)
w.Header().Set("Location", loginURL)
w.WriteHeader(http.StatusFound) // 302
}
// constructOriginalURL reconstructs the original URL from Traefik forwarded headers
func (h *Handlers) constructOriginalURL(r *http.Request) string {
// Traefik sets various headers that we can use to reconstruct the original URL
// Check for X-Forwarded-Host (original host)
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Header.Get("X-Original-Host")
}
if host == "" {
// Fallback to Host header
host = r.Header.Get("Host")
}
// Check for X-Forwarded-Uri (original path + query)
uri := r.Header.Get("X-Forwarded-Uri")
if uri == "" {
uri = r.Header.Get("X-Original-URI")
}
if uri == "" {
// Fallback to request URI
uri = r.RequestURI
}
// Determine scheme
scheme := "http"
if r.Header.Get("X-Forwarded-Proto") == "https" || r.TLS != nil {
scheme = "https"
}
if host == "" {
return ""
}
// Construct the full URL
return scheme + "://" + host + uri
}
// GetAuthStatus returns the current authentication status
+225 -138
View File
@@ -10,139 +10,7 @@ import (
"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) {
func TestAuthCheckNginx(t *testing.T) {
// Setup test config
cfg := &config.Config{
Auth: config.AuthConfig{
@@ -171,25 +39,244 @@ func TestAuthCheckWithHost(t *testing.T) {
// Create handlers
h := New(db, webAuthn, cfg)
// Test with Host header
req, err := http.NewRequest("GET", "/auth?rd=https://example.com/protected", nil)
tests := []struct {
name string
authenticated bool
expectedStatus int
}{
{
name: "Unauthenticated user",
authenticated: false,
expectedStatus: http.StatusUnauthorized,
},
{
name: "Authenticated user",
authenticated: true,
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request
req, err := http.NewRequest("GET", "/auth/nginx", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Mock session if authenticated
if tt.authenticated {
session, _ := h.store.Get(req, "auth-session")
session.Values["authenticated"] = true
session.Values["user_id"] = 1
session.Values["user_email"] = "test@example.com"
w := httptest.NewRecorder()
session.Save(req, w)
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.AuthCheckNginx(w, req)
// Check status code
if w.Code != tt.expectedStatus {
t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code)
}
// 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 TestAuthCheckTraefik(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)
tests := []struct {
name string
authenticated bool
headers map[string]string
expectedStatus int
expectedRedirect string
}{
{
name: "Unauthenticated without headers",
authenticated: false,
headers: map[string]string{},
expectedStatus: http.StatusFound,
expectedRedirect: "/login.html",
},
{
name: "Unauthenticated with forwarded headers",
authenticated: false,
headers: map[string]string{
"X-Forwarded-Host": "example.com",
"X-Forwarded-Uri": "/protected/page",
"X-Forwarded-Proto": "https",
},
expectedStatus: http.StatusFound,
expectedRedirect: "/login.html?redirect=https://example.com/protected/page",
},
{
name: "Authenticated user",
authenticated: true,
headers: map[string]string{},
expectedStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create request
req, err := http.NewRequest("GET", "/auth/traefik", nil)
if err != nil {
t.Fatalf("Failed to create request: %v", err)
}
// Add headers
for key, value := range tt.headers {
req.Header.Set(key, value)
}
// Mock session if authenticated
if tt.authenticated {
session, _ := h.store.Get(req, "auth-session")
session.Values["authenticated"] = true
session.Values["user_id"] = 1
session.Values["user_email"] = "test@example.com"
w := httptest.NewRecorder()
session.Save(req, w)
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.AuthCheckTraefik(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.expectedRedirect {
t.Errorf("Expected Location header %s, got %s", tt.expectedRedirect, 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 TestAuthCheckTraefikWithHost(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 for full URL construction
req, err := http.NewRequest("GET", "/auth/traefik", 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")
req.Header.Set("X-Forwarded-Host", "app.example.com")
req.Header.Set("X-Forwarded-Uri", "/protected/resource")
w := httptest.NewRecorder()
h.AuthCheck(w, req)
h.AuthCheckTraefik(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"
expectedLocation := "https://auth.example.com/login.html?redirect=https://app.example.com/protected/resource"
location := w.Header().Get("Location")
if location != expectedLocation {
t.Errorf("Expected Location header %s, got %s", expectedLocation, location)
}
}
}
+6 -4
View File
@@ -61,10 +61,12 @@ func main() {
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
api.HandleFunc("/users/{id}", h.DeleteUser).Methods("DELETE")
// 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")
// Auth backend endpoints for ingress controllers
// Nginx auth_request: Returns 200 for authenticated, 401 for unauthenticated
router.HandleFunc("/auth/nginx", h.AuthCheckNginx).Methods("GET", "HEAD")
// Traefik ForwardAuth: Returns 200 for authenticated, 302 redirect for unauthenticated
router.HandleFunc("/auth/traefik", h.AuthCheckTraefik).Methods("GET", "HEAD")
// Health check
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {