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
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ var jwtPrivateClaimLogicalNames = []string{
"domain",
"project",
"service_account",
"uid",
}

// PrivateClaimLogicalNames returns a defensive copy of the local
Expand Down
4 changes: 2 additions & 2 deletions internal/handlers/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ func (h *TokenHandler) TokenInfo(c *gin.Context) {

// Identify whether this is a user-delegated token or a machine (client credentials) token
subjectType := "user"
if strings.HasPrefix(result.UserID, "client:") {
if services.IsMachineUserID(result.UserID) {
subjectType = "client"
}

Expand Down Expand Up @@ -379,7 +379,7 @@ func (h *TokenHandler) Introspect(c *gin.Context) {
}

// Add username for user-delegated tokens (not M2M / client credentials tokens)
if !strings.HasPrefix(tok.UserID, "client:") {
if !services.IsMachineUserID(tok.UserID) {
if user, err := h.tokenService.GetUserByID(tok.UserID); err == nil {
resp["username"] = user.Username
}
Expand Down
2 changes: 1 addition & 1 deletion internal/services/audit.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ func (s *AuditService) buildAuditLog(
// client_credentials grant, which uses a "client:<clientID>" format and
// has no corresponding user row).
if entry.ActorUsername == "" && entry.ActorUserID != "" &&
!strings.HasPrefix(entry.ActorUserID, "client:") {
!IsMachineUserID(entry.ActorUserID) {
if user, err := s.store.GetUserByID(entry.ActorUserID); err == nil {
entry.ActorUsername = user.Username
}
Expand Down
49 changes: 36 additions & 13 deletions internal/services/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,18 +280,23 @@ func mergeCallerExtraClaims(system, caller map[string]any) map[string]any {
return out
}

// buildServerClaims returns the JWT claims sourced from the AuthGate process
// configuration (currently just `domain` from JWT_DOMAIN), emitted under the
// supplied private-claim prefix (e.g. "extra_domain" with the default
// prefix). The caller is responsible for passing an already-normalized
// prefix — ad-hoc empty inputs would compose "_domain" which never matches
// downstream reserved/strip semantics. Returns nil when domain is empty so
// callers skip an empty allocation.
func buildServerClaims(domain, prefix string) map[string]any {
if domain == "" {
// buildServerClaims returns the JWT claims sourced from server-attested state
// (`<prefix>_domain` from JWT_DOMAIN, `<prefix>_uid` from User.Username),
// emitted under the supplied (already-normalized) prefix. Each source is
// independently optional; empty inputs are omitted. See token/types.go for
// trust-model details.
func buildServerClaims(domain, username, prefix string) map[string]any {
if domain == "" && username == "" {
return nil
}
return map[string]any{token.EmittedName(prefix, "domain"): domain}
out := make(map[string]any, 2)
if domain != "" {
out[token.EmittedName(prefix, "domain")] = domain
}
if username != "" {
out[token.EmittedName(prefix, "uid")] = username
}
return out
}

// applyServerClaims overlays server-attested claims onto an already-merged
Expand All @@ -312,17 +317,35 @@ func applyServerClaims(claims, server map[string]any) map[string]any {
return claims
}

// resolveUsernameForUID returns the User.Username to emit as `<prefix>_uid`,
// or "" to omit the claim. Returns "" for empty / machine UserIDs and on
// store-lookup failure (logged so the silent omission is diagnosable);
// issuance never fails on a missing user.
func (s *TokenService) resolveUsernameForUID(userID string) string {
if userID == "" || IsMachineUserID(userID) {
return ""
}
user, err := s.store.GetUserByID(userID)
if err != nil {
log.Printf("[Token] uid claim: GetUserByID failed user_id=%s: %v", userID, err)
return ""
}
return user.Username
}

// composeIssuanceClaims builds the merged claim map handed to the token
// provider on every issuance path (auth_code, device_code, client_credentials,
// refresh). Precedence is caller → client → server, with server writing last
// so JWT_DOMAIN cannot be shadowed.
// so server-attested claims cannot be shadowed by caller-supplied extra_claims.
func (s *TokenService) composeIssuanceClaims(
client *models.OAuthApplication,
userID string,
caller map[string]any,
) map[string]any {
prefix := s.privateClaimPrefix
username := s.resolveUsernameForUID(userID)
claims := mergeCallerExtraClaims(buildClientClaims(client, prefix), caller)
return applyServerClaims(claims, buildServerClaims(s.config.JWTDomain, prefix))
return applyServerClaims(claims, buildServerClaims(s.config.JWTDomain, username, prefix))
}

// generateAndPersistTokenPair generates access and refresh tokens via the
Expand Down Expand Up @@ -358,7 +381,7 @@ func (s *TokenService) generateAndPersistTokenPair(
if client != nil {
accessTTL, refreshTTL = s.ttlForClient(client)
}
extraClaims = s.composeIssuanceClaims(client, p.ExtraClaims)
extraClaims = s.composeIssuanceClaims(client, p.UserID, p.ExtraClaims)

accessResult, err := s.tokenProvider.GenerateToken(
ctx, p.UserID, p.ClientID, p.Scopes, accessTTL, extraClaims,
Expand Down
25 changes: 23 additions & 2 deletions internal/services/token_client_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ import (
"github.com/google/uuid"
)

// MachineUserIDPrefix marks the synthetic UserID used for client_credentials
// tokens — they have no real user, so UserID is "client:<clientID>". Audit,
// introspect, token issuance, and downstream authorization all use this
// prefix to distinguish machine-to-machine tokens from user-delegated ones.
const MachineUserIDPrefix = "client:"

// MachineUserID returns the synthetic UserID for a client_credentials token.
func MachineUserID(clientID string) string {
return MachineUserIDPrefix + clientID
}

// IsMachineUserID reports whether userID — the value stored in
// AccessToken.UserID and propagated as the JWT `sub` claim — is the synthetic
// identity issued by the client_credentials grant. AuthGate's real User.ID
// is a UUID (see uses of uuid.New().String() in store seeding and
// UpsertExternalUser) and never contains `:`, so the prefix is an unambiguous
// discriminator and callers can rely on it without a store lookup.
func IsMachineUserID(userID string) bool {
return strings.HasPrefix(userID, MachineUserIDPrefix)
}

// IssueClientCredentialsToken issues an access token for the client_credentials grant
// (RFC 6749 §4.4). Only confidential clients with EnableClientCredentialsFlow=true may use
// this flow. No refresh token is issued (per RFC 6749 §4.4.3).
Expand Down Expand Up @@ -71,7 +92,7 @@ func (s *TokenService) IssueClientCredentialsToken(

// 6. Generate access token — synthetic machine identity carries no real user
start := time.Now()
machineUserID := "client:" + clientID
machineUserID := MachineUserID(clientID)

// TokenProfile governs user-delegated access/refresh tokens only.
// Passing ttl=0 here keeps CLIENT_CREDENTIALS_TOKEN_EXPIRATION as the
Expand All @@ -84,7 +105,7 @@ func (s *TokenService) IssueClientCredentialsToken(
clientID,
effectiveScopes,
0,
s.composeIssuanceClaims(client, callerExtra),
s.composeIssuanceClaims(client, machineUserID, callerExtra),
)
if providerErr != nil {
log.Printf(
Expand Down
20 changes: 14 additions & 6 deletions internal/services/token_domain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ func domainTestConfig(domain string) *config.Config {
}
}

// assertDomainClaim asserts the issued JWT carries the prefixed domain claim
// (`<prefix>_domain: want`) when want is non-empty, or that the claim is
// absent when want is empty.
func assertDomainClaim(t *testing.T, cfg *config.Config, raw, want string) {
// assertPrivateClaim asserts the issued JWT carries the prefixed claim
// (`<prefix>_<logical>: want`) when want is non-empty, or that the claim is
// absent when want is empty. Used across private-claim tests (domain, uid).
func assertPrivateClaim(t *testing.T, cfg *config.Config, raw, logical, want string) {
t.Helper()
claims := decodeJWTClaims(t, raw)
key := token.EmittedName(cfg.JWTPrivateClaimPrefix, "domain")
key := token.EmittedName(cfg.JWTPrivateClaimPrefix, logical)
got, ok := claims[key]
if want == "" {
assert.False(t, ok, "expected %q claim to be omitted, got %v", key, got)
Expand All @@ -53,6 +53,14 @@ func assertDomainClaim(t *testing.T, cfg *config.Config, raw, want string) {
assert.Equal(t, want, got)
}

// assertDomainClaim is a thin wrapper retained for readable call sites in the
// domain-specific tests; new private-claim tests should call assertPrivateClaim
// directly.
func assertDomainClaim(t *testing.T, cfg *config.Config, raw, want string) {
t.Helper()
assertPrivateClaim(t, cfg, raw, "domain", want)
}

// TestDeviceCodeFlow_DomainClaim covers both the present and absent cases for
// the device-code grant. Auth-code shares generateAndPersistTokenPair, so this
// pins both paths.
Expand Down Expand Up @@ -167,7 +175,7 @@ func TestServerDomainOverridesCallerExtraClaims(t *testing.T) {
merged := mergeCallerExtraClaims(nil, map[string]any{domainKey: "evil"})
merged = applyServerClaims(
merged,
buildServerClaims(cfg.JWTDomain, cfg.JWTPrivateClaimPrefix),
buildServerClaims(cfg.JWTDomain, "", cfg.JWTPrivateClaimPrefix),
)

result, err := provider.GenerateToken(
Expand Down
45 changes: 34 additions & 11 deletions internal/services/token_extra_claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,35 +64,58 @@ func TestBuildClientClaims(t *testing.T) {

func TestBuildServerClaims(t *testing.T) {
domainKey := token.EmittedName("extra", "domain")
uidKey := token.EmittedName("extra", "uid")
tests := []struct {
name string
domain string
prefix string
want map[string]any
name string
domain string
username string
prefix string
want map[string]any
}{
{name: "empty domain returns nil", domain: "", prefix: "extra", want: nil},
{
name: "populated domain returns prefixed claim",
name: "empty domain and username returns nil",
prefix: "extra",
want: nil,
},
{
name: "domain only returns domain claim",
domain: "oa",
prefix: "extra",
want: map[string]any{domainKey: "oa"},
},
{
name: "username only returns uid claim",
username: "alice",
prefix: "extra",
want: map[string]any{uidKey: "alice"},
},
{
name: "both populated returns both claims",
domain: "oa",
username: "alice",
prefix: "extra",
want: map[string]any{domainKey: "oa", uidKey: "alice"},
},
{
name: "verbatim case preserved",
domain: "OA",
prefix: "extra",
want: map[string]any{domainKey: "OA"},
},
{
name: "custom prefix produces acme_domain",
domain: "oa",
prefix: "acme",
want: map[string]any{token.EmittedName("acme", "domain"): "oa"},
name: "custom prefix produces acme_domain and acme_uid",
domain: "oa",
username: "alice",
prefix: "acme",
want: map[string]any{
token.EmittedName("acme", "domain"): "oa",
token.EmittedName("acme", "uid"): "alice",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, buildServerClaims(tt.domain, tt.prefix))
assert.Equal(t, tt.want, buildServerClaims(tt.domain, tt.username, tt.prefix))
})
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/services/token_introspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func (s *TokenService) IntrospectToken(
s.auditService.Log(ctx, core.AuditLogEntry{
EventType: models.EventTokenIntrospected,
Severity: models.SeverityInfo,
ActorUserID: "client:" + callerClientID,
ActorUserID: MachineUserID(callerClientID),
ResourceType: models.ResourceToken,
ResourceID: tok.ID,
Action: "Token introspected",
Expand Down
2 changes: 1 addition & 1 deletion internal/services/token_private_claim_prefix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func TestPrivateClaimPrefix_CallerCannotImpersonatePrefixedClaim(t *testing.T) {
merged := mergeCallerExtraClaims(nil, map[string]any{domainKey: "evil"})
merged = applyServerClaims(
merged,
buildServerClaims(cfg.JWTDomain, cfg.JWTPrivateClaimPrefix),
buildServerClaims(cfg.JWTDomain, "", cfg.JWTPrivateClaimPrefix),
)

result, err := provider.GenerateToken(
Expand Down
2 changes: 1 addition & 1 deletion internal/services/token_refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func (s *TokenService) RefreshAccessToken(
client = c
}
}
extraClaims := s.composeIssuanceClaims(client, callerExtra)
extraClaims := s.composeIssuanceClaims(client, refreshToken.UserID, callerExtra)
refreshResult, providerErr := s.tokenProvider.RefreshAccessToken(
ctx,
refreshTokenString,
Expand Down
Loading
Loading