package shared import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // httpClient is the shared client for Vault API calls with a sane timeout. var httpClient = &http.Client{Timeout: 30 * time.Second} // passwordMethods are auth methods whose login takes a username in the path and // a password in the body (POST auth//login/ {"password": ...}). var passwordMethods = map[string]bool{ "ldap": true, "userpass": true, "okta": true, "radius": true, } // NeedsPassword reports whether an auth method prompts for a password. func NeedsPassword(method string) bool { return passwordMethods[method] } // IsTokenMethod reports whether the method authenticates with a raw token the // user pastes in, rather than a username/password login. func IsTokenMethod(method string) bool { return method == "token" } // authResponse models the /auth block returned by a Vault login/renew call. type authResponse struct { Auth struct { ClientToken string `json:"client_token"` Accessor string `json:"accessor"` Policies []string `json:"policies"` TokenPolicies []string `json:"token_policies"` LeaseDuration int `json:"lease_duration"` Renewable bool `json:"renewable"` } `json:"auth"` } // lookupResponse models the /auth/token/lookup-self data block, used when the // method is a raw token (no /auth block is returned by a login call). type lookupResponse struct { Data struct { Accessor string `json:"accessor"` Policies []string `json:"policies"` TTL int `json:"ttl"` Renewable bool `json:"renewable"` DisplayName string `json:"display_name"` } `json:"data"` } // vaultError decodes Vault's {"errors": [...]} response body into a message. func vaultError(status int, body []byte) error { var e struct { Errors []string `json:"errors"` } if json.Unmarshal(body, &e) == nil && len(e.Errors) > 0 { return fmt.Errorf("vault returned HTTP %d: %s", status, strings.Join(e.Errors, "; ")) } msg := strings.TrimSpace(string(body)) if msg == "" { return fmt.Errorf("vault returned HTTP %d", status) } return fmt.Errorf("vault returned HTTP %d: %s", status, msg) } // doRequest performs a Vault API request and returns the response body on 2xx. func doRequest(method, address, path, namespace, token string, payload any) ([]byte, error) { var body io.Reader if payload != nil { b, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("encoding request: %w", err) } body = bytes.NewReader(b) } url := strings.TrimRight(address, "/") + "/v1/" + strings.TrimLeft(path, "/") req, err := http.NewRequest(method, url, body) if err != nil { return nil, fmt.Errorf("building request: %w", err) } if token != "" { req.Header.Set("X-Vault-Token", token) } if namespace != "" { req.Header.Set("X-Vault-Namespace", namespace) } if payload != nil { req.Header.Set("Content-Type", "application/json") } resp, err := httpClient.Do(req) if err != nil { return nil, fmt.Errorf("request to %s failed: %w", url, err) } defer func() { _ = resp.Body.Close() }() data, _ := io.ReadAll(resp.Body) if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, vaultError(resp.StatusCode, data) } return data, nil } // tokenFromAuth builds a cache Token from a resolved context and an /auth block. func tokenFromAuth(rc ResolvedContext, ar authResponse) *Token { policies := ar.Auth.TokenPolicies if len(policies) == 0 { policies = ar.Auth.Policies } now := time.Now().UTC() t := &Token{ Context: rc.Name, Address: rc.Address, Namespace: rc.Namespace, Token: ar.Auth.ClientToken, Accessor: ar.Auth.Accessor, Policies: policies, Renewable: ar.Auth.Renewable, LeaseDurationSeconds: ar.Auth.LeaseDuration, IssuedAt: now, } if ar.Auth.LeaseDuration > 0 { t.ExpiresAt = now.Add(time.Duration(ar.Auth.LeaseDuration) * time.Second) } return t } // Login authenticates against a context and returns a Token ready to cache. // For password methods, secret is the password; for the token method, secret is // the raw client token to adopt (verified via lookup-self). func Login(rc ResolvedContext, secret string) (*Token, error) { if IsTokenMethod(rc.Method) { return loginWithToken(rc, secret) } if !NeedsPassword(rc.Method) { return nil, fmt.Errorf("unsupported auth method %q", rc.Method) } if rc.User == "" { return nil, fmt.Errorf("context %q: no user for %s login", rc.Name, rc.Method) } path := fmt.Sprintf("auth/%s/login/%s", rc.Path, rc.User) data, err := doRequest(http.MethodPost, rc.Address, path, rc.Namespace, "", map[string]string{"password": secret}) if err != nil { return nil, err } var ar authResponse if err := json.Unmarshal(data, &ar); err != nil { return nil, fmt.Errorf("decoding login response: %w", err) } if ar.Auth.ClientToken == "" { return nil, fmt.Errorf("login for context %q returned no token", rc.Name) } return tokenFromAuth(rc, ar), nil } // loginWithToken adopts a raw client token, verifying it and filling in details // via /auth/token/lookup-self. func loginWithToken(rc ResolvedContext, token string) (*Token, error) { if token == "" { return nil, fmt.Errorf("context %q: empty token", rc.Name) } data, err := doRequest(http.MethodGet, rc.Address, "auth/token/lookup-self", rc.Namespace, token, nil) if err != nil { return nil, err } var lr lookupResponse if err := json.Unmarshal(data, &lr); err != nil { return nil, fmt.Errorf("decoding token lookup: %w", err) } now := time.Now().UTC() t := &Token{ Context: rc.Name, Address: rc.Address, Namespace: rc.Namespace, Token: token, Accessor: lr.Data.Accessor, Policies: lr.Data.Policies, Renewable: lr.Data.Renewable, LeaseDurationSeconds: lr.Data.TTL, IssuedAt: now, } if lr.Data.TTL > 0 { t.ExpiresAt = now.Add(time.Duration(lr.Data.TTL) * time.Second) } return t, nil } // Renew renews the given cached token against its context and returns the // updated Token (new lease/expiry), preserving the accessor from the prior // token when the renew response omits it. func Renew(rc ResolvedContext, prev *Token) (*Token, error) { if prev == nil || prev.Token == "" { return nil, fmt.Errorf("context %q: no token to renew", rc.Name) } data, err := doRequest(http.MethodPost, rc.Address, "auth/token/renew-self", rc.Namespace, prev.Token, map[string]string{}) if err != nil { return nil, err } var ar authResponse if err := json.Unmarshal(data, &ar); err != nil { return nil, fmt.Errorf("decoding renew response: %w", err) } t := tokenFromAuth(rc, ar) // renew-self echoes the same client token; guard against an empty echo and // carry over the accessor if the response omitted it. if t.Token == "" { t.Token = prev.Token } if t.Accessor == "" { t.Accessor = prev.Accessor } return t, nil }