diff --git a/internal/config/config.go b/internal/config/config.go index d76a845..175ca52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -101,6 +101,7 @@ var jwtPrivateClaimLogicalNames = []string{ "domain", "project", "service_account", + "uid", } // PrivateClaimLogicalNames returns a defensive copy of the local diff --git a/internal/handlers/token.go b/internal/handlers/token.go index fae1982..2147ab3 100644 --- a/internal/handlers/token.go +++ b/internal/handlers/token.go @@ -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" } @@ -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 } diff --git a/internal/services/audit.go b/internal/services/audit.go index f8c940c..87cb8b4 100644 --- a/internal/services/audit.go +++ b/internal/services/audit.go @@ -214,7 +214,7 @@ func (s *AuditService) buildAuditLog( // client_credentials grant, which uses a "client:" 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 } diff --git a/internal/services/token.go b/internal/services/token.go index f4de278..6d4263a 100644 --- a/internal/services/token.go +++ b/internal/services/token.go @@ -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 +// (`_domain` from JWT_DOMAIN, `_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 @@ -312,17 +317,35 @@ func applyServerClaims(claims, server map[string]any) map[string]any { return claims } +// resolveUsernameForUID returns the User.Username to emit as `_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 @@ -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, diff --git a/internal/services/token_client_credentials.go b/internal/services/token_client_credentials.go index 22a612c..9e12d4c 100644 --- a/internal/services/token_client_credentials.go +++ b/internal/services/token_client_credentials.go @@ -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:". 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). @@ -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 @@ -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( diff --git a/internal/services/token_domain_test.go b/internal/services/token_domain_test.go index 2c4d4d0..26ac62e 100644 --- a/internal/services/token_domain_test.go +++ b/internal/services/token_domain_test.go @@ -38,13 +38,13 @@ func domainTestConfig(domain string) *config.Config { } } -// assertDomainClaim asserts the issued JWT carries the prefixed domain claim -// (`_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 +// (`_: 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) @@ -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. @@ -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( diff --git a/internal/services/token_extra_claims_test.go b/internal/services/token_extra_claims_test.go index d988040..de0f009 100644 --- a/internal/services/token_extra_claims_test.go +++ b/internal/services/token_extra_claims_test.go @@ -64,19 +64,38 @@ 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", @@ -84,15 +103,19 @@ func TestBuildServerClaims(t *testing.T) { 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)) }) } } diff --git a/internal/services/token_introspect.go b/internal/services/token_introspect.go index 38d6b13..65f981d 100644 --- a/internal/services/token_introspect.go +++ b/internal/services/token_introspect.go @@ -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", diff --git a/internal/services/token_private_claim_prefix_test.go b/internal/services/token_private_claim_prefix_test.go index ea59eb9..02def13 100644 --- a/internal/services/token_private_claim_prefix_test.go +++ b/internal/services/token_private_claim_prefix_test.go @@ -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( diff --git a/internal/services/token_refresh.go b/internal/services/token_refresh.go index f215bcc..cdd8bdd 100644 --- a/internal/services/token_refresh.go +++ b/internal/services/token_refresh.go @@ -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, diff --git a/internal/services/token_uid_test.go b/internal/services/token_uid_test.go new file mode 100644 index 0000000..ae0a9ee --- /dev/null +++ b/internal/services/token_uid_test.go @@ -0,0 +1,155 @@ +package services + +import ( + "context" + "testing" + + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/store" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// seedUserForAuthorizedDeviceCode recovers the post-authorize UserID by +// re-reading the device code (createAuthorizedDeviceCode returns a +// pre-authorize snapshot), seeds a User row at that ID with a deterministic +// username, and mutates dc.UserID in place. dc.DeviceCode (gorm:"-") +// is preserved so subsequent ExchangeDeviceCode calls still resolve. +func seedUserForAuthorizedDeviceCode( + t *testing.T, + s *store.Store, + dc *models.DeviceCode, + username string, +) { + t.Helper() + fresh, err := s.GetDeviceCodeByUserCode(dc.UserCode) + require.NoError(t, err) + require.NotEmpty(t, fresh.UserID, "device code must be authorized before seeding user") + dc.UserID = fresh.UserID + require.NoError(t, s.CreateUser(&models.User{ + ID: fresh.UserID, + Username: username, + Email: username + "@example.com", + IsActive: true, + })) +} + +func TestAuthCodeFlow_EmitsUidClaim(t *testing.T) { + t.Run("auth_code", func(t *testing.T) { + s := setupTestStore(t) + cfg := domainTestConfig("") + svc := createTestTokenService(t, s, cfg) + + user := &models.User{ + ID: uuid.New().String(), + Username: "alice", + Email: "alice@example.com", + IsActive: true, + } + require.NoError(t, s.CreateUser(user)) + client := createTestClient(t, s, true) + authCode := createTestAuthCodeRecord(t, s, client, user.ID) + + access, refresh, _, err := svc.ExchangeAuthorizationCode( + context.Background(), authCode, nil, nil, + ) + require.NoError(t, err) + + assertPrivateClaim(t, cfg, access.RawToken, "uid", "alice") + assertPrivateClaim(t, cfg, refresh.RawToken, "uid", "alice") + }) + + t.Run("device_code", func(t *testing.T) { + s := setupTestStore(t) + cfg := domainTestConfig("") + svc := createTestTokenService(t, s, cfg) + + client := createTestClient(t, s, true) + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + seedUserForAuthorizedDeviceCode(t, s, dc, "bob") + + access, refresh, err := svc.ExchangeDeviceCode( + context.Background(), dc.DeviceCode, client.ClientID, nil, + ) + require.NoError(t, err) + + assertPrivateClaim(t, cfg, access.RawToken, "uid", "bob") + assertPrivateClaim(t, cfg, refresh.RawToken, "uid", "bob") + }) +} + +// TestClientCredentialsFlow_OmitsUidClaim diverges intentionally from +// TestClientCredentialsFlow_DomainClaim: client_credentials has no real user, +// so uid is suppressed while domain is still emitted. +func TestClientCredentialsFlow_OmitsUidClaim(t *testing.T) { + s := setupTestStore(t) + cfg := domainTestConfig("oa") + svc := createTestTokenService(t, s, cfg) + + client, plainSecret := createConfidentialClientWithCCFlow(t, s, true) + + tok, err := svc.IssueClientCredentialsToken( + context.Background(), client.ClientID, plainSecret, "", nil, + ) + require.NoError(t, err) + + assertPrivateClaim(t, cfg, tok.RawToken, "uid", "") + // Sanity: domain still emitted, confirming server-claim composition reached + // the JWT — the absence above is targeted at uid only. + assertPrivateClaim(t, cfg, tok.RawToken, "domain", "oa") +} + +// TestRefresh_ReResolvesUidAfterUsernameChange pins live re-resolution: an +// admin rename between issuance and refresh must propagate to the next +// refreshed token, mirroring the JWT_DOMAIN re-resolution pattern. +func TestRefresh_ReResolvesUidAfterUsernameChange(t *testing.T) { + s := setupTestStore(t) + cfg := domainTestConfig("") + cfg.EnableTokenRotation = true // rotation produces a fresh refresh JWT to decode + svc := createTestTokenService(t, s, cfg) + + client := createTestClient(t, s, true) + dc := createAuthorizedDeviceCode(t, s, client.ClientID) + seedUserForAuthorizedDeviceCode(t, s, dc, "alice") + + _, refresh, err := svc.ExchangeDeviceCode( + context.Background(), dc.DeviceCode, client.ClientID, nil, + ) + require.NoError(t, err) + assertPrivateClaim(t, cfg, refresh.RawToken, "uid", "alice") + + user, err := s.GetUserByID(dc.UserID) + require.NoError(t, err) + user.Username = "alice2" + require.NoError(t, s.UpdateUser(user)) + + newAccess, newRefresh, err := svc.RefreshAccessToken( + context.Background(), refresh.RawToken, client.ClientID, "read write", nil, + ) + require.NoError(t, err) + + assertPrivateClaim(t, cfg, newAccess.RawToken, "uid", "alice2") + assertPrivateClaim(t, cfg, newRefresh.RawToken, "uid", "alice2") +} + +// TestUidClaim_OmittedWhenUserLookupFails pins the "log + omit, never fail" +// contract: a missing User row must leave issuance succeeding with the claim +// absent rather than carrying a stale, empty, or fabricated value. +func TestUidClaim_OmittedWhenUserLookupFails(t *testing.T) { + s := setupTestStore(t) + cfg := domainTestConfig("") + svc := createTestTokenService(t, s, cfg) + + client := createTestClient(t, s, true) + missingUserID := uuid.New().String() + authCode := createTestAuthCodeRecord(t, s, client, missingUserID) + + access, refresh, _, err := svc.ExchangeAuthorizationCode( + context.Background(), authCode, nil, nil, + ) + require.NoError(t, err, "issuance must not fail when uid lookup misses") + + assertPrivateClaim(t, cfg, access.RawToken, "uid", "") + assertPrivateClaim(t, cfg, refresh.RawToken, "uid", "") +} diff --git a/internal/token/types.go b/internal/token/types.go index 82ffff8..cc41b1c 100644 --- a/internal/token/types.go +++ b/internal/token/types.go @@ -20,6 +20,11 @@ const ( // Trust levels differ across the registry: // - `domain` is **server-attested**: sourced from the AuthGate process's // JWT_DOMAIN configuration; cannot be set per-client or by a caller. +// - `uid` is **server-attested**: sourced from the User.Username row +// resolved at issuance/refresh time; cannot be set per-client or by a +// caller. Omitted when the issuance has no real user (client_credentials +// grant) or the user lookup fails. Reflects username-at-issuance — a +// subsequent admin rename surfaces on the next issuance/refresh. // - `project` and `service_account` are **owner-set**: sourced from the // OAuthApplication row (admin or client owner). A signed JWT only // proves AuthGate emitted these values, not that the asserted project / @@ -58,6 +63,7 @@ var privateClaims = []PrivateClaim{ {LogicalName: "domain"}, {LogicalName: "project"}, {LogicalName: "service_account"}, + {LogicalName: "uid"}, } // PrivateClaimRegistry returns a defensive copy of the AuthGate-emitted