Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions jwksauth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```

Expand All @@ -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)

Expand Down
27 changes: 17 additions & 10 deletions jwksauth/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 "<prefix>_..." 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,
Expand Down Expand Up @@ -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": {},
Expand All @@ -102,21 +106,23 @@ var staticReservedClaimKeys = map[string]struct{}{
}

// claimKeys holds the resolved "<prefix>_<logical>" 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",
}
}

Expand All @@ -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 {
Expand Down
60 changes: 58 additions & 2 deletions jwksauth/claims_prefix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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")
Expand All @@ -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",
})
Comment on lines 76 to +80
rec := runMiddleware(t, v, AccessRule{Domains: []string{"oa"}}, func(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+tok)
})
Expand All @@ -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)
Expand All @@ -98,13 +111,26 @@ 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")
}
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)
Expand Down Expand Up @@ -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
Expand Down
15 changes: 9 additions & 6 deletions jwksauth/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +37 to +45
//
// Any other non-standard payload keys (for example a caller-supplied
// "tenant") are surfaced via [Claims.Extras]; read them with
Expand Down
40 changes: 40 additions & 0 deletions jwksauth/middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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\"")
Expand All @@ -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 {
Expand Down
Loading