diff --git a/Examples.md b/Examples.md index 497bc5ee..899c2c1d 100644 --- a/Examples.md +++ b/Examples.md @@ -19,6 +19,7 @@ - [4.2. Client Initiated Backchannel Authorization (CIBA) with authorization details](#42-client-initiated-backchannel-authorization-ciba-with-authorization-details) - [5. Multi-Resource Refresh Token (MRRT)](#5-multi-resource-refresh-token-mrrt) - [6. Custom Token Exchange (CTE)](#6-custom-token-exchange-cte) +- [7. Token Vault (Federated Connection Access Token)](#7-token-vault-federated-connection-access-token) ## 1. Client Initialization @@ -359,6 +360,36 @@ if (sttResponse.IssuedTokenType == TokenType.SessionTransferToken) [Go to Top](#) +## 7. Token Vault (Federated Connection Access Token) + +Token Vault lets you exchange an Auth0 Access Token / Refresh Token for an access token issued by one of +your federated connections (Google, Facebook, etc.), so your app can call that provider's +APIs on the user's behalf. The client must be a private client. + +```csharp +using Auth0.AuthenticationApi; +using Auth0.AuthenticationApi.Models; + +var auth = new AuthenticationApiClient("YOUR_AUTH0_DOMAIN"); + +var tokenResponse = await auth.GetTokenAsync(new FederatedConnectionAccessTokenRequest +{ + ClientId = "YOUR_CLIENT_ID", + ClientSecret = "YOUR_CLIENT_SECRET", + SubjectToken = "THE_AUTH0_ACCESS_TOKEN", + SubjectTokenType = TokenType.AccessToken, + Connection = "google-oauth2", + LoginHint = "THE_GOOGLE_USER_ID" // optional +}); + +Console.WriteLine($"Federated Access Token: {tokenResponse.AccessToken}"); +``` + +> `Connection` is required — omitting it throws `ArgumentException` before any network +> call. `requested_token_type` is fixed to the federated-connection URN and set for you. + +[Go to Top](#) + # Management API - [1. Management Client Initialization](#1-management-client-initialization) diff --git a/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs b/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs index 20b3c835..5fe18f84 100644 --- a/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs +++ b/src/Auth0.AuthenticationApi/AuthenticationApiClient.cs @@ -270,6 +270,51 @@ public async Task GetTokenAsync(TokenExchangeTokenRequest r return response; } + /// + public async Task GetTokenAsync(FederatedConnectionAccessTokenRequest request, CancellationToken cancellationToken = default) + { + request.ThrowIfNull(); + + if (string.IsNullOrEmpty(request.SubjectToken)) + throw new ArgumentException( + "SubjectToken is required for a federated connection access token exchange.", + nameof(request.SubjectToken)); + + if (string.IsNullOrEmpty(request.SubjectTokenType)) + throw new ArgumentException( + "SubjectTokenType is required for a federated connection access token exchange.", + nameof(request.SubjectTokenType)); + + if (string.IsNullOrEmpty(request.Connection)) + throw new ArgumentException( + "Connection is required for a federated connection access token exchange.", + nameof(request.Connection)); + + var body = new Dictionary { + { "grant_type", "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" }, + { "client_id", request.ClientId }, + { "subject_token", request.SubjectToken }, + { "subject_token_type", request.SubjectTokenType }, + { "requested_token_type", TokenType.FederatedConnectionAccessToken }, + { "connection", request.Connection } + }; + + ApplyClientAuthentication(request, body); + + body.AddIfNotEmpty("login_hint", request.LoginHint); + + var response = await connection.SendAsync( + HttpMethod.Post, + tokenUri, + body, + cancellationToken: cancellationToken + ).ConfigureAwait(false); + + await AssertIdTokenValidIfExisting(response.IdToken, request.ClientId, request.SigningAlgorithm, request.ClientSecret, null, request.Nonce).ConfigureAwait(false); + + return response; + } + /// public async Task GetTokenAsync(ResourceOwnerTokenRequest request, CancellationToken cancellationToken = default) { diff --git a/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs b/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs index c909bf29..40a0df85 100644 --- a/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs +++ b/src/Auth0.AuthenticationApi/IAuthenticationApiClient.cs @@ -98,6 +98,17 @@ public interface IAuthenticationApiClient : IDisposable /// to determine the kind of token issued. Task GetTokenAsync(TokenExchangeTokenRequest request, CancellationToken cancellationToken = default); + /// + /// Exchanges an Auth0 token for an access token issued by a federated connection + /// (Token Vault), using the Auth0 federated-connection-access-token grant. + /// + /// containing the subject token, connection, and associated parameters. + /// The cancellation token to cancel operation. + /// representing the async operation containing + /// a . The federated connection access token is + /// returned in . + Task GetTokenAsync(FederatedConnectionAccessTokenRequest request, CancellationToken cancellationToken = default); + /// /// Performs authentication by providing user-supplied information in a . /// diff --git a/src/Auth0.AuthenticationApi/Models/FederatedConnectionAccessTokenRequest.cs b/src/Auth0.AuthenticationApi/Models/FederatedConnectionAccessTokenRequest.cs new file mode 100644 index 00000000..33eb40dd --- /dev/null +++ b/src/Auth0.AuthenticationApi/Models/FederatedConnectionAccessTokenRequest.cs @@ -0,0 +1,73 @@ +using Microsoft.IdentityModel.Tokens; + +namespace Auth0.AuthenticationApi.Models; + +/// +/// Represents a request to exchange an Auth0 token for an access token issued by one of +/// Auth0's federated connections (Token Vault), using the Auth0 token-exchange grant +/// urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token. +/// +/// +/// The client must be a private client. The subject token may be an Auth0 access token +/// () or refresh token (). +/// +public class FederatedConnectionAccessTokenRequest : IClientAuthentication +{ + /// + /// The Auth0 token being exchanged. This may be a valid Auth0 access token or refresh token. + /// Required. + /// + public string SubjectToken { get; set; } + + /// + /// An identifier that indicates the type of , as a URN. + /// Either or . Required. + /// + public string SubjectTokenType { get; set; } + + /// + /// The federated connection to obtain the access token for (for example + /// google-oauth2). Required. + /// + public string Connection { get; set; } + + /// + /// Optional user ID of the current user within the IdP named by . + /// For example, if the connection is google-oauth2, this is the Google user ID. + /// + public string LoginHint { get; set; } + + /// + /// Client ID of the application. + /// + public string ClientId { get; set; } + + /// + /// Client Secret of the application. + /// + public string ClientSecret { get; set; } + + /// + /// Security Key to use with Client Assertion. + /// + public SecurityKey ClientAssertionSecurityKey { get; set; } + + /// + /// Algorithm for the Security Key to use with Client Assertion. + /// + public string ClientAssertionSecurityKeyAlgorithm { get; set; } + + /// + /// What is used to verify the signature of Id Tokens. + /// + public JwtSignatureAlgorithm SigningAlgorithm { get; set; } + + /// + /// Optional nonce to validate against the nonce claim in a returned ID token. + /// + /// + /// When set, the nonce claim in the returned ID token must exactly match this value. + /// Leave null (the default) to skip nonce validation. + /// + public string? Nonce { get; set; } +} diff --git a/src/Auth0.AuthenticationApi/Models/TokenType.cs b/src/Auth0.AuthenticationApi/Models/TokenType.cs index c0bd7141..def69b87 100644 --- a/src/Auth0.AuthenticationApi/Models/TokenType.cs +++ b/src/Auth0.AuthenticationApi/Models/TokenType.cs @@ -30,4 +30,14 @@ public static class TokenType /// Indicates that the token is an Auth0 Session Transfer Token: urn:auth0:params:oauth:token-type:session_transfer_token. /// public const string SessionTransferToken = "urn:auth0:params:oauth:token-type:session_transfer_token"; + + /// + /// Indicates that the token is an OAuth 2.0 refresh token: urn:ietf:params:oauth:token-type:refresh_token. + /// + public const string RefreshToken = "urn:ietf:params:oauth:token-type:refresh_token"; + + /// + /// Indicates that the token is an Auth0 federated connection access token: http://auth0.com/oauth/token-type/federated-connection-access-token. + /// + public const string FederatedConnectionAccessToken = "http://auth0.com/oauth/token-type/federated-connection-access-token"; } diff --git a/tests/Auth0.AuthenticationApi.IntegrationTests/TokenVaultTests.cs b/tests/Auth0.AuthenticationApi.IntegrationTests/TokenVaultTests.cs new file mode 100644 index 00000000..70c17013 --- /dev/null +++ b/tests/Auth0.AuthenticationApi.IntegrationTests/TokenVaultTests.cs @@ -0,0 +1,423 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Auth0.AuthenticationApi.Models; +using Auth0.Core.Exceptions; +using Auth0.Tests.Shared; +using FluentAssertions; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Auth0.AuthenticationApi.IntegrationTests; + +public class TokenVaultTests +{ + private const string Domain = "test-tenant.auth0.com"; + private const string ClientId = "test-client-id"; + private const string ClientSecret = "test-client-secret"; + private const string AccessToken = "test-access-token"; + private const string Connection = "google-oauth2"; + private const string GrantType = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token"; + + private static AuthenticationApiClient CreateClient( + AccessTokenResponse response, + Dictionary expectedParams) + { + var mockHandler = new Mock(MockBehavior.Strict); + + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri.ToString() == $"https://{Domain}/oauth/token" + && ValidateRequestContent(req, expectedParams)), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(response, response.GetType()), + Encoding.UTF8, + "application/json"), + }); + + var httpClient = new HttpClient(mockHandler.Object); + return new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient)); + } + + private static bool ValidateRequestContent(HttpRequestMessage content, Dictionary contentParams) + { + string body = content.Content.ReadAsStringAsync().Result; + var result = body.Split("&") + .ToDictionary(kv => kv.Split("=")[0], kv => HttpUtility.UrlDecode(kv.Split("=")[1])); + return contentParams.Aggregate(true, (acc, kv) => acc && result.GetValueOrDefault(kv.Key) == kv.Value); + } + + [Fact] + public void TokenType_constants_have_expected_token_vault_values() + { + TokenType.RefreshToken.Should().Be("urn:ietf:params:oauth:token-type:refresh_token"); + TokenType.AccessToken.Should().Be("urn:ietf:params:oauth:token-type:access_token"); + TokenType.FederatedConnectionAccessToken.Should().Be("http://auth0.com/oauth/token-type/federated-connection-access-token"); + } + + [Fact] + public void FederatedConnectionAccessTokenRequest_implements_IClientAuthentication() + { + var request = new FederatedConnectionAccessTokenRequest + { + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection, + ClientId = ClientId + }; + + request.Should().BeAssignableTo(); + request.SubjectTokenType.Should().Be("urn:ietf:params:oauth:token-type:access_token"); + request.Connection.Should().Be(Connection); + } + + [Fact] + public async Task Can_exchange_access_token_for_federated_connection_access_token() + { + var response = new AccessTokenResponse + { + AccessToken = "federated-access-token", + TokenType = "Bearer", + ExpiresIn = 3600, + IssuedTokenType = TokenType.FederatedConnectionAccessToken + }; + var expectedParams = new Dictionary + { + { "grant_type", GrantType }, + { "client_id", ClientId }, + { "subject_token", AccessToken }, + { "subject_token_type", TokenType.AccessToken }, + { "requested_token_type", TokenType.FederatedConnectionAccessToken }, + { "connection", Connection } + }; + + var client = CreateClient(response, expectedParams); + + var result = await client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection + }); + + result.Should().NotBeNull(); + result.AccessToken.Should().Be("federated-access-token"); + result.IssuedTokenType.Should().Be(TokenType.FederatedConnectionAccessToken); + } + + [Fact] + public async Task Can_exchange_refresh_token_for_federated_connection_access_token() + { + var response = new AccessTokenResponse + { + AccessToken = "federated-access-token", + TokenType = "Bearer", + ExpiresIn = 3600, + IssuedTokenType = TokenType.FederatedConnectionAccessToken + }; + var expectedParams = new Dictionary + { + { "grant_type", GrantType }, + { "client_id", ClientId }, + { "subject_token", "test-refresh-token" }, + { "subject_token_type", TokenType.RefreshToken }, + { "requested_token_type", TokenType.FederatedConnectionAccessToken }, + { "connection", Connection } + }; + + var client = CreateClient(response, expectedParams); + + var result = await client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = "test-refresh-token", + SubjectTokenType = TokenType.RefreshToken, + Connection = Connection + }); + + result.Should().NotBeNull(); + result.AccessToken.Should().Be("federated-access-token"); + result.IssuedTokenType.Should().Be(TokenType.FederatedConnectionAccessToken); + } + + [Fact] + public async Task Sends_login_hint_when_set() + { + var response = new AccessTokenResponse + { + AccessToken = "federated-access-token", + TokenType = "Bearer", + ExpiresIn = 3600, + IssuedTokenType = TokenType.FederatedConnectionAccessToken + }; + var expectedParams = new Dictionary + { + { "grant_type", GrantType }, + { "subject_token", AccessToken }, + { "subject_token_type", TokenType.AccessToken }, + { "connection", Connection }, + { "login_hint", "google-user-123" } + }; + + var client = CreateClient(response, expectedParams); + + var result = await client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection, + LoginHint = "google-user-123" + }); + + result.Should().NotBeNull(); + result.AccessToken.Should().Be("federated-access-token"); + } + + [Fact] + public async Task Omits_login_hint_when_unset() + { + var response = new AccessTokenResponse + { + AccessToken = "federated-access-token", + TokenType = "Bearer", + ExpiresIn = 3600, + IssuedTokenType = TokenType.FederatedConnectionAccessToken + }; + + var mockHandler = new Mock(MockBehavior.Strict); + string capturedBody = null; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri.ToString() == $"https://{Domain}/oauth/token"), + ItExpr.IsAny() + ) + .Callback((req, _) => + capturedBody = req.Content.ReadAsStringAsync().Result) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(response), + Encoding.UTF8, + "application/json"), + }); + + var httpClient = new HttpClient(mockHandler.Object); + var client = new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient)); + + await client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection + }); + + capturedBody.Should().NotContain("login_hint"); + } + + [Fact] + public async Task Sends_requested_token_type_as_fixed_federated_urn() + { + var response = new AccessTokenResponse + { + AccessToken = "federated-access-token", + TokenType = "Bearer", + ExpiresIn = 3600, + IssuedTokenType = TokenType.FederatedConnectionAccessToken + }; + + var mockHandler = new Mock(MockBehavior.Strict); + string capturedBody = null; + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri.ToString() == $"https://{Domain}/oauth/token"), + ItExpr.IsAny() + ) + .Callback((req, _) => + capturedBody = req.Content.ReadAsStringAsync().Result) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.OK, + Content = new StringContent( + JsonSerializer.Serialize(response), + Encoding.UTF8, + "application/json"), + }); + + var httpClient = new HttpClient(mockHandler.Object); + var client = new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient)); + + await client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection + }); + + capturedBody.Should().Contain("requested_token_type="); + var parsed = capturedBody.Split("&") + .ToDictionary(kv => kv.Split("=")[0], kv => HttpUtility.UrlDecode(kv.Split("=")[1])); + parsed["requested_token_type"].Should().Be("http://auth0.com/oauth/token-type/federated-connection-access-token"); + } + + [Fact] + public async Task Throws_when_connection_is_missing() + { + var mockHandler = new Mock(MockBehavior.Strict); + var httpClient = new HttpClient(mockHandler.Object); + var client = new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient)); + + Func act = () => client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Throws_when_subject_token_is_missing() + { + var mockHandler = new Mock(MockBehavior.Strict); + var httpClient = new HttpClient(mockHandler.Object); + var client = new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient)); + + Func act = () => client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Throws_when_subject_token_type_is_missing() + { + var mockHandler = new Mock(MockBehavior.Strict); + var httpClient = new HttpClient(mockHandler.Object); + var client = new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient)); + + Func act = () => client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + Connection = Connection + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Applies_client_authentication() + { + var response = new AccessTokenResponse + { + AccessToken = "federated-access-token", + TokenType = "Bearer", + ExpiresIn = 3600, + IssuedTokenType = TokenType.FederatedConnectionAccessToken + }; + var expectedParams = new Dictionary + { + { "grant_type", GrantType }, + { "client_id", ClientId }, + { "client_secret", ClientSecret }, + { "subject_token", AccessToken }, + { "subject_token_type", TokenType.AccessToken }, + { "connection", Connection } + }; + + var client = CreateClient(response, expectedParams); + + var result = await client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection + }); + + result.Should().NotBeNull(); + } + + [Fact] + public async Task Surfaces_server_error() + { + var mockHandler = new Mock(MockBehavior.Strict); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.RequestUri.ToString() == $"https://{Domain}/oauth/token"), + ItExpr.IsAny() + ) + .ReturnsAsync(new HttpResponseMessage + { + StatusCode = HttpStatusCode.BadRequest, + Content = new StringContent( + "{\"error\":\"invalid_request\",\"error_description\":\"connection is not enabled for token vault\"}", + Encoding.UTF8, + "application/json"), + }); + + var httpClient = new HttpClient(mockHandler.Object); + var client = new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient)); + + Func act = () => client.GetTokenAsync(new FederatedConnectionAccessTokenRequest + { + ClientId = ClientId, + ClientSecret = ClientSecret, + SubjectToken = AccessToken, + SubjectTokenType = TokenType.AccessToken, + Connection = Connection + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public void Deserializes_issued_token_type() + { + var json = "{\"access_token\":\"fat\",\"token_type\":\"Bearer\",\"issued_token_type\":\"http://auth0.com/oauth/token-type/federated-connection-access-token\"}"; + + var response = JsonSerializer.Deserialize(json); + + response.IssuedTokenType.Should().Be("http://auth0.com/oauth/token-type/federated-connection-access-token"); + } +}