mirror of
https://github.com/hanzoai/jwt.git
synced 2026-09-30 22:47:57 +00:00
the issuer states the identity class, so a verifier stops inferring it
IAM stamps `type` on a token the client_credentials grant minted and leaves it off a person's, resolving it from the grant shape — a fact only the issuer holds and no caller can ask for. This Claims never carried the field, so every consumer reading these claims had to work the class out for itself. Each available inference is wrong somewhere. The subject's shape is the tempting one, since a machine subject reads `owner/name`; but GetUserBySubject resolves that same form to a PERSON whenever the token names the account rather than its id, so the inference reports those people as machines. A consumer that files the two classes in two places — which a custody path must, or one principal's path becomes another's — then files a person under a machine's name. Field parity with what IAM emits is this package's whole reason to exist, so the claim belongs here rather than being read a second time beside it.
This commit is contained in:
@@ -34,6 +34,11 @@ type NumericDate = gojwt.NumericDate
|
||||
// NewNumericDate wraps a time as a JWT numeric date for the exp/nbf/iat claims.
|
||||
func NewNumericDate(t time.Time) *NumericDate { return gojwt.NewNumericDate(t) }
|
||||
|
||||
// Program is the [Claims.Type] of a token minted for an application rather than
|
||||
// a person. A person's token carries no Type at all, so the two classes are told
|
||||
// apart by a value the issuer states, never by the shape of another claim.
|
||||
const Program = "application"
|
||||
|
||||
// User is the identity projection embedded in every Hanzo access token: exactly
|
||||
// the fields a bearer needs to identify the principal and its home org. Owner is
|
||||
// the tenant/organization slug — the home-org anchor every downstream authorizes
|
||||
@@ -61,6 +66,19 @@ type User struct {
|
||||
// inlines the registered claims.
|
||||
type Claims struct {
|
||||
*User
|
||||
// Type is the principal's identity CLASS: [Program] on a token the
|
||||
// client_credentials grant minted, absent for a person. The issuer resolves
|
||||
// it from the GRANT SHAPE, so only the issuer can say it and a caller cannot
|
||||
// ask for it.
|
||||
//
|
||||
// It is here because the alternative is for each verifier to INFER the class,
|
||||
// and every available inference is wrong somewhere. The subject's shape is
|
||||
// the tempting one — a machine subject reads `owner/name` — but a person's
|
||||
// subject takes that same form whenever the token names the account rather
|
||||
// than its id, so the inference reads those people as machines. A verifier
|
||||
// that puts the two classes in different places, as a custody path must,
|
||||
// then files a person under a machine's name.
|
||||
Type string `json:"type,omitempty"`
|
||||
TokenType string `json:"tokenType,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
Tag string `json:"tag"`
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package jwt_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/jwt"
|
||||
)
|
||||
|
||||
// A verifier that files two identity classes in two places needs the issuer to
|
||||
// say which class it minted. These pin that the answer survives the wire.
|
||||
|
||||
func TestAProgramSaysSoOnTheWire(t *testing.T) {
|
||||
key := rsaKey(t)
|
||||
raw, err := jwt.Sign(key, &jwt.Claims{
|
||||
User: &jwt.User{Owner: "hanzo", Name: "hanzo-egress"},
|
||||
Type: jwt.Program,
|
||||
Azp: "hanzo-egress",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "admin/hanzo-egress",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
got, err := jwt.Parse(raw, key)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.Type != jwt.Program {
|
||||
t.Errorf("type = %q, want %q", got.Type, jwt.Program)
|
||||
}
|
||||
}
|
||||
|
||||
// A person carries no class at all, so the claim must be absent rather than
|
||||
// present-and-empty: a verifier reading it is asking "is this a program", and an
|
||||
// empty string answers no only if it is the ONE spelling of no.
|
||||
func TestAPersonCarriesNoClass(t *testing.T) {
|
||||
key := rsaKey(t)
|
||||
raw, err := jwt.Sign(key, &jwt.Claims{
|
||||
User: &jwt.User{Owner: "hanzo", Name: "z"},
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: "b474eaa5-e000-474d-b317-c37bbd9a6945",
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
got, err := jwt.Parse(raw, key)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if got.Type != "" {
|
||||
t.Errorf("a person's token carried type %q", got.Type)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(&jwt.Claims{User: &jwt.User{Owner: "hanzo"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), `"type"`) {
|
||||
t.Errorf("an empty class was emitted anyway: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The claim reaches this package under the name the issuer writes. A rename on
|
||||
// either side is a verifier that silently reads every program as a person.
|
||||
func TestTheClassIsReadUnderTheIssuersName(t *testing.T) {
|
||||
var c jwt.Claims
|
||||
if err := json.Unmarshal([]byte(`{"type":"application","owner":"hanzo"}`), &c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Type != jwt.Program {
|
||||
t.Fatalf("type = %q — the wire name and the field disagree", c.Type)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user