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) }