diff --git a/README.md b/README.md index 7616f89..dc18606 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/helm/passkey-auth/README.md b/helm/passkey-auth/README.md index 065e845..d5a8295 100644 --- a/helm/passkey-auth/README.md +++ b/helm/passkey-auth/README.md @@ -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 diff --git a/helm/passkey-auth/templates/traefik-middleware.yaml b/helm/passkey-auth/templates/traefik-middleware.yaml index 25debc0..98f7500 100644 --- a/helm/passkey-auth/templates/traefik-middleware.yaml +++ b/helm/passkey-auth/templates/traefik-middleware.yaml @@ -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 diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 808208a..1a842cd 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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 diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 2894adf..cf69033 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -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) } -} +} \ No newline at end of file diff --git a/main.go b/main.go index 9cb263e..7c3fd4b 100644 --- a/main.go +++ b/main.go @@ -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) {