From 0d2a2921579f3f907a69a6352e8b5f25e7b1871e Mon Sep 17 00:00:00 2001 From: Bruno Gale Date: Fri, 25 Sep 2026 22:06:11 +0200 Subject: [PATCH 1/2] feat(gotrue): add sign-out scope to the stateless client --- .../Stateless/StatelessSignOutTests.cs | 33 +++++++++++++++++++ .../Interfaces/IGotrueStatelessClient.cs | 6 ++++ .../Gotrue/Gotrue/PublicAPI.Unshipped.txt | 2 ++ packages/Gotrue/Gotrue/StatelessClient.cs | 7 ++-- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs diff --git a/packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs b/packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs new file mode 100644 index 00000000..aa70bd50 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs @@ -0,0 +1,33 @@ +#region + +using System.Threading.Tasks; +using Gotrue.Tests.Support; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using static Supabase.Gotrue.Constants; +using static Supabase.Gotrue.StatelessClient; + +#endregion + +namespace Gotrue.Tests.Stateless; + +/// +/// Stateless sign-out sends the requested scope to /logout. +/// +[TestClass] +[TestCategory("Contract")] +public class StatelessSignOutTests +{ + [TestMethod] + public async Task SignOutAsync_ShouldSendTheRequestedScope() + { + using var server = new MockGotrueServer(); + server.Given(Request.Create().WithPath("/logout").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(200)); + var options = new StatelessClientOptions { Url = server.Url }; + await new StatelessClient().SignOutAsync("user-access-token", options, SignOutScope.Local); + server.VerifySingleReceivedRequest().WithQueryParam("scope", "local"); + } +} diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs index db205f66..3e0628da 100644 --- a/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs +++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs @@ -199,6 +199,12 @@ public interface IGotrueStatelessClient /// Task SignOut(string accessToken, StatelessClientOptions options); + /// + /// Signs out the user from the sessions in the given scope. + /// JWT tokens will still be valid for stateless auth until they expire. + /// + Task SignOutAsync(string accessToken, StatelessClientOptions options, SignOutScope scope); + /// /// Signs up a user /// diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt index 852353a9..a2b12d94 100644 --- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt +++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt @@ -114,3 +114,5 @@ Supabase.Gotrue.Exceptions.FailureHint.Reason.InvalidJwt = 22 -> Supabase.Gotrue Supabase.Gotrue.Interfaces.IGotrueApi.GetJwksAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Interfaces.IGotrueApi.GetUserAsync(string! jwt, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Interfaces.IGotrueClient.GetClaimsAsync(string? jwt = null, Supabase.Gotrue.Claims.GetClaimsOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Supabase.Gotrue.Interfaces.IGotrueStatelessClient.SignOutAsync(string! accessToken, Supabase.Gotrue.StatelessClient.StatelessClientOptions! options, Supabase.Gotrue.Constants.SignOutScope scope) -> System.Threading.Tasks.Task! +Supabase.Gotrue.StatelessClient.SignOutAsync(string! accessToken, Supabase.Gotrue.StatelessClient.StatelessClientOptions! options, Supabase.Gotrue.Constants.SignOutScope scope) -> System.Threading.Tasks.Task! diff --git a/packages/Gotrue/Gotrue/StatelessClient.cs b/packages/Gotrue/Gotrue/StatelessClient.cs index 72c004b8..892c0f37 100644 --- a/packages/Gotrue/Gotrue/StatelessClient.cs +++ b/packages/Gotrue/Gotrue/StatelessClient.cs @@ -176,9 +176,12 @@ public async Task SignIn(string email, StatelessClientOptions options, Sig public ProviderAuthState SignIn(Provider provider, StatelessClientOptions options, SignInOptions? signInOptions = null) => this.GetApi(options).GetUriForProvider(provider, signInOptions); /// - public async Task SignOut(string accessToken, StatelessClientOptions options) + public Task SignOut(string accessToken, StatelessClientOptions options) => this.SignOutAsync(accessToken, options, SignOutScope.Global); + + /// + public async Task SignOutAsync(string accessToken, StatelessClientOptions options, SignOutScope scope) { - var result = await this.GetApi(options).SignOut(accessToken); + var result = await this.GetApi(options).SignOut(accessToken, scope).ConfigureAwait(false); result.ResponseMessage?.EnsureSuccessStatusCode(); return true; } From bbb3e6075fce62f2866c791e3ca90b721dd4cbfc Mon Sep 17 00:00:00 2001 From: Bruno Gale Date: Mon, 28 Sep 2026 16:21:29 +0200 Subject: [PATCH 2/2] chore(gotrue): make the scoped sign-out a Task overload and test every scope --- .../Stateless/StatelessSignOutTests.cs | 48 +++++++++++++++---- .../Interfaces/IGotrueStatelessClient.cs | 2 +- .../Gotrue/Gotrue/PublicAPI.Unshipped.txt | 4 +- packages/Gotrue/Gotrue/StatelessClient.cs | 9 ++-- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs b/packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs index aa70bd50..2c63bff6 100644 --- a/packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs +++ b/packages/Gotrue/Gotrue.Tests/Stateless/StatelessSignOutTests.cs @@ -1,9 +1,11 @@ #region using System.Threading.Tasks; +using FluentAssertions; using Gotrue.Tests.Support; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using static Supabase.Gotrue.Constants; @@ -14,20 +16,50 @@ namespace Gotrue.Tests.Stateless; /// -/// Stateless sign-out sends the requested scope to /logout. +/// Stateless sign-out sends the requested scope to /logout, global by default, and throws when the request fails. /// [TestClass] [TestCategory("Contract")] public class StatelessSignOutTests { + private MockGotrueServer server = null!; + + [TestInitialize] + public void TestInitialize() => this.server = new MockGotrueServer(); + + [TestCleanup] + public void TestCleanup() => this.server.Dispose(); + + [TestMethod] + [DataRow(SignOutScope.Global, "global")] + [DataRow(SignOutScope.Local, "local")] + [DataRow(SignOutScope.Others, "others")] + public async Task SignOut_ShouldSendTheRequestedScope(SignOutScope scope, string expected) + { + this.StubLogout(200); + await new StatelessClient().SignOut("user-access-token", this.Options(), scope); + this.server.VerifySingleReceivedRequest().WithQueryParam("scope", expected); + } + [TestMethod] - public async Task SignOutAsync_ShouldSendTheRequestedScope() + public async Task SignOut_ShouldSendGlobalScope_GivenNoScope() { - using var server = new MockGotrueServer(); - server.Given(Request.Create().WithPath("/logout").UsingPost()) - .RespondWith(Response.Create().WithStatusCode(200)); - var options = new StatelessClientOptions { Url = server.Url }; - await new StatelessClient().SignOutAsync("user-access-token", options, SignOutScope.Local); - server.VerifySingleReceivedRequest().WithQueryParam("scope", "local"); + this.StubLogout(200); + await new StatelessClient().SignOut("user-access-token", this.Options()); + this.server.VerifySingleReceivedRequest().WithQueryParam("scope", "global"); } + + [TestMethod] + public async Task SignOut_ShouldThrow_GivenErrorResponse() + { + this.StubLogout(401); + var signOut = () => new StatelessClient().SignOut("user-access-token", this.Options(), SignOutScope.Local); + await signOut.Should().ThrowAsync(); + } + + private void StubLogout(int statusCode) => + this.server.Given(Request.Create().WithPath("/logout").UsingPost()) + .RespondWith(Response.Create().WithStatusCode(statusCode)); + + private StatelessClientOptions Options() => new() { Url = this.server.Url }; } diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs index 3e0628da..3cadca16 100644 --- a/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs +++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueStatelessClient.cs @@ -203,7 +203,7 @@ public interface IGotrueStatelessClient /// Signs out the user from the sessions in the given scope. /// JWT tokens will still be valid for stateless auth until they expire. /// - Task SignOutAsync(string accessToken, StatelessClientOptions options, SignOutScope scope); + Task SignOut(string accessToken, StatelessClientOptions options, SignOutScope scope); /// /// Signs up a user diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt index a2b12d94..6960d464 100644 --- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt +++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt @@ -114,5 +114,5 @@ Supabase.Gotrue.Exceptions.FailureHint.Reason.InvalidJwt = 22 -> Supabase.Gotrue Supabase.Gotrue.Interfaces.IGotrueApi.GetJwksAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Interfaces.IGotrueApi.GetUserAsync(string! jwt, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Interfaces.IGotrueClient.GetClaimsAsync(string? jwt = null, Supabase.Gotrue.Claims.GetClaimsOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -Supabase.Gotrue.Interfaces.IGotrueStatelessClient.SignOutAsync(string! accessToken, Supabase.Gotrue.StatelessClient.StatelessClientOptions! options, Supabase.Gotrue.Constants.SignOutScope scope) -> System.Threading.Tasks.Task! -Supabase.Gotrue.StatelessClient.SignOutAsync(string! accessToken, Supabase.Gotrue.StatelessClient.StatelessClientOptions! options, Supabase.Gotrue.Constants.SignOutScope scope) -> System.Threading.Tasks.Task! +Supabase.Gotrue.Interfaces.IGotrueStatelessClient.SignOut(string! accessToken, Supabase.Gotrue.StatelessClient.StatelessClientOptions! options, Supabase.Gotrue.Constants.SignOutScope scope) -> System.Threading.Tasks.Task! +Supabase.Gotrue.StatelessClient.SignOut(string! accessToken, Supabase.Gotrue.StatelessClient.StatelessClientOptions! options, Supabase.Gotrue.Constants.SignOutScope scope) -> System.Threading.Tasks.Task! diff --git a/packages/Gotrue/Gotrue/StatelessClient.cs b/packages/Gotrue/Gotrue/StatelessClient.cs index 892c0f37..65edca12 100644 --- a/packages/Gotrue/Gotrue/StatelessClient.cs +++ b/packages/Gotrue/Gotrue/StatelessClient.cs @@ -176,14 +176,17 @@ public async Task SignIn(string email, StatelessClientOptions options, Sig public ProviderAuthState SignIn(Provider provider, StatelessClientOptions options, SignInOptions? signInOptions = null) => this.GetApi(options).GetUriForProvider(provider, signInOptions); /// - public Task SignOut(string accessToken, StatelessClientOptions options) => this.SignOutAsync(accessToken, options, SignOutScope.Global); + public async Task SignOut(string accessToken, StatelessClientOptions options) + { + await this.SignOut(accessToken, options, SignOutScope.Global).ConfigureAwait(false); + return true; + } /// - public async Task SignOutAsync(string accessToken, StatelessClientOptions options, SignOutScope scope) + public async Task SignOut(string accessToken, StatelessClientOptions options, SignOutScope scope) { var result = await this.GetApi(options).SignOut(accessToken, scope).ConfigureAwait(false); result.ResponseMessage?.EnsureSuccessStatusCode(); - return true; } ///