From d66ca348bafc1e6c66d9678b6525b9413ec2b357 Mon Sep 17 00:00:00 2001 From: tr00d Date: Thu, 29 Oct 2026 17:12:11 +0100 Subject: [PATCH] feat(auth): list the identities linked to the signed-in user --- .../Gotrue/Gotrue.Tests/Gotrue.Tests.csproj | 1 + .../Identities/Data/expired_jwt_error.json | 5 + .../Identities/Data/user_with_identities.json | 27 +++ .../Data/user_with_null_identities.json | 5 + .../Identities/UserIdentitiesContractTests.cs | 172 ++++++++++++++++ .../Identities/UserIdentitiesTests.cs | 27 +++ .../ObservabilityContractTests.cs | 19 ++ packages/Gotrue/Gotrue/Client.cs | 14 ++ .../Gotrue/Gotrue/GotrueInstrumentation.cs | 184 +++++++++--------- .../Gotrue/Gotrue/Interfaces/IGotrueClient.cs | 24 +++ .../Gotrue/Gotrue/PublicAPI.Unshipped.txt | 2 + packages/Gotrue/Gotrue/User.cs | 2 - 12 files changed, 388 insertions(+), 94 deletions(-) create mode 100644 packages/Gotrue/Gotrue.Tests/Identities/Data/expired_jwt_error.json create mode 100644 packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_identities.json create mode 100644 packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_null_identities.json create mode 100644 packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesContractTests.cs create mode 100644 packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesTests.cs diff --git a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj index e02659b5..0af7db47 100644 --- a/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj +++ b/packages/Gotrue/Gotrue.Tests/Gotrue.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/packages/Gotrue/Gotrue.Tests/Identities/Data/expired_jwt_error.json b/packages/Gotrue/Gotrue.Tests/Identities/Data/expired_jwt_error.json new file mode 100644 index 00000000..f3d911f8 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/Identities/Data/expired_jwt_error.json @@ -0,0 +1,5 @@ +{ + "code": 403, + "error_code": "bad_jwt", + "msg": "invalid JWT: unable to parse or verify signature, token has invalid claims: token is expired" +} diff --git a/packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_identities.json b/packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_identities.json new file mode 100644 index 00000000..6c5df362 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_identities.json @@ -0,0 +1,27 @@ +{ + "id": "user-id-123", + "aud": "authenticated", + "email": "user@example.com", + "identities": [ + { + "identity_id": "identity-email-1", + "id": "user-id-123", + "user_id": "user-id-123", + "identity_data": { "email": "user@example.com", "sub": "user-id-123" }, + "provider": "email", + "created_at": "2026-10-01T10:00:00Z", + "last_sign_in_at": "2026-10-01T10:00:00Z", + "updated_at": "2026-10-01T10:00:00Z" + }, + { + "identity_id": "identity-github-1", + "id": "42", + "user_id": "user-id-123", + "identity_data": { "user_name": "octocat", "sub": "42" }, + "provider": "github", + "created_at": "2026-10-02T10:00:00Z", + "last_sign_in_at": "2026-10-02T10:00:00Z", + "updated_at": "2026-10-02T10:00:00Z" + } + ] +} diff --git a/packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_null_identities.json b/packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_null_identities.json new file mode 100644 index 00000000..81e6ee19 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/Identities/Data/user_with_null_identities.json @@ -0,0 +1,5 @@ +{ + "id": "user-id-123", + "aud": "authenticated", + "identities": null +} diff --git a/packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesContractTests.cs b/packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesContractTests.cs new file mode 100644 index 00000000..d1502137 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesContractTests.cs @@ -0,0 +1,172 @@ +#region + +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Gotrue.Tests.Support; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Supabase.Gotrue; +using Supabase.Gotrue.Exceptions; +using Supabase.Gotrue.Interfaces; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using static Supabase.Gotrue.Exceptions.FailureHint.Reason; + +#endregion + +namespace Gotrue.Tests.Identities; + +/// +/// Pins how GetUserIdentitiesAsync reads the signed-in user's linked identities: the GET /user request +/// it puts on the wire, how the user's identities array maps to the result, and the failures raised before +/// and after the request — all against a stubbed server. +/// +[TestClass] +[TestCategory("Contract")] +public class UserIdentitiesContractTests +{ + private const string AccessToken = "an-access-token"; + private IGotrueClient client = null!; + private MockGotrueServer server = null!; + + [TestInitialize] + public void TestInitializer() + { + this.server = new MockGotrueServer(); + this.client = TestClients.Against(this.server); + } + + [TestCleanup] + public void TestCleanup() => this.server.Dispose(); + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldGetTheUserWithTheSessionBearer() + { + this.SignIn(); + this.MockUserResponse(200, Fixture("user_with_identities.json")); + await this.client.GetUserIdentitiesAsync(); + this.server.VerifySingleReceivedRequest() + .WithMethod(HttpMethod.Get) + .WithPath("/user") + .WithHeader("Authorization", $"Bearer {AccessToken}") + .WithHeader("apikey", MockGotrueServer.ApiKey); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldReturnEveryLinkedIdentity_GivenUserWithIdentities() + { + this.SignIn(); + this.MockUserResponse(200, Fixture("user_with_identities.json")); + var identities = await this.client.GetUserIdentitiesAsync(); + identities.Select(identity => identity.IdentityId).Should().Equal(new[] { "identity-email-1", "identity-github-1" }, + "the result is the identities array of the user GoTrue returns from GET /user, in order, not the cached session user who has none"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldReturnEmpty_GivenUserWithNullIdentities() + { + this.SignIn(); + this.MockUserResponse(200, Fixture("user_with_null_identities.json")); + var identities = await this.client.GetUserIdentitiesAsync(); + identities.Should().BeEmpty("collections are never null: an absent identities array means no linked identity"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldReturnEmpty_GivenNullUserBody() + { + this.SignIn(); + this.MockUserResponse(200, "null"); + var identities = await this.client.GetUserIdentitiesAsync(); + identities.Should().BeEmpty("collections are never null: a response without a user means no linked identity"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldThrowServerRejection_GivenExpiredJwt() + { + this.SignIn(); + this.MockUserResponse(403, Fixture("expired_jwt_error.json")); + var listing = () => this.client.GetUserIdentitiesAsync(); + (await listing.Should().ThrowAsync()).Which.ErrorCode.Should().Be("bad_jwt", + "GoTrue rejects an expired access token on GET /user with error_code bad_jwt"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldThrowNoSessionFound_GivenNoSession() + { + var listing = () => this.client.GetUserIdentitiesAsync(); + (await listing.Should().ThrowAsync().WithMessage("A valid session is required.")).Which.Reason.Should().Be(NoSessionFound, + "GET /user requires the signed-in user's access token"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldThrowNoSessionFound_GivenSessionWithoutAccessToken() + { + this.SignIn(new Session { RefreshToken = "a-refresh-token", ExpiresIn = 3600 }); + var listing = () => this.client.GetUserIdentitiesAsync(); + (await listing.Should().ThrowAsync()).Which.Reason.Should().Be(NoSessionFound, + "GET /user requires the signed-in user's access token"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldNotSendARequest_GivenNoSession() + { + var listing = () => this.client.GetUserIdentitiesAsync(); + await listing.Should().ThrowAsync(); + this.server.CountReceivedRequests().Should().Be(0, "the missing session is detected before the wire"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldThrowOffline_GivenClientIsOffline() + { + this.SignIn(); + this.client.Online = false; + var listing = () => this.client.GetUserIdentitiesAsync(); + (await listing.Should().ThrowAsync().WithMessage("Only supported when online")).Which.Reason.Should().Be(Offline, + "reading the identities needs the server, like the other identity-linking operations"); + } + + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldCancelTheRequest_GivenCancelledToken() + { + this.SignIn(); + this.MockUserResponse(200, Fixture("user_with_identities.json")); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var listing = () => this.client.GetUserIdentitiesAsync(cancellation.Token); + await listing.Should().ThrowAsync("the cancellation token reaches the HTTP call"); + } + + [TestMethod] + public void GetUserIdentitiesAsync_ShouldComplete_GivenCallerBlockingOnContextThatNeverRunsContinuations() + { + this.SignIn(); + this.MockUserResponse(200, Fixture("user_with_identities.json")); + BlockingCaller.Completes(() => this.client.GetUserIdentitiesAsync()).Should().BeTrue( + "a library must not resume on the caller's context: a UI thread blocking on the task would deadlock"); + } + + private void SignIn() => this.SignIn(new Session { AccessToken = AccessToken, RefreshToken = "a-refresh-token", ExpiresIn = 3600, User = new User { Id = "user-id-123" } }); + + private void SignIn(Session session) + { + var persistence = SessionPersistenceSubstitute.Tracking(); + persistence.SaveSession(session); + this.client.SetPersistence(persistence); + this.client.LoadSession(); + } + + private void MockUserResponse(int statusCode, string body) => + this.server + .Given(Request.Create().WithPath("/user").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(statusCode) + .WithHeader("Content-Type", "application/json") + .WithBody(body)); + + private static string Fixture(string name) => + File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Identities", "Data", name)); +} diff --git a/packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesTests.cs b/packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesTests.cs new file mode 100644 index 00000000..93aaa236 --- /dev/null +++ b/packages/Gotrue/Gotrue.Tests/Identities/UserIdentitiesTests.cs @@ -0,0 +1,27 @@ +#region + +using System.Threading.Tasks; +using FluentAssertions; +using Gotrue.Tests.Support; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +#endregion + +namespace Gotrue.Tests.Identities; + +/// +/// End-to-end listing of the identities linked to the signed-in user against the live stack. +/// +[TestClass] +[TestCategory("E2E")] +public class UserIdentitiesTests : AuthClientFixture +{ + [TestMethod] + public async Task GetUserIdentitiesAsync_ShouldReturnTheEmailIdentity_GivenUserSignedUpWithEmail() + { + var session = await this.SignUpNewUser(); + var identities = await this.Client.GetUserIdentitiesAsync(); + identities.Should().ContainSingle(identity => identity.Provider == "email" && identity.UserId == session.User!.Id, + "GoTrue creates an email identity for an email/password sign-up (GET /user identities)"); + } +} diff --git a/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs b/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs index e1d6c7c0..36008076 100644 --- a/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs +++ b/packages/Gotrue/Gotrue.Tests/Observability/ObservabilityContractTests.cs @@ -119,6 +119,17 @@ public async Task DomainSpan_ShouldParentTheHttpSpan() this.HttpTokenSpan().ParentSpanId.Should().Be(domainSpan.SpanId); } + [TestMethod] + public async Task DomainSpan_ShouldParentTheHttpSpan_GivenUserIdentitiesListing() + { + this.MockTokenSuccess(); + await this.Refresh(); + this.MockUserSuccess(); + await this.client.GetUserIdentitiesAsync(); + var domainSpan = this.activities.Should().ContainSingle(a => a.OperationName == "gotrue.get_user_identities").Which; + this.activities.Should().ContainSingle(a => a.OperationName == "GET /user").Which.ParentSpanId.Should().Be(domainSpan.SpanId); + } + [TestMethod] public async Task HttpSpan_ShouldBeMarkedError_GivenFailedRequest() { @@ -217,6 +228,14 @@ private void MockTokenSuccess() => .WithHeader("Content-Type", "application/json") .WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "TokenRefresh", "Fixtures", "token_success.json")))); + private void MockUserSuccess() => + this.server + .Given(Request.Create().WithPath("/user").UsingGet()) + .RespondWith(Response.Create() + .WithStatusCode(200) + .WithHeader("Content-Type", "application/json") + .WithBody(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Identities", "Data", "user_with_identities.json")))); + private void MockTokenFailure() => this.server .Given(Request.Create().WithPath("/token").UsingPost()) diff --git a/packages/Gotrue/Gotrue/Client.cs b/packages/Gotrue/Gotrue/Client.cs index a5d84fd2..c5e15d7e 100644 --- a/packages/Gotrue/Gotrue/Client.cs +++ b/packages/Gotrue/Gotrue/Client.cs @@ -537,6 +537,20 @@ public Task UnlinkIdentity(UserIdentity userIdentity) return this.api.UnlinkIdentity(this.CurrentSession.AccessToken!, userIdentity); } + /// + [ImplementedCapability("auth.identities.list_identities")] + public async Task> GetUserIdentitiesAsync(CancellationToken cancellationToken = default) + { + using var activity = GotrueInstrumentation.Source.StartActivity(GotrueInstrumentation.Spans.GetUserIdentities); + if (!this.Online) + { + throw new GotrueException("Only supported when online", Offline); + } + var accessToken = this.CurrentSession?.AccessToken ?? throw new GotrueException("A valid session is required.", NoSessionFound); + var user = await this.api.GetUserAsync(accessToken, cancellationToken).ConfigureAwait(false); + return user?.Identities ?? new List(); + } + /// [ImplementedCapability("auth.session.sign_out")] public async Task SignOut(SignOutScope scope = SignOutScope.Global) diff --git a/packages/Gotrue/Gotrue/GotrueInstrumentation.cs b/packages/Gotrue/Gotrue/GotrueInstrumentation.cs index 802cc38f..1090f20f 100644 --- a/packages/Gotrue/Gotrue/GotrueInstrumentation.cs +++ b/packages/Gotrue/Gotrue/GotrueInstrumentation.cs @@ -8,97 +8,97 @@ #endregion -namespace Supabase.Gotrue +namespace Supabase.Gotrue; + +/// +/// Diagnostics for the GoTrue client, exposed through so +/// consumers can subscribe with the OpenTelemetry SDK using +/// (AddSource(...) / AddMeter(...)). Emission is zero-cost when nothing is listening. +/// Telemetry must never carry secrets: URLs are recorded without their query string (GoTrue +/// puts grant types, tokens and API keys there) and no tag may contain a JWT, refresh token, +/// or other credential or PII. +/// +internal static class GotrueInstrumentation { - /// - /// Diagnostics for the GoTrue client, exposed through so - /// consumers can subscribe with the OpenTelemetry SDK using - /// (AddSource(...) / AddMeter(...)). Emission is zero-cost when nothing is listening. - /// Telemetry must never carry secrets: URLs are recorded without their query string (GoTrue - /// puts grant types, tokens and API keys there) and no tag may contain a JWT, refresh token, - /// or other credential or PII. - /// - internal static class GotrueInstrumentation - { - - internal static readonly ActivitySource Source = - Instrumentation.CreateActivitySource(typeof(GotrueInstrumentation).Assembly, GotrueDiagnostics.SourceName); - - private static readonly Meter Meter = - Instrumentation.CreateMeter(typeof(GotrueInstrumentation).Assembly, GotrueDiagnostics.SourceName); - - private static readonly Histogram RequestDuration = Meter.CreateHistogram( - "supabase.gotrue.http.request.duration", "s", "Duration of HTTP requests sent by the GoTrue client."); - - /// - /// Starts the client span for an outgoing HTTP request, tagged per OpenTelemetry HTTP - /// conventions with the sanitized (query-less) URL. Returns null when nothing is listening. - /// - internal static Activity? StartHttpActivity(HttpMethod method, Uri uri) => - Source.StartActivity($"{method.Method} {uri.AbsolutePath}", ActivityKind.Client) - .SetHttpRequestTags(method.Method, uri); - - /// - /// Records the request duration histogram for an HTTP request outcome. - /// - internal static void RecordRequest(HttpMethod method, Uri uri, int? statusCode, string? errorType, long startTimestamp) - { - if (!RequestDuration.Enabled) - return; - - var tags = new TagList - { - { "http.request.method", method.Method }, - { "server.address", uri.Host }, - { "url.path", uri.AbsolutePath } - }; - - if (statusCode.HasValue) - tags.Add("http.response.status_code", statusCode.Value); - - if (errorType != null) - tags.Add("error.type", errorType); - - RequestDuration.Record(GetElapsedSeconds(startTimestamp), tags); - } - private static double GetElapsedSeconds(long startTimestamp) => (Stopwatch.GetTimestamp() - startTimestamp) / (double)Stopwatch.Frequency; - - /// Names of the domain spans emitted for the public GoTrue operations. - internal static class Spans - { - internal const string SignUp = "gotrue.sign_up"; - internal const string SendMagicLink = "gotrue.send_magic_link"; - internal const string SignInWithIdToken = "gotrue.sign_in.id_token"; - internal const string LinkIdentityWithIdToken = "gotrue.link_identity.id_token"; - internal const string SignInWithOtp = "gotrue.sign_in.otp"; - internal const string SignIn = "gotrue.sign_in"; - internal const string SignInAnonymously = "gotrue.sign_in.anonymous"; - internal const string VerifyOtp = "gotrue.verify_otp"; - internal const string VerifyTokenHash = "gotrue.verify_token_hash"; - internal const string SignOut = "gotrue.sign_out"; - internal const string UpdateUser = "gotrue.update_user"; - internal const string RefreshSession = "gotrue.refresh_session"; - internal const string SetSession = "gotrue.set_session"; - internal const string RetrieveSession = "gotrue.retrieve_session"; - internal const string ExchangeCode = "gotrue.exchange_code"; - internal const string RefreshToken = "gotrue.refresh_token"; - } - - /// Keys of the tags set on the domain spans. - internal static class Tags - { - internal const string SignUpType = "gotrue.sign_up.type"; - internal const string Provider = "gotrue.provider"; - internal const string OtpChannel = "gotrue.otp.channel"; - internal const string SignInType = "gotrue.sign_in.type"; - internal const string SignOutScope = "gotrue.sign_out.scope"; - } - - /// Values for the tag. - internal static class Channels - { - internal const string Email = "email"; - internal const string Phone = "phone"; - } - } + + internal static readonly ActivitySource Source = + Instrumentation.CreateActivitySource(typeof(GotrueInstrumentation).Assembly, GotrueDiagnostics.SourceName); + + private static readonly Meter Meter = + Instrumentation.CreateMeter(typeof(GotrueInstrumentation).Assembly, GotrueDiagnostics.SourceName); + + private static readonly Histogram RequestDuration = Meter.CreateHistogram( + "supabase.gotrue.http.request.duration", "s", "Duration of HTTP requests sent by the GoTrue client."); + + /// + /// Starts the client span for an outgoing HTTP request, tagged per OpenTelemetry HTTP + /// conventions with the sanitized (query-less) URL. Returns null when nothing is listening. + /// + internal static Activity? StartHttpActivity(HttpMethod method, Uri uri) => + Source.StartActivity($"{method.Method} {uri.AbsolutePath}", ActivityKind.Client) + .SetHttpRequestTags(method.Method, uri); + + /// + /// Records the request duration histogram for an HTTP request outcome. + /// + internal static void RecordRequest(HttpMethod method, Uri uri, int? statusCode, string? errorType, long startTimestamp) + { + if (!RequestDuration.Enabled) + return; + + var tags = new TagList + { + { "http.request.method", method.Method }, + { "server.address", uri.Host }, + { "url.path", uri.AbsolutePath } + }; + + if (statusCode.HasValue) + tags.Add("http.response.status_code", statusCode.Value); + + if (errorType != null) + tags.Add("error.type", errorType); + + RequestDuration.Record(GetElapsedSeconds(startTimestamp), tags); + } + private static double GetElapsedSeconds(long startTimestamp) => (Stopwatch.GetTimestamp() - startTimestamp) / (double) Stopwatch.Frequency; + + /// Names of the domain spans emitted for the public GoTrue operations. + internal static class Spans + { + internal const string SignUp = "gotrue.sign_up"; + internal const string SendMagicLink = "gotrue.send_magic_link"; + internal const string SignInWithIdToken = "gotrue.sign_in.id_token"; + internal const string LinkIdentityWithIdToken = "gotrue.link_identity.id_token"; + internal const string GetUserIdentities = "gotrue.get_user_identities"; + internal const string SignInWithOtp = "gotrue.sign_in.otp"; + internal const string SignIn = "gotrue.sign_in"; + internal const string SignInAnonymously = "gotrue.sign_in.anonymous"; + internal const string VerifyOtp = "gotrue.verify_otp"; + internal const string VerifyTokenHash = "gotrue.verify_token_hash"; + internal const string SignOut = "gotrue.sign_out"; + internal const string UpdateUser = "gotrue.update_user"; + internal const string RefreshSession = "gotrue.refresh_session"; + internal const string SetSession = "gotrue.set_session"; + internal const string RetrieveSession = "gotrue.retrieve_session"; + internal const string ExchangeCode = "gotrue.exchange_code"; + internal const string RefreshToken = "gotrue.refresh_token"; + } + + /// Keys of the tags set on the domain spans. + internal static class Tags + { + internal const string SignUpType = "gotrue.sign_up.type"; + internal const string Provider = "gotrue.provider"; + internal const string OtpChannel = "gotrue.otp.channel"; + internal const string SignInType = "gotrue.sign_in.type"; + internal const string SignOutScope = "gotrue.sign_out.scope"; + } + + /// Values for the tag. + internal static class Channels + { + internal const string Email = "email"; + internal const string Phone = "phone"; + } } \ No newline at end of file diff --git a/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs b/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs index 932bddeb..18e06978 100644 --- a/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs +++ b/packages/Gotrue/Gotrue/Interfaces/IGotrueClient.cs @@ -1,6 +1,7 @@ #region using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Supabase.Core.Interfaces; @@ -470,6 +471,29 @@ public interface IGotrueClient : IGettableHeaders /// Task UnlinkIdentity(UserIdentity userIdentity); + /// + /// Lists the external identities linked to the signed-in user, read from the server so identities linked + /// or unlinked since sign-in are included. Returns an empty list when the user has none. + /// + /// + /// The identities linked to the signed-in user. + /// + /// when the client is offline, + /// when no user is signed in, + /// or the server's rejection of the request. + /// + /// + /// + /// var identities = await client.GetUserIdentitiesAsync(); + /// var github = identities.FirstOrDefault(identity => identity.Provider == "github"); + /// if (github != null) + /// { + /// await client.UnlinkIdentity(github); + /// } + /// + /// + Task> GetUserIdentitiesAsync(CancellationToken cancellationToken = default); + /// /// Add a listener to get errors that occur outside of a typical Exception flow. /// In particular, this is used to get errors and messages from the background thread diff --git a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt index 6b8b7a19..c6c65b66 100644 --- a/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt +++ b/packages/Gotrue/Gotrue/PublicAPI.Unshipped.txt @@ -34,6 +34,7 @@ Supabase.Gotrue.Api.CreateCustomProviderAsync(string! jwt, Supabase.Gotrue.Creat Supabase.Gotrue.Api.GetCustomProviderAsync(string! jwt, string! identifier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Api.ListCustomProvidersAsync(string! jwt, Supabase.Gotrue.Common.CustomProviderType? type = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Supabase.Gotrue.Api.UpdateCustomProviderAsync(string! jwt, string! identifier, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Supabase.Gotrue.Client.GetUserIdentitiesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Supabase.Gotrue.Common.CustomProvider Supabase.Gotrue.Common.CustomProvider.$() -> Supabase.Gotrue.Common.CustomProvider! Supabase.Gotrue.Common.CustomProvider.AcceptableClientIds.get -> System.Collections.Generic.IReadOnlyList! @@ -159,6 +160,7 @@ Supabase.Gotrue.Interfaces.IGotrueApi.GetCustomProviderAsync(st Supabase.Gotrue.Interfaces.IGotrueApi.ListCustomProvidersAsync(string! jwt, Supabase.Gotrue.Common.CustomProviderType? type = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Supabase.Gotrue.Interfaces.IGotrueApi.SignOutAsync(string! jwt, Supabase.Gotrue.Constants.SignOutScope scope = Supabase.Gotrue.Constants.SignOutScope.Global, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! Supabase.Gotrue.Interfaces.IGotrueApi.UpdateCustomProviderAsync(string! jwt, string! identifier, Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges! changes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Supabase.Gotrue.Interfaces.IGotrueClient.GetUserIdentitiesAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!>! Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.$() -> Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges! Supabase.Gotrue.UpdateCustomProvider.CustomProviderChanges.AuthorizationUrl.get -> System.Uri? diff --git a/packages/Gotrue/Gotrue/User.cs b/packages/Gotrue/Gotrue/User.cs index 78643164..7349173e 100644 --- a/packages/Gotrue/Gotrue/User.cs +++ b/packages/Gotrue/Gotrue/User.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Text.Json.Serialization; -using Supabase.Core.Attributes; using Supabase.Gotrue.Mfa; #endregion @@ -45,7 +44,6 @@ public class User public string? Id { get; set; } [JsonPropertyName("identities")] - [PartialCapability("auth.identities.list_identities", "No dedicated getUserIdentities method; linked identities are reachable only via the User.Identities property.")] public List Identities { get; set; } = new List(); [JsonPropertyName("invited_at")]