From a51c14426f63c22f8632e848c4195cd031712058 Mon Sep 17 00:00:00 2001 From: Rock Date: Wed, 9 Sep 2026 00:00:07 +0000 Subject: [PATCH] feat(proxy): add /auth/token and /auth/refresh endpoints (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes homelab#6 (P3.1) and homelab#8 (P3.3) ## Endpoints | Path | Method | Body | What it does | |------|--------|------|-------------| | `/auth/token` | POST | `{username, password, scope?}` | Password grant → JWT | | `/auth/refresh` | POST | `{refresh_token, scope?}` | Refresh grant → new JWT | Both proxy to Authentik `tokenUrl` (from P3.7 config). Upstream response forwarded verbatim — client sees Authentik errors directly. --- internal/proxy/auth_endpoints.go | 160 +++++++++++++ internal/proxy/auth_endpoints_test.go | 315 ++++++++++++++++++++++++++ internal/proxy/proxy.go | 16 ++ 3 files changed, 491 insertions(+) create mode 100644 internal/proxy/auth_endpoints.go create mode 100644 internal/proxy/auth_endpoints_test.go diff --git a/internal/proxy/auth_endpoints.go b/internal/proxy/auth_endpoints.go new file mode 100644 index 0000000..f6fda64 --- /dev/null +++ b/internal/proxy/auth_endpoints.go @@ -0,0 +1,160 @@ +package proxy + +import ( + "encoding/json" + "io" + "net/http" + "net/url" + "time" + + "forgejo.riotpiao.com/rock/homelab-frontend/internal/logging" +) + +// tokenRequest is the JSON body for POST /auth/token. +type tokenRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Scope string `json:"scope,omitempty"` +} + +// refreshRequest is the JSON body for POST /auth/refresh. +type refreshRequest struct { + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope,omitempty"` +} + +// authClient handles token exchange with the upstream identity provider. +// Extracted for testability — production uses http.DefaultClient, +// tests inject a stub. +type authClient interface { + PostForm(url string, data url.Values) (*http.Response, error) +} + +// httpAuthClient wraps http.Client to implement authClient. +type httpAuthClient struct { + client *http.Client +} + +func (c *httpAuthClient) PostForm(url string, data url.Values) (*http.Response, error) { + return c.client.PostForm(url, data) +} + +func newAuthClient() authClient { + return &httpAuthClient{ + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// handleAuthToken exchanges username+password for a JWT via the upstream +// identity provider's token endpoint using grant_type=password. +func (h *Handler) handleAuthToken(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeProblemDetail(w, http.StatusMethodNotAllowed, + "about:blank#method-not-allowed", "Method Not Allowed", + "POST only", nil) + return + } + + if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" { + writeProblemDetail(w, http.StatusServiceUnavailable, + "about:blank#not-configured", "Not Configured", + "token endpoint not configured", nil) + return + } + + var req tokenRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeProblemDetail(w, http.StatusBadRequest, + "about:blank#bad-request", "Bad Request", + "invalid JSON body", nil) + return + } + + if req.Username == "" || req.Password == "" { + writeProblemDetail(w, http.StatusBadRequest, + "about:blank#bad-request", "Bad Request", + "username and password are required", nil) + return + } + + scope := req.Scope + if scope == "" { + scope = "openid roles permissions" + } + + form := url.Values{ + "grant_type": {"password"}, + "username": {req.Username}, + "password": {req.Password}, + "client_id": {h.config.Auth.ClientID}, + "client_secret": {h.config.Auth.ClientSecret}, + "scope": {scope}, + } + + h.forwardTokenResponse(w, form, "/auth/token") +} + +// handleAuthRefresh exchanges a refresh token for a new JWT. +func (h *Handler) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + writeProblemDetail(w, http.StatusMethodNotAllowed, + "about:blank#method-not-allowed", "Method Not Allowed", + "POST only", nil) + return + } + + if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" { + writeProblemDetail(w, http.StatusServiceUnavailable, + "about:blank#not-configured", "Not Configured", + "token endpoint not configured", nil) + return + } + + var req refreshRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeProblemDetail(w, http.StatusBadRequest, + "about:blank#bad-request", "Bad Request", + "invalid JSON body", nil) + return + } + + if req.RefreshToken == "" { + writeProblemDetail(w, http.StatusBadRequest, + "about:blank#bad-request", "Bad Request", + "refresh_token is required", nil) + return + } + + form := url.Values{ + "grant_type": {"refresh_token"}, + "refresh_token": {req.RefreshToken}, + "client_id": {h.config.Auth.ClientID}, + "client_secret": {h.config.Auth.ClientSecret}, + } + + if req.Scope != "" { + form.Set("scope", req.Scope) + } + + h.forwardTokenResponse(w, form, "/auth/refresh") +} + +// forwardTokenResponse posts form data to the token endpoint and +// forwards the response verbatim to the client. +func (h *Handler) forwardTokenResponse(w http.ResponseWriter, form url.Values, logPath string) { + resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form) + if err != nil { + writeProblemDetail(w, http.StatusBadGateway, + "about:blank#bad-gateway", "Bad Gateway", + "identity provider unreachable", nil) + logging.Errorf("auth upstream error", err, map[string]string{ + "path": logPath, + }) + return + } + defer resp.Body.Close() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) +} diff --git a/internal/proxy/auth_endpoints_test.go b/internal/proxy/auth_endpoints_test.go new file mode 100644 index 0000000..b1e5a9a --- /dev/null +++ b/internal/proxy/auth_endpoints_test.go @@ -0,0 +1,315 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "forgejo.riotpiao.com/rock/homelab-frontend/internal/config" +) + +// stubAuthClient captures the form data sent and returns a canned response. +type stubAuthClient struct { + lastForm url.Values + statusCode int + body string + err error +} + +func (s *stubAuthClient) PostForm(u string, data url.Values) (*http.Response, error) { + s.lastForm = data + if s.err != nil { + return nil, s.err + } + return &http.Response{ + StatusCode: s.statusCode, + Body: io.NopCloser(strings.NewReader(s.body)), + Header: http.Header{"Content-Type": {"application/json"}}, + }, nil +} + +func newTestHandler(tokenURL, clientID, clientSecret string, client authClient) *Handler { + cfg := &config.Config{ + Auth: config.AuthConfig{ + TokenURL: tokenURL, + ClientID: clientID, + ClientSecret: clientSecret, + }, + } + h := &Handler{ + config: cfg, + authHTTP: client, + routes: make(map[string]*Route), + transports: make(map[string]*http.Transport), + } + return h +} + +func TestAuthToken_Success(t *testing.T) { + stub := &stubAuthClient{ + statusCode: 200, + body: `{"access_token":"jwt.token.here","refresh_token":"refresh","expires_in":3600}`, + } + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub) + + body := `{"username":"rock","password":"pass123"}` + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify form sent to upstream + if stub.lastForm.Get("grant_type") != "password" { + t.Errorf("grant_type = %q, want password", stub.lastForm.Get("grant_type")) + } + if stub.lastForm.Get("username") != "rock" { + t.Errorf("username = %q, want rock", stub.lastForm.Get("username")) + } + if stub.lastForm.Get("client_id") != "api-gw" { + t.Errorf("client_id = %q, want api-gw", stub.lastForm.Get("client_id")) + } + if stub.lastForm.Get("client_secret") != "secret" { + t.Errorf("client_secret = %q, want secret", stub.lastForm.Get("client_secret")) + } + if stub.lastForm.Get("scope") != "openid roles permissions" { + t.Errorf("scope = %q, want default scope", stub.lastForm.Get("scope")) + } + + // Verify response forwarded + var resp map[string]interface{} + json.NewDecoder(w.Body).Decode(&resp) + if resp["access_token"] != "jwt.token.here" { + t.Errorf("access_token not forwarded") + } +} + +func TestAuthToken_CustomScope(t *testing.T) { + stub := &stubAuthClient{statusCode: 200, body: `{}`} + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub) + + body := `{"username":"rock","password":"pass","scope":"openid roles"}` + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if stub.lastForm.Get("scope") != "openid roles" { + t.Errorf("scope = %q, want custom scope", stub.lastForm.Get("scope")) + } +} + +func TestAuthToken_MissingUsername(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + + body := `{"password":"pass"}` + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestAuthToken_MissingPassword(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + + body := `{"username":"rock"}` + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestAuthToken_InvalidJSON(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader("not json")) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestAuthToken_WrongMethod(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + + r := httptest.NewRequest("GET", "/auth/token", nil) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if w.Code != 405 { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestAuthToken_NotConfigured(t *testing.T) { + h := newTestHandler("", "", "", &stubAuthClient{}) + + body := `{"username":"rock","password":"pass"}` + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if w.Code != 503 { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestAuthToken_UpstreamError(t *testing.T) { + stub := &stubAuthClient{err: io.ErrUnexpectedEOF} + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub) + + body := `{"username":"rock","password":"pass"}` + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + if w.Code != 502 { + t.Errorf("expected 502, got %d", w.Code) + } +} + +func TestAuthToken_UpstreamRejectsCredentials(t *testing.T) { + stub := &stubAuthClient{ + statusCode: 400, + body: `{"error":"invalid_grant","error_description":"bad password"}`, + } + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub) + + body := `{"username":"rock","password":"wrong"}` + r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + // Upstream error forwarded verbatim + if w.Code != 400 { + t.Errorf("expected 400 (forwarded), got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "invalid_grant") { + t.Errorf("expected upstream error forwarded, got %s", w.Body.String()) + } +} + +// --- /auth/refresh tests --- + +func TestAuthRefresh_Success(t *testing.T) { + stub := &stubAuthClient{ + statusCode: 200, + body: `{"access_token":"new.jwt","refresh_token":"new.refresh","expires_in":3600}`, + } + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub) + + body := `{"refresh_token":"old.refresh"}` + r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(body)) + w := httptest.NewRecorder() + + h.handleAuthRefresh(w, r) + + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if stub.lastForm.Get("grant_type") != "refresh_token" { + t.Errorf("grant_type = %q, want refresh_token", stub.lastForm.Get("grant_type")) + } + if stub.lastForm.Get("refresh_token") != "old.refresh" { + t.Errorf("refresh_token not sent") + } +} + +func TestAuthRefresh_MissingToken(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + + r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{}`)) + w := httptest.NewRecorder() + + h.handleAuthRefresh(w, r) + + if w.Code != 400 { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestAuthRefresh_WrongMethod(t *testing.T) { + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{}) + + r := httptest.NewRequest("GET", "/auth/refresh", nil) + w := httptest.NewRecorder() + + h.handleAuthRefresh(w, r) + + if w.Code != 405 { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestAuthRefresh_NotConfigured(t *testing.T) { + h := newTestHandler("", "", "", &stubAuthClient{}) + + r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"x"}`)) + w := httptest.NewRecorder() + + h.handleAuthRefresh(w, r) + + if w.Code != 503 { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestAuthRefresh_ExpiredToken(t *testing.T) { + stub := &stubAuthClient{ + statusCode: 401, + body: `{"error":"invalid_grant","error_description":"token expired"}`, + } + h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub) + + r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"expired"}`)) + w := httptest.NewRecorder() + + h.handleAuthRefresh(w, r) + + if w.Code != 401 { + t.Errorf("expected 401 (forwarded), got %d", w.Code) + } +} + +// Verify no credentials are leaked in response bodies +func TestAuthToken_NoCredentialLeak(t *testing.T) { + stub := &stubAuthClient{statusCode: 200, body: `{"access_token":"tok"}`} + h := newTestHandler("https://auth.example.com/token/", "api-gw", "super-secret", stub) + + body := `{"username":"rock","password":"my-password"}` + r := httptest.NewRequest("POST", "/auth/token", bytes.NewReader([]byte(body))) + w := httptest.NewRecorder() + + h.handleAuthToken(w, r) + + respBody := w.Body.String() + if strings.Contains(respBody, "super-secret") { + t.Error("client_secret leaked in response") + } + if strings.Contains(respBody, "my-password") { + t.Error("password leaked in response") + } +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index b21125e..4adadf7 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -29,6 +29,8 @@ type Handler struct { config *config.Config // jwtValidator validates JWT tokens for authenticated endpoints jwtValidator *auth.Validator + // authHTTP is the HTTP client for token exchange with the identity provider. + authHTTP authClient // Default timeouts for synthesized routes (model-based dispatch) defaultConnectTimeout time.Duration defaultReadTimeout time.Duration @@ -86,6 +88,10 @@ func New(cfg *config.Config) *Handler { ) } + if cfg.Auth.TokenURL != "" { + h.authHTTP = newAuthClient() + } + for name, route := range cfg.Routes { // Create a transport per unique upstream address for connection reuse transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream) @@ -228,6 +234,16 @@ func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, d // ServeHTTP implements http.Handler. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Auth endpoints — no JWT required (they issue tokens) + if r.URL.Path == "/auth/token" { + h.handleAuthToken(w, r) + return + } + if r.URL.Path == "/auth/refresh" { + h.handleAuthRefresh(w, r) + return + } + // Handle /v1/models endpoint (no routing needed, derived from config) if r.URL.Path == "/v1/models" && r.Method == "GET" { h.handleModelsEndpoint(w, r)