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
135 changes: 135 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
- [Calling an API](#calling-an-api)
- [Configuring the refresh leeway](#configuring-the-refresh-leeway)
- [Server-side session storage](#server-side-session-storage)
- [Multi-Resource Refresh Tokens (MRRT)](#multi-resource-refresh-tokens-mrrt)
- [Requesting a token for another audience](#requesting-a-token-for-another-audience)
- [Configuring default scopes per audience](#configuring-default-scopes-per-audience)
- [Forcing a refresh](#forcing-a-refresh)
- [Handling refresh failures](#handling-refresh-failures)
- [Organizations](#organizations)
- [Extra parameters](#extra-parameters)
- [Roles](#roles)
Expand Down Expand Up @@ -165,6 +170,8 @@ services

A larger leeway refreshes more eagerly (fewer near-expiry tokens, more refresh calls); a smaller leeway refreshes later. The leeway only takes effect when `UseRefreshTokens` is enabled.

> :information_source: To obtain access tokens for **additional** audiences or scopes on demand (without a second login), see [Multi-Resource Refresh Tokens (MRRT)](#multi-resource-refresh-tokens-mrrt).

#### Detecting the absense of a refresh token

In the event where the API, defined in your Auth0 dashboard, isn't configured to [allow offline access](https://auth0.com/docs/get-started/dashboard/api-settings), or the user was already logged in before the use of refresh tokens was enabled (e.g. a user logs in a few minutes before the use of refresh tokens is deployed), it might be useful to detect the absense of a refresh token in order to react accordingly (e.g. log the user out locally and force them to re-login).
Expand Down Expand Up @@ -276,6 +283,134 @@ public class RedisTicketStore : ITicketStore
>
> :warning: **Protecting the ticket:** it holds sensitive data (claims, access and refresh tokens). Encrypting the payload with `IDataProtector` as shown above means an attacker who gains read access to the cache cannot recover those tokens. Treat the cache backend itself as sensitive too: restrict access and enable encryption in transit (e.g. Redis AUTH + TLS) and at rest.

## Multi-Resource Refresh Tokens (MRRT)

[Multi-Resource Refresh Tokens (MRRT)](https://auth0.com/docs/secure/tokens/refresh-tokens/multi-resource-refresh-token) let a single session obtain access tokens for *additional* audiences and scopes on demand, by exchanging the session's refresh token — without forcing the user through another interactive login.

A typical use case: the user logs in once, and your web app then needs to call a downstream API that expects a token for a *different* audience than the one requested at login. Instead of re-authenticating, you exchange the existing refresh token for a token scoped to that API.

> :information_source: MRRT requires refresh tokens. Configure `UseRefreshTokens = true` and a `ClientSecret`, and ensure MRRT is enabled for your client/APIs in the Auth0 Dashboard. Tokens obtained for additional audiences are cached in the session alongside the login-time ("primary") token and reused until they near expiry.

> :warning: **Token storage and cookie size.** Each additional audience/scope you obtain a token for adds another entry to the cached token set, which by default is serialized into the encrypted **authentication cookie** along with the rest of the session. Cookies cannot grow indefinitely — browsers cap them at around 4 KB each, and request-header limits apply on top of that. An application that fans out across several audiences can therefore accumulate enough tokens to exceed those limits and have the session rejected. If you expect to hold tokens for more than a couple of audiences, move the session **server-side** so only a small session key rides in the cookie while the token set lives in a store you control — see [Server-side session storage](#server-side-session-storage) above. The MRRT API is identical either way; only where the token set is persisted changes.

### Requesting a token for another audience

Use `HttpContext.GetAccessTokenAsync` with an `AccessTokenRequest` describing the `Audience` and/or `Scope` you need. The SDK first tries to satisfy the request from the session (the primary token or a previously cached additional token), and only exchanges the refresh token when no usable cached token exists. Newly obtained tokens are persisted back into the session automatically.

```csharp
[Authorize]
public async Task<IActionResult> CallMessagesApi()
{
var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest
{
Audience = "https://messages.example.com",
Scope = "read:messages"
});

if (accessToken == null)
{
// No refresh token available, or the refresh failed — see "Handling refresh failures" below.
return Challenge();
}

var request = new HttpRequestMessage(HttpMethod.Get, "https://messages.example.com/api/messages");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);

var response = await _httpClient.SendAsync(request);
return Content(await response.Content.ReadAsStringAsync());
}
```

The requested `Scope` is merged (order-preserving union) with the configured default scopes for the resolved audience. Omitting `Audience` falls back to the globally configured `Audience`; omitting `Scope` falls back to the configured default for that audience.

> :information_source: `GetAccessTokenAsync` returns `null` rather than throwing when no refresh token is available or the refresh fails. Always check for `null` before using the token.

When called with no arguments matching the primary audience/scope, `GetAccessTokenAsync` is equivalent to retrieving the login-time access token (and refreshing it when expired).

### Configuring default scopes per audience

You can configure default scopes for each additional audience using `ScopeByAudience`. When an audience is present in this map, its value is used as the default scope for that audience; otherwise the global `Scope` is used as the fallback.

```csharp
services
.AddAuth0WebAppAuthentication(options =>
{
options.Domain = Configuration["Auth0:Domain"];
options.ClientId = Configuration["Auth0:ClientId"];
options.ClientSecret = Configuration["Auth0:ClientSecret"];
})
.WithAccessToken(options =>
{
options.Audience = Configuration["Auth0:Audience"];
options.UseRefreshTokens = true;
options.ScopeByAudience = new Dictionary<string, string>
{
["https://messages.example.com"] = "read:messages write:messages",
["https://billing.example.com"] = "read:invoices"
};
});
```

With this configured, a request for the `https://messages.example.com` audience defaults to the `read:messages write:messages` scopes, so callers can omit `Scope` for that audience:

```csharp
var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest
{
Audience = "https://messages.example.com"
});
```

### Forcing a refresh

Set `ForceRefresh = true` to bypass the cache and always exchange the refresh token for a new access token. The freshly retrieved token still replaces the cached entry.

```csharp
var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest
{
Audience = "https://messages.example.com",
Scope = "read:messages",
ForceRefresh = true
});
```

### Handling refresh failures

When a refresh token is present but the exchange fails, the `OnAccessTokenRefreshFailed` event fires and `GetAccessTokenAsync` returns `null`. The supplied `AccessTokenRefreshFailedContext` carries the failure details so you can distinguish a **terminal** failure (such as an `invalid_grant` for a revoked or expired refresh token, which warrants a re-login) from a **transient** one (such as a timeout or rate-limit, which may be retried).

All refresh failures — token-endpoint rejections, malformed responses, and transport/misconfiguration errors — flow through this single event. For HTTP rejections, `StatusCode`, `Error`, and `ErrorDescription` are populated; for transport failures, `Exception` is populated instead.

```csharp
services
.AddAuth0WebAppAuthentication(options =>
{
options.Domain = Configuration["Auth0:Domain"];
options.ClientId = Configuration["Auth0:ClientId"];
options.ClientSecret = Configuration["Auth0:ClientSecret"];
})
.WithAccessToken(options =>
{
options.Audience = Configuration["Auth0:Audience"];
options.UseRefreshTokens = true;
options.Events = new Auth0WebAppWithAccessTokenEvents
{
OnAccessTokenRefreshFailed = async (context) =>
{
// A revoked or expired refresh token is terminal — force a re-login.
if (context.Error == "invalid_grant")
{
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var authenticationProperties = new LogoutAuthenticationPropertiesBuilder().WithRedirectUri("/").Build();
await context.HttpContext.ChallengeAsync(Auth0Constants.AuthenticationScheme, authenticationProperties);
}
// Otherwise (e.g. a timeout surfaced via context.Exception, or a 429), you may
// choose to log and let the caller retry.
}
};
});
```

> :warning: `AccessTokenRefreshFailedContext.Exception` may contain transport/diagnostic detail — log it server-side only, do not surface it to end users.

## 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.
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ services.AddAuth0WebAppAuthentication(options =>

For detailed configuration options, caching strategies, security requirements, and more examples, see the [Multiple Custom Domain (MCD) Examples](EXAMPLES.md#multiple-custom-domain-mcd-support).

## Multi-Resource Refresh Tokens (MRRT)

Multi-Resource Refresh Tokens (MRRT) let a single session obtain access tokens for additional audiences and scopes on demand, by exchanging the session's refresh token — without forcing the user through another interactive login.

```csharp
var accessToken = await HttpContext.GetAccessTokenAsync(new AccessTokenRequest
{
Audience = "https://messages.example.com",
Scope = "read:messages"
});
```

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).

## API reference
Explore public API's available in auth0-aspnetcore-authentication.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Http;
using System;

namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Provides the details of a failed access-token refresh to subscribers of
/// <see cref="Auth0WebAppWithAccessTokenEvents.OnAccessTokenRefreshFailed"/>, so they can
/// distinguish a terminal failure (such as an <c>invalid_grant</c> for a revoked or expired
/// refresh token, which warrants a re-login) from a transient one (such as a timeout or a
/// rate-limit, which may be retried).
/// </summary>
public class AccessTokenRefreshFailedContext
{
private AccessTokenRefreshFailedContext(HttpContext httpContext, string? audience, string? scope, int? statusCode, string? error, string? errorDescription, Exception? exception)
{
HttpContext = httpContext;
Audience = audience;
Scope = scope;
StatusCode = statusCode;
Error = error;
ErrorDescription = errorDescription;
Exception = exception;
}

/// <summary>
/// Creates a context for a failure where the token endpoint returned a non-success
/// HTTP response. <see cref="Exception"/> is left <c>null</c>.
/// </summary>
internal static AccessTokenRefreshFailedContext FromHttpRejection(HttpContext httpContext, string? audience, string? scope, int? statusCode, string? error, string? errorDescription) =>
new AccessTokenRefreshFailedContext(httpContext, audience, scope, statusCode, error, errorDescription, exception: null);

/// <summary>
/// Creates a context for a failure where the refresh threw before producing an HTTP
/// response (for example a transport failure, timeout, or misconfiguration).
/// <see cref="StatusCode"/>, <see cref="Error"/>, and <see cref="ErrorDescription"/> are left <c>null</c>.
/// </summary>
internal static AccessTokenRefreshFailedContext FromException(HttpContext httpContext, string? audience, string? scope, Exception exception) =>
new AccessTokenRefreshFailedContext(httpContext, audience, scope, statusCode: null, error: null, errorDescription: null, exception: exception);

/// <summary>
/// The current <see cref="HttpContext"/>, allowing you to sign the user out, challenge
/// for re-login, or resolve services to react to the failure.
/// </summary>
public HttpContext HttpContext { get; }

/// <summary>
/// The audience the token was being requested for, when one was resolved.
/// </summary>
public string? Audience { get; }

/// <summary>
/// The scope the token was being requested for, when one was resolved.
/// </summary>
public string? Scope { get; }

/// <summary>
/// The HTTP status code returned by the token endpoint, when the failure was an HTTP
/// rejection. <c>null</c> when the request never produced a response (for example a
/// transport failure — see <see cref="Exception"/>).
/// </summary>
public int? StatusCode { get; }

/// <summary>
/// The <c>error</c> code from the token endpoint's error response (for example
/// <c>invalid_grant</c>), when present.
/// </summary>
public string? Error { get; }

/// <summary>
/// The <c>error_description</c> from the token endpoint's error response, when present.
/// </summary>
public string? ErrorDescription { get; }

/// <summary>
/// The exception that caused the failure, when the refresh threw rather than returning
/// an HTTP error (for example a transport failure, timeout, or misconfiguration).
/// <c>null</c> when the failure was an HTTP rejection — see <see cref="StatusCode"/>,
/// <see cref="Error"/>, and <see cref="ErrorDescription"/>.
/// May contain transport/diagnostic detail; log server-side only, do not surface to end users.
/// </summary>
public Exception? Exception { get; }
}
}
28 changes: 28 additions & 0 deletions src/Auth0.AspNetCore.Authentication/AccessTokenRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Describes a request for an access token, optionally targeting a specific
/// audience and/or scope.
/// </summary>
public class AccessTokenRequest
{
/// <summary>
/// The audience to request an access token for. When omitted, the audience
/// configured via <see cref="Auth0WebAppWithAccessTokenOptions.Audience"/> is used.
/// </summary>
public string? Audience { get; set; }

/// <summary>
/// The scopes to request. Merged (order-preserving union) with the configured
/// default scopes for the resolved audience.
/// </summary>
public string? Scope { get; set; }

/// <summary>
/// When <c>true</c>, always exchanges the refresh token for a new access token,
/// ignoring any cached token. The freshly retrieved token still replaces the
/// cached entry. Defaults to <c>false</c>.
/// </summary>
public bool ForceRefresh { get; set; }
}
}
6 changes: 6 additions & 0 deletions src/Auth0.AspNetCore.Authentication/AccessTokenResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,11 @@ internal class AccessTokenResponse
/// </summary>
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = null!;

/// <summary>
/// Space-separated scopes granted for the access token.
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; set; }
}
}
48 changes: 48 additions & 0 deletions src/Auth0.AspNetCore.Authentication/AccessTokenSet.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Text.Json.Serialization;

namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Represents an additional access token retrieved for a specific audience/scope
/// combination, stored alongside the primary token in the session.
/// </summary>
internal class AccessTokenSet
{
/// <summary>
/// The access token.
/// </summary>
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = null!;

/// <summary>
/// Expiration time as a Unix timestamp (seconds).
/// </summary>
[JsonPropertyName("expires_at")]
public long ExpiresAt { get; set; }

/// <summary>
/// The audience the token was requested for.
/// </summary>
[JsonPropertyName("audience")]
public string Audience { get; set; } = null!;

/// <summary>
/// The scopes actually granted by the authorization server.
/// </summary>
[JsonPropertyName("scope")]
public string? Scope { get; set; }

/// <summary>
/// The scopes originally requested. Tracked separately from <see cref="Scope"/>
/// so that future requests for a subset/superset resolve to the same entry.
/// </summary>
[JsonPropertyName("requested_scope")]
public string? RequestedScope { get; set; }

/// <summary>
/// The token type (e.g. "Bearer"). Optional.
/// </summary>
[JsonPropertyName("token_type")]
public string? TokenType { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,11 @@ private void EnableWithAccessToken(Action<Auth0WebAppWithAccessTokenOptions> con

ValidateOptions(_options);

// GetAccessTokenAsync (MRRT) resolves an IHttpClientFactory to exchange the refresh
// token when no Backchannel is configured. Register it here so the factory is always
// available.
_services.AddHttpClient();

_services.Configure(_authenticationScheme, configureOptions);
_services.AddOptions<OpenIdConnectOptions>(_authenticationScheme)
.Configure(options =>
Expand Down
Loading
Loading