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")]