Files
zeekayandHanzo Dev 31f5b58b91 Form-encode the client credential in the IAM token request
RFC 6749 §2.3.1 form-urlencodes the id and secret before base64, and IAM
decodes them, so a secret carrying '+' or '%' was refused.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-23 03:04:10 -07:00

131 lines
4.7 KiB
Go

// Copyright 2023 Hanzo Industries Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package service
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Identity mints the access tokens visor presents to other Hanzo services:
// egress, and cloud's commerce API.
//
// It is visor's OWN IAM identity — the clientId and clientSecret this process
// already signs in with — exchanged for an access token, not a second
// credential minted for this purpose. A service verifies it the way it verifies
// every caller: `iss` against the issuer, signature against the published JWKS,
// and — where it names one — `aud` against the audience. So there is one
// authority, one kind of token, and nothing to paste into a config file.
//
// A static bearer would be the alternative and it is worse in the way that
// matters: it does not expire, so it is a credential in a config value that
// nobody rotates, and it says nothing about WHO is calling — which is the one
// question a service that spends or charges money must answer first.
type Identity struct {
endpoint string // IAM, e.g. https://hanzo.id
id string // clientId
secret string // clientSecret
audience string // the `aud` the receiving service requires (RFC 8707 resource); empty names none
client *http.Client
mu sync.Mutex
held string
until time.Time
}
// NewIdentity is the identity the client credential (id, secret) holds at the
// IAM endpoint, scoped to audience when one is named. client nil takes a 30s
// default.
func NewIdentity(endpoint, id, secret, audience string, client *http.Client) *Identity {
if client == nil {
client = &http.Client{Timeout: 30 * time.Second}
}
return &Identity{endpoint: endpoint, id: id, secret: secret, audience: audience, client: client}
}
// early is how long before expiry a held token stops being offered. A token that
// expires in flight is a 401 the caller cannot distinguish from a revoked
// identity, so it is replaced while it still works.
const early = 60 * time.Second
// Token returns a live access token, minting one when what is held is gone or
// nearly so.
func (i *Identity) Token() (string, error) {
i.mu.Lock()
defer i.mu.Unlock()
if i.held != "" && time.Now().Before(i.until.Add(-early)) {
return i.held, nil
}
tok, ttl, err := i.mint()
if err != nil {
return "", err
}
i.held, i.until = tok, time.Now().Add(ttl)
return tok, nil
}
// mint performs the client_credentials exchange (HIP-0111), scoped by RFC 8707
// `resource` when an audience is named, so such a token is useless anywhere else.
func (i *Identity) mint() (string, time.Duration, error) {
form := url.Values{"grant_type": {"client_credentials"}}
if i.audience != "" {
form.Set("resource", i.audience)
}
req, err := http.NewRequest(http.MethodPost,
strings.TrimRight(i.endpoint, "/")+"/v1/iam/oauth/token",
strings.NewReader(form.Encode()))
if err != nil {
return "", 0, err
}
// client_secret_basic: RFC 6749 §2.3.1 form-urlencodes both halves before
// base64, and IAM decodes them, so a secret carrying '+' or '%' authenticates.
req.SetBasicAuth(url.QueryEscape(i.id), url.QueryEscape(i.secret))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := i.client.Do(req)
if err != nil {
return "", 0, fmt.Errorf("iam identity: %w", err)
}
defer resp.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
Description string `json:"error_description"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", 0, fmt.Errorf("iam identity: %s answered %d with no readable body", i.endpoint, resp.StatusCode)
}
if resp.StatusCode != http.StatusOK || out.AccessToken == "" {
// Say which identity was refused. A 401 here reads identically whether
// the client id is wrong, the secret is stale, or the app may not use
// this grant, and the reader is holding none of those.
return "", 0, fmt.Errorf("iam identity: %s refused client %q: %d %s %s",
i.endpoint, i.id, resp.StatusCode, out.Error, out.Description)
}
ttl := time.Duration(out.ExpiresIn) * time.Second
if ttl <= 0 {
ttl = 5 * time.Minute
}
return out.AccessToken, ttl, nil
}