From d09b6a4f8e76cc27be558afc06f0e9c4c21bf81a Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 26 Jun 2026 10:21:32 +0530 Subject: [PATCH 1/5] feat: Adds TokenClient helper for exchanging refresh token for Connection --- .../TokenClient.cs | 41 ++++++++ .../TokenClientTests.cs | 94 +++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/src/Auth0.AspNetCore.Authentication/TokenClient.cs b/src/Auth0.AspNetCore.Authentication/TokenClient.cs index 9410778..fa31ca0 100644 --- a/src/Auth0.AspNetCore.Authentication/TokenClient.cs +++ b/src/Auth0.AspNetCore.Authentication/TokenClient.cs @@ -53,6 +53,47 @@ public async Task Refresh(Auth0WebAppOptions options, string ApplyClientAuthentication(options, body, tokenEndpointDomain); + return await Send(body, tokenEndpointDomain).ConfigureAwait(false); + } + + public async Task ExchangeRefreshTokenForConnectionToken( + Auth0WebAppOptions options, + string refreshToken, + string connection, + string? domain = null, + string? loginHint = null) + { + var body = new Dictionary + { + { "grant_type", "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" }, + { "subject_token_type", "urn:ietf:params:oauth:token-type:refresh_token" }, + { "subject_token", refreshToken }, + { "requested_token_type", "http://auth0.com/oauth/token-type/federated-connection-access-token" }, + { "connection", connection }, + { "client_id", options.ClientId } + }; + + if (!string.IsNullOrWhiteSpace(loginHint)) + { + body.Add("login_hint", loginHint); + } + + var tokenEndpointDomain = domain ?? options.Domain; + + if (string.IsNullOrWhiteSpace(tokenEndpointDomain)) + { + throw new InvalidOperationException( + "Cannot determine domain for token endpoint. " + + "Ensure Domain is set or domain resolution is properly configured."); + } + + ApplyClientAuthentication(options, body, tokenEndpointDomain); + + return await Send(body, tokenEndpointDomain).ConfigureAwait(false); + } + + private async Task Send(Dictionary body, string tokenEndpointDomain) + { var requestContent = new FormUrlEncodedContent(body.Select(p => new KeyValuePair(p.Key, p.Value ?? ""))); using (var request = new HttpRequestMessage(HttpMethod.Post, $"https://{tokenEndpointDomain}/oauth/token") { Content = requestContent }) diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs index d1fa772..871c01b 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/TokenClientTests.cs @@ -445,5 +445,99 @@ public async Task Refresh_WhenMfaRequired_ParsesMfaRequirements() result.MfaRequirements!.Challenge.Should().HaveCount(2); result.MfaRequirements.Challenge![1].OobChannels.Should().ContainSingle().Which.Should().Be("sms"); } + + [Fact] + public async Task ExchangeForConnection_Succeeds_AndReturnsToken() + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"fc_token\",\"expires_in\":3600,\"scope\":\"email\"}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.ExchangeRefreshTokenForConnectionToken( + new Auth0WebAppOptions { Domain = "local.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123", + "google-oauth2"); + + result.IsSuccess.Should().BeTrue(); + result.Response!.AccessToken.Should().Be("fc_token"); + result.Response.Scope.Should().Be("email"); + } + + [Fact] + public async Task ExchangeForConnection_SendsFederatedConnectionGrantParameters() + { + string capturedBody = string.Empty; + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, _) => + { + capturedBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + }) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent("{\"access_token\":\"fc_token\",\"expires_in\":3600,\"scope\":\"\"}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + await client.ExchangeRefreshTokenForConnectionToken( + new Auth0WebAppOptions { Domain = "local.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123", + "google-oauth2", + loginHint: "108251234567890123456"); + + capturedBody.Should().Contain("grant_type=urn%3Aauth0%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange%3Afederated-connection-access-token"); + capturedBody.Should().Contain("subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Arefresh_token"); + capturedBody.Should().Contain("requested_token_type=http%3A%2F%2Fauth0.com%2Foauth%2Ftoken-type%2Ffederated-connection-access-token"); + capturedBody.Should().Contain("subject_token=refresh_123"); + capturedBody.Should().Contain("connection=google-oauth2"); + capturedBody.Should().Contain("login_hint=108251234567890123456"); + // The federated-connection exchange must not send a requested scope — this is + // what makes the connection cache key scope-independent. + capturedBody.Should().NotContain("scope="); + } + + [Fact] + public async Task ExchangeForConnection_ReturnsFailure_WhenRejected() + { + var mockHandler = new Mock(); + mockHandler + .Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent("{\"error\":\"invalid_request\",\"error_description\":\"no linked account\"}") + }); + + var client = new TokenClient(new HttpClient(mockHandler.Object)); + var result = await client.ExchangeRefreshTokenForConnectionToken( + new Auth0WebAppOptions { Domain = "local.auth0.com", ClientId = "cid", ClientSecret = "secret" }, + "refresh_123", + "google-oauth2"); + + result.IsSuccess.Should().BeFalse(); + result.StatusCode.Should().Be((int)HttpStatusCode.BadRequest); + result.Error.Should().Be("invalid_request"); + result.ErrorDescription.Should().Be("no linked account"); + } } } From d9312780e563d502a6078f09786419638c45937f Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 26 Jun 2026 10:22:29 +0530 Subject: [PATCH 2/5] feat: Adds caching connection tokens --- .../AccessTokenForConnectionRequest.cs | 28 ++++++ .../ConnectionTokenSet.cs | 35 ++++++++ .../ConnectionTokenSetHelpers.cs | 52 +++++++++++ .../ConnectionTokenSetHelpersTests.cs | 89 +++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 src/Auth0.AspNetCore.Authentication/AccessTokenForConnectionRequest.cs create mode 100644 src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs create mode 100644 src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs create mode 100644 tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs diff --git a/src/Auth0.AspNetCore.Authentication/AccessTokenForConnectionRequest.cs b/src/Auth0.AspNetCore.Authentication/AccessTokenForConnectionRequest.cs new file mode 100644 index 0000000..715c8e3 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/AccessTokenForConnectionRequest.cs @@ -0,0 +1,28 @@ +namespace Auth0.AspNetCore.Authentication +{ + /// + /// Describes a request for a federated connection (Token Vault) access token — + /// a third-party API token (e.g. Google, GitHub) for the logged-in user. + /// + public class AccessTokenForConnectionRequest + { + /// + /// The federated connection to retrieve an access token for (e.g. "google-oauth2"). + /// Required. + /// + public string Connection { get; set; } = null!; + + /// + /// Optional login hint forwarded to the token endpoint to disambiguate which linked + /// identity to use. This is the provider-side identity provider user ID (e.g. a Google + /// user ID) — not the Auth0 user sub and not the user's email. + /// + public string? LoginHint { get; set; } + + /// + /// When true, always exchanges the refresh token for a new connection token, + /// ignoring any cached token. Defaults to false. + /// + public bool ForceRefresh { get; set; } + } +} diff --git a/src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs new file mode 100644 index 0000000..b33c124 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Auth0.AspNetCore.Authentication +{ + /// + /// Represents a third-party (federated connection) access token retrieved for a + /// specific connection and cached alongside the primary token in the session. + /// + internal class ConnectionTokenSet + { + /// + /// The federated connection name the token was retrieved for (e.g. "google-oauth2"). + /// + [JsonPropertyName("connection")] + public string Connection { get; set; } = null!; + + /// + /// The federated connection access token. + /// + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } = null!; + + /// + /// Expiration time as a Unix timestamp (seconds). + /// + [JsonPropertyName("expires_at")] + public long ExpiresAt { get; set; } + + /// + /// The scopes granted for the connection token, when returned. Optional. + /// + [JsonPropertyName("scope")] + public string? Scope { get; set; } + } +} diff --git a/src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs new file mode 100644 index 0000000..2605405 --- /dev/null +++ b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Auth0.AspNetCore.Authentication +{ + /// + /// Pure helper functions for matching, upserting and pruning per-connection + /// (federated connection) access tokens stored in the session. + /// + internal static class ConnectionTokenSetHelpers + { + /// + /// Finds the cached token for a connection, or null when none is present. + /// + public static ConnectionTokenSet? FindConnectionTokenSet(IEnumerable? sets, string connection) + { + return sets?.FirstOrDefault(set => set.Connection == connection); + } + + /// + /// Applies a freshly retrieved connection token to the collection: prunes expired + /// entries first, then replaces the entry for the connection if present, otherwise + /// appends a new one. Returns the updated list (a new list instance). + /// + public static List UpsertConnectionTokenSet(IEnumerable? sets, string connection, AccessTokenResponse response) + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var result = sets?.Where(set => set.ExpiresAt > now).ToList() ?? new List(); + + var entry = new ConnectionTokenSet + { + Connection = connection, + AccessToken = response.AccessToken, + ExpiresAt = now + response.ExpiresIn, + Scope = response.Scope + }; + + var existing = result.FirstOrDefault(set => set.Connection == connection); + if (existing != null) + { + result[result.IndexOf(existing)] = entry; + } + else + { + result.Add(entry); + } + + return result; + } + } +} diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs new file mode 100644 index 0000000..5db7741 --- /dev/null +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs @@ -0,0 +1,89 @@ +using FluentAssertions; +using System; +using System.Collections.Generic; +using Xunit; + +namespace Auth0.AspNetCore.Authentication.IntegrationTests +{ + public class ConnectionTokenSetHelpersTests + { + [Fact] + public void FindConnectionTokenSet_ReturnsNull_WhenSetsNull() + { + ConnectionTokenSetHelpers.FindConnectionTokenSet(null, "google-oauth2").Should().BeNull(); + } + + [Fact] + public void FindConnectionTokenSet_MatchesByConnection() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "github", AccessToken = "gh", ExpiresAt = now + 100 }, + new ConnectionTokenSet { Connection = "google-oauth2", AccessToken = "goog", ExpiresAt = now + 100 } + }; + + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2")!.AccessToken.Should().Be("goog"); + } + + [Fact] + public void FindConnectionTokenSet_ReturnsNull_WhenNoMatch() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "github", AccessToken = "gh", ExpiresAt = now + 100 } + }; + + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2").Should().BeNull(); + } + + [Fact] + public void Upsert_AppendsNewConnection() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var response = new AccessTokenResponse { AccessToken = "goog", ExpiresIn = 3600, Scope = "email" }; + + var result = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(null, "google-oauth2", response); + + result.Should().HaveCount(1); + result[0].Connection.Should().Be("google-oauth2"); + result[0].AccessToken.Should().Be("goog"); + result[0].Scope.Should().Be("email"); + result[0].ExpiresAt.Should().BeGreaterThan(now); + } + + [Fact] + public void Upsert_ReplacesExistingConnectionInPlace() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", AccessToken = "old", ExpiresAt = now + 100, Scope = "email" } + }; + var response = new AccessTokenResponse { AccessToken = "new", ExpiresIn = 3600, Scope = "email profile" }; + + var result = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(sets, "google-oauth2", response); + + result.Should().HaveCount(1); + result[0].AccessToken.Should().Be("new"); + result[0].Scope.Should().Be("email profile"); + } + + [Fact] + public void Upsert_PrunesExpiredEntries() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "github", AccessToken = "stale", ExpiresAt = now - 10 } + }; + var response = new AccessTokenResponse { AccessToken = "goog", ExpiresIn = 3600, Scope = "" }; + + var result = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(sets, "google-oauth2", response); + + result.Should().HaveCount(1); + result[0].Connection.Should().Be("google-oauth2"); + } + } +} From 948d7b485c7402ffd87f7deaedb4e53ef6fb56bc Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 26 Jun 2026 10:24:17 +0530 Subject: [PATCH 3/5] feat: Adds public methods to retrieve access token for connection --- .../HttpContextExtensions.cs | 117 +++++++++++++++++ .../HttpContextExtensionsTests.cs | 119 ++++++++++++++++++ 2 files changed, 236 insertions(+) diff --git a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs index 403cf2f..5a12343 100644 --- a/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs +++ b/src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs @@ -20,6 +20,7 @@ namespace Auth0.AspNetCore.Authentication public static class HttpContextExtensions { internal const string AccessTokensItemKey = ".Token.access_tokens"; + internal const string ConnectionTokensItemKey = ".Token.connection_tokens"; /// /// Retrieves an access token for the audience/scope described by . @@ -175,6 +176,98 @@ await FireRefreshFailed(optionsWithAccessToken, return response.AccessToken; } + /// + /// Retrieves a federated connection (Token Vault) access token for the audience/connection + /// described by — a third-party API token (e.g. Google, GitHub) + /// for the logged-in user. Reuses a cached token from the session when one is present and not + /// expired; otherwise exchanges the session's refresh token for a new connection token and + /// persists it. + /// + /// + /// Like , this method is not safe to call concurrently for the + /// same session: it reads, mutates, and writes the stored tokens, and a concurrent refresh-token + /// exchange can trip rotation reuse detection. Serialize calls per session. + /// + /// The current . + /// The connection (and optional login hint) to request a token for. + /// The Auth0 authentication scheme. Defaults to . + /// The connection access token, or null when no refresh token is available or the exchange failed. + public static async Task GetAccessTokenForConnectionAsync(this HttpContext context, AccessTokenForConnectionRequest request, string? scheme = null) + { + scheme ??= Auth0Constants.AuthenticationScheme; + + var options = context.RequestServices.GetRequiredService>().Get(scheme); + var optionsWithAccessToken = context.RequestServices.GetRequiredService>().Get(scheme); + + var authenticateResult = await context.AuthenticateAsync(options.CookieAuthenticationScheme).ConfigureAwait(false); + if (!authenticateResult.Succeeded || authenticateResult.Properties == null) + { + return null; + } + + var properties = authenticateResult.Properties; + + // 1. Serve from the per-connection cache unless the caller bypasses it. + if (!request.ForceRefresh) + { + var sets = ReadConnectionTokenSets(properties); + var match = ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, request.Connection); + if (match != null && match.ExpiresAt > DateTimeOffset.UtcNow.Add(optionsWithAccessToken.AccessTokenExpirationLeeway).ToUnixTimeSeconds()) + { + return match.AccessToken; + } + } + + // 2. A federated connection token can only be obtained via the refresh token. + if (!properties.Items.TryGetValue(".Token.refresh_token", out var refreshToken) || string.IsNullOrWhiteSpace(refreshToken)) + { + if (optionsWithAccessToken.Events?.OnMissingRefreshToken != null) + { + await optionsWithAccessToken.Events.OnMissingRefreshToken(context).ConfigureAwait(false); + } + + return null; + } + + var httpClient = options.Backchannel ?? context.RequestServices.GetRequiredService().CreateClient(); + var tokenClient = new TokenClient(httpClient); + var resolvedDomain = context.GetResolvedDomain(); + + TokenRefreshResult result; + try + { + result = await tokenClient.ExchangeRefreshTokenForConnectionToken(options, refreshToken, request.Connection, resolvedDomain, request.LoginHint).ConfigureAwait(false); + } + catch (Exception ex) + { + await FireRefreshFailed(optionsWithAccessToken, + AccessTokenRefreshFailedContext.FromException(context, null, null, ex)).ConfigureAwait(false); + return null; + } + + if (!result.IsSuccess) + { + await FireRefreshFailed(optionsWithAccessToken, + AccessTokenRefreshFailedContext.FromHttpRejection(context, null, null, result.StatusCode, result.Error, result.ErrorDescription)).ConfigureAwait(false); + return null; + } + + var response = result.Response!; + + // 3. Cache the connection token and persist. A rotated refresh token, if any, is also persisted. + var updated = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(ReadConnectionTokenSets(properties), request.Connection, response); + WriteConnectionTokenSets(properties, updated); + + if (!string.IsNullOrEmpty(response.RefreshToken)) + { + properties.UpdateTokenValue("refresh_token", response.RefreshToken); + } + + await context.SignInAsync(options.CookieAuthenticationScheme, authenticateResult.Principal!, properties).ConfigureAwait(false); + + return response.AccessToken; + } + /// /// Retrieves the resolved domain from the collection. /// @@ -282,5 +375,29 @@ private static void WriteAccessTokenSets(AuthenticationProperties properties, Li { properties.Items[AccessTokensItemKey] = JsonSerializer.Serialize(sets); } + + private static List ReadConnectionTokenSets(AuthenticationProperties properties) + { + if (properties.Items.TryGetValue(ConnectionTokensItemKey, out var json) && !string.IsNullOrEmpty(json)) + { + // Corrupted or version-skewed session data is treated as a cache miss: the + // token gets re-fetched, which is preferable to throwing out of a public method. + try + { + return JsonSerializer.Deserialize>(json) ?? new List(); + } + catch (JsonException) + { + return new List(); + } + } + + return new List(); + } + + private static void WriteConnectionTokenSets(AuthenticationProperties properties, List sets) + { + properties.Items[ConnectionTokensItemKey] = JsonSerializer.Serialize(sets); + } } } diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs index 095bb6e..66c6ed4 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs @@ -636,6 +636,125 @@ private static Mock CreateRawTokenHandler(string body) return handler; } + [Fact] + public async Task GetAccessTokenForConnectionAsync_WithCachedToken_ReturnsCachedWithoutCallingBackchannel() + { + // A handler that would throw if called — proves the cache short-circuits the grant. + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ThrowsAsync(new InvalidOperationException("backchannel should not be called on a cache hit")); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.refresh_token"] = "rt"; + properties.Items[HttpContextExtensions.ConnectionTokensItemKey] = JsonSerializer.Serialize(new List + { + new ConnectionTokenSet + { + Connection = "google-oauth2", + AccessToken = "cached_google_token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), + Scope = "email" + } + }); + + var context = BuildContext(handler.Object, properties, out var authService); + + var result = await context.GetAccessTokenForConnectionAsync( + new AccessTokenForConnectionRequest { Connection = "google-oauth2" }); + + result.Should().Be("cached_google_token"); + authService.Verify(s => s.SignInAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Never()); + } + + [Fact] + public async Task GetAccessTokenForConnectionAsync_WithForceRefresh_BypassesCacheAndPersistsNewToken() + { + var handler = CreateRawTokenHandler("{\"access_token\":\"fresh_google_token\",\"expires_in\":3600,\"scope\":\"email\"}"); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.refresh_token"] = "rt"; + properties.Items[HttpContextExtensions.ConnectionTokensItemKey] = JsonSerializer.Serialize(new List + { + new ConnectionTokenSet + { + Connection = "google-oauth2", + AccessToken = "stale_cached_token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds() + } + }); + + AuthenticationProperties? persisted = null; + var context = BuildContext(handler.Object, properties, out var authService); + authService + .Setup(s => s.SignInAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, p) => persisted = p) + .Returns(Task.CompletedTask); + + var result = await context.GetAccessTokenForConnectionAsync( + new AccessTokenForConnectionRequest { Connection = "google-oauth2", ForceRefresh = true }); + + result.Should().Be("fresh_google_token"); + authService.Verify(s => s.SignInAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once()); + persisted.Should().NotBeNull(); + } + + [Fact] + public async Task GetAccessTokenForConnectionAsync_NoRefreshToken_FiresEventAndReturnsNull() + { + var handler = CreateRawTokenHandler("{\"access_token\":\"unused\",\"expires_in\":3600}"); + + var properties = new AuthenticationProperties(); // no refresh token + + var missingRefreshTokenFired = false; + var context = BuildContext(handler.Object, properties, out _, configureWithAccessToken: withAccessTokenOptions => + { + withAccessTokenOptions.Events = new Auth0WebAppWithAccessTokenEvents + { + OnMissingRefreshToken = _ => { missingRefreshTokenFired = true; return Task.CompletedTask; } + }; + }); + + var result = await context.GetAccessTokenForConnectionAsync( + new AccessTokenForConnectionRequest { Connection = "google-oauth2" }); + + result.Should().BeNull(); + missingRefreshTokenFired.Should().BeTrue(); + } + + [Fact] + public async Task GetAccessTokenForConnectionAsync_WhenExchangeRejected_FiresRefreshFailedEventAndReturnsNull() + { + var handler = CreateFailingHandler(HttpStatusCode.BadRequest, "{\"error\":\"invalid_request\",\"error_description\":\"no linked account\"}"); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.refresh_token"] = "rt"; + + AccessTokenRefreshFailedContext? failedContext = null; + var context = BuildContext(handler.Object, properties, out _, configureWithAccessToken: withAccessTokenOptions => + { + withAccessTokenOptions.Events = new Auth0WebAppWithAccessTokenEvents + { + OnAccessTokenRefreshFailed = c => { failedContext = c; return Task.CompletedTask; } + }; + }); + + var result = await context.GetAccessTokenForConnectionAsync( + new AccessTokenForConnectionRequest { Connection = "google-oauth2" }); + + result.Should().BeNull(); + failedContext.Should().NotBeNull(); + failedContext!.Error.Should().Be("invalid_request"); + } + private static HttpContext BuildContext( HttpMessageHandler backchannelHandler, AuthenticationProperties properties, From 2a19823a4be5db562a547eb669ab454b162dd649 Mon Sep 17 00:00:00 2001 From: Kailash B Date: Fri, 26 Jun 2026 10:24:31 +0530 Subject: [PATCH 4/5] feat: Docs update --- EXAMPLES.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 14 +++++++++++ 2 files changed, 85 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1c77828..abbd5d9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -12,6 +12,10 @@ - [Handling refresh failures](#handling-refresh-failures) - [Handling MFA during token exchange (mfa_required)](#handling-mfa-during-token-exchange-mfa_required) - [Completing an out-of-band (OOB) challenge with polling](#completing-an-out-of-band-oob-challenge-with-polling) +- [Token Vault (Federated Connection Access Tokens)](#token-vault-federated-connection-access-tokens) + - [Retrieving a federated connection token](#retrieving-a-federated-connection-token) + - [Forcing a refresh](#forcing-a-refresh-1) + - [Handling a missing refresh token or exchange failure](#handling-a-missing-refresh-token-or-exchange-failure) - [Organizations](#organizations) - [Extra parameters](#extra-parameters) - [Roles](#roles) @@ -607,6 +611,73 @@ public async Task PollOob() > 5-minute window. This is the **same requirement** as the authentication cookie that already > carries these tokens. +## Token Vault (Federated Connection Access Tokens) + +[Token Vault](https://auth0.com/docs/secure/tokens/token-vault) lets your web app obtain a **third-party API access token** (for a federated connection such as Google, GitHub, or Slack) for the logged-in user — so your app, or an agent acting on the user's behalf, can call that provider's API. The token is obtained by exchanging the session's refresh token; the user does not have to re-authenticate. + +A typical use case: the user logged in with (or has linked) their Google account, and your app needs a Google access token to read their calendar. Instead of running a separate Google OAuth flow, you exchange the existing refresh token for a Google connection token. + +> :information_source: Token Vault requires refresh tokens. Configure `UseRefreshTokens = true` and a `ClientSecret`, and enable Token Vault for the connection in the Auth0 Dashboard. Connection tokens are cached in the session, keyed by connection name, and reused until they near expiry. + +> :information_source: Unlike MRRT, the federated-connection exchange does **not** take a requested `scope` — it returns the scopes already granted for the connection. Tokens are therefore cached per **connection**, not per audience/scope. To change the granted scopes, reconfigure the connection (or re-link the account) in the Auth0 Dashboard. + +> :warning: **Token storage and cookie size.** Like MRRT, each connection token is cached in the encrypted **authentication cookie** by default, so retrieving tokens for several connections grows the session the same way fanning out across audiences does. If you expect to hold tokens for more than a couple of connections, move the session **server-side** — see [Server-side session storage](#server-side-session-storage). Where the token set is persisted is the only thing that changes; the API is identical either way. + +### Retrieving a federated connection token + +Use `HttpContext.GetAccessTokenForConnectionAsync` with an `AccessTokenForConnectionRequest` describing the `Connection`. The SDK serves a cached token from the session when one is present and unexpired, and only exchanges the refresh token otherwise. Newly obtained tokens are persisted back into the session automatically. + +```csharp +[Authorize] +public async Task CallGoogleCalendar() +{ + var googleToken = await HttpContext.GetAccessTokenForConnectionAsync(new AccessTokenForConnectionRequest + { + Connection = "google-oauth2" + }); + + if (googleToken == null) + { + // No refresh token available, or the exchange failed — see "Handling…" below. + return Challenge(); + } + + var request = new HttpRequestMessage(HttpMethod.Get, "https://www.googleapis.com/calendar/v3/users/me/calendarList"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", googleToken); + + var response = await _httpClient.SendAsync(request); + return Content(await response.Content.ReadAsStringAsync()); +} +``` + +> :information_source: `GetAccessTokenForConnectionAsync` returns `null` rather than throwing when no refresh token is available or the exchange fails. Always check for `null` before using the token. + +The optional `LoginHint` disambiguates which linked identity to use when the user has more than one. It is the **provider-side identity-provider user ID** (e.g. a Google user ID) — not the Auth0 user `sub`, and not the user's email. + +```csharp +var googleToken = await HttpContext.GetAccessTokenForConnectionAsync(new AccessTokenForConnectionRequest +{ + Connection = "google-oauth2", + LoginHint = "108251234567890123456" +}); +``` + +### Forcing a refresh + +Set `ForceRefresh = true` to bypass the cache and always exchange the refresh token for a new connection token. The freshly retrieved token replaces the cached entry. + +```csharp +var googleToken = await HttpContext.GetAccessTokenForConnectionAsync(new AccessTokenForConnectionRequest +{ + Connection = "google-oauth2", + ForceRefresh = true +}); +``` + +### Handling a missing refresh token or exchange failure + +A federated connection token can only be obtained via the session's refresh token. When none is present, the `OnMissingRefreshToken` event fires and the method returns `null`. When a refresh token is present but the exchange is rejected, the `OnAccessTokenRefreshFailed` event fires (carrying `StatusCode`, `Error`, `ErrorDescription`) and the method returns `null`. These are the same events used by MRRT — see [Handling refresh failures](#handling-refresh-failures) and [Detecting the absense of a refresh token](#detecting-the-absense-of-a-refresh-token) for full configuration examples. + ## Organizations [Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications. diff --git a/README.md b/README.md index df2a16d..6b2af45 100644 --- a/README.md +++ b/README.md @@ -155,9 +155,23 @@ var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest For configuring per-audience default scopes, forcing a refresh, and handling refresh failures, see the [Multi-Resource Refresh Tokens (MRRT) Examples](EXAMPLES.md#multi-resource-refresh-tokens-mrrt). +## Token Vault (Federated Connection Access Tokens) + +Token Vault lets your web app obtain a third-party API access token (for a federated connection such as Google, GitHub, or Slack) for the logged-in user, by exchanging the session's refresh token — without running a separate provider OAuth flow. + +```csharp +var googleToken = await HttpContext.GetAccessTokenForConnectionAsync(new AccessTokenForConnectionRequest +{ + Connection = "google-oauth2" +}); +``` + +For forcing a refresh, supplying a login hint, and handling missing refresh tokens or exchange failures, see the [Token Vault Examples](EXAMPLES.md#token-vault-federated-connection-access-tokens). + ## API reference Explore public API's available in auth0-aspnetcore-authentication. +- [AccessTokenForConnectionRequest](https://auth0.github.io/auth0-aspnetcore-authentication/api/Auth0.AspNetCore.Authentication.AccessTokenForConnectionRequest.html) - [Auth0WebAppOptions](https://auth0.github.io/auth0-aspnetcore-authentication/api/Auth0.AspNetCore.Authentication.Auth0WebAppOptions.html) - [Auth0WebAppWithAccessTokenOptions](https://auth0.github.io/auth0-aspnetcore-authentication/api/Auth0.AspNetCore.Authentication.Auth0WebAppWithAccessTokenOptions.html) - [LoginAuthenticationPropertiesBuilder](https://auth0.github.io/auth0-aspnetcore-authentication/api/Auth0.AspNetCore.Authentication.LoginAuthenticationPropertiesBuilder.html) From f7232c2246128e941a744dd3b95cfa375742e6ea Mon Sep 17 00:00:00 2001 From: Kailash B Date: Mon, 29 Jun 2026 12:34:54 +0530 Subject: [PATCH 5/5] chore: Address review comments --- EXAMPLES.md | 2 + .../ConnectionTokenSet.cs | 8 ++ .../ConnectionTokenSetHelpers.cs | 23 ++-- .../HttpContextExtensions.cs | 11 +- .../ConnectionTokenSetHelpersTests.cs | 115 ++++++++++++++++++ .../HttpContextExtensionsTests.cs | 112 +++++++++++++++++ 6 files changed, 261 insertions(+), 10 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index abbd5d9..acec0d2 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -654,6 +654,8 @@ public async Task CallGoogleCalendar() The optional `LoginHint` disambiguates which linked identity to use when the user has more than one. It is the **provider-side identity-provider user ID** (e.g. a Google user ID) — not the Auth0 user `sub`, and not the user's email. +`LoginHint` is part of the cache key: tokens for different login hints on the same connection are cached separately, so requesting identity B never returns identity A's cached token. A request with no `LoginHint` is cached separately from one that specifies a hint. + ```csharp var googleToken = await HttpContext.GetAccessTokenForConnectionAsync(new AccessTokenForConnectionRequest { diff --git a/src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs index b33c124..14b7fbd 100644 --- a/src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs +++ b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSet.cs @@ -14,6 +14,14 @@ internal class ConnectionTokenSet [JsonPropertyName("connection")] public string Connection { get; set; } = null!; + /// + /// The login hint the token was retrieved for, when supplied. Part of the cache key + /// alongside so tokens for different linked identities on the + /// same connection are cached separately rather than overwriting one another. + /// + [JsonPropertyName("login_hint")] + public string? LoginHint { get; set; } + /// /// The federated connection access token. /// diff --git a/src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs index 2605405..4bded34 100644 --- a/src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs +++ b/src/Auth0.AspNetCore.Authentication/ConnectionTokenSetHelpers.cs @@ -11,19 +11,22 @@ namespace Auth0.AspNetCore.Authentication internal static class ConnectionTokenSetHelpers { /// - /// Finds the cached token for a connection, or null when none is present. + /// Finds the cached token for a connection and login hint, or null when none is + /// present. The login hint is part of the key: a request with a given hint only matches a + /// cached entry stored for that same hint (a request with no hint matches an entry stored + /// with no hint), so tokens for different linked identities on one connection don't collide. /// - public static ConnectionTokenSet? FindConnectionTokenSet(IEnumerable? sets, string connection) + public static ConnectionTokenSet? FindConnectionTokenSet(IEnumerable? sets, string connection, string? loginHint = null) { - return sets?.FirstOrDefault(set => set.Connection == connection); + return sets?.FirstOrDefault(set => Matches(set, connection, loginHint)); } /// /// Applies a freshly retrieved connection token to the collection: prunes expired - /// entries first, then replaces the entry for the connection if present, otherwise - /// appends a new one. Returns the updated list (a new list instance). + /// entries first, then replaces the entry for the connection and login hint if present, + /// otherwise appends a new one. Returns the updated list (a new list instance). /// - public static List UpsertConnectionTokenSet(IEnumerable? sets, string connection, AccessTokenResponse response) + public static List UpsertConnectionTokenSet(IEnumerable? sets, string connection, AccessTokenResponse response, string? loginHint = null) { var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var result = sets?.Where(set => set.ExpiresAt > now).ToList() ?? new List(); @@ -31,12 +34,13 @@ public static List UpsertConnectionTokenSet(IEnumerable set.Connection == connection); + var existing = result.FirstOrDefault(set => Matches(set, connection, loginHint)); if (existing != null) { result[result.IndexOf(existing)] = entry; @@ -48,5 +52,10 @@ public static List UpsertConnectionTokenSet(IEnumerable DateTimeOffset.UtcNow.Add(optionsWithAccessToken.AccessTokenExpirationLeeway).ToUnixTimeSeconds()) { return match.AccessToken; @@ -236,7 +241,7 @@ await FireRefreshFailed(optionsWithAccessToken, TokenRefreshResult result; try { - result = await tokenClient.ExchangeRefreshTokenForConnectionToken(options, refreshToken, request.Connection, resolvedDomain, request.LoginHint).ConfigureAwait(false); + result = await tokenClient.ExchangeRefreshTokenForConnectionToken(options, refreshToken, request.Connection, resolvedDomain, loginHint).ConfigureAwait(false); } catch (Exception ex) { @@ -255,7 +260,7 @@ await FireRefreshFailed(optionsWithAccessToken, var response = result.Response!; // 3. Cache the connection token and persist. A rotated refresh token, if any, is also persisted. - var updated = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(ReadConnectionTokenSets(properties), request.Connection, response); + var updated = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(ReadConnectionTokenSets(properties), request.Connection, response, loginHint); WriteConnectionTokenSets(properties, updated); if (!string.IsNullOrEmpty(response.RefreshToken)) diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs index 5db7741..f72f619 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/ConnectionTokenSetHelpersTests.cs @@ -85,5 +85,120 @@ public void Upsert_PrunesExpiredEntries() result.Should().HaveCount(1); result[0].Connection.Should().Be("google-oauth2"); } + + [Fact] + public void FindConnectionTokenSet_DistinguishesByLoginHint() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", LoginHint = "identityA", AccessToken = "tokenA", ExpiresAt = now + 100 }, + new ConnectionTokenSet { Connection = "google-oauth2", LoginHint = "identityB", AccessToken = "tokenB", ExpiresAt = now + 100 } + }; + + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2", "identityA")!.AccessToken.Should().Be("tokenA"); + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2", "identityB")!.AccessToken.Should().Be("tokenB"); + } + + [Fact] + public void FindConnectionTokenSet_WithLoginHint_DoesNotMatchEntryWithDifferentHint() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", LoginHint = "identityA", AccessToken = "tokenA", ExpiresAt = now + 100 } + }; + + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2", "identityB").Should().BeNull(); + } + + [Fact] + public void FindConnectionTokenSet_WithoutLoginHint_MatchesLegacyEntryWithNoHint() + { + // Entries cached before login-hint awareness deserialize with LoginHint == null; + // a request with no hint must still match them so existing sessions keep working. + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", AccessToken = "legacy", ExpiresAt = now + 100 } + }; + + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2").Should().NotBeNull(); + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2")!.AccessToken.Should().Be("legacy"); + } + + [Fact] + public void FindConnectionTokenSet_WithLoginHint_DoesNotMatchLegacyEntryWithNoHint() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", AccessToken = "legacy", ExpiresAt = now + 100 } + }; + + ConnectionTokenSetHelpers.FindConnectionTokenSet(sets, "google-oauth2", "identityA").Should().BeNull(); + } + + [Fact] + public void Upsert_AppendsWhenSameConnectionButDifferentLoginHint() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", LoginHint = "identityA", AccessToken = "tokenA", ExpiresAt = now + 100 } + }; + var response = new AccessTokenResponse { AccessToken = "tokenB", ExpiresIn = 3600, Scope = "email" }; + + var result = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(sets, "google-oauth2", response, "identityB"); + + result.Should().HaveCount(2); + result.Should().Contain(s => s.LoginHint == "identityA" && s.AccessToken == "tokenA"); + result.Should().Contain(s => s.LoginHint == "identityB" && s.AccessToken == "tokenB"); + } + + [Fact] + public void Upsert_ReplacesWhenConnectionAndLoginHintMatch() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", LoginHint = "identityA", AccessToken = "old", ExpiresAt = now + 100 } + }; + var response = new AccessTokenResponse { AccessToken = "new", ExpiresIn = 3600, Scope = "email" }; + + var result = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(sets, "google-oauth2", response, "identityA"); + + result.Should().HaveCount(1); + result[0].LoginHint.Should().Be("identityA"); + result[0].AccessToken.Should().Be("new"); + } + + [Fact] + public void Upsert_StoresLoginHintOnNewEntry() + { + var response = new AccessTokenResponse { AccessToken = "tokenA", ExpiresIn = 3600, Scope = "email" }; + + var result = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(null, "google-oauth2", response, "identityA"); + + result.Should().HaveCount(1); + result[0].LoginHint.Should().Be("identityA"); + } + + [Fact] + public void Upsert_WithLoginHint_DoesNotOverwriteLegacyEntryWithNoHint() + { + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var sets = new List + { + new ConnectionTokenSet { Connection = "google-oauth2", AccessToken = "legacy", ExpiresAt = now + 100 } + }; + var response = new AccessTokenResponse { AccessToken = "tokenA", ExpiresIn = 3600, Scope = "email" }; + + var result = ConnectionTokenSetHelpers.UpsertConnectionTokenSet(sets, "google-oauth2", response, "identityA"); + + result.Should().HaveCount(2); + result.Should().Contain(s => s.LoginHint == null && s.AccessToken == "legacy"); + result.Should().Contain(s => s.LoginHint == "identityA" && s.AccessToken == "tokenA"); + } } } diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs index 66c6ed4..7a187ea 100644 --- a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/HttpContextExtensionsTests.cs @@ -707,6 +707,118 @@ public async Task GetAccessTokenForConnectionAsync_WithForceRefresh_BypassesCach persisted.Should().NotBeNull(); } + [Fact] + public async Task GetAccessTokenForConnectionAsync_DifferentLoginHint_DoesNotReturnOtherIdentitysCachedToken() + { + // Identity A's token is cached for the connection. A request for identity B on the + // same connection must not be served A's token from the cache — it must exchange and + // return B's own token. + var handler = CreateRawTokenHandler("{\"access_token\":\"identityB_token\",\"expires_in\":3600,\"scope\":\"email\"}"); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.refresh_token"] = "rt"; + properties.Items[HttpContextExtensions.ConnectionTokensItemKey] = JsonSerializer.Serialize(new List + { + new ConnectionTokenSet + { + Connection = "google-oauth2", + LoginHint = "identityA", + AccessToken = "identityA_token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), + Scope = "email" + } + }); + + AuthenticationProperties? persisted = null; + var context = BuildContext(handler.Object, properties, out var authService); + authService + .Setup(s => s.SignInAsync(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, p) => persisted = p) + .Returns(Task.CompletedTask); + + var result = await context.GetAccessTokenForConnectionAsync( + new AccessTokenForConnectionRequest { Connection = "google-oauth2", LoginHint = "identityB" }); + + result.Should().Be("identityB_token"); + result.Should().NotBe("identityA_token"); + + // Both identities should now be cached side by side under the one connection. + persisted.Should().NotBeNull(); + var stored = JsonSerializer.Deserialize>( + persisted!.Items[HttpContextExtensions.ConnectionTokensItemKey]!); + stored.Should().HaveCount(2); + stored.Should().Contain(s => s.LoginHint == "identityA" && s.AccessToken == "identityA_token"); + stored.Should().Contain(s => s.LoginHint == "identityB" && s.AccessToken == "identityB_token"); + } + + [Fact] + public async Task GetAccessTokenForConnectionAsync_MatchingLoginHint_ReturnsCachedWithoutCallingBackchannel() + { + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ThrowsAsync(new InvalidOperationException("backchannel should not be called on a cache hit")); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.refresh_token"] = "rt"; + properties.Items[HttpContextExtensions.ConnectionTokensItemKey] = JsonSerializer.Serialize(new List + { + new ConnectionTokenSet + { + Connection = "google-oauth2", + LoginHint = "identityA", + AccessToken = "identityA_token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), + Scope = "email" + } + }); + + var context = BuildContext(handler.Object, properties, out _); + + var result = await context.GetAccessTokenForConnectionAsync( + new AccessTokenForConnectionRequest { Connection = "google-oauth2", LoginHint = "identityA" }); + + result.Should().Be("identityA_token"); + } + + [Fact] + public async Task GetAccessTokenForConnectionAsync_WhitespaceLoginHint_TreatedSameAsNoHint_ReturnsCachedWithoutCallingBackchannel() + { + // An empty/whitespace LoginHint addresses the same default identity as no hint (the token + // endpoint omits it either way), so it must hit the entry cached with no hint rather than + // missing the cache and triggering a redundant exchange. + var handler = new Mock(); + handler + .Protected() + .Setup>( + "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) + .ThrowsAsync(new InvalidOperationException("backchannel should not be called on a cache hit")); + + var properties = new AuthenticationProperties(); + properties.Items[".Token.refresh_token"] = "rt"; + properties.Items[HttpContextExtensions.ConnectionTokensItemKey] = JsonSerializer.Serialize(new List + { + new ConnectionTokenSet + { + Connection = "google-oauth2", + AccessToken = "no_hint_token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), + Scope = "email" + } + }); + + var context = BuildContext(handler.Object, properties, out _); + + var result = await context.GetAccessTokenForConnectionAsync( + new AccessTokenForConnectionRequest { Connection = "google-oauth2", LoginHint = " " }); + + result.Should().Be("no_hint_token"); + } + [Fact] public async Task GetAccessTokenForConnectionAsync_NoRefreshToken_FiresEventAndReturnsNull() {