From 09dd2099322193cd74f08d67ef2df1bab45cb310 Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 4 Aug 2026 09:42:11 +0200 Subject: [PATCH 1/2] fix: match auth header names case-insensitively (enables developer override) GetAuthHeaders seeded `apiKey` into a case-sensitive dictionary, so a developer-supplied `apikey` (the Supabase convention) was added as a separate entry instead of replacing it. Both went on the wire as a duplicate, multi-valued `apikey` header, so header-based Storage RLS saw the wrong value and List() returned empty with a 200. Build the header map with StringComparer.OrdinalIgnoreCase so any-cased developer header replaces the SDK default rather than coexisting with it. Fixes supabase-community/storage-csharp#19 --- Supabase/Client.cs | 8 ++++---- .../SupabaseClientCompositionTests.cs | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/Supabase/Client.cs b/Supabase/Client.cs index efe7631c..e4b90f94 100644 --- a/Supabase/Client.cs +++ b/Supabase/Client.cs @@ -257,10 +257,8 @@ public Task Rpc(string procedureName, object? parameters) => /// internal Dictionary GetAuthHeaders() { - var headers = new Dictionary - { - ["X-Client-Info"] = Util.GetAssemblyVersion(typeof(Client)) - }; + var headers = CaseInsensitiveHeaders(); + headers["X-Client-Info"] = Util.GetAssemblyVersion(typeof(Client)); if (_supabaseKey != null) headers["apiKey"] = _supabaseKey; @@ -282,5 +280,7 @@ internal Dictionary GetAuthHeaders() return headers; } + + private static Dictionary CaseInsensitiveHeaders() => new(StringComparer.OrdinalIgnoreCase); } } diff --git a/SupabaseTests/SupabaseClientCompositionTests.cs b/SupabaseTests/SupabaseClientCompositionTests.cs index 77fbe342..41c146fb 100644 --- a/SupabaseTests/SupabaseClientCompositionTests.cs +++ b/SupabaseTests/SupabaseClientCompositionTests.cs @@ -1,5 +1,7 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Net.WebSockets; using System.Threading.Tasks; using FluentAssertions; @@ -139,6 +141,24 @@ public void SupabaseClient_ShouldPreferDeveloperAuthorizationHeader_GivenAuthori "an explicit Authorization header must win over the key-derived bearer (issue #5)"); } + [TestMethod] + public void SupabaseClient_ShouldPreferDeveloperApiKeyHeader_GivenApiKeyInOptions() + { + var options = new SupabaseOptions + { + AutoConnectRealtime = false, + Headers = + { + ["apikey"] = "developer-key" + } + }; + UrlClient(options).Postgrest.GetHeaders!() + .Where(header => string.Equals(header.Key, "apikey", StringComparison.OrdinalIgnoreCase)) + .Should().ContainSingle( + "a developer apikey must replace the key-derived one, not be sent alongside it as a duplicate header that breaks header-based RLS (issue #19)") + .Which.Value.Should().Be("developer-key"); + } + [TestMethod] [DataRow(AuthState.SignedIn)] [DataRow(AuthState.TokenRefreshed)] From 1f323a2be8f5296abe7bc3bc8504c161e80692f1 Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 4 Aug 2026 09:46:59 +0200 Subject: [PATCH 2/2] refactor: apply code cleanup and rules, simplify method --- Supabase/Client.cs | 156 +++++++++++++++++++++------------------------ 1 file changed, 74 insertions(+), 82 deletions(-) diff --git a/Supabase/Client.cs b/Supabase/Client.cs index e4b90f94..f141ff87 100644 --- a/Supabase/Client.cs +++ b/Supabase/Client.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using Supabase.Postgrest.Interfaces; @@ -28,18 +29,17 @@ public class Client : ISupabaseClient public IGotrueClient Auth { - get => _auth; + get => this.auth; set { // Remove existing internal state listener (if applicable) - _auth.RemoveStateChangedListener(Auth_StateChanged); - - _auth = value; - _auth.AddStateChangedListener(Auth_StateChanged); + this.auth.RemoveStateChangedListener(this.Auth_StateChanged); + this.auth = value; + this.auth.AddStateChangedListener(this.Auth_StateChanged); } } - private IGotrueClient _auth; + private IGotrueClient auth; /// /// Returns a Stateless Gotrue Admin client given a service_key JWT. This should really only be accessed from a @@ -50,11 +50,11 @@ public IGotrueClient Auth public IGotrueAdminClient AdminAuth(string serviceKey) => new AdminClient(serviceKey, new Gotrue.ClientOptions { - Url = string.Format(_options.AuthUrlFormat, _supabaseUrl), - AutoRefreshToken = _options.AutoRefreshToken + Url = string.Format(this.options.AuthUrlFormat, this.supabaseUrl), + AutoRefreshToken = this.options.AutoRefreshToken }) { - GetHeaders = GetAuthHeaders, + GetHeaders = this.GetAuthHeaders, }; /// @@ -62,53 +62,53 @@ public IGotrueAdminClient AdminAuth(string serviceKey) => /// public IRealtimeClient Realtime { - get => _realtime; + get => this.realtime; set { // Disconnect from previous RealtimeSocket (if applicable) - _realtime.Disconnect(); - _realtime = value; + this.realtime.Disconnect(); + this.realtime = value; } } - private IRealtimeClient _realtime; + private IRealtimeClient realtime; /// /// Supabase Edge functions allow you to deploy and invoke edge functions. /// public IFunctionsClient Functions { - get => _functions; - set => _functions = value; + get => this.functions; + set => this.functions = value; } - private IFunctionsClient _functions; + private IFunctionsClient functions; /// - /// Supabase Postgrest allows for strongly typed REST interactions with the your database. + /// Supabase Postgrest allows for strongly typed REST interactions with your database. /// public IPostgrestClient Postgrest { - get => _postgrest; - set => _postgrest = value; + get => this.postgrest; + set => this.postgrest = value; } - private IPostgrestClient _postgrest; + private IPostgrestClient postgrest; /// /// Supabase Storage allows you to manage user-generated content, such as photos or videos. /// public IStorageClient Storage { - get => _storage; - set => _storage = value; + get => this.storage; + set => this.storage = value; } - private IStorageClient _storage; + private IStorageClient storage; - private readonly string? _supabaseUrl; - private readonly string? _supabaseKey; - private readonly SupabaseOptions _options; + private readonly string? supabaseUrl; + private readonly string? supabaseKey; + private readonly SupabaseOptions options; /// /// Constructor supplied for dependency injection support. @@ -123,13 +123,13 @@ public Client(IGotrueClient auth, IRealtimeClient storage, SupabaseOptions options) { - _auth = auth; - _realtime = realtime; - _functions = functions; - _postgrest = postgrest; - _storage = storage; - _options = options; - _realtime.Options.PostgrestClient = _postgrest; + this.auth = auth; + this.realtime = realtime; + this.functions = functions; + this.postgrest = postgrest; + this.storage = storage; + this.options = options; + this.realtime.Options.PostgrestClient = this.postgrest; } /// @@ -140,15 +140,15 @@ public Client(IGotrueClient auth, IRealtimeClient public Client(string supabaseUrl, string? supabaseKey, SupabaseOptions? options = null) { - _supabaseUrl = supabaseUrl; - _supabaseKey = supabaseKey; - _options = options ?? new SupabaseOptions(); + this.supabaseUrl = supabaseUrl; + this.supabaseKey = supabaseKey; + this.options = options ?? new SupabaseOptions(); - var authUrl = string.Format(_options.AuthUrlFormat, supabaseUrl); - var restUrl = string.Format(_options.RestUrlFormat, supabaseUrl); - var realtimeUrl = string.Format(_options.RealtimeUrlFormat, supabaseUrl).Replace("http", "ws"); - var storageUrl = string.Format(_options.StorageUrlFormat, supabaseUrl); - var schema = _options.Schema; + var authUrl = string.Format(this.options.AuthUrlFormat, supabaseUrl); + var restUrl = string.Format(this.options.RestUrlFormat, supabaseUrl); + var realtimeUrl = string.Format(this.options.RealtimeUrlFormat, supabaseUrl).Replace("http", "ws"); + var storageUrl = string.Format(this.options.StorageUrlFormat, supabaseUrl); + var schema = this.options.Schema; // See: https://github.com/supabase/supabase-js/blob/09065a65f171bc28a9fd7b831af2c24e5f1a380b/src/SupabaseClient.ts#L77-L83 var isPlatform = new Regex(@"(supabase\.co)|(supabase\.in)").Match(supabaseUrl); @@ -161,40 +161,35 @@ public Client(string supabaseUrl, string? supabaseKey, SupabaseOptions? options } else { - functionsUrl = string.Format(_options.FunctionsUrlFormat, supabaseUrl); + functionsUrl = string.Format(this.options.FunctionsUrlFormat, supabaseUrl); } // Init Auth var gotrueOptions = new Gotrue.ClientOptions { Url = authUrl, - AutoRefreshToken = _options.AutoRefreshToken + AutoRefreshToken = this.options.AutoRefreshToken }; - - _auth = new Gotrue.Client(gotrueOptions); - _auth.SetPersistence(_options.SessionHandler); - _auth.AddStateChangedListener(Auth_StateChanged); - _auth.GetHeaders = GetAuthHeaders; - - _postgrest = new Postgrest.Client(restUrl, new Postgrest.ClientOptions { Schema = schema }); - _postgrest.GetHeaders = GetAuthHeaders; + this.auth = new Gotrue.Client(gotrueOptions); + this.auth.SetPersistence(this.options.SessionHandler); + this.auth.AddStateChangedListener(this.Auth_StateChanged); + this.auth.GetHeaders = this.GetAuthHeaders; + this.postgrest = new Postgrest.Client(restUrl, new Postgrest.ClientOptions { Schema = schema }); + this.postgrest.GetHeaders = this.GetAuthHeaders; // Init Realtime var realtimeOptions = new Realtime.ClientOptions { - Parameters = { ApiKey = _supabaseKey }, - PostgrestClient = _postgrest + Parameters = { ApiKey = this.supabaseKey }, + PostgrestClient = this.postgrest }; - - _realtime = new Realtime.Client(realtimeUrl, realtimeOptions); - _realtime.GetHeaders = GetAuthHeaders; - - _functions = new Functions.Client(functionsUrl); - _functions.GetHeaders = GetAuthHeaders; - - _storage = new Storage.Client(storageUrl, _options.StorageClientOptions); - _storage.GetHeaders = GetAuthHeaders; + this.realtime = new Realtime.Client(realtimeUrl, realtimeOptions); + this.realtime.GetHeaders = this.GetAuthHeaders; + this.functions = new Functions.Client(functionsUrl); + this.functions.GetHeaders = this.GetAuthHeaders; + this.storage = new Storage.Client(storageUrl, this.options.StorageClientOptions); + this.storage.GetHeaders = this.GetAuthHeaders; } @@ -204,10 +199,10 @@ public Client(string supabaseUrl, string? supabaseKey, SupabaseOptions? options public async Task> InitializeAsync() { - await Auth.RetrieveSessionAsync(); + await this.Auth.RetrieveSessionAsync(); - if (_options.AutoConnectRealtime) - await Realtime.ConnectAsync(); + if (this.options.AutoConnectRealtime) + await this.Realtime.ConnectAsync(); return this; } @@ -221,17 +216,16 @@ private void Auth_StateChanged(object sender, AuthState e) case AuthState.SignedIn: case AuthState.TokenRefreshed: case AuthState.UserUpdated: - if (Auth.CurrentSession?.AccessToken != null) - Realtime.SetAuth(Auth.CurrentSession.AccessToken); + if (this.Auth.CurrentSession?.AccessToken != null) + this.Realtime.SetAuth(this.Auth.CurrentSession.AccessToken); break; // Remove Realtime Subscriptions on Auth Sign-out. case AuthState.SignedOut: - if (Realtime.Subscriptions.Values != null) - foreach (var subscription in Realtime.Subscriptions.Values) - subscription.Unsubscribe(); + this.Realtime.Subscriptions.Values?.ToList().ForEach(subscription => subscription.Unsubscribe()); break; - case AuthState.PasswordRecovery: break; + case AuthState.PasswordRecovery: case AuthState.Shutdown: break; + case AuthState.MfaChallengeVerified: default: throw new ArgumentOutOfRangeException(nameof(e), e, null); } } @@ -242,40 +236,38 @@ private void Auth_StateChanged(object sender, AuthState e) /// /// public ISupabaseTable From() where TModel : BaseModel, new() => - new SupabaseTable(Postgrest, Realtime); + new SupabaseTable(this.Postgrest, this.Realtime); /// - public Task Rpc(string procedureName, object? parameters) => - _postgrest.Rpc(procedureName, parameters); + public Task Rpc(string procedureName, object? parameters) => this.postgrest.Rpc(procedureName, parameters); /// - public Task Rpc(string procedureName, object? parameters) => - _postgrest.Rpc(procedureName, parameters); + public Task Rpc(string procedureName, object? parameters) => this.postgrest.Rpc(procedureName, parameters); /// - /// Produces dictionary of Headers that will be supplied to child clients. + /// Produces a dictionary of Headers that will be supplied to child clients. /// internal Dictionary GetAuthHeaders() { var headers = CaseInsensitiveHeaders(); headers["X-Client-Info"] = Util.GetAssemblyVersion(typeof(Client)); - if (_supabaseKey != null) - headers["apiKey"] = _supabaseKey; + if (this.supabaseKey != null) + headers["apiKey"] = this.supabaseKey; // In Regard To: https://github.com/supabase/supabase-csharp/issues/5 - if (_options.Headers.TryGetValue("Authorization", out var header)) + if (this.options.Headers.TryGetValue("Authorization", out var header)) { headers["Authorization"] = header; } else { - var bearer = Auth.CurrentSession?.AccessToken ?? _supabaseKey; + var bearer = this.Auth.CurrentSession?.AccessToken ?? this.supabaseKey; headers["Authorization"] = $"Bearer {bearer}"; } // Add supplied headers from `ClientOptions` by developer - foreach (var kvp in _options.Headers) + foreach (var kvp in this.options.Headers) headers[kvp.Key] = kvp.Value; return headers;