oidc: emit preferred_username — the token never carried a username

Discovery has advertised preferred_username in claims_supported since it was
written, and no token ever emitted it. The only user-ish claims on the wire were
sub (a UUID) and `name`, and userClaims fills `name` from User.DisplayName — so a
real token read

  sub = e7d7fda0-…   name = "Zach Kelling"   email = z@hanzo.ai

A resource server that needs the IAM USERNAME (the `<name>` half of
`<owner>/<name>`) had nothing to read and fell back to `name`. cloud's money path
addresses a wallet exactly that way, so it addressed `hanzo/Zach Kelling` — a
wallet no funding path can name, a human label with a space in it — while the
balance sat in `hanzo/z`. Every signed-in completion 402'd against a funded
account, which is what took hanzo.chat dark.

userClaims already had the value and discarded it: it computes DisplayName for
`name` and drops u.Name. It now returns both, and Sign/SignUserToken/SignID take
the username and emit it as preferred_username. omitempty keeps a machine token
(no user, no username) omitting the claim rather than emitting it empty, so one
struct still serves both token shapes.

DisplayName is unchanged and still carried in `name` — this adds the missing
claim, it does not repurpose an existing one.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
This commit is contained in:
zeekay
2026-07-26 22:43:17 -07:00
co-authored by hanzo-dev
parent 23b97f5911
commit 5c0ea823f4
7 changed files with 108 additions and 54 deletions
+1 -1
View File
@@ -87,7 +87,7 @@ func issueUserTokenHandler(db orm.DB) zip.Handler {
if display == "" {
display = user.Name
}
access, err := signer.SignUserToken(subject, user.Owner, aud, clientApp.ClientId, user.Email, display, "", store.MemberOrgRefs(ctx, db, user), ttl, now)
access, err := signer.SignUserToken(subject, user.Owner, aud, clientApp.ClientId, user.Email, display, user.Name, "", store.MemberOrgRefs(ctx, db, user), ttl, now)
if err != nil {
return mintErr(c, 500, "server_error")
}
+47 -31
View File
@@ -39,9 +39,22 @@ type Claims struct {
Organization string `json:"organization,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
Nonce string `json:"nonce,omitempty"`
Azp string `json:"azp,omitempty"`
TokenType string `json:"tokenType,omitempty"`
// PreferredUsername is the IAM USERNAME (the `<name>` half of `<owner>/<name>`,
// e.g. "z"), not a display name. OIDC gives `name` display semantics, so a
// resource server that needs the username has nothing else to read: this token
// carried only sub (a UUID), email, and `name` = DisplayName ("Zach Kelling").
//
// Discovery has advertised preferred_username in claims_supported all along
// while no token ever emitted it, and downstream paid for the gap. cloud's
// money path addresses a wallet as `<org>/<username>`; with no username claim
// it fell back to `name` and addressed `hanzo/Zach Kelling` — a wallet no
// funding path can name — while the balance sat in `hanzo/z`. Every signed-in
// completion then 402'd with a funded account. Emitting the username is what
// makes the address derivable rather than guessed.
PreferredUsername string `json:"preferred_username,omitempty"`
Nonce string `json:"nonce,omitempty"`
Azp string `json:"azp,omitempty"`
TokenType string `json:"tokenType,omitempty"`
// Orgs is the membership set — the tenancy the identity may act in, home org
// first — a resource server reads to authorize an org-switch (X-Org-Id ∈ orgs)
// with no round-trip. omitempty ⇒ a nil set omits the claim entirely (a machine
@@ -119,7 +132,7 @@ func NewRSASigner(key *rsa.PrivateKey, kid, issuer string) *Signer {
// resolved membership set (home org first); nil for a machine token, which omits
// the claim — the Signer stays decoupled from schema.User, so the caller resolves
// the tenancy (store.MemberOrgRefs) and passes it.
func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string, orgs []schema.OrgRef, ttl time.Duration, now time.Time) (string, error) {
func (s *Signer) Sign(app *schema.Application, userID, email, name, username, scope string, orgs []schema.OrgRef, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
@@ -137,14 +150,15 @@ func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string
IssuedAt: jwt.NewNumericDate(now),
ID: jti,
},
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
Azp: app.ClientId,
TokenType: "access-token",
Orgs: orgs,
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
PreferredUsername: username,
Azp: app.ClientId,
TokenType: "access-token",
Orgs: orgs,
}
return s.signClaims(claims)
}
@@ -160,7 +174,7 @@ func (s *Signer) Sign(app *schema.Application, userID, email, name, scope string
// JWKS verifies it — the token is indistinguishable from one the user obtained
// directly, which is the point. The Signer stays decoupled from schema.User: the
// handler resolves and passes the values it authorized.
func (s *Signer) SignUserToken(subject, owner, aud, azp, email, name, scope string, orgs []schema.OrgRef, ttl time.Duration, now time.Time) (string, error) {
func (s *Signer) SignUserToken(subject, owner, aud, azp, email, name, username, scope string, orgs []schema.OrgRef, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
@@ -178,14 +192,15 @@ func (s *Signer) SignUserToken(subject, owner, aud, azp, email, name, scope stri
IssuedAt: jwt.NewNumericDate(now),
ID: jti,
},
Scope: scope,
Owner: owner,
Organization: owner,
Email: email,
Name: name,
Azp: azp,
TokenType: "access-token",
Orgs: orgs,
Scope: scope,
Owner: owner,
Organization: owner,
Email: email,
Name: name,
PreferredUsername: username,
Azp: azp,
TokenType: "access-token",
Orgs: orgs,
}
return s.signClaims(claims)
}
@@ -194,7 +209,7 @@ func (s *Signer) SignUserToken(subject, owner, aud, azp, email, name, scope stri
// token by carrying the echoed nonce and by declaring tokenType "id-token"; the
// audience is the client the token was minted for (the RP), and iss matches the
// discovery issuer so a standard OIDC client validates it.
func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, nonce string, orgs []schema.OrgRef, ttl time.Duration, now time.Time) (string, error) {
func (s *Signer) SignID(app *schema.Application, userID, email, name, username, scope, nonce string, orgs []schema.OrgRef, ttl time.Duration, now time.Time) (string, error) {
if s == nil {
return "", errors.New("jwt: nil signer")
}
@@ -212,15 +227,16 @@ func (s *Signer) SignID(app *schema.Application, userID, email, name, scope, non
NotBefore: jwt.NewNumericDate(now),
ID: jti,
},
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
Nonce: nonce,
Azp: app.ClientId,
TokenType: "id-token",
Orgs: orgs,
Scope: scope,
Owner: app.Organization,
Organization: app.Organization,
Email: email,
Name: name,
PreferredUsername: username,
Nonce: nonce,
Azp: app.ClientId,
TokenType: "id-token",
Orgs: orgs,
}
return s.signClaims(claims)
}
+40 -4
View File
@@ -4,6 +4,7 @@ package oidc
import (
"crypto/rsa"
"strings"
"testing"
"time"
@@ -30,7 +31,7 @@ func TestSign_RoundTripAndClaims(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
app := testApp()
tokenStr, err := s.Sign(app, "hanzo/alice", "alice@hanzo.ai", "Alice", "openid profile", nil, time.Hour, now)
tokenStr, err := s.Sign(app, "hanzo/alice", "alice@hanzo.ai", "Alice", "", "openid profile", nil, time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -73,7 +74,7 @@ func TestSign_ExpiredTokenRejected(t *testing.T) {
key := testKey(t)
s := NewRSASigner(key, "cert-hanzo", "https://iam.hanzo.ai")
now := time.Unix(1_800_000_000, 0)
tokenStr, err := s.Sign(testApp(), "u", "", "", "openid", nil, time.Minute, now)
tokenStr, err := s.Sign(testApp(), "u", "", "", "", "openid", nil, time.Minute, now)
if err != nil {
t.Fatal(err)
}
@@ -90,7 +91,7 @@ func TestSign_WrongKeyRejected(t *testing.T) {
s := NewRSASigner(testKey(t), "cert-hanzo", "https://iam.hanzo.ai")
other := testKey(t)
now := time.Unix(1_800_000_000, 0)
tokenStr, _ := s.Sign(testApp(), "u", "", "", "openid", nil, time.Hour, now)
tokenStr, _ := s.Sign(testApp(), "u", "", "", "", "openid", nil, time.Hour, now)
var claims Claims
_, err := jwt.ParseWithClaims(tokenStr, &claims, func(*jwt.Token) (any, error) { return &other.PublicKey, nil },
jwt.WithValidMethods([]string{"RS256"}))
@@ -119,7 +120,7 @@ func TestNewRSASignerFromCert_PEMRoundTrip(t *testing.T) {
}
// Sign+verify to prove the parsed key works.
now := time.Unix(1_800_000_000, 0)
str, err := s.Sign(testApp(), "u", "", "", "openid", nil, time.Hour, now)
str, err := s.Sign(testApp(), "u", "", "", "", "openid", nil, time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -129,3 +130,38 @@ func TestNewRSASignerFromCert_PEMRoundTrip(t *testing.T) {
t.Fatalf("verify with cert-loaded key: %v", err)
}
}
// TestSignEmitsPreferredUsername pins that the username reaches the wire. Discovery
// has advertised preferred_username in claims_supported all along while no token
// emitted it, and `name` carries the DISPLAY name — so a resource server needing the
// `<owner>/<name>` username had nothing to read. cloud's money path addresses a
// wallet as `<org>/<username>`; without this claim it fell back to `name` and
// addressed `hanzo/Zach Kelling` while the balance sat in `hanzo/z`.
func TestSignEmitsPreferredUsername(t *testing.T) {
s := NewRSASigner(testKey(t), "cert-hanzo", "https://iam.hanzo.ai")
now := time.Unix(1_800_000_000, 0)
tokenStr, err := s.Sign(testApp(), "hanzo/z", "z@hanzo.ai", "Zach Kelling", "z", "openid profile", nil, time.Hour, now)
if err != nil {
t.Fatalf("Sign: %v", err)
}
var got Claims
if _, _, err := jwt.NewParser().ParseUnverified(tokenStr, &got); err != nil {
t.Fatalf("parse: %v", err)
}
if got.PreferredUsername != "z" {
t.Fatalf("preferred_username = %q; want %q", got.PreferredUsername, "z")
}
// The display name is still carried, and must NOT be the username.
if got.Name != "Zach Kelling" {
t.Fatalf("name = %q; want the display name preserved", got.Name)
}
// A machine token has no user, so the claim is omitted entirely rather than
// emitted empty — omitempty is what keeps one struct serving both shapes.
machine, err := s.Sign(testApp(), "hanzo/app", "", "app", "", "openid", nil, time.Hour, now)
if err != nil {
t.Fatalf("Sign machine: %v", err)
}
if strings.Contains(machine, "preferred_username") {
t.Fatal("machine token must omit preferred_username, not emit it empty")
}
}
+6 -6
View File
@@ -57,7 +57,7 @@ func TestSigner_ES256RoundTrip(t *testing.T) {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0)
tok, err := s.Sign(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", nil, time.Hour, now)
tok, err := s.Sign(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "alice", "openid", nil, time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -94,7 +94,7 @@ func TestSigner_MLDSA65RoundTripThroughVerify(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
nowFuncSet(t, now.Add(time.Minute))
tok, err := s.SignID(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "openid", "nonce-xyz", nil, time.Hour, now)
tok, err := s.SignID(testApp(), "hanzo/alice", "alice@hanzo.ai", "Alice", "alice", "openid", "nonce-xyz", nil, time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -112,7 +112,7 @@ func TestSignID_EchoesNonce(t *testing.T) {
key := sharedKey(t)
s := NewRSASigner(key, "cert-hanzo", "https://hanzo.id")
now := time.Unix(1_800_000_000, 0)
tok, err := s.SignID(testApp(), "hanzo/alice", "a@h.ai", "Alice", "openid", "n-123", nil, time.Hour, now)
tok, err := s.SignID(testApp(), "hanzo/alice", "a@h.ai", "Alice", "alice", "openid", "n-123", nil, time.Hour, now)
if err != nil {
t.Fatal(err)
}
@@ -151,7 +151,7 @@ func TestVerifyToken_RejectsUnknownKid(t *testing.T) {
s, _ := NewSignerFromCert(other, testApp(), "https://hanzo.id")
now := time.Unix(1_800_000_000, 0)
nowFuncSet(t, now.Add(time.Minute))
tok, _ := s.Sign(testApp(), "hanzo/alice", "", "", "openid", nil, time.Hour, now)
tok, _ := s.Sign(testApp(), "hanzo/alice", "", "", "", "openid", nil, time.Hour, now)
if _, err := verifyToken(context.Background(), db, tok); err == nil {
t.Fatal("token with an unknown kid was accepted")
}
@@ -179,7 +179,7 @@ func TestVerify_TenantCannotShadowSigningKey(t *testing.T) {
// Attacker forges a token signed with THEIR key, kid=cert-hanzo, claiming admin.
forger := NewRSASigner(attackerKey, "cert-hanzo", "https://hanzo.id")
forged, err := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "openid", nil, time.Hour, base)
forged, err := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "", "openid", nil, time.Hour, base)
if err != nil {
t.Fatal(err)
}
@@ -204,7 +204,7 @@ func TestVerify_NonPlatformCertNeverTrusted(t *testing.T) {
persistCert(t, db, ac)
forger := NewRSASigner(attackerKey, "cert-evil", "https://hanzo.id")
forged, _ := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "openid", nil, time.Hour, base)
forged, _ := forger.Sign(&schema.Application{ClientId: "victim"}, "admin/superadmin", "", "", "", "openid", nil, time.Hour, base)
if _, err := verifyToken(context.Background(), db, forged); err == nil {
t.Fatal("a non-platform cert must never verify a token")
}
+12 -10
View File
@@ -222,7 +222,7 @@ func clientCredentialsGrant(c *zip.Ctx, db orm.DB) error {
sub := app.GetId() // <appOwner>/<appName>, per v1
// A machine token has no user and therefore no membership set — nil orgs omits
// the claim, so an app token can never carry a tenancy it did not earn.
access, err := signer.Sign(app, sub, "", app.Name, scope, nil, ttl, now)
access, err := signer.Sign(app, sub, "", app.Name, "", scope, nil, ttl, now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
@@ -379,9 +379,9 @@ func issueTokens(ctx context.Context, db orm.DB, c *zip.Ctx, app *schema.Applica
if err != nil {
return tokenResponse{}, err
}
sub, email, name, orgs := userClaims(ctx, db, row.User)
sub, email, name, username, orgs := userClaims(ctx, db, row.User)
access, err := signer.Sign(app, sub, email, name, row.Scope, orgs, ttl, now)
access, err := signer.Sign(app, sub, email, name, username, row.Scope, orgs, ttl, now)
if err != nil {
return tokenResponse{}, err
}
@@ -411,7 +411,7 @@ func issueTokens(ctx context.Context, db orm.DB, c *zip.Ctx, app *schema.Applica
Scope: row.Scope,
}
if hasScope(row.Scope, "openid") {
idt, err := signer.SignID(app, sub, email, name, row.Scope, row.Nonce, orgs, ttl, now)
idt, err := signer.SignID(app, sub, email, name, username, row.Scope, row.Nonce, orgs, ttl, now)
if err != nil {
return tokenResponse{}, err
}
@@ -506,8 +506,8 @@ func signAccessToken(ctx context.Context, db orm.DB, app *schema.Application, to
if err != nil {
return "", err
}
sub, _, _, orgs := userClaims(ctx, db, tok.User)
return signer.Sign(app, sub, "", "", tok.Scope, orgs, ttl, now)
sub, _, _, _, orgs := userClaims(ctx, db, tok.User)
return signer.Sign(app, sub, "", "", "", tok.Scope, orgs, ttl, now)
}
// tokenIssuer is the canonical OIDC issuer for this request — the value discovery
@@ -544,20 +544,22 @@ func subjectOf(u *schema.User) string {
// is the token row's (owner/name) User key. A subject with no user row (a machine
// token, or a since-deleted user) yields the passed-in id as sub, empty profile,
// and nil orgs — the claim is omitted, not forged.
func userClaims(ctx context.Context, db orm.DB, userID string) (sub, email, name string, orgs []schema.OrgRef) {
func userClaims(ctx context.Context, db orm.DB, userID string) (sub, email, name, username string, orgs []schema.OrgRef) {
owner, uname := splitSub(userID)
if owner == "" || uname == "" {
return userID, "", "", nil
return userID, "", "", "", nil
}
u, err := store.GetUserByName(ctx, db, owner, uname)
if err != nil || u == nil {
return userID, "", "", nil
return userID, "", "", "", nil
}
name = u.DisplayName
if name == "" {
name = u.Name
}
return subjectOf(u), u.Email, name, store.MemberOrgRefs(ctx, db, u)
// u.Name is the IAM username — the `<name>` half of `<owner>/<name>` and the
// only value downstream can address a wallet with. DisplayName is for humans.
return subjectOf(u), u.Email, name, u.Name, store.MemberOrgRefs(ctx, db, u)
}
// splitSub splits a subject "owner/name" into its two parts.
+1 -1
View File
@@ -119,7 +119,7 @@ func tokenExchangeGrant(c *zip.Ctx, db orm.DB) error {
if display == "" {
display = user.Name
}
access, err := signer.SignUserToken(subject, owner, aud, clientApp.ClientId, user.Email, display, scope, store.MemberOrgRefs(ctx, db, user), ttl, now)
access, err := signer.SignUserToken(subject, owner, aud, clientApp.ClientId, user.Email, display, user.Name, scope, store.MemberOrgRefs(ctx, db, user), ttl, now)
if err != nil {
return tokenError(c, 500, "server_error", "")
}
+1 -1
View File
@@ -80,7 +80,7 @@ func directSubjectToken(t *testing.T, db orm.DB, certName, owner, name string) s
t.Fatalf("user %s/%s not seeded: %v", owner, name, err)
}
app := &schema.Application{Organization: u.Owner, ClientId: "direct"}
tok, err := signer.Sign(app, subjectOf(u), u.Email, u.Name, "openid profile", nil, time.Hour, nowFunc())
tok, err := signer.Sign(app, subjectOf(u), u.Email, u.Name, u.Name, "openid profile", nil, time.Hour, nowFunc())
if err != nil {
t.Fatalf("sign subject token: %v", err)
}