// Package vaultauth mints short-lived Gitea credentials from Vault using the // pod's projected kubernetes service account token. // // repospawner deliberately talks to Vault natively rather than shelling out to // agentpr: the AppRole path agentpr uses is CIDR-bound to hosts outside the // cluster, so an in-cluster login must go through the kubernetes auth mount. package vaultauth import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "os" "strings" "sync" "time" ) // Creds is a dynamic Gitea credential. Token is secret and must never be // logged; String deliberately redacts it. type Creds struct { Username string Token string } func (c Creds) String() string { return "vaultauth.Creds{Username:" + c.Username + ", Token:REDACTED}" } // Client reads credentials from Vault. It holds no long-lived state: every // Creds call performs a fresh login, so nothing outlives the operation that // needed it. type Client struct { Addr string Mount string Role string TokenPath string HTTP *http.Client } // New builds a Client with a bounded HTTP client. func New(addr, mount, role, tokenPath string) *Client { return &Client{ Addr: strings.TrimSuffix(addr, "/"), Mount: strings.Trim(mount, "/"), Role: role, TokenPath: tokenPath, HTTP: &http.Client{Timeout: 20 * time.Second}, } } // Login exchanges the projected service account token for a Vault token. func (c *Client) Login(ctx context.Context) (string, error) { jwt, err := os.ReadFile(c.TokenPath) if err != nil { return "", fmt.Errorf("read service account token: %w", err) } body, err := json.Marshal(map[string]string{ "role": c.Role, "jwt": strings.TrimSpace(string(jwt)), }) if err != nil { return "", err } var out struct { Auth struct { ClientToken string `json:"client_token"` } `json:"auth"` } url := c.Addr + "/v1/auth/" + c.Mount + "/login" if err := c.do(ctx, http.MethodPost, url, "", bytes.NewReader(body), &out); err != nil { return "", fmt.Errorf("vault kubernetes login: %w", err) } if out.Auth.ClientToken == "" { return "", fmt.Errorf("vault kubernetes login: empty client token") } return out.Auth.ClientToken, nil } // Creds logs in and reads a dynamic Gitea credential from path. func (c *Client) Creds(ctx context.Context, path string) (Creds, error) { token, err := c.Login(ctx) if err != nil { return Creds{}, err } var out struct { Data struct { Username string `json:"username"` Token string `json:"token"` } `json:"data"` } url := c.Addr + "/v1/" + strings.TrimPrefix(path, "/") if err := c.do(ctx, http.MethodGet, url, token, nil, &out); err != nil { return Creds{}, fmt.Errorf("read gitea credential: %w", err) } if out.Data.Token == "" { return Creds{}, fmt.Errorf("read gitea credential: response carried no token") } return Creds{Username: out.Data.Username, Token: out.Data.Token}, nil } // do issues a request and decodes a JSON body, mapping any non-2xx to an error // that names the status but never echoes a token back. func (c *Client) do(ctx context.Context, method, url, token string, body io.Reader, out any) error { req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return err } req.Header.Set("Accept", "application/json") if body != nil { req.Header.Set("Content-Type", "application/json") } if token != "" { req.Header.Set("X-Vault-Token", token) } resp, err := c.client().Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("vault %s %s: status %d", method, redactPath(url), resp.StatusCode) } return json.NewDecoder(resp.Body).Decode(out) } func (c *Client) client() *http.Client { if c.HTTP != nil { return c.HTTP } return http.DefaultClient } // redactPath keeps the path for debugging but drops any query string, which is // where a stray wrapped token would otherwise appear. func redactPath(url string) string { if i := strings.IndexByte(url, '?'); i >= 0 { return url[:i] } return url } // TokenSource hands out Gitea tokens, caching the last mint until a caller asks // for a fresh one. Gitea tokens from the dynamic engine live about an hour, so // long-running watch jobs re-mint on the first 401 rather than on a timer. type TokenSource struct { client *Client path string mu sync.Mutex cached Creds } // NewTokenSource builds a TokenSource over c reading path. func NewTokenSource(c *Client, path string) *TokenSource { return &TokenSource{client: c, path: path} } // Token returns a Gitea token. When force is true the cached value is discarded // and a new credential is minted. func (t *TokenSource) Token(ctx context.Context, force bool) (string, error) { t.mu.Lock() defer t.mu.Unlock() if !force && t.cached.Token != "" { return t.cached.Token, nil } creds, err := t.client.Creds(ctx, t.path) if err != nil { return "", err } t.cached = creds return creds.Token, nil } // StaticTokenSource is a TokenFunc over a fixed token, for tests and for the // server's read-only lookups when a credential has already been minted. func StaticTokenSource(token string) func(context.Context, bool) (string, error) { return func(context.Context, bool) (string, error) { return token, nil } }