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,