Return status.bearerToken, not status.value, from minted tokens
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

ext.cattle.io token creation returns TWO fields: status.value (just the secret
fragment) and status.bearerToken (the full usable credential, formatted
ext/<name>:<secret>). The plugin was returning status.value, so every minted
credential and every rotated root token was non-functional (401 against
Rancher). Verified: bearerToken authenticates (HTTP 200), value alone does not.

- client.go: MintToken returns status.bearerToken, falling back to status.value
  only if a Rancher build omits it.
- Reflect bearerToken in the mock Rancher and unit-test fake; assert the minted
  token is the ext/ bearer form.
This commit is contained in:
Ben Vincent
2026-07-18 16:27:05 +10:00
parent 67989d9768
commit 22c036d930
3 changed files with 33 additions and 12 deletions
+10 -4
View File
@@ -48,10 +48,10 @@ func (f *fakeRancher) server(t *testing.T) *httptest.Server {
_ = json.NewDecoder(r.Body).Decode(&in)
f.minted++
newName := in.Metadata.GenerateName + "abcd"
value := "token-" + newName
bearer := "ext/" + newName + ":secret"
f.byName[newName] = in.Spec.ClusterName
f.valid[value] = newName
out := token{Metadata: tokenMetadata{Name: newName}, Status: tokenStatus{Value: value}}
f.valid[bearer] = newName
out := token{Metadata: tokenMetadata{Name: newName}, Status: tokenStatus{BearerToken: bearer, Value: "secret"}}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(out)
@@ -150,9 +150,15 @@ func TestLifecycle(t *testing.T) {
t.Fatal("creds returned no secret")
}
tokenName, _ := creds.Data["token_name"].(string)
if tokenName == "" || creds.Data["token"].(string) == "" {
tokenVal, _ := creds.Data["token"].(string)
if tokenName == "" || tokenVal == "" {
t.Fatal("creds missing token/token_name")
}
// Must be the usable bearer credential (ext/<name>:<secret>), not the bare
// secret fragment from status.value — see the bearerToken fix.
if !strings.HasPrefix(tokenVal, "ext/") {
t.Errorf("token = %q, want the ext/ bearerToken form", tokenVal)
}
if creds.Data["cluster_name"].(string) != "c-m-abc123" {
t.Errorf("cluster_name = %q, want c-m-abc123", creds.Data["cluster_name"])
}
+15 -4
View File
@@ -59,8 +59,13 @@ type tokenSpec struct {
}
type tokenStatus struct {
// Value is the usable token string. It is returned only in the creation
// response and never again.
// BearerToken is the full, usable credential for the Authorization header —
// ext.cattle.io tokens are of the form "ext/<name>:<secret>". It is returned
// only in the creation response and never again. This is the field to use;
// Value alone is just the secret fragment and does NOT authenticate.
BearerToken string `json:"bearerToken,omitempty"`
// Value is the secret fragment. Kept as a fallback for Rancher builds that
// don't populate bearerToken.
Value string `json:"value,omitempty"`
}
@@ -124,10 +129,16 @@ func (c *rancherClient) MintToken(ctx context.Context, req mintRequest) (value,
if err := c.do(ctx, http.MethodPost, tokensAPIPath, body, &out); err != nil {
return "", "", err
}
if out.Status.Value == "" {
// Prefer bearerToken ("ext/<name>:<secret>") — the field that actually
// authenticates. Fall back to value only if a Rancher build omits it.
tok := out.Status.BearerToken
if tok == "" {
tok = out.Status.Value
}
if tok == "" {
return "", "", errors.New("rancher returned an empty token value")
}
return out.Status.Value, out.Metadata.Name, nil
return tok, out.Metadata.Name, nil
}
// DeleteToken removes a Token by its resource name. A missing token is success.
+8 -4
View File
@@ -31,7 +31,8 @@ type tokenSpec struct {
}
type tokenStatus struct {
Value string `json:"value,omitempty"`
BearerToken string `json:"bearerToken,omitempty"`
Value string `json:"value,omitempty"`
}
type token struct {
@@ -88,16 +89,19 @@ func (s *store) handle(w http.ResponseWriter, r *http.Request) {
if newName == "" {
newName = req.Metadata.GenerateName + randHex(4)
}
value := "token-" + newName + ":" + randHex(16)
// ext.cattle.io returns the usable credential in status.bearerToken,
// formatted "ext/<name>:<secret>"; status.value is only the secret.
secret := randHex(16)
bearer := "ext/" + newName + ":" + secret
t := &token{
APIVersion: "ext.cattle.io/v1",
Kind: "Token",
Metadata: tokenMeta{Name: newName},
Spec: req.Spec,
Status: tokenStatus{Value: value},
Status: tokenStatus{BearerToken: bearer, Value: secret},
}
s.tokens[newName] = t
s.valid[value] = newName
s.valid[bearer] = newName
writeJSON(w, http.StatusCreated, t)
case r.Method == http.MethodGet && name != "": // read
t, ok := s.tokens[name]