diff --git a/packages/Gotrue/Gotrue.Tests/Admin/AdminApiKeyHeaderContractTests.cs b/packages/Gotrue/Gotrue.Tests/Admin/AdminApiKeyHeaderContractTests.cs index 249a37e6..ab5b5028 100644 --- a/packages/Gotrue/Gotrue.Tests/Admin/AdminApiKeyHeaderContractTests.cs +++ b/packages/Gotrue/Gotrue.Tests/Admin/AdminApiKeyHeaderContractTests.cs @@ -25,6 +25,12 @@ public class AdminApiKeyHeaderContractTests private const string ServiceKey = "sb_secret_service_role_key"; private const string UserId = "user-123"; + // JWT-shaped test token; no valid signature needed. + private const string UserToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + + "eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoyMDAwMDAwMDAwfQ." + + "not-a-real-signature"; + private MockGotrueServer server = null!; [TestInitialize] @@ -33,6 +39,8 @@ public void TestInitialize() this.server = new MockGotrueServer(); this.server.Given(Request.Create().WithPath($"/admin/users/{UserId}").UsingDelete()) .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "application/json").WithBody("{}")); + this.server.Given(Request.Create().WithPath("/user").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "application/json").WithBody("{}")); } [TestCleanup] @@ -75,4 +83,15 @@ public async Task DeleteUser_ShouldNotDuplicateApiKey_GivenMetaStyleCasing() await admin.DeleteUser(UserId); this.server.VerifySingleReceivedRequest().WithHeader("apiKey", "project-anon-key"); } + + [TestMethod] + public async Task GetUser_ShouldNotSendUserTokenAsApiKey_GivenNoApiKeyConfigured() + { + // Copying a user token into apikey caused a 401 (#424). + var admin = new AdminClient(ServiceKey, new ClientOptions { Url = this.server.Url }); + await admin.GetUser(UserToken); + this.server.VerifySingleReceivedRequest() + .WithHeader("Authorization", $"Bearer {UserToken}") + .WithoutHeader("apikey"); + } } diff --git a/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs b/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs index 8e152ddc..eb9ac270 100644 --- a/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs +++ b/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs @@ -83,6 +83,13 @@ internal ReceivedRequest WithHeader(string name, string expected) return this; } + internal ReceivedRequest WithoutHeader(string name) + { + this.request.Headers!.Keys.Should().NotContain(key => string.Equals(key, name, StringComparison.OrdinalIgnoreCase), + $"'{name}' should not have been sent"); + return this; + } + internal ReceivedRequest WithJsonContentType() { this.request.Headers.Should().ContainKey("Content-Type").WhoseValue.Single().Should().StartWith("application/json"); diff --git a/packages/Gotrue/Gotrue/Api.cs b/packages/Gotrue/Gotrue/Api.cs index 9c2a0958..bc9137e3 100644 --- a/packages/Gotrue/Gotrue/Api.cs +++ b/packages/Gotrue/Gotrue/Api.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; using System.Linq; using System.Net.Http; using System.Text.Json; @@ -523,11 +524,9 @@ private Dictionary CreateAuthedRequestHeaders(string jwt) ["Authorization"] = $"Bearer {jwt}" }; - // New-format API keys (sb_publishable_/sb_secret_) are rejected on Authorization-only requests; - // the gateway requires an apikey header too. Preserve an apikey the caller already supplied (the - // stateful client injects the project key via GetHeaders — note the "apiKey" casing), otherwise - // fall back to the bearer token, matching supabase-js standalone admin usage. - if (!headers.Keys.Any(key => string.Equals(key, "apikey", StringComparison.OrdinalIgnoreCase))) + // Opaque keys need an apikey header too. Keep any configured key and never copy a JWT here (#424). + var hasApiKey = headers.Keys.Any(key => string.Equals(key, "apikey", StringComparison.OrdinalIgnoreCase)); + if (!hasApiKey && !new JwtSecurityTokenHandler().CanReadToken(jwt)) headers["apikey"] = jwt; return headers;