POST /auth/token: exchanges username+password for JWT via upstream identity provider (grant_type=password). POST /auth/refresh: exchanges refresh_token for new JWT. Both proxy to Authentik token endpoint using config from P3.7. Upstream responses forwarded verbatim. No credentials logged or leaked in responses. authClient interface extracted for testability. 15 tests covering: success, custom scope, missing fields, invalid JSON, wrong method, not configured, upstream error, credential rejection, token expiry, no credential leak. Closes homelab#6 Closes homelab#8 Co-authored-by: poimen <[email protected]>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user