From a3d0147dc4b1dea95148871d70cafffc8f0f31ea Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Fri, 17 Apr 2026 09:17:14 -0400 Subject: [PATCH] Normalize external token req client token to internal ID (#13434) (#13825) * core: normalize JWT req client token to internal ID Fix enterprise JWT request handling to replace req.ClientToken with the internal jwt. token ID after JIT token entry creation, ensuring downstream auth/lease flows use internal IDs instead of raw JWT strings. Add regression assertions in request handling enterprise tests to verify req/auth/token entry IDs are normalized and raw JWT is not propagated. * vault: document perf-standby JIT forwarding * vault: fix enterprise JWT RAR enforcement Preserve internal JWT token-id normalization while enforcing RAR constraints from request-populated authorization details, with JWT parsing fallback for compatibility. * Fix perf-standby JWT forwarding token restoration Prefer inbound original token when restoring forwarding auth headers so perf-standby forwards raw JWT instead of normalized internal token ID. Also add regression tests for header restoration behavior and clarify godocs for InboundSSCToken semantics. * Add missing Go docs for forwarding tests Fix code-checker lint failure by adding go doc comments to new Test* functions in request_handling_test.go. * Address PR review feedback on type checks and CE wording Split map lookup and type assertion in getMapString for clarity, and adjust InboundSSCToken doc wording to avoid JWT-specific language in CE file. * Canonicalize enterprise token handling Normalize enterprise token inputs to canonical internal IDs in token store paths and remove dual-representation RAR fallback. * Address review nits on token normalization Rename enterprise token normalization helper for clarity and update tests to use require.NoError/require.Equal as requested in review feedback. * Guard sdk ent token tests with enterprise tag Add enterprise build constraint to sdk/logical/token_ent_test.go so CE-mode sdk/logical checks can run without enterprise-only EntToken fields. * Remove enterprise build tag from token_ent_test Revert the temporary build constraint addition in sdk/logical/token_ent_test.go. --------- Co-authored-by: Bianca <48203644+biazmoreira@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- sdk/logical/request.go | 12 ++++++-- vault/request_handling.go | 26 +++++++++++------- vault/request_handling_test.go | 50 ++++++++++++++++++++++++++++++++++ vault/token_store.go | 12 +++++--- vault/token_store_ce.go | 4 +++ 5 files changed, 88 insertions(+), 16 deletions(-) diff --git a/sdk/logical/request.go b/sdk/logical/request.go index 8184764f0f..e5be393a86 100644 --- a/sdk/logical/request.go +++ b/sdk/logical/request.go @@ -153,6 +153,11 @@ type Request struct { // EnterpriseTokenAuthorizationDetails stores enterprise token authorization details. EnterpriseTokenAuthorizationDetails []AuthorizationDetail `json:"enterprise_token_authorization_details,omitempty" structs:"enterprise_token_authorization_details" mapstructure:"enterprise_token_authorization_details"` + // EnterpriseTokenAuthorizationDetailsPresent indicates whether the inbound + // enterprise token included an authorization_details claim at all. This lets + // callers distinguish "claim missing" from "claim present but empty". + EnterpriseTokenAuthorizationDetailsPresent bool `json:"enterprise_token_authorization_details_present,omitempty" structs:"enterprise_token_authorization_details_present" mapstructure:"enterprise_token_authorization_details_present"` + // ClientTokenAccessor is provided to the core so that the it can get // logged as part of request audit logging. ClientTokenAccessor string `json:"client_token_accessor" structs:"client_token_accessor" mapstructure:"client_token_accessor" sentinel:""` @@ -279,8 +284,11 @@ type Request struct { // client token. ClientID string `json:"client_id" structs:"client_id" mapstructure:"client_id" sentinel:""` - // InboundSSCToken is the token that arrives on an inbound request, supplied - // by the vault user. + // InboundSSCToken stores the original token value as supplied by the caller + // on the inbound request (header/body), before token decoding or + // normalization (for example SSCT decoding or enterprise token normalization + // to internal IDs). This allows response/forwarding paths to preserve the + // caller-visible token representation when needed. InboundSSCToken string // When a request has been forwarded, contains information of the host the request was forwarded 'from' diff --git a/vault/request_handling.go b/vault/request_handling.go index 8754eea141..9655e7c9d2 100644 --- a/vault/request_handling.go +++ b/vault/request_handling.go @@ -252,6 +252,7 @@ func (c *Core) fetchACLTokenEntryAndEntity(ctx context.Context, req *logical.Req req.EnterpriseTokenMetadata = getEnterpriseTokenMetadata(tokenMetadataContainer) req.EnterpriseTokenIssuer = getEnterpriseTokenIssuer(tokenMetadataContainer) req.EnterpriseTokenAudience = getEnterpriseTokenAudience(tokenMetadataContainer) + _, req.EnterpriseTokenAuthorizationDetailsPresent = tokenMetadataContainer["authorization_details"] req.EnterpriseTokenAuthorizationDetails = getEnterpriseTokenAuthorizationDetails(tokenMetadataContainer) secondEntity = actorEntity err = c.createAndStoreEnterpriseTokenEntry(ctx, req, tokenMetadataContainer, entity, actorEntity) @@ -419,9 +420,19 @@ func (c *Core) fetchACLTokenEntryAndEntity(ctx context.Context, req *logical.Req } // restoreForwardingTokenHeaders restores client token headers so forwarded -// requests preserve the original auth source on the active node. +// requests preserve the caller's original token representation on the active +// node. It prefers Request.InboundSSCToken (captured before any token +// normalization) and falls back to Request.ClientToken when no inbound value is +// available. func restoreForwardingTokenHeaders(req *logical.Request) { - if req == nil || req.ClientToken == "" { + if req == nil { + return + } + tokenToForward := req.InboundSSCToken + if tokenToForward == "" { + tokenToForward = req.ClientToken + } + if tokenToForward == "" { return } if req.Headers == nil { @@ -429,9 +440,9 @@ func restoreForwardingTokenHeaders(req *logical.Request) { } switch req.ClientTokenSource { case logical.ClientTokenFromVaultHeader: - req.Headers[consts.AuthHeaderName] = []string{req.ClientToken} + req.Headers[consts.AuthHeaderName] = []string{tokenToForward} case logical.ClientTokenFromAuthzHeader: - req.Headers["Authorization"] = append(req.Headers["Authorization"], fmt.Sprintf("Bearer %s", req.ClientToken)) + req.Headers["Authorization"] = append(req.Headers["Authorization"], fmt.Sprintf("Bearer %s", tokenToForward)) } } @@ -675,12 +686,7 @@ func (c *Core) CheckToken(ctx context.Context, req *logical.Request, unauth bool // forward this request properly to the active node. if retErr.ErrorOrNil() != nil && checkErrControlGroupTokenNeedsCreated(retErr) && c.perfStandby && len(req.ClientToken) != 0 { - switch req.ClientTokenSource { - case logical.ClientTokenFromVaultHeader: - req.Headers[consts.AuthHeaderName] = []string{req.ClientToken} - case logical.ClientTokenFromAuthzHeader: - req.Headers["Authorization"] = append(req.Headers["Authorization"], fmt.Sprintf("Bearer %s", req.ClientToken)) - } + restoreForwardingTokenHeaders(req) // We also return the appropriate error so that the caller can forward the // request to the active node return auth, te, logical.ErrPerfStandbyPleaseForward diff --git a/vault/request_handling_test.go b/vault/request_handling_test.go index 3972378640..55362fd69d 100644 --- a/vault/request_handling_test.go +++ b/vault/request_handling_test.go @@ -54,6 +54,56 @@ func TestRequiresMaterializedTokenState(t *testing.T) { } } +// TestRestoreForwardingTokenHeaders_UsesInboundToken verifies Authorization +// forwarding prefers the original inbound token when present. +func TestRestoreForwardingTokenHeaders_UsesInboundToken(t *testing.T) { + t.Parallel() + + req := &logical.Request{ + ClientToken: "jwt.internal-id", + InboundSSCToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig", + ClientTokenSource: logical.ClientTokenFromAuthzHeader, + Headers: map[string][]string{ + "Authorization": {"Basic abc123"}, + }, + } + + restoreForwardingTokenHeaders(req) + + require.Equal(t, []string{"Basic abc123", "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig"}, req.Headers["Authorization"]) +} + +// TestRestoreForwardingTokenHeaders_FallsBackToClientToken verifies fallback to +// req.ClientToken when no inbound token is present. +func TestRestoreForwardingTokenHeaders_FallsBackToClientToken(t *testing.T) { + t.Parallel() + + req := &logical.Request{ + ClientToken: "jwt.jti-value", + ClientTokenSource: logical.ClientTokenFromVaultHeader, + } + + restoreForwardingTokenHeaders(req) + + require.Equal(t, []string{"jwt.jti-value"}, req.Headers["X-Vault-Token"]) +} + +// TestRestoreForwardingTokenHeaders_UsesInboundTokenForVaultHeader verifies +// X-Vault-Token forwarding prefers the original inbound token. +func TestRestoreForwardingTokenHeaders_UsesInboundTokenForVaultHeader(t *testing.T) { + t.Parallel() + + req := &logical.Request{ + ClientToken: "jwt.jti-value", + InboundSSCToken: "jwt.raw.value", + ClientTokenSource: logical.ClientTokenFromVaultHeader, + } + + restoreForwardingTokenHeaders(req) + + require.Equal(t, []string{"jwt.raw.value"}, req.Headers["X-Vault-Token"]) +} + func TestRequestHandling_Wrapping(t *testing.T) { core, _, root := TestCoreUnsealed(t) diff --git a/vault/token_store.go b/vault/token_store.go index 5b2446f23f..40f72e60aa 100644 --- a/vault/token_store.go +++ b/vault/token_store.go @@ -2684,7 +2684,8 @@ func (ts *TokenStore) handleCreate(ctx context.Context, req *logical.Request, d // handleCreateCommon handles the auth/token/create path for creation of new tokens func (ts *TokenStore) handleCreateCommon(ctx context.Context, req *logical.Request, d *framework.FieldData, orphan bool, role *tsRoleEntry) (*logical.Response, error) { - if !orphan && IsEnterpriseToken(req.ClientToken) { + normalizedClientToken := normalizeEnterpriseTokenToID(req.ClientToken) + if !orphan && IsEnterpriseTokenId(normalizedClientToken) { return logical.ErrorResponse("enterprise tokens cannot create child tokens"), logical.ErrInvalidRequest } @@ -3355,7 +3356,8 @@ func (ts *TokenStore) handleRevokeTree(ctx context.Context, req *logical.Request } func (ts *TokenStore) revokeCommon(ctx context.Context, req *logical.Request, data *framework.FieldData, id string) (*logical.Response, error) { - if IsEnterpriseToken(id) { + normalizedID := normalizeEnterpriseTokenToID(id) + if IsEnterpriseTokenId(normalizedID) { return logical.ErrorResponse("cannot revoke ent token"), nil } te, err := ts.Lookup(ctx, id) @@ -3402,7 +3404,8 @@ func (ts *TokenStore) handleRevokeOrphan(ctx context.Context, req *logical.Reque return logical.ErrorResponse("missing token ID"), logical.ErrInvalidRequest } - if IsEnterpriseToken(id) { + normalizedID := normalizeEnterpriseTokenToID(id) + if IsEnterpriseTokenId(normalizedID) { return logical.ErrorResponse("enterprise token cannot be revoked"), nil } @@ -3569,7 +3572,8 @@ func (ts *TokenStore) handleRenew(ctx context.Context, req *logical.Request, dat if id == "" { return logical.ErrorResponse("missing token ID"), logical.ErrInvalidRequest } - if IsEnterpriseToken(id) { + normalizedID := normalizeEnterpriseTokenToID(id) + if IsEnterpriseTokenId(normalizedID) { return logical.ErrorResponse("enterprise tokens cannot be renewed"), nil } incrementRaw := data.Get("increment").(int) diff --git a/vault/token_store_ce.go b/vault/token_store_ce.go index 8a60254e70..8685c7dbda 100644 --- a/vault/token_store_ce.go +++ b/vault/token_store_ce.go @@ -16,6 +16,10 @@ func getEnterpriseTokenId(_ string) string { return "" } +func normalizeEnterpriseTokenToID(token string) string { + return token +} + func (ts *TokenStore) handleTidyEnterpriseTokens(_ context.Context, _ *namespace.Namespace, _ *multierror.Error) error { return nil }