Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
36 changes: 36 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,41 @@ 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 — check before relying on them.
if (tokenResponse.Scope != requestedScope)

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 comparison is not reliable. The scope value is a space separated list and the server does not guarantee any ordering. If the server grants everything but returns the scopes in a different order (like write:data read:data), this check will still report a warning even though nothing is actually missing.

Can we compare them as sets instead? Something like splitting both strings on spaces and checking that every requested scope is present in the granted ones. Since this example gets copied into real apps, I would rather we show the correct pattern here.

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.

Updated the example

{
Console.WriteLine("Warning: not all requested scopes were granted.");
}
```

[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 @@ -220,7 +220,7 @@ public async Task<AccessTokenResponse> GetTokenAsync(RefreshTokenRequest request
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
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