passmgr.GetToken returns an api-key in padded-cell mode (provisioned by
scripts/provision-hf-accounts.sh via 'hf user reset-apikey'), but every
call site funneled that through client.New which sent it as a
'Authorization: Bearer <hex>'. The HF backend's HTTPBearer middleware
expects JWT shape there and rejects hex strings as 'Could not validate
credentials'. The d2b83ad backend fix added a Bearer-fallback that tries
the value as an api-key, which masked the issue against current prod;
older backends or any future change in that fallback still 401.
Two changes:
- client.New auto-detects shape: 'eyJ'-prefix + two dots == JWT (Bearer),
anything else == api-key (X-API-Key). Empty token sets neither header.
- internal/help/surface.go's loadPermissionState (called by hf --help
introspection) switches to client.NewWithAPIKey explicitly so the
command-discovery path doesn't depend on the heuristic at all. When
that path failed silently (Known:false), agents would see only the
always-permitted commands ('user.*', 'agent.status', 'config',
'health', 'version') and conclude they had no project permission.
Adds internal/client/client_test.go covering both header paths plus
empty-token, isLikelyJWT cases, and NewWithAPIKey precedence.
Verified end-to-end in sim against a rebuilt hf-backend matching prod
(commit d2b83ad): cli with --token <api-key> sends X-Api-Key header,
backend returns 200 on /projects + /auth/me/permissions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
105 lines
3.6 KiB
Go
105 lines
3.6 KiB
Go
package client
|
|
|
|
import (
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestIsLikelyJWT(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
want bool
|
|
why string
|
|
}{
|
|
// Real JWT minted by an HS256 signer (header `{"alg":"HS256","typ":"JWT"}`).
|
|
{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature", true, "valid JWT shape"},
|
|
// HF backend api-keys are 64-char hex from /users/{id}/apikey.
|
|
{"f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0", false, "hex api-key"},
|
|
// Fabric api-keys carry a fak_ prefix.
|
|
{"fak_30791357ca11ac2ff963999bf265f6a5f240593eb01c06fc", false, "fabric api-key"},
|
|
// eyJ prefix without three segments isn't a JWT.
|
|
{"eyJabc", false, "prefix only"},
|
|
{"eyJabc.def", false, "two segments"},
|
|
// Empty / nonsense.
|
|
{"", false, "empty"},
|
|
{"....", false, "dots only"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := isLikelyJWT(c.in); got != c.want {
|
|
t.Errorf("isLikelyJWT(%q) = %v, want %v (%s)", c.in, got, c.want, c.why)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewAutoSelectsAuthHeader(t *testing.T) {
|
|
// Capture which auth header reaches the server for each token shape.
|
|
var lastReq *http.Request
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastReq = r
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, "{}")
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// api-key path: should go via X-API-Key, NOT Authorization.
|
|
apiKey := "f654c3ff0bbc09e6a22294dfbbbff371a4550366849f59de68ddf064742831a0"
|
|
if _, err := New(srv.URL, apiKey).Get("/anything"); err != nil {
|
|
t.Fatalf("api-key call failed: %v", err)
|
|
}
|
|
if got := lastReq.Header.Get("X-API-Key"); got != apiKey {
|
|
t.Errorf("api-key not sent as X-API-Key (got %q)", got)
|
|
}
|
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
|
t.Errorf("api-key leaked into Authorization header (got %q)", got)
|
|
}
|
|
|
|
// JWT path: should go via Authorization: Bearer, NOT X-API-Key.
|
|
jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
|
|
if _, err := New(srv.URL, jwt).Get("/anything"); err != nil {
|
|
t.Fatalf("jwt call failed: %v", err)
|
|
}
|
|
if got := lastReq.Header.Get("Authorization"); !strings.HasPrefix(got, "Bearer ") || !strings.HasSuffix(got, jwt) {
|
|
t.Errorf("jwt not sent as Bearer (got %q)", got)
|
|
}
|
|
if got := lastReq.Header.Get("X-API-Key"); got != "" {
|
|
t.Errorf("jwt leaked into X-API-Key header (got %q)", got)
|
|
}
|
|
|
|
// Empty token: neither header set.
|
|
if _, err := New(srv.URL, "").Get("/anything"); err != nil {
|
|
t.Fatalf("empty-token call failed: %v", err)
|
|
}
|
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
|
t.Errorf("empty token set Authorization (got %q)", got)
|
|
}
|
|
if got := lastReq.Header.Get("X-API-Key"); got != "" {
|
|
t.Errorf("empty token set X-API-Key (got %q)", got)
|
|
}
|
|
}
|
|
|
|
func TestNewWithAPIKeyAlwaysUsesAPIKeyHeader(t *testing.T) {
|
|
// Even if someone passes a JWT-shaped string via NewWithAPIKey, it must
|
|
// still go via X-API-Key — the explicit constructor wins.
|
|
var lastReq *http.Request
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
lastReq = r
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(w, "{}")
|
|
}))
|
|
defer srv.Close()
|
|
|
|
jwtShape := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.signature"
|
|
if _, err := NewWithAPIKey(srv.URL, jwtShape).Get("/anything"); err != nil {
|
|
t.Fatalf("call failed: %v", err)
|
|
}
|
|
if got := lastReq.Header.Get("X-API-Key"); got != jwtShape {
|
|
t.Errorf("NewWithAPIKey didn't use X-API-Key (got %q)", got)
|
|
}
|
|
if got := lastReq.Header.Get("Authorization"); got != "" {
|
|
t.Errorf("NewWithAPIKey set Authorization (got %q)", got)
|
|
}
|
|
}
|