diff --git a/Gotrue/Helpers.cs b/Gotrue/Helpers.cs
index bcee01b..71adaa8 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 28a1169..140809d 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 4223345..cfd44fc 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/AnonKeyClientTests.cs b/GotrueTests/AnonKeyClientTests.cs
index e949a85..397414f 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/PKCE/PkceContractTests.cs b/GotrueTests/PKCE/PkceContractTests.cs
index 49a1e0a..d00d3e9 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();
diff --git a/GotrueTests/StatelessClientTests.cs b/GotrueTests/StatelessClientTests.cs
index 094ed62..feab0c5 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")]