Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
- [4. Rich Authorization Requests (RAR)](#4-rich-authorization-requests-rar)
- [4.1. Pushed Authorization Request (PAR) with authorization details](#41-pushed-authorization-request-par-with-authorization-details)
- [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)

## 1. Client Initialization

Expand Down Expand Up @@ -262,6 +263,45 @@ if (tokenResponse.AuthorizationDetails != null)

[Go to Top](#)

## 5. Multi-Resource Refresh Token (MRRT)

A Multi-Resource Refresh Token lets you exchange a single refresh token for access
tokens targeting different APIs (`Audience`) and/or broader scopes, without a full
re-authentication. At the protocol level this is a standard refresh-token grant with
`Audience` and/or `Scope` set.

The token endpoint returns the scopes that were **actually granted** in
`AccessTokenResponse.Scope` (RFC 6749 §5.1), which may be narrower than what you
requested. Compare the two to detect an insufficient-scope grant.

```csharp
var requestedScope = "read:data write:data";

var tokenResponse = await authClient.GetTokenAsync(new RefreshTokenRequest
{
ClientId = _clientId,
ClientSecret = _clientSecret,
RefreshToken = _refreshToken,
Audience = "https://my-api.example.com", // target a different API (optional)
Scope = requestedScope // request broader scopes (optional)
});

Console.WriteLine($"Access Token : {tokenResponse.AccessToken}");
Console.WriteLine($"Granted Scope: {tokenResponse.Scope}");

// The server may grant fewer scopes than requested, and returns them in no guaranteed
// order — compare as sets rather than comparing the raw strings.
var requested = requestedScope.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var granted = (tokenResponse.Scope ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries);
var missingScopes = requested.Except(granted).ToList();
if (missingScopes.Count > 0)
{
Console.WriteLine($"Warning: not all requested scopes were granted. Missing: {string.Join(" ", missingScopes)}");
}
```

[Go to Top](#)

# Management API

- [1. Management Client Initialization](#1-management-client-initialization)
Expand Down
2 changes: 1 addition & 1 deletion src/Auth0.AuthenticationApi/AuthenticationApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
/// <code>var client = new AuthenticationApiClient(baseUri, new HttpClientAuthenticationConnection(myHttpClient));</code> or
/// <code>var client = new AuthenticationApiClient(baseUri, new HttpClientAuthenticationConnection(myHttpMessageHandler));</code> or
/// </remarks>
public AuthenticationApiClient(Uri baseUri, IAuthenticationConnection connection = null)

Check warning on line 47 in src/Auth0.AuthenticationApi/AuthenticationApiClient.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.
{
BaseUri = baseUri;
if (connection == null)
Expand Down Expand Up @@ -74,7 +74,7 @@
/// <code>var client = new AuthenticationApiClient(domain, new HttpClientAuthenticationConnection(myHttpClient));</code> or
/// <code>var client = new AuthenticationApiClient(domain, new HttpClientAuthenticationConnection(myHttpMessageHandler));</code> or
/// </remarks>
public AuthenticationApiClient(string domain, IAuthenticationConnection connection = null)

Check warning on line 77 in src/Auth0.AuthenticationApi/AuthenticationApiClient.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.
: this(new Uri($"https://{domain}"), connection)
{
}
Expand Down Expand Up @@ -220,7 +220,7 @@
cancellationToken: cancellationToken
).ConfigureAwait(false);

await AssertIdTokenValid(response.IdToken, request.ClientId, request.SigningAlgorithm, request.ClientSecret, request.Organization, request.Nonce).ConfigureAwait(false);
await AssertIdTokenValidIfExisting(response.IdToken, request.ClientId, request.SigningAlgorithm, request.ClientSecret, request.Organization, request.Nonce).ConfigureAwait(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change looks correct for MRRT. A refresh that targets a different audience and drops openid will not return an id_token, so skipping validation when the token is absent is the right call and it matches the other flows.

One small thing worth noting: when a caller does request openid and expects an id_token back, an absent token now passes silently and IdToken will be null instead of throwing. Not a blocker, but it might be worth a line in the docs telling openid callers to null check response.IdToken.


return response;
}
Expand Down Expand Up @@ -718,7 +718,7 @@
GC.SuppressFinalize(this);
}

private async Task AssertIdTokenValidIfExisting(string idToken, string audience, JwtSignatureAlgorithm algorithm, string clientSecret, string organization = null, string nonce = null)

Check warning on line 721 in src/Auth0.AuthenticationApi/AuthenticationApiClient.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.

Check warning on line 721 in src/Auth0.AuthenticationApi/AuthenticationApiClient.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.
{
if (!string.IsNullOrEmpty(idToken))
{
Expand All @@ -735,7 +735,7 @@
/// <param name="clientSecret">Client secret used for HS256 signature validation; null for asymmetric algorithms.</param>
/// <param name="organization">Optional organization (org_id/org_name) to validate; null skips org validation.</param>
/// <param name="nonce">Optional nonce that must match the nonce claim in the ID token; null skips nonce validation.</param>
private Task AssertIdTokenValid(string idToken, string audience, JwtSignatureAlgorithm algorithm, string clientSecret, string organization = null, string nonce = null)

Check warning on line 738 in src/Auth0.AuthenticationApi/AuthenticationApiClient.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.

Check warning on line 738 in src/Auth0.AuthenticationApi/AuthenticationApiClient.cs

View workflow job for this annotation

GitHub Actions / build

Cannot convert null literal to non-nullable reference type.
{
var requirements = new IdTokenRequirements(algorithm, BaseUri.AbsoluteUri, audience, idTokenValidationLeeway, null, organization);
requirements.Nonce = nonce;
Expand Down
13 changes: 12 additions & 1 deletion src/Auth0.AuthenticationApi/Models/AccessTokenResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ public class AccessTokenResponse : TokenBase
/// </summary>
[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }


/// <summary>
/// The scopes that were actually granted for this token.
/// </summary>
/// <remarks>
/// The authorization server returns the granted scope, which
/// may be narrower than the scope that was requested. Compare this against the
/// requested scope to detect an insufficient-scope grant.
/// </remarks>
[JsonPropertyName("scope")]
public string Scope { get; set; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving Scope up to the base class is fine for source code, but it is a binary breaking change. Any app that was compiled against the older version and reads ClientInitiatedBackchannelAuthorizationTokenResponse.Scope will throw a MissingMethodException at runtime until it is recompiled.

In practice most people recompile with the new package, so the impact is small, but the PR description says this is non breaking. Can we update the description or the release notes to call this out?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a callout in the PR description. Will ensure to add a callout in the CHANGELOG on Release


public IDictionary<string, IEnumerable<string>> Headers { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ namespace Auth0.AuthenticationApi.Models.Ciba;

public class ClientInitiatedBackchannelAuthorizationTokenResponse : AccessTokenResponse
{
[JsonPropertyName("scope")]
public string Scope { get; set; }

/// <summary>
/// Raw <c>authorization_details</c> JSON returned by the token endpoint as part of a
/// Rich Authorization Requests (RAR) flow. Use <see cref="AuthorizationDetails"/> for a strongly-typed view.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,15 @@ public void AuthorizationDetails_caches_parsed_result_across_accesses()

first.Should().BeSameAs(second);
}

[Fact]
public void Scope_deserializes_onto_inherited_base_property()
{
var json = "{\"access_token\":\"at\",\"token_type\":\"Bearer\",\"scope\":\"openid profile\"}";

var response = System.Text.Json.JsonSerializer
.Deserialize<ClientInitiatedBackchannelAuthorizationTokenResponse>(json);

response.Scope.Should().Be("openid profile");
}
}
260 changes: 260 additions & 0 deletions tests/Auth0.AuthenticationApi.IntegrationTests/MrrtTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
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 MrrtTests
{
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 RefreshToken = "test-refresh-token";

private static AuthenticationApiClient CreateClient(
AccessTokenResponse response,
Dictionary<string, string> expectedParams)
{
var mockHandler = new Mock<HttpMessageHandler>(MockBehavior.Strict);

mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.RequestUri.ToString() == $"https://{Domain}/oauth/token"
&& ValidateRequestContent(req, expectedParams)),
ItExpr.IsAny<CancellationToken>()
)
.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<string, string> 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 async Task Can_upscope_for_existing_audience()
{
var response = new AccessTokenResponse
{
AccessToken = "new-access-token",
TokenType = "Bearer",
ExpiresIn = 86400,
Scope = "read:data write:data"
};
var expectedParams = new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "refresh_token", RefreshToken },
{ "audience", "https://api.example.com" },
{ "scope", "read:data write:data" }
};

var client = CreateClient(response, expectedParams);

var result = await client.GetTokenAsync(new RefreshTokenRequest
{
ClientId = ClientId,
ClientSecret = ClientSecret,
RefreshToken = RefreshToken,
Audience = "https://api.example.com",
Scope = "read:data write:data"
});

result.Should().NotBeNull();
result.AccessToken.Should().Be("new-access-token");
result.Scope.Should().Be("read:data write:data");
}

[Fact]
public async Task Can_exchange_token_for_different_audience()
{
var response = new AccessTokenResponse
{
AccessToken = "audience-b-token",
TokenType = "Bearer",
ExpiresIn = 86400,
Scope = "read:b"
};
var expectedParams = new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "refresh_token", RefreshToken },
{ "audience", "https://api-b.example.com" },
{ "scope", "read:b" }
};

var client = CreateClient(response, expectedParams);

var result = await client.GetTokenAsync(new RefreshTokenRequest
{
ClientId = ClientId,
ClientSecret = ClientSecret,
RefreshToken = RefreshToken,
Audience = "https://api-b.example.com",
Scope = "read:b"
});

result.Should().NotBeNull();
result.AccessToken.Should().Be("audience-b-token");
result.Scope.Should().Be("read:b");
}

[Fact]
public async Task Can_upscope_for_default_audience()
{
var response = new AccessTokenResponse
{
AccessToken = "default-audience-token",
TokenType = "Bearer",
ExpiresIn = 86400,
Scope = "openid profile email"
};
var expectedParams = new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "refresh_token", RefreshToken },
{ "scope", "openid profile email" }
};

var client = CreateClient(response, expectedParams);

var result = await client.GetTokenAsync(new RefreshTokenRequest
{
ClientId = ClientId,
ClientSecret = ClientSecret,
RefreshToken = RefreshToken,
Scope = "openid profile email"
});

result.Should().NotBeNull();
result.Scope.Should().Be("openid profile email");
}

[Fact]
public async Task Can_exchange_from_default_to_specific_audience()
{
var response = new AccessTokenResponse
{
AccessToken = "specific-api-token",
TokenType = "Bearer",
ExpiresIn = 86400,
Scope = "read:tickets"
};
var expectedParams = new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "refresh_token", RefreshToken },
{ "audience", "https://named-api.example.com" }
};

var client = CreateClient(response, expectedParams);

var result = await client.GetTokenAsync(new RefreshTokenRequest
{
ClientId = ClientId,
ClientSecret = ClientSecret,
RefreshToken = RefreshToken,
Audience = "https://named-api.example.com"
});

result.Should().NotBeNull();
result.AccessToken.Should().Be("specific-api-token");
result.Scope.Should().Be("read:tickets");
}

[Fact]
public async Task Scope_field_reflects_actual_grants()
{
const string requestedScope = "read:data write:data delete:data";
const string grantedScope = "read:data";

var response = new AccessTokenResponse
{
AccessToken = "narrow-token",
TokenType = "Bearer",
ExpiresIn = 86400,
Scope = grantedScope
};
var expectedParams = new Dictionary<string, string>
{
{ "grant_type", "refresh_token" },
{ "refresh_token", RefreshToken },
{ "scope", requestedScope }
};

var client = CreateClient(response, expectedParams);

var result = await client.GetTokenAsync(new RefreshTokenRequest
{
ClientId = ClientId,
ClientSecret = ClientSecret,
RefreshToken = RefreshToken,
Scope = requestedScope
});

result.Scope.Should().Be(grantedScope);
result.Scope.Should().NotBe(requestedScope);
}

[Fact]
public async Task Returns_error_when_no_refresh_token()
{
var mockHandler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.RequestUri.ToString() == $"https://{Domain}/oauth/token"),
ItExpr.IsAny<CancellationToken>()
)
.ReturnsAsync(new HttpResponseMessage
{
StatusCode = HttpStatusCode.BadRequest,
Content = new StringContent(
"{\"error\":\"invalid_request\",\"error_description\":\"Missing required parameter: refresh_token\"}",
Encoding.UTF8,
"application/json"),
});

var httpClient = new HttpClient(mockHandler.Object);
var client = new TestAuthenticationApiClient(Domain, new TestHttpClientAuthenticationConnection(httpClient));

Func<Task> act = () => client.GetTokenAsync(new RefreshTokenRequest
{
ClientId = ClientId,
ClientSecret = ClientSecret,
RefreshToken = null
});

await act.Should().ThrowAsync<ErrorApiException>();
}
}
Loading