diff --git a/src/HotChocolate/Core/src/Authorization/Extensions/AuthorizationFeatureExtensions.cs b/src/HotChocolate/Core/src/Authorization/Extensions/AuthorizationFeatureExtensions.cs index 707fbdd67a6..94d440d8327 100644 --- a/src/HotChocolate/Core/src/Authorization/Extensions/AuthorizationFeatureExtensions.cs +++ b/src/HotChocolate/Core/src/Authorization/Extensions/AuthorizationFeatureExtensions.cs @@ -155,12 +155,24 @@ public static AuthorizationOptions GetAuthorizationOptions( this ISchemaBuilder builder) => builder.Features.GetOrSet(); + // Legacy well-known ContextData key for UserState, removed when UserState moved to the typed + // Features collection (PRs #8267/#8329). Consumers that bypass the request interceptors + // (notably the Strawberry Shake in-memory client) still inject a ready-made UserState under + // this key via SetGlobalState, so it is promoted into Features here. + private const string LegacyUserStateContextDataKey = "HotChocolate.Authorization.UserState"; + public static RequestContext TryCreateUserStateIfNotExists( this RequestContext context) { var userState = context.Features.Get(); if (userState is null + && context.ContextData.TryGetValue(LegacyUserStateContextDataKey, out var legacyValue) + && legacyValue is UserState legacyUserState) + { + context.Features.Set(legacyUserState); + } + else if (userState is null && context.ContextData.TryGetValue(nameof(ClaimsPrincipal), out var value) && value is ClaimsPrincipal principal) { diff --git a/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs b/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs index 8c21b444620..3a126bf5550 100644 --- a/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs +++ b/src/HotChocolate/Core/test/Authorization.Tests/AnnotationBasedAuthorizationTests.cs @@ -953,6 +953,72 @@ public async Task Assert_UserState_Exists() """); } + [Fact] + public async Task Authorize_Should_Use_Explicit_UserState_When_Passed_Through_ContextData() + { + // arrange + var handler = new AuthHandler( + resolver: (ctx, _) + => ctx.Features.TryGet(out UserState? _) + ? AuthorizeResult.Allowed + : AuthorizeResult.NotAllowed, + validation: (ctx, _) + => ctx.Features.TryGet(out UserState? _) + ? AuthorizeResult.Allowed + : AuthorizeResult.NotAllowed); + + var services = CreateServices( + handler, + options => + { + options.ConfigureNodeFields = + descriptor => descriptor.Authorize("READ_NODE"); + }); + + var executor = await services.GetRequestExecutorAsync(cancellationToken: TestContext.Current.CancellationToken); + + // act + // supply a ready-made UserState under the legacy well-known key (as the in-memory client does) + var result = await executor.ExecuteAsync( + builder => + builder + .SetDocument( + """ + { + nodes(ids: "abc") { + __typename + } + } + """) + .SetGlobalState( + "HotChocolate.Authorization.UserState", + new UserState(new ClaimsPrincipal(), isAuthenticated: true)), + TestContext.Current.CancellationToken); + + // assert + // auth passes (legacy UserState promoted into Features), so it fails later on the node ID + Snapshot + .Create() + .Add(result) + .MatchInline( + """ + { + "errors": [ + { + "message": "The node ID string has an invalid format.", + "path": [ + "nodes" + ], + "extensions": { + "originalValue": "abc" + } + } + ], + "data": null + } + """); + } + [Fact] public async Task Skip_After_Validation_For_Null() {