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:
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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", "")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user