diff --git a/jwksauth/README.md b/jwksauth/README.md index 32c9b82..39fe7e7 100644 --- a/jwksauth/README.md +++ b/jwksauth/README.md @@ -62,19 +62,23 @@ func profile(w http.ResponseWriter, r *http.Request) { ## Server-attested private claims and the prefix -AuthGate may emit up to three private claims on a token: **Domain**, -**Project**, **ServiceAccount**. Each is optional — tokens that don't -need a given dimension simply omit the claim. When present they appear -in the payload under a configurable prefix (default `extra`), so the -JWT keys are `extra_domain`, `extra_project`, `extra_service_account`. -The SDK reads them out of the box. +AuthGate may emit up to four private claims on a token: **Domain**, +**Project**, **ServiceAccount**, **UID**. Each is optional — tokens that +don't need a given dimension simply omit the claim. When present they +appear in the payload under a configurable prefix (default `extra`), so +the JWT keys are `extra_domain`, `extra_project`, +`extra_service_account`, and `extra_uid`. The SDK reads them out of the +box. UID carries the username for user-bearing flows (Authorization +Code + PKCE, Device Authorization Grant); Client Credentials tokens have +no user, so `Claims.UID` is the empty string for those tokens. ```json { "iss": "https://auth.example.com", "extra_domain": "oa", "extra_project": "p1", - "extra_service_account": "sync-bot@oa.local" + "extra_service_account": "sync-bot@oa.local", + "extra_uid": "alice" } ``` @@ -83,12 +87,15 @@ same value to the verifier: ```go v, err := jwksauth.NewVerifier(ctx, issuerURL, audience, - jwksauth.WithPrivateClaimPrefix("acme")) // reads acme_domain, acme_project, acme_service_account + jwksauth.WithPrivateClaimPrefix("acme")) // reads acme_domain, acme_project, acme_service_account, acme_uid ``` Server and SDK must agree byte-for-byte. Reading with the wrong prefix -yields empty Domain / Project / ServiceAccount and (when `AccessRule` -covers those dimensions) fails closed. +yields empty Domain / Project / ServiceAccount / UID. For the three +dimensions `AccessRule` checks (Domain, Project, ServiceAccount), this +fails closed. UID is identity, not authorization — `AccessRule` does +not gate on it, so callers must check `info.Claims.UID` in their +handler when they want to restrict by user. ### Caller-supplied keys (Extras) diff --git a/jwksauth/claims.go b/jwksauth/claims.go index 63d3e8a..717a4b3 100644 --- a/jwksauth/claims.go +++ b/jwksauth/claims.go @@ -11,10 +11,13 @@ import ( // Claims holds the AuthGate-specific JWT claims plus a generic Extras map // for any caller-supplied keys the issuer included in the payload. // -// Domain, Project, and ServiceAccount are server-attested by AuthGate; -// configure the JWT payload prefix via [WithPrivateClaimPrefix]. Extras -// carries every other non-standard key — read individual values with -// [TokenInfo.Extra]. +// Domain, Project, ServiceAccount, and UID are server-attested by AuthGate; +// configure the JWT payload prefix via [WithPrivateClaimPrefix]. UID +// carries the username for tokens issued by user-bearing flows +// (Authorization Code + PKCE, Device Authorization Grant); the Client +// Credentials flow has no user, so UID will be the empty string for those +// tokens. Extras carries every other non-standard key — read individual +// values with [TokenInfo.Extra]. // // Claims is populated by the SDK's verifier from a verified IDToken via // custom decoding (not via struct-tag JSON unmarshal), so it deliberately @@ -30,9 +33,10 @@ type Claims struct { Domain string ServiceAccount string Project string + UID string // Extras carries any payload keys that are neither in the SDK's - // reserved-key set (see [staticReservedClaimKeys]) nor the three + // reserved-key set (see [staticReservedClaimKeys]) nor the four // server-attested "_..." keys. The reserved set covers RFC // 7519 standard JWT keys and a hand-picked subset of OIDC keys, so // common OIDC keys the SDK does not name explicitly (e.g. email, @@ -93,7 +97,7 @@ func (t *TokenInfo) Extra(key string) (any, bool) { // claim registry — common OIDC claims the SDK does not name explicitly // (e.g. email, name) will surface via Extras when the issuer emits them. // -// The three server-attested keys are excluded dynamically by +// The four server-attested keys are excluded dynamically by // newTokenInfo via the resolved [claimKeys]. var staticReservedClaimKeys = map[string]struct{}{ "iss": {}, "sub": {}, "aud": {}, "exp": {}, "nbf": {}, "iat": {}, "jti": {}, @@ -102,21 +106,23 @@ var staticReservedClaimKeys = map[string]struct{}{ } // claimKeys holds the resolved "_" payload keys for the -// three server-attested AuthGate claims. Construction-time once; read-only -// on the verify hot path. +// server-attested AuthGate claims. Construction-time once; read-only on +// the verify hot path. type claimKeys struct { domain string project string serviceAccount string + uid string } -// newClaimKeys composes the three server-attested payload keys from prefix. +// newClaimKeys composes the server-attested payload keys from prefix. // Mirrors upstream's EmittedName(prefix, logical) = prefix + "_" + logical. func newClaimKeys(prefix string) claimKeys { return claimKeys{ domain: prefix + "_domain", project: prefix + "_project", serviceAccount: prefix + "_service_account", + uid: prefix + "_uid", } } @@ -134,13 +140,14 @@ func newTokenInfo(tok *oidc.IDToken, keys claimKeys) (*TokenInfo, error) { Domain: stringFromRaw(raw, keys.domain), Project: stringFromRaw(raw, keys.project), ServiceAccount: stringFromRaw(raw, keys.serviceAccount), + UID: stringFromRaw(raw, keys.uid), } for k, v := range raw { if _, reserved := staticReservedClaimKeys[k]; reserved { continue } - if k == keys.domain || k == keys.project || k == keys.serviceAccount { + if k == keys.domain || k == keys.project || k == keys.serviceAccount || k == keys.uid { continue } if c.Extras == nil { diff --git a/jwksauth/claims_prefix_test.go b/jwksauth/claims_prefix_test.go index 873504b..fb011f3 100644 --- a/jwksauth/claims_prefix_test.go +++ b/jwksauth/claims_prefix_test.go @@ -22,6 +22,7 @@ func TestPrefixedClaims_DefaultPrefix_HappyPath(t *testing.T) { "extra_domain": "oa", "extra_project": "p1", "extra_service_account": "sync@oa", + "extra_uid": "alice", "tenant": "a76", }) rule := AccessRule{ @@ -49,6 +50,9 @@ func TestPrefixedClaims_DefaultPrefix_HappyPath(t *testing.T) { if info.Claims.ServiceAccount != "sync@oa" { t.Errorf("ServiceAccount = %q, want sync@oa", info.Claims.ServiceAccount) } + if info.Claims.UID != "alice" { + t.Errorf("UID = %q, want alice", info.Claims.UID) + } got, ok := info.Extra("tenant") if !ok { t.Fatalf("Extra(\"tenant\") missing") @@ -70,7 +74,10 @@ func TestPrefixedClaims_CustomPrefix(t *testing.T) { } t.Run("acme_prefix_hits", func(t *testing.T) { - tok := fi.Sign(t, "api://x", time.Minute, map[string]any{"acme_domain": "oa"}) + tok := fi.Sign(t, "api://x", time.Minute, map[string]any{ + "acme_domain": "oa", + "acme_uid": "alice", + }) rec := runMiddleware(t, v, AccessRule{Domains: []string{"oa"}}, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+tok) }) @@ -84,10 +91,16 @@ func TestPrefixedClaims_CustomPrefix(t *testing.T) { if info.Claims.Domain != "oa" { t.Errorf("Claims.Domain = %q, want oa", info.Claims.Domain) } + if info.Claims.UID != "alice" { + t.Errorf("Claims.UID = %q, want alice", info.Claims.UID) + } }) t.Run("default_prefix_no_fallback", func(t *testing.T) { - tok := fi.Sign(t, "api://x", time.Minute, map[string]any{"extra_domain": "oa"}) + tok := fi.Sign(t, "api://x", time.Minute, map[string]any{ + "extra_domain": "oa", + "extra_uid": "alice", + }) info, err := v.Verify(context.Background(), tok) if err != nil { t.Fatalf("Verify: %v", err) @@ -98,6 +111,12 @@ func TestPrefixedClaims_CustomPrefix(t *testing.T) { info.Claims.Domain, ) } + if info.Claims.UID != "" { + t.Errorf( + "Claims.UID = %q, want empty (wrong prefix must not fall back)", + info.Claims.UID, + ) + } got, ok := info.Extra("extra_domain") if !ok { t.Fatalf("extra_domain should land in Extras when prefix is acme") @@ -105,6 +124,13 @@ func TestPrefixedClaims_CustomPrefix(t *testing.T) { if s, _ := got.(string); s != "oa" { t.Errorf("Extras[extra_domain] = %v, want \"oa\"", got) } + got, ok = info.Extra("extra_uid") + if !ok { + t.Fatalf("extra_uid should land in Extras when prefix is acme") + } + if s, _ := got.(string); s != "alice" { + t.Errorf("Extras[extra_uid] = %v, want \"alice\"", got) + } rec := runMiddleware(t, v, AccessRule{Domains: []string{"oa"}}, func(req *http.Request) { req.Header.Set("Authorization", "Bearer "+tok) @@ -155,6 +181,36 @@ func TestPrefixedClaims_BareDomainIgnored(t *testing.T) { } } +// A token carrying only the bare "uid" key (no extra_uid) must not be +// promoted to Claims.UID. The bare key is not lost — it surfaces via +// Extras["uid"] — but it is not treated as server-attested. +func TestPrefixedClaims_BareUIDIgnored(t *testing.T) { + fi := newFakeIssuer(t) + v, err := NewVerifier(t.Context(), fi.URL(), "api://x") + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + tok := fi.Sign(t, "api://x", time.Minute, map[string]any{"uid": "alice"}) + + info, err := v.Verify(context.Background(), tok) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if info.Claims.UID != "" { + t.Errorf( + "Claims.UID = %q, want empty (bare key must not be read as server-attested)", + info.Claims.UID, + ) + } + got, ok := info.Extra("uid") + if !ok { + t.Fatalf("Extra(\"uid\") missing — bare key must still surface via Extras") + } + if s, _ := got.(string); s != "alice" { + t.Errorf("Extras[uid] = %v, want \"alice\"", got) + } +} + // Caller-supplied keys outside the server-attested registry never leak // into the typed Claims fields, regardless of whether they collide with // the prefix space. Both extra_foo and foo land in Extras under their diff --git a/jwksauth/doc.go b/jwksauth/doc.go index 2dfafab..a0a5b0c 100644 --- a/jwksauth/doc.go +++ b/jwksauth/doc.go @@ -34,12 +34,15 @@ // // # Server-attested private claims and the prefix // -// AuthGate emits three private claims — Domain, Project, and ServiceAccount -// — under a configurable prefix (default "extra"), so the JWT payload keys -// are "extra_domain", "extra_project", and "extra_service_account". The -// SDK reads them out of the box; if your AuthGate deployment has overridden -// JWT_PRIVATE_CLAIM_PREFIX, pass the same value via -// [WithPrivateClaimPrefix]. +// AuthGate emits four private claims — Domain, Project, ServiceAccount, +// and UID — under a configurable prefix (default "extra"), so the JWT +// payload keys are "extra_domain", "extra_project", "extra_service_account", +// and "extra_uid". The SDK reads them out of the box; if your AuthGate +// deployment has overridden JWT_PRIVATE_CLAIM_PREFIX, pass the same value +// via [WithPrivateClaimPrefix]. UID carries the username for tokens +// issued by user-bearing flows (Authorization Code + PKCE, Device +// Authorization Grant); the Client Credentials flow has no user, so UID +// is the empty string for those tokens. // // Any other non-standard payload keys (for example a caller-supplied // "tenant") are surfaced via [Claims.Extras]; read them with diff --git a/jwksauth/middleware_test.go b/jwksauth/middleware_test.go index 7e6f73a..fe4a45f 100644 --- a/jwksauth/middleware_test.go +++ b/jwksauth/middleware_test.go @@ -120,6 +120,7 @@ func TestVerifier_HappyPath(t *testing.T) { "tenant": "A76", "extra_service_account": "sync@oa", "extra_project": "p1", + "extra_uid": "alice", }) info, err := v.Verify(context.Background(), tok) if err != nil { @@ -134,6 +135,9 @@ func TestVerifier_HappyPath(t *testing.T) { if info.Domain() != "oa" { t.Errorf("Domain() = %q, want lower-cased 'oa'", info.Domain()) } + if info.Claims.UID != "alice" { + t.Errorf("Claims.UID = %q, want alice", info.Claims.UID) + } got, ok := info.Extra("tenant") if !ok { t.Errorf("Extra(\"tenant\") missing, want \"A76\"") @@ -142,6 +146,42 @@ func TestVerifier_HappyPath(t *testing.T) { } } +// Client Credentials flow has no user, so the token does not carry +// extra_uid. Verify must succeed, Claims.UID must be empty (not panic, +// not pulled from anywhere else), and an empty AccessRule lets the +// request through with 200. +func TestVerifier_ClientCredentialsShape_UIDEmpty(t *testing.T) { + fi := newFakeIssuer(t) + v, err := NewVerifier(t.Context(), fi.URL(), "api://x") + if err != nil { + t.Fatalf("NewVerifier: %v", err) + } + tok := fi.Sign(t, "api://x", time.Minute, map[string]any{ + "client_id": "cli", + "scope": "email", + "extra_domain": "oa", + "extra_project": "p1", + }) + + info, err := v.Verify(context.Background(), tok) + if err != nil { + t.Fatalf("Verify: %v", err) + } + if info.Claims.UID != "" { + t.Errorf( + "Claims.UID = %q, want empty string (Client Credentials token has no user)", + info.Claims.UID, + ) + } + + rec := runMiddleware(t, v, AccessRule{}, func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+tok) + }) + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (AccessRule{} does not check UID)", rec.Code) + } +} + func TestNewVerifier_RejectsEmptyAudience(t *testing.T) { cases := []string{"", " ", "\t\n"} for _, aud := range cases {