diff --git a/packages/Gotrue/Gotrue.Tests/Claims/GetClaimsTests.cs b/packages/Gotrue/Gotrue.Tests/Claims/GetClaimsTests.cs new file mode 100644 index 00000000..d84e222e --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/Claims/GetClaimsTests.cs @@ -0,0 +1,282 @@ +#region + +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Gotrue.Tests.Support; +using Microsoft.IdentityModel.Tokens; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using Supabase.Gotrue.Claims; +using Supabase.Gotrue.Exceptions; +using Supabase.Gotrue.Interfaces; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using static Supabase.Gotrue.Exceptions.FailureHint.Reason; +using JwtHeader = System.IdentityModel.Tokens.Jwt.JwtHeader; + +#endregion + +namespace Gotrue.Tests.Claims; + +/// +/// Pins how GetClaimsAsync verifies a token: locally against the server's published key set when the +/// signing key is asymmetric, through GET /user otherwise (issues #427 and #262). +/// +[TestClass] +[TestCategory("Contract")] +public class GetClaimsTests +{ + private const string JwksPath = "/.well-known/jwks.json"; + private const string UserPath = "/user"; + private static readonly DateTime FutureExpiry = DateTime.UtcNow.AddHours(1); + private static readonly DateTime PastExpiry = DateTime.UtcNow.AddHours(-1); + + private readonly List publishedKeys = new(); + + private MockGotrueServer server = null!; + private IGotrueClient client = null!; + private SigningCredentials publishedCredentials = null!; + + [TestInitialize] + public void TestInitialize() + { + this.server = new MockGotrueServer(); + this.client = TestClients.Against(this.server); + this.publishedCredentials = this.PublishSigningKey("ES256", "key-1"); + this.server.Given(Request.Create().WithPath(UserPath).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "application/json").WithBody("{}")); + this.server.Given(Request.Create().WithPath(JwksPath).UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Content-Type", "application/json") + .WithBody(_ => $$"""{"keys":[{{string.Join(",", this.publishedKeys)}}]}""")); + } + + [TestCleanup] + public void TestCleanup() => this.server.Dispose(); + + [TestMethod] + [DataRow("ES256")] + [DataRow("RS256")] + public async Task GetClaimsAsync_ShouldVerifyLocally_GivenAsymmetricKey(string alg) + { + var response = await this.client.GetClaimsAsync(GenerateToken(this.PublishSigningKey(alg, "key-2"), FutureExpiry)); + using (new AssertionScope()) + { + response.Claims.Sub.Should().Be("user-123"); + response.Header.Alg.Should().Be(alg); + response.Claims.Aud.Should().Equal(new[] { "authenticated" }, "aud also accepts a single string, RFC 7519 §4.1.3"); + this.server.CountReceivedRequests(UserPath).Should().Be(0, "asymmetric tokens are verified locally (issue #427)"); + } + } + + [TestMethod] + public async Task GetClaimsAsync_ShouldReturnClaims_GivenExpiredTokenAndAllowExpired() + { + var response = await this.client.GetClaimsAsync(GenerateToken(this.publishedCredentials, PastExpiry), new GetClaimsOptions { AllowExpired = true }); + response.Claims.Sub.Should().Be("user-123", "AllowExpired skips expiration validation"); + } + + [TestMethod] + public async Task GetClaimsAsync_ShouldReadClaimsAsSigned_GivenCaseDistinctClaimsAndArrayAudience() + { + var token = WriteToken(this.publishedCredentials, new JwtPayload + { + ["sub"] = "user-123", + ["SUB"] = "custom-value", + ["role"] = "authenticated", + ["ROLE"] = "service_role", + ["aud"] = new[] { "authenticated", "api" }, + ["iat"] = EpochTime.GetIntDate(DateTime.UtcNow), + ["exp"] = EpochTime.GetIntDate(FutureExpiry), + }); + var response = await this.client.GetClaimsAsync(token); + using (new AssertionScope()) + { + response.Claims.Sub.Should().Be("user-123", "claim names are case sensitive, RFC 7519 §7.3"); + response.Claims.Role.Should().Be("authenticated"); + response.Claims.AdditionalClaims.Keys.Should().Contain(new[] { "SUB", "ROLE" }); + response.Claims.Aud.Should().Equal(new[] { "authenticated", "api" }, "aud may be an array, RFC 7519 §4.1.3"); + } + } + + [TestMethod] + [DataRow("HS256", "key-1", 0)] + [DataRow("ES256", null, 0)] + [DataRow("ES256", "key-2", 1)] + public async Task GetClaimsAsync_ShouldFallBackToServer_GivenNoLocalKey(string alg, string? kid, int jwksFetches) + { + var credentials = alg == "HS256" ? SymmetricKey(kid) : GenerateSigningKey(alg, kid).Credentials; + await this.client.GetClaimsAsync(GenerateToken(credentials, FutureExpiry)); + using (new AssertionScope()) + { + this.server.CountReceivedRequests(JwksPath).Should().Be(jwksFetches, "JWKS lookup requires a supported algorithm and a key id"); + this.server.CountReceivedRequests(UserPath).Should().Be(1, "tokens without a local key require server verification"); + } + } + + [TestMethod] + public async Task GetClaimsAsync_ShouldFallBackToServer_GivenNullKeyEntry() + { + this.publishedKeys[0] = "null"; + await this.client.GetClaimsAsync(GenerateToken(this.publishedCredentials, FutureExpiry)); + this.server.CountReceivedRequests(UserPath).Should().Be(1, "a null key entry cannot be used for local verification"); + } + + [TestMethod] + [DataRow("expired")] + [DataRow("no exp")] + [DataRow("malformed")] + public async Task GetClaimsAsync_ShouldThrowInvalidJwt_GivenUnverifiableToken(string scenario) + { + var token = scenario switch + { + "expired" => GenerateToken(this.publishedCredentials, PastExpiry), + "no exp" => GenerateToken(this.publishedCredentials, null), + "malformed" => "not-a-jwt", + _ => throw new ArgumentOutOfRangeException(nameof(scenario), scenario, null), + }; + var getClaims = () => this.client.GetClaimsAsync(token); + using (new AssertionScope()) + { + (await getClaims.Should().ThrowAsync()) + .Which.Reason.Should().Be(InvalidJwt, "invalid tokens have a specific failure reason (issue #262)"); + this.server.CountReceivedRequests().Should().Be(0, "local validation precedes network requests"); + } + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task GetClaimsAsync_ShouldThrowInvalidJwt_GivenTamperedSignature(bool allowExpired) + { + var token = Tamper(GenerateToken(this.publishedCredentials, allowExpired ? PastExpiry : FutureExpiry)); + var getClaims = () => this.client.GetClaimsAsync(token, new GetClaimsOptions { AllowExpired = allowExpired }); + using (new AssertionScope()) + { + (await getClaims.Should().ThrowAsync()) + .Which.Reason.Should().Be(InvalidJwt); + this.server.CountReceivedRequests(UserPath).Should().Be(0, "invalid signatures must not trigger server fallback"); + } + } + + [TestMethod] + [DataRow("exp", "1700000000")] + [DataRow("aud", null)] + [DataRow("aud", new object?[] { "authenticated", null })] + public async Task GetClaimsAsync_ShouldThrowInvalidJwt_GivenUnreadableClaimValue(string claim, object? value) + { + var token = WriteToken(this.publishedCredentials, new JwtPayload + { + ["sub"] = "user-123", + ["exp"] = EpochTime.GetIntDate(FutureExpiry), + [claim] = value, + }); + var getClaims = () => this.client.GetClaimsAsync(token); + (await getClaims.Should().ThrowAsync("claim conversion errors use the SDK error contract")) + .Which.Reason.Should().Be(InvalidJwt); + } + + [TestMethod] + public async Task GetClaimsAsync_ShouldReuseCachedJwks() + { + var token = GenerateToken(this.publishedCredentials, FutureExpiry); + await this.client.GetClaimsAsync(token); + await this.client.GetClaimsAsync(token); + this.server.CountReceivedRequests(JwksPath).Should().Be(1, "fresh keys are cached"); + } + + [TestMethod] + public async Task GetClaimsAsync_ShouldRefetchJwks_GivenUnknownKid() + { + await this.client.GetClaimsAsync(GenerateToken(this.publishedCredentials, FutureExpiry)); + await this.client.GetClaimsAsync(GenerateToken(this.PublishSigningKey("ES256", "key-2"), FutureExpiry)); + using (new AssertionScope()) + { + this.server.CountReceivedRequests(JwksPath).Should().Be(2, "an unknown kid refreshes the cache"); + this.server.CountReceivedRequests(UserPath).Should().Be(0, "the rotated key is verified locally"); + } + } + + [TestMethod] + public async Task GetClaimsAsync_ShouldSkipNetwork_GivenSuppliedJwks() + { + var key = JsonSerializer.Deserialize(this.publishedKeys[0])!; + var options = new GetClaimsOptions + { + Jwks = new Jwks { Keys = new[] { key } }, + }; + await this.client.GetClaimsAsync(GenerateToken(this.publishedCredentials, FutureExpiry), options); + this.server.CountReceivedRequests().Should().Be(0, "supplied keys take precedence"); + } + + [TestMethod] + public async Task GetClaimsAsync_ShouldThrowNoSessionFound_GivenNoTokenAndNoSession() + { + var getClaims = () => this.client.GetClaimsAsync(); + (await getClaims.Should().ThrowAsync()).Which.Reason.Should().Be(NoSessionFound); + } + + private static (SigningCredentials Credentials, string Json) GenerateSigningKey(string alg, string? kid) + { + if (alg == "RS256") + { + var rsa = RSA.Create(2048); + var rsaParameters = rsa.ExportParameters(false); + var n = Base64UrlEncoder.Encode(rsaParameters.Modulus); + var e = Base64UrlEncoder.Encode(rsaParameters.Exponent); + return (new SigningCredentials(new RsaSecurityKey(rsa) { KeyId = kid }, SecurityAlgorithms.RsaSha256), + $$"""{"kty":"RSA","kid":"{{kid}}","alg":"RS256","n":"{{n}}","e":"{{e}}"}"""); + } + var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var ecParameters = ecdsa.ExportParameters(false); + var x = Base64UrlEncoder.Encode(ecParameters.Q.X); + var y = Base64UrlEncoder.Encode(ecParameters.Q.Y); + return (new SigningCredentials(new ECDsaSecurityKey(ecdsa) { KeyId = kid }, SecurityAlgorithms.EcdsaSha256), + $$"""{"kty":"EC","kid":"{{kid}}","alg":"ES256","crv":"P-256","x":"{{x}}","y":"{{y}}"}"""); + } + + private static SigningCredentials SymmetricKey(string? kid) => + new(new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestClients.CliJwtSecret)) { KeyId = kid }, SecurityAlgorithms.HmacSha256); + + private SigningCredentials PublishSigningKey(string alg, string? kid) + { + var key = GenerateSigningKey(alg, kid); + this.publishedKeys.Add(key.Json); + return key.Credentials; + } + + private static string GenerateToken(SigningCredentials credentials, DateTime? expires) + { + var payload = new JwtPayload + { + ["sub"] = "user-123", + ["role"] = "authenticated", + ["aud"] = "authenticated", + }; + if (expires != null) + { + payload["exp"] = EpochTime.GetIntDate(expires.Value); + } + return WriteToken(credentials, payload); + } + + private static string WriteToken(SigningCredentials credentials, JwtPayload payload) + { + var handler = new JwtSecurityTokenHandler(); + return handler.WriteToken(new JwtSecurityToken(new JwtHeader(credentials), payload)); + } + + private static string Tamper(string token) + { + var signatureStart = token.LastIndexOf('.') + 1; + var signature = Base64UrlEncoder.DecodeBytes(token.Substring(signatureStart)); + signature[0] ^= 1; + return token.Substring(0, signatureStart) + Base64UrlEncoder.Encode(signature); + } +} diff --git a/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs b/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs index eb9ac270..5f80a778 100644 --- a/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs +++ b/packages/Gotrue/Gotrue.Tests/Support/MockGotrueServer.cs @@ -31,6 +31,9 @@ internal sealed class MockGotrueServer : IDisposable internal void Reset() => this.server.ResetMappings(); + internal int CountReceivedRequests(string? path = null) => + this.server.LogEntries.Count(entry => path == null || entry.RequestMessage!.Path == path); + internal ReceivedRequest VerifySingleReceivedRequest() { var entry = this.server.LogEntries.Should().ContainSingle("the SDK should emit exactly one request").Which; diff --git a/packages/Gotrue/Gotrue/Api.cs b/packages/Gotrue/Gotrue/Api.cs index 4116a62d..604e5a2d 100644 --- a/packages/Gotrue/Gotrue/Api.cs +++ b/packages/Gotrue/Gotrue/Api.cs @@ -4,10 +4,12 @@ using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Supabase.Core; using Supabase.Core.Extensions; using Supabase.Core.Http; +using Supabase.Gotrue.Claims; using Supabase.Gotrue.Exceptions; using Supabase.Gotrue.Interfaces; using Supabase.Gotrue.Mfa; @@ -65,12 +67,14 @@ public Api(string url, Dictionary? headers = null, HttpClient? h } /// Routes through the resolved and policy. - private Task MakeRequestAsync(HttpMethod method, string url, object? data = null, Dictionary? headers = null) => - Helpers.MakeRequestAsync(method, url, data, headers, this.httpClient, this.retry); + private Task MakeRequestAsync(HttpMethod method, string url, object? data = null, Dictionary? headers = null, + CancellationToken cancellationToken = default) => + Helpers.MakeRequestAsync(method, url, data, headers, this.httpClient, this.retry, cancellationToken); /// Routes through the resolved and policy. - private Task MakeRequestAsync(HttpMethod method, string url, object? data = null, Dictionary? headers = null) where T : class => - Helpers.MakeRequestAsync(method, url, data, headers, this.httpClient, this.retry); + private Task MakeRequestAsync(HttpMethod method, string url, object? data = null, Dictionary? headers = null, + CancellationToken cancellationToken = default) where T : class => + Helpers.MakeRequestAsync(method, url, data, headers, this.httpClient, this.retry, cancellationToken); /// /// Signs a user up using an email address and password. @@ -686,12 +690,16 @@ public Task SignOut(string jwt, SignOutScope scope = SignOutScope. /// /// /// - public Task GetUser(string jwt) - { - var data = new Dictionary(); + public Task GetUser(string jwt) => this.GetUserAsync(jwt); - return this.MakeRequestAsync(HttpMethod.Get, $"{this.Url}/user", data, this.CreateAuthedRequestHeaders(jwt)); - } + /// + /// Gets User Details + /// + /// + /// + /// + public Task GetUserAsync(string jwt, CancellationToken cancellationToken = default) => + this.MakeRequestAsync(HttpMethod.Get, $"{this.Url}/user", null, this.CreateAuthedRequestHeaders(jwt), cancellationToken); /// /// Get User details by Id @@ -810,6 +818,14 @@ public Task DeleteUser(string uid, string jwt, bool shouldSoftDele /// public Task Settings() => this.MakeRequestAsync(HttpMethod.Get, $"{this.Url}/settings", null, this.Headers); + /// + /// Gets the server's public keys for verifying JWT signatures. + /// + /// + /// + public Task GetJwksAsync(CancellationToken cancellationToken = default) => + this.MakeRequestAsync(HttpMethod.Get, $"{this.Url}/.well-known/jwks.json", null, this.Headers, cancellationToken); + /// /// Generates email links and OTPs to be sent via a custom email provider. /// diff --git a/packages/Gotrue/Gotrue/Claims/AudienceConverter.cs b/packages/Gotrue/Gotrue/Claims/AudienceConverter.cs new file mode 100644 index 00000000..1a260302 --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/AudienceConverter.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Supabase.Gotrue.Claims; + +/// +/// Reads the "aud" claim, which RFC 7519 §4.1.3 allows to be one string or an array of them. +/// +internal sealed class AudienceConverter : JsonConverter> +{ + public override bool HandleNull => true; + + public override IReadOnlyList Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var audiences = reader.TokenType == JsonTokenType.String + ? new[] { reader.GetString()! } + : JsonSerializer.Deserialize(ref reader, options); + if (audiences == null || Array.IndexOf(audiences, null) >= 0) + { + throw new JsonException("aud must be a string or an array of strings."); + } + return audiences; + } + + public override void Write(Utf8JsonWriter writer, IReadOnlyList value, JsonSerializerOptions options) => + JsonSerializer.Serialize(writer, value, options); +} diff --git a/packages/Gotrue/Gotrue/Claims/GetClaimsOptions.cs b/packages/Gotrue/Gotrue/Claims/GetClaimsOptions.cs new file mode 100644 index 00000000..95945979 --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/GetClaimsOptions.cs @@ -0,0 +1,13 @@ +namespace Supabase.Gotrue.Claims; + +/// +/// Options for . +/// +public sealed record GetClaimsOptions +{ + /// Skips the local check of the "exp" claim. The signature is still verified. + public bool AllowExpired { get; init; } + + /// Keys tried before the cached set and the server. + public Jwks? Jwks { get; init; } +} diff --git a/packages/Gotrue/Gotrue/Claims/GetClaimsResponse.cs b/packages/Gotrue/Gotrue/Claims/GetClaimsResponse.cs new file mode 100644 index 00000000..eb671aba --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/GetClaimsResponse.cs @@ -0,0 +1,18 @@ +using System; + +namespace Supabase.Gotrue.Claims; + +/// +/// The header and claims of a verified JWT. +/// +public sealed record GetClaimsResponse +{ + /// The claims carried by the token payload. + public JwtClaims Claims { get; init; } = new(); + + /// The token header. + public JwtHeader Header { get; init; } = new(); + + /// The raw signature bytes of the token. + public byte[] Signature { get; init; } = Array.Empty(); +} diff --git a/packages/Gotrue/Gotrue/Claims/Jwk.cs b/packages/Gotrue/Gotrue/Claims/Jwk.cs new file mode 100644 index 00000000..951c96be --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/Jwk.cs @@ -0,0 +1,45 @@ +using System.Text.Json.Serialization; + +namespace Supabase.Gotrue.Claims; + +/// +/// A single JSON Web Key published by the server at /.well-known/jwks.json. +/// +public sealed record Jwk +{ + /// Key type, "EC" or "RSA". + [JsonPropertyName("kty")] + public string? Kty { get; init; } + + /// Key id, matched against the "kid" of the JWT header. + [JsonPropertyName("kid")] + public string? Kid { get; init; } + + /// Signing algorithm. + [JsonPropertyName("alg")] + public string? Alg { get; init; } + + /// Intended use of the key, "sig" for signing keys. + [JsonPropertyName("use")] + public string? Use { get; init; } + + /// RSA modulus. + [JsonPropertyName("n")] + public string? N { get; init; } + + /// RSA exponent. + [JsonPropertyName("e")] + public string? E { get; init; } + + /// Curve name, "P-256". + [JsonPropertyName("crv")] + public string? Crv { get; init; } + + /// Elliptic curve x coordinate. + [JsonPropertyName("x")] + public string? X { get; init; } + + /// Elliptic curve y coordinate. + [JsonPropertyName("y")] + public string? Y { get; init; } +} diff --git a/packages/Gotrue/Gotrue/Claims/Jwks.cs b/packages/Gotrue/Gotrue/Claims/Jwks.cs new file mode 100644 index 00000000..77435cd3 --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/Jwks.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Supabase.Gotrue.Claims; + +/// +/// Public keys used to verify JWT signatures. +/// +public sealed record Jwks +{ + /// The published keys, empty when the server signs symmetrically. + [JsonPropertyName("keys")] + public IReadOnlyList Keys { get; init; } = Array.Empty(); +} diff --git a/packages/Gotrue/Gotrue/Claims/JwtClaims.cs b/packages/Gotrue/Gotrue/Claims/JwtClaims.cs new file mode 100644 index 00000000..0028bf9a --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/JwtClaims.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Supabase.Gotrue.Claims; + +/// +/// Standard JWT claims. Anything else is available in . +/// +public sealed record JwtClaims +{ + /// Issuer of the token. + [JsonPropertyName("iss")] + public string? Iss { get; init; } + + /// The user id. + [JsonPropertyName("sub")] + public string? Sub { get; init; } + + /// Audiences the token was issued for, one or many. + [JsonPropertyName("aud")] + [JsonConverter(typeof(AudienceConverter))] + public IReadOnlyList Aud { get; init; } = Array.Empty(); + + /// Expiry, as seconds since the Unix epoch. + [JsonPropertyName("exp")] + public long? Exp { get; init; } + + /// Issued at, Unix seconds. + [JsonPropertyName("iat")] + public long? Iat { get; init; } + + /// Postgres role the token authenticates as. + [JsonPropertyName("role")] + public string? Role { get; init; } + + /// Authenticator assurance level reached by the session. + [JsonPropertyName("aal")] + public string? Aal { get; init; } + + /// Id of the session the token belongs to. + [JsonPropertyName("session_id")] + public string? SessionId { get; init; } + + /// Every other claim carried by the token, including custom ones. + [JsonExtensionData] + public Dictionary AdditionalClaims { get; init; } = new(); +} diff --git a/packages/Gotrue/Gotrue/Claims/JwtHeader.cs b/packages/Gotrue/Gotrue/Claims/JwtHeader.cs new file mode 100644 index 00000000..f6a198cf --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/JwtHeader.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Supabase.Gotrue.Claims; + +/// +/// The decoded header of a JWT. +/// +public sealed record JwtHeader +{ + /// Signing algorithm. + [JsonPropertyName("alg")] + public string? Alg { get; init; } + + /// Id of the key that signed the token, when present. + [JsonPropertyName("kid")] + public string? Kid { get; init; } + + /// Token type. + [JsonPropertyName("typ")] + public string? Typ { get; init; } +} diff --git a/packages/Gotrue/Gotrue/Claims/JwtVerification.cs b/packages/Gotrue/Gotrue/Claims/JwtVerification.cs new file mode 100644 index 00000000..6794ee11 --- /dev/null +++ b/packages/Gotrue/Gotrue/Claims/JwtVerification.cs @@ -0,0 +1,92 @@ +using System; +using System.IdentityModel.Tokens.Jwt; +using System.Text.Json; +using Microsoft.IdentityModel.Tokens; +using Supabase.Gotrue.Exceptions; +using static Supabase.Gotrue.Exceptions.FailureHint.Reason; + +namespace Supabase.Gotrue.Claims; + +/// +/// Decoding, expiry and signature checks behind GetClaimsAsync. +/// +internal static class JwtVerification +{ + private static readonly JwtSecurityTokenHandler Handler = new JwtSecurityTokenHandler(); + + internal static JwtSecurityToken Decode(string token) + { + try + { + return Handler.ReadJwtToken(token); + } + catch (Exception ex) when (ex is ArgumentException or SecurityTokenException) + { + throw new GotrueException("The token could not be read as a JWT.", InvalidJwt, ex); + } + } + + internal static void ValidateExpiry(JwtSecurityToken token) + { + if (!token.Payload.ContainsKey("exp")) + { + throw new GotrueException("The token is missing the exp claim.", InvalidJwt); + } + if (token.ValidTo <= DateTime.UtcNow) + { + throw new GotrueException("The token has expired.", InvalidJwt); + } + } + + internal static void VerifySignature(string token, Jwk jwk) + { + var parameters = new TokenValidationParameters + { + IssuerSigningKey = new JsonWebKey + { + Kty = jwk.Kty, + Kid = jwk.Kid, + Alg = jwk.Alg, + Use = jwk.Use, + N = jwk.N, + E = jwk.E, + Crv = jwk.Crv, + X = jwk.X, + Y = jwk.Y, + }, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, + }; + try + { + Handler.ValidateToken(token, parameters, out _); + } + // IdentityModel throws NullReferenceException on an array claim with a null entry. + catch (Exception ex) when (ex is SecurityTokenException or NullReferenceException) + { + throw new GotrueException("The token could not be verified.", InvalidJwt, ex); + } + } + + internal static GetClaimsResponse BuildResponse(JwtSecurityToken token) => + new GetClaimsResponse + { + Claims = Deserialize(token.RawPayload), + Header = Deserialize(token.RawHeader), + Signature = Base64UrlEncoder.DecodeBytes(token.RawSignature), + }; + + // Claim names are case sensitive (RFC 7519 §7.3). + private static T Deserialize(string segment) + { + try + { + return JsonSerializer.Deserialize(Base64UrlEncoder.DecodeBytes(segment))!; + } + catch (JsonException ex) + { + throw new GotrueException("The token's claims could not be read.", InvalidJwt, ex); + } + } +} diff --git a/packages/Gotrue/Gotrue/Client.cs b/packages/Gotrue/Gotrue/Client.cs index 39269d58..c92969af 100644 --- a/packages/Gotrue/Gotrue/Client.cs +++ b/packages/Gotrue/Gotrue/Client.cs @@ -10,6 +10,7 @@ using System.Web; using Supabase.Core.Diagnostics; using Supabase.Core.Http; +using Supabase.Gotrue.Claims; using Supabase.Gotrue.Exceptions; using Supabase.Gotrue.Interfaces; using Supabase.Gotrue.Mfa; @@ -62,6 +63,16 @@ public class Client : IGotrueClient /// internal RefreshAttempt? refreshAttempt; + /// + /// How long a fetched key set is reused before the server is asked again. + /// + private static readonly TimeSpan JwksTtl = TimeSpan.FromMinutes(10); + + /// + /// Cached public keys, refreshed when no matching key is found. + /// + private JwksCache? jwksCache; + /// /// Initializes the GoTrue stateful client. /// You will likely want to at least specify a @@ -1107,6 +1118,57 @@ private void ApplyLoadedSession(Session? before, Session? loaded) return Task.FromResult(response); } + /// + public async Task GetClaimsAsync(string? jwt = null, GetClaimsOptions? options = null, CancellationToken cancellationToken = default) + { + var token = jwt ?? this.CurrentSession?.AccessToken; + if (string.IsNullOrEmpty(token)) + { + throw new GotrueException("Not Logged in.", NoSessionFound); + } + var decoded = JwtVerification.Decode(token); + if (options?.AllowExpired != true) + { + JwtVerification.ValidateExpiry(decoded); + } + var key = await this.FindSigningKeyAsync(decoded.Header.Alg, decoded.Header.Kid, options?.Jwks, cancellationToken).ConfigureAwait(false); + if (key == null) + { + await this.api.GetUserAsync(token, cancellationToken).ConfigureAwait(false); + } + else + { + JwtVerification.VerifySignature(token, key); + } + return JwtVerification.BuildResponse(decoded); + } + + /// + /// Finds a matching public key, or returns null to use server verification. + /// + private async Task FindSigningKeyAsync(string? alg, string? kid, Jwks? suppliedKeys, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(kid) || alg is not ("RS256" or "ES256")) + { + return null; + } + Jwk? Match(Jwks? keys) => keys?.Keys?.FirstOrDefault(jwk => jwk?.Kid == kid); + var cachedKeys = this.jwksCache is { } cached && cached.FetchedAt + JwksTtl > DateTime.UtcNow + ? cached.Jwks + : null; + var matchingKey = Match(suppliedKeys) ?? Match(cachedKeys); + if (matchingKey != null) + { + return matchingKey; + } + var fetched = await this.api.GetJwksAsync(cancellationToken).ConfigureAwait(false); + if (fetched != null) + { + this.jwksCache = new JwksCache(fetched, DateTime.UtcNow); + } + return Match(fetched); + } + private AuthState? SetCurrentSession(Session? session) { bool dirty; @@ -1169,4 +1231,6 @@ internal readonly struct RefreshAttempt(string token, Task refresh) internal Task Refresh { get; } = refresh; } + + private sealed record JwksCache(Jwks Jwks, DateTime FetchedAt); } diff --git a/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs b/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs index 1987ccbb..349de78f 100644 --- a/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs +++ b/packages/Gotrue/Gotrue/Exceptions/FailureReason.cs @@ -122,6 +122,11 @@ public enum Reason /// 520-524, 530: Cloudflare-specific error codes (web server down, connection timed out, etc.) /// CloudflareNetworkError, + + /// + /// The JWT is expired, malformed, or its signature did not verify. + /// + InvalidJwt, } /// diff --git a/packages/Gotrue/Gotrue/Gotrue.csproj b/packages/Gotrue/Gotrue/Gotrue.csproj index 0cf6a531..10755966 100644 --- a/packages/Gotrue/Gotrue/Gotrue.csproj +++ b/packages/Gotrue/Gotrue/Gotrue.csproj @@ -42,6 +42,10 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs index 2ed2d1af..03254a0e 100644 --- a/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs +++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueApi.cs @@ -1,6 +1,8 @@ using System; +using System.Threading; using System.Threading.Tasks; using Supabase.Core.Interfaces; +using Supabase.Gotrue.Claims; using Supabase.Gotrue.Mfa; using Supabase.Gotrue.Responses; using static Supabase.Gotrue.Constants; @@ -15,7 +17,9 @@ public interface IGotrueApi : IGettableHeaders { Task CreateUser(string jwt, AdminUserAttributes? attributes = null); Task DeleteUser(string uid, string jwt, bool shouldSoftDelete = false); + Task GetJwksAsync(CancellationToken cancellationToken = default); Task GetUser(string jwt); + Task GetUserAsync(string jwt, CancellationToken cancellationToken = default); Task GetUserById(string jwt, string userId); Task InviteUserByEmail(string email, string jwt, InviteUserByEmailOptions? options = null); Task?> ListUsers(string jwt, string? filter = null, string? sortBy = null, SortOrder sortOrder = SortOrder.Descending, int? page = null, int? perPage = null); diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs index 9f6034dd..2d721e60 100644 --- a/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs +++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Supabase.Core.Interfaces; +using Supabase.Gotrue.Claims; using Supabase.Gotrue.Exceptions; using Supabase.Gotrue.Mfa; using static Supabase.Gotrue.Constants; @@ -498,6 +499,16 @@ public interface IGotrueClient : IGettableHeaders /// Task GetUser(string jwt); + /// + /// Verifies an access token and returns its claims. Uses local verification for RS256 and ES256 + /// when a matching public key is available; otherwise, uses server verification. + /// + /// The token to read. Defaults to the current session's access token. + /// Set to skip the local exp check. + /// + /// + Task GetClaimsAsync(string? jwt = null, GetClaimsOptions? options = null, CancellationToken cancellationToken = default); + /// /// Posts messages and exceptions to the debug listener. This is particularly useful for sorting /// out issues with the refresh token background thread. diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt index 7dc5c581..852353a9 100644 --- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt +++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt @@ -1 +1,116 @@ #nullable enable +override Supabase.Gotrue.Claims.GetClaimsOptions.Equals(object? obj) -> bool +override Supabase.Gotrue.Claims.GetClaimsOptions.GetHashCode() -> int +override Supabase.Gotrue.Claims.GetClaimsOptions.ToString() -> string! +override Supabase.Gotrue.Claims.GetClaimsResponse.Equals(object? obj) -> bool +override Supabase.Gotrue.Claims.GetClaimsResponse.GetHashCode() -> int +override Supabase.Gotrue.Claims.GetClaimsResponse.ToString() -> string! +override Supabase.Gotrue.Claims.Jwk.Equals(object? obj) -> bool +override Supabase.Gotrue.Claims.Jwk.GetHashCode() -> int +override Supabase.Gotrue.Claims.Jwk.ToString() -> string! +override Supabase.Gotrue.Claims.Jwks.Equals(object? obj) -> bool +override Supabase.Gotrue.Claims.Jwks.GetHashCode() -> int +override Supabase.Gotrue.Claims.Jwks.ToString() -> string! +override Supabase.Gotrue.Claims.JwtClaims.Equals(object? obj) -> bool +override Supabase.Gotrue.Claims.JwtClaims.GetHashCode() -> int +override Supabase.Gotrue.Claims.JwtClaims.ToString() -> string! +override Supabase.Gotrue.Claims.JwtHeader.Equals(object? obj) -> bool +override Supabase.Gotrue.Claims.JwtHeader.GetHashCode() -> int +override Supabase.Gotrue.Claims.JwtHeader.ToString() -> string! +static Supabase.Gotrue.Claims.GetClaimsOptions.operator !=(Supabase.Gotrue.Claims.GetClaimsOptions? left, Supabase.Gotrue.Claims.GetClaimsOptions? right) -> bool +static Supabase.Gotrue.Claims.GetClaimsOptions.operator ==(Supabase.Gotrue.Claims.GetClaimsOptions? left, Supabase.Gotrue.Claims.GetClaimsOptions? right) -> bool +static Supabase.Gotrue.Claims.GetClaimsResponse.operator !=(Supabase.Gotrue.Claims.GetClaimsResponse? left, Supabase.Gotrue.Claims.GetClaimsResponse? right) -> bool +static Supabase.Gotrue.Claims.GetClaimsResponse.operator ==(Supabase.Gotrue.Claims.GetClaimsResponse? left, Supabase.Gotrue.Claims.GetClaimsResponse? right) -> bool +static Supabase.Gotrue.Claims.Jwk.operator !=(Supabase.Gotrue.Claims.Jwk? left, Supabase.Gotrue.Claims.Jwk? right) -> bool +static Supabase.Gotrue.Claims.Jwk.operator ==(Supabase.Gotrue.Claims.Jwk? left, Supabase.Gotrue.Claims.Jwk? right) -> bool +static Supabase.Gotrue.Claims.Jwks.operator !=(Supabase.Gotrue.Claims.Jwks? left, Supabase.Gotrue.Claims.Jwks? right) -> bool +static Supabase.Gotrue.Claims.Jwks.operator ==(Supabase.Gotrue.Claims.Jwks? left, Supabase.Gotrue.Claims.Jwks? right) -> bool +static Supabase.Gotrue.Claims.JwtClaims.operator !=(Supabase.Gotrue.Claims.JwtClaims? left, Supabase.Gotrue.Claims.JwtClaims? right) -> bool +static Supabase.Gotrue.Claims.JwtClaims.operator ==(Supabase.Gotrue.Claims.JwtClaims? left, Supabase.Gotrue.Claims.JwtClaims? right) -> bool +static Supabase.Gotrue.Claims.JwtHeader.operator !=(Supabase.Gotrue.Claims.JwtHeader? left, Supabase.Gotrue.Claims.JwtHeader? right) -> bool +static Supabase.Gotrue.Claims.JwtHeader.operator ==(Supabase.Gotrue.Claims.JwtHeader? left, Supabase.Gotrue.Claims.JwtHeader? right) -> bool +Supabase.Gotrue.Api.GetJwksAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Supabase.Gotrue.Api.GetUserAsync(string! jwt, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Supabase.Gotrue.Claims.GetClaimsOptions +Supabase.Gotrue.Claims.GetClaimsOptions.$() -> Supabase.Gotrue.Claims.GetClaimsOptions! +Supabase.Gotrue.Claims.GetClaimsOptions.AllowExpired.get -> bool +Supabase.Gotrue.Claims.GetClaimsOptions.AllowExpired.init -> void +Supabase.Gotrue.Claims.GetClaimsOptions.Equals(Supabase.Gotrue.Claims.GetClaimsOptions? other) -> bool +Supabase.Gotrue.Claims.GetClaimsOptions.GetClaimsOptions() -> void +Supabase.Gotrue.Claims.GetClaimsOptions.Jwks.get -> Supabase.Gotrue.Claims.Jwks? +Supabase.Gotrue.Claims.GetClaimsOptions.Jwks.init -> void +Supabase.Gotrue.Claims.GetClaimsResponse +Supabase.Gotrue.Claims.GetClaimsResponse.$() -> Supabase.Gotrue.Claims.GetClaimsResponse! +Supabase.Gotrue.Claims.GetClaimsResponse.Claims.get -> Supabase.Gotrue.Claims.JwtClaims! +Supabase.Gotrue.Claims.GetClaimsResponse.Claims.init -> void +Supabase.Gotrue.Claims.GetClaimsResponse.Equals(Supabase.Gotrue.Claims.GetClaimsResponse? other) -> bool +Supabase.Gotrue.Claims.GetClaimsResponse.GetClaimsResponse() -> void +Supabase.Gotrue.Claims.GetClaimsResponse.Header.get -> Supabase.Gotrue.Claims.JwtHeader! +Supabase.Gotrue.Claims.GetClaimsResponse.Header.init -> void +Supabase.Gotrue.Claims.GetClaimsResponse.Signature.get -> byte[]! +Supabase.Gotrue.Claims.GetClaimsResponse.Signature.init -> void +Supabase.Gotrue.Claims.Jwk +Supabase.Gotrue.Claims.Jwk.$() -> Supabase.Gotrue.Claims.Jwk! +Supabase.Gotrue.Claims.Jwk.Alg.get -> string? +Supabase.Gotrue.Claims.Jwk.Alg.init -> void +Supabase.Gotrue.Claims.Jwk.Crv.get -> string? +Supabase.Gotrue.Claims.Jwk.Crv.init -> void +Supabase.Gotrue.Claims.Jwk.E.get -> string? +Supabase.Gotrue.Claims.Jwk.E.init -> void +Supabase.Gotrue.Claims.Jwk.Equals(Supabase.Gotrue.Claims.Jwk? other) -> bool +Supabase.Gotrue.Claims.Jwk.Jwk() -> void +Supabase.Gotrue.Claims.Jwk.Kid.get -> string? +Supabase.Gotrue.Claims.Jwk.Kid.init -> void +Supabase.Gotrue.Claims.Jwk.Kty.get -> string? +Supabase.Gotrue.Claims.Jwk.Kty.init -> void +Supabase.Gotrue.Claims.Jwk.N.get -> string? +Supabase.Gotrue.Claims.Jwk.N.init -> void +Supabase.Gotrue.Claims.Jwk.Use.get -> string? +Supabase.Gotrue.Claims.Jwk.Use.init -> void +Supabase.Gotrue.Claims.Jwk.X.get -> string? +Supabase.Gotrue.Claims.Jwk.X.init -> void +Supabase.Gotrue.Claims.Jwk.Y.get -> string? +Supabase.Gotrue.Claims.Jwk.Y.init -> void +Supabase.Gotrue.Claims.Jwks +Supabase.Gotrue.Claims.Jwks.$() -> Supabase.Gotrue.Claims.Jwks! +Supabase.Gotrue.Claims.Jwks.Equals(Supabase.Gotrue.Claims.Jwks? other) -> bool +Supabase.Gotrue.Claims.Jwks.Jwks() -> void +Supabase.Gotrue.Claims.Jwks.Keys.get -> System.Collections.Generic.IReadOnlyList! +Supabase.Gotrue.Claims.Jwks.Keys.init -> void +Supabase.Gotrue.Claims.JwtClaims +Supabase.Gotrue.Claims.JwtClaims.$() -> Supabase.Gotrue.Claims.JwtClaims! +Supabase.Gotrue.Claims.JwtClaims.Aal.get -> string? +Supabase.Gotrue.Claims.JwtClaims.Aal.init -> void +Supabase.Gotrue.Claims.JwtClaims.AdditionalClaims.get -> System.Collections.Generic.Dictionary! +Supabase.Gotrue.Claims.JwtClaims.AdditionalClaims.init -> void +Supabase.Gotrue.Claims.JwtClaims.Aud.get -> System.Collections.Generic.IReadOnlyList! +Supabase.Gotrue.Claims.JwtClaims.Aud.init -> void +Supabase.Gotrue.Claims.JwtClaims.Equals(Supabase.Gotrue.Claims.JwtClaims? other) -> bool +Supabase.Gotrue.Claims.JwtClaims.Exp.get -> long? +Supabase.Gotrue.Claims.JwtClaims.Exp.init -> void +Supabase.Gotrue.Claims.JwtClaims.Iat.get -> long? +Supabase.Gotrue.Claims.JwtClaims.Iat.init -> void +Supabase.Gotrue.Claims.JwtClaims.Iss.get -> string? +Supabase.Gotrue.Claims.JwtClaims.Iss.init -> void +Supabase.Gotrue.Claims.JwtClaims.JwtClaims() -> void +Supabase.Gotrue.Claims.JwtClaims.Role.get -> string? +Supabase.Gotrue.Claims.JwtClaims.Role.init -> void +Supabase.Gotrue.Claims.JwtClaims.SessionId.get -> string? +Supabase.Gotrue.Claims.JwtClaims.SessionId.init -> void +Supabase.Gotrue.Claims.JwtClaims.Sub.get -> string? +Supabase.Gotrue.Claims.JwtClaims.Sub.init -> void +Supabase.Gotrue.Claims.JwtHeader +Supabase.Gotrue.Claims.JwtHeader.$() -> Supabase.Gotrue.Claims.JwtHeader! +Supabase.Gotrue.Claims.JwtHeader.Alg.get -> string? +Supabase.Gotrue.Claims.JwtHeader.Alg.init -> void +Supabase.Gotrue.Claims.JwtHeader.Equals(Supabase.Gotrue.Claims.JwtHeader? other) -> bool +Supabase.Gotrue.Claims.JwtHeader.JwtHeader() -> void +Supabase.Gotrue.Claims.JwtHeader.Kid.get -> string? +Supabase.Gotrue.Claims.JwtHeader.Kid.init -> void +Supabase.Gotrue.Claims.JwtHeader.Typ.get -> string? +Supabase.Gotrue.Claims.JwtHeader.Typ.init -> void +Supabase.Gotrue.Client.GetClaimsAsync(string? jwt = null, Supabase.Gotrue.Claims.GetClaimsOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Supabase.Gotrue.Exceptions.FailureHint.Reason.InvalidJwt = 22 -> Supabase.Gotrue.Exceptions.FailureHint.Reason +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! diff --git a/packages/Gotrue/README.md b/packages/Gotrue/README.md index 8ffa5c46..01573e0d 100644 --- a/packages/Gotrue/README.md +++ b/packages/Gotrue/README.md @@ -80,6 +80,20 @@ bool SaveSession(Session session) } ``` +## JWT claims + +`GetClaimsAsync` verifies an access token and returns its claims. It uses the current session's token +unless you provide one. RS256 and ES256 tokens can be verified locally using cached public keys; +otherwise, verification requires a request to the Auth server. + +```csharp +var result = await client.GetClaimsAsync(); +var userId = result.Claims.Sub; + +// Verify an explicitly supplied access token. +var incoming = await client.GetClaimsAsync(accessToken); +``` + ## OAuth (PKCE flow) For third-party OAuth the PKCE flow is preferred. Configure a callback URL in the Supabase dashboard,