CI / CI (pull_request) Successful in 3m18s
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]>
161 lines
4.3 KiB
Go
161 lines
4.3 KiB
Go
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)
|
|
}
|