From 4ae5b7d34147d5dac786fd658a9afce8c4a15604 Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 10 Jul 2026 15:51:03 +0200 Subject: [PATCH 1/2] feat: add state parameter support to OAuth provider sign-in --- Gotrue/Helpers.cs | 4 ++++ Gotrue/ProviderAuthState.cs | 6 ++++++ Gotrue/SignInOptions.cs | 8 ++++++++ GotrueTests/PKCE/PkceContractTests.cs | 21 +++++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/Gotrue/Helpers.cs b/Gotrue/Helpers.cs index bcee01b7..71adaa8d 100644 --- a/Gotrue/Helpers.cs +++ b/Gotrue/Helpers.cs @@ -108,6 +108,10 @@ internal static ProviderAuthState GetUrlForProvider(string url, Constants.Provid if (attr == null) throw new Exception("Unknown provider"); + var state = !string.IsNullOrEmpty(options.State) ? options.State : GenerateNonce(); + query.Add("state", state); + result.State = state; + query.Add("provider", attr.Mapping); if (!string.IsNullOrEmpty(options.Scopes)) diff --git a/Gotrue/ProviderAuthState.cs b/Gotrue/ProviderAuthState.cs index 28a11692..140809d5 100644 --- a/Gotrue/ProviderAuthState.cs +++ b/Gotrue/ProviderAuthState.cs @@ -18,6 +18,12 @@ public class ProviderAuthState /// public string? PKCEVerifier { get; set; } + /// + /// The state parameter included in the OAuth URL for CSRF protection (RFC 6749 §10.12). + /// Validate this against the state echoed back in the OAuth callback. + /// + public string State { get; set; } = null!; + /// /// Constructor /// diff --git a/Gotrue/SignInOptions.cs b/Gotrue/SignInOptions.cs index 4223345d..cfd44fca 100644 --- a/Gotrue/SignInOptions.cs +++ b/Gotrue/SignInOptions.cs @@ -29,5 +29,13 @@ public class SignInOptions /// PKCE is recommended for mobile and server-side applications. /// public OAuthFlowType FlowType { get; set; } = OAuthFlowType.Implicit; + + /// + /// An optional state parameter for CSRF protection (RFC 6749 §10.12). + /// If not provided, one will be generated automatically. + /// Store the returned value and validate it + /// against the state echoed back in the OAuth callback. + /// + public string? State { get; set; } } } diff --git a/GotrueTests/PKCE/PkceContractTests.cs b/GotrueTests/PKCE/PkceContractTests.cs index 49a1e0af..d00d3e9a 100644 --- a/GotrueTests/PKCE/PkceContractTests.cs +++ b/GotrueTests/PKCE/PkceContractTests.cs @@ -77,6 +77,27 @@ public async Task ResetPasswordForEmailPkce_ChallengeIsHashOfVerifier() request.ReadJsonBodyField("code_challenge").Should().Be(S256(state.PKCEVerifier!), "server must receive BASE64URL(SHA256(verifier)) per RFC 7636 §4.2"); } + [TestMethod("SignInWithProvider: auto-generates state and includes it in the URL")] + public async Task SignInWithProvider_AutoGeneratesState() + { + var result = await client.SignIn(Provider.Github, new SignInOptions { FlowType = OAuthFlowType.PKCE }); + result.State.Should().NotBeNullOrEmpty(); + result.Uri.Query.Should().Contain($"state={result.State}"); + } + + [TestMethod("SignInWithProvider: uses developer-provided state and includes it in the URL")] + public async Task SignInWithProvider_UsesDeveloperProvidedState() + { + var customState = "my-server-generated-csrf-token"; + var result = await client.SignIn(Provider.Github, new SignInOptions + { + FlowType = OAuthFlowType.PKCE, + State = customState + }); + result.State.Should().Be(customState); + result.Uri.Query.Should().Contain($"state={customState}"); + } + private static string S256(string verifier) { using var sha = SHA256.Create(); From b46fbea65aef809d75e604572efdc5bc66218475 Mon Sep 17 00:00:00 2001 From: tr00d Date: Fri, 10 Jul 2026 15:51:03 +0200 Subject: [PATCH 2/2] feat: add state parameter support to OAuth provider sign-in --- GotrueTests/AnonKeyClientTests.cs | 8 ++++++-- GotrueTests/StatelessClientTests.cs | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/GotrueTests/AnonKeyClientTests.cs b/GotrueTests/AnonKeyClientTests.cs index e949a85c..397414f6 100644 --- a/GotrueTests/AnonKeyClientTests.cs +++ b/GotrueTests/AnonKeyClientTests.cs @@ -243,10 +243,14 @@ public async Task ClientSendsMagicLoginEmailAlias() public async Task ClientReturnsAuthUrlForProvider() { var result1 = await _client.SignIn(Constants.Provider.Google); - AreEqual($"{TestClients.CliAuthUrl}/authorize?provider=google", result1.Uri.ToString()); + IsTrue(result1.Uri.ToString().StartsWith($"{TestClients.CliAuthUrl}/authorize")); + IsTrue(result1.Uri.Query.Contains("provider=google")); + IsTrue(result1.Uri.Query.Contains("state=")); var result2 = await _client.SignIn(Constants.Provider.Google, new SignInOptions { Scopes = "special scopes please" }); - AreEqual($"{TestClients.CliAuthUrl}/authorize?provider=google&scopes=special+scopes+please", result2.Uri.ToString()); + IsTrue(result2.Uri.Query.Contains("provider=google")); + IsTrue(result2.Uri.Query.Contains("scopes=special+scopes+please")); + IsTrue(result2.Uri.Query.Contains("state=")); } [TestMethod("Client: Returns Verification Code for Provider")] diff --git a/GotrueTests/StatelessClientTests.cs b/GotrueTests/StatelessClientTests.cs index 094ed626..feab0c5e 100644 --- a/GotrueTests/StatelessClientTests.cs +++ b/GotrueTests/StatelessClientTests.cs @@ -205,10 +205,14 @@ public async Task SendsMagicLoginEmailAlias() public void ReturnsAuthUrlForProvider() { var result1 = _client.SignIn(Provider.Google, Options); - Assert.AreEqual($"{TestClients.CliAuthUrl}/authorize?provider=google", result1.Uri.ToString()); + Assert.IsTrue(result1.Uri.ToString().StartsWith($"{TestClients.CliAuthUrl}/authorize")); + Assert.IsTrue(result1.Uri.Query.Contains("provider=google")); + Assert.IsTrue(result1.Uri.Query.Contains("state=")); var result2 = _client.SignIn(Provider.Google, Options, new SignInOptions { Scopes = "special scopes please" }); - Assert.AreEqual($"{TestClients.CliAuthUrl}/authorize?provider=google&scopes=special+scopes+please", result2.Uri.ToString()); + Assert.IsTrue(result2.Uri.Query.Contains("provider=google")); + Assert.IsTrue(result2.Uri.Query.Contains("scopes=special+scopes+please")); + Assert.IsTrue(result2.Uri.Query.Contains("state=")); } [TestMethod("StatelessClient: Update user")]