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
54 changes: 54 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
- [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)
- [Custom Token Exchange](#custom-token-exchange)
- [Delegation / impersonation](#delegation--impersonation)
- [Organizations](#organizations)
- [Extra parameters](#extra-parameters)
- [Roles](#roles)
Expand Down Expand Up @@ -680,6 +682,58 @@ var googleToken = await HttpContext.GetAccessTokenForConnectionAsync(new AccessT

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.

## Custom Token Exchange

[Custom Token Exchange](https://auth0.com/docs/authenticate/custom-token-exchange) (RFC 8693) exchanges an
existing external/custom security token for Auth0 tokens, without a browser redirect. Use it for
delegation/impersonation and agent-identity scenarios. It requires a configured Custom Token Exchange Profile
and a validation Action in your Auth0 tenant.

`CustomTokenExchangeAsync` is **stateless**: it performs the exchange and returns the tokens, but does **not**
sign the user in or write any cookie. The caller decides what to persist.

```csharp
try
{
var result = await HttpContext.CustomTokenExchangeAsync(new CustomTokenExchangeRequest
{
SubjectToken = externalToken,
SubjectTokenType = "urn:acme:legacy-token", // a custom URI matching your CTE Profile
Audience = "https://api.example.com", // optional
Scope = "read:data" // optional
});

// result.AccessToken, result.IdToken, result.RefreshToken, result.ExpiresIn, result.Scope
}
catch (CustomTokenExchangeException ex)
{
// ex.StatusCode, ex.Error, ex.ErrorDescription describe a token-endpoint rejection;
// a validation failure carries a descriptive message instead.
}
```

### Delegation / impersonation

Pass an actor token pair to act on behalf of another party (RFC 8693 delegation). When delegation is in play,
the `act` claim from the returned ID token is decoded and exposed on `result.Act` (the outermost `Sub` is the
current actor; nested `Act` values are prior actors, informational only). Auth0 suppresses the refresh token in
delegation flows.

```csharp
var result = await HttpContext.CustomTokenExchangeAsync(new CustomTokenExchangeRequest
{
SubjectToken = externalToken,
SubjectTokenType = "urn:acme:legacy-token",
ActorToken = actorToken,
ActorTokenType = "urn:acme:actor-token"
});

var currentActor = result.Act?.Sub;
```

> **Note:** `subject_token_type` (and `actor_token_type`) must be custom URIs. The reserved `urn:ietf:` and
> `urn:auth0:` namespaces are rejected client-side.

## 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
77 changes: 77 additions & 0 deletions src/Auth0.AspNetCore.Authentication/ActClaimReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;
using System.Text;
using System.Text.Json;

namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Best-effort decoder for the RFC 8693 <c>act</c> (actor) claim from an ID token's JWT payload.
/// Performs no signature verification — the token comes directly from the Auth0 token endpoint
/// over the backchannel TLS connection. Any malformed input yields <c>null</c>.
/// </summary>
internal static class ActClaimReader
{
public static ActClaim? TryRead(string? idToken)
{
if (string.IsNullOrEmpty(idToken))
{
return null;
}

try
{
var parts = idToken.Split('.');
if (parts.Length != 3)
{
return null;
}

var payloadJson = Encoding.UTF8.GetString(Base64UrlDecode(parts[1]));
using var document = JsonDocument.Parse(payloadJson);

if (!document.RootElement.TryGetProperty("act", out var actElement) ||
actElement.ValueKind != JsonValueKind.Object)
{
return null;
}

return ReadActElement(actElement);
}
catch (Exception)
{
// Best-effort: a decode/parse hiccup must not fail an exchange the endpoint accepted.
return null;
}
}

private static ActClaim ReadActElement(JsonElement element)
{
var claim = new ActClaim();

if (element.TryGetProperty("sub", out var subElement) &&
subElement.ValueKind == JsonValueKind.String)
{
claim.Sub = subElement.GetString();
}

if (element.TryGetProperty("act", out var nestedElement) &&
nestedElement.ValueKind == JsonValueKind.Object)
{
claim.Act = ReadActElement(nestedElement);
}

return claim;
}

private static byte[] Base64UrlDecode(string input)
{
var output = input.Replace('-', '+').Replace('_', '/');
switch (output.Length % 4)
{
case 2: output += "=="; break;
case 3: output += "="; break;
}
return Convert.FromBase64String(output);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;

namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Thrown when a Custom Token Exchange fails — either client-side validation of the
/// request, or rejection by the Auth0 token endpoint. Carries the token-endpoint status code and
/// error details when the failure came from the network; never carries token-bearing bytes.
/// </summary>
public class CustomTokenExchangeException : Exception
{
/// <summary>The HTTP status code returned by the token endpoint, when the failure was a rejection.</summary>
public int? StatusCode { get; }

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

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

/// <summary>Creates an exception for a client-side validation failure.</summary>
public CustomTokenExchangeException(string message) : base(message)
{
}

/// <summary>Creates an exception for a token-endpoint rejection.</summary>
public CustomTokenExchangeException(int? statusCode, string? error, string? errorDescription)
: base(BuildMessage(statusCode, error, errorDescription))
{
StatusCode = statusCode;
Error = error;
ErrorDescription = errorDescription;
}

private static string BuildMessage(int? statusCode, string? error, string? errorDescription)
{
var code = error ?? "token_exchange_failed";
var description = errorDescription ?? "The custom token exchange was rejected by the token endpoint.";
return statusCode.HasValue
? $"Custom token exchange failed ({statusCode}): {code} - {description}"
: $"Custom token exchange failed: {code} - {description}";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Describes a Custom Token Exchange request: exchanging an external/custom
/// security token for Auth0 tokens, without a browser redirect.
/// </summary>
public class CustomTokenExchangeRequest
{
/// <summary>
/// The external token to exchange. Validated by your Auth0 Action with the Custom Token
/// Exchange trigger. Required; must not be empty/whitespace and must not include a
/// <c>"Bearer "</c> prefix.
/// </summary>
public string SubjectToken { get; set; } = null!;

/// <summary>
/// A custom URI identifying the subject token type, used as the routing key to select a
/// Custom Token Exchange Profile. Required. The token endpoint validates the value against
/// your configured profile.
/// </summary>
public string SubjectTokenType { get; set; } = null!;

/// <summary>The unique identifier of the target API. Optional.</summary>
public string? Audience { get; set; }

/// <summary>Space-delimited OAuth 2.0 scopes. Optional.</summary>
public string? Scope { get; set; }

/// <summary>
/// Actor token for delegation/impersonation. If set, <see cref="ActorTokenType"/>
/// is required.
/// </summary>
public string? ActorToken { get; set; }

/// <summary>
/// Actor token type URI. Required when <see cref="ActorToken"/> is set.
/// </summary>
public string? ActorTokenType { get; set; }

/// <summary>Organization ID or name for multi-tenant scenarios. Optional.</summary>
public string? Organization { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System;

namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// Validates a <see cref="CustomTokenExchangeRequest"/> client-side, before any network call.
/// Throws <see cref="CustomTokenExchangeException"/> on the first violation.
/// </summary>
internal static class CustomTokenExchangeRequestValidator
{
public static void Validate(CustomTokenExchangeRequest request)
{
if (string.IsNullOrWhiteSpace(request.SubjectToken))
{
throw new CustomTokenExchangeException("subject_token is required and cannot be empty.");
}

if (request.SubjectToken != request.SubjectToken.Trim())
{
throw new CustomTokenExchangeException("subject_token must not include leading or trailing whitespace.");
}

if (request.SubjectToken.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
throw new CustomTokenExchangeException("subject_token must not include a \"Bearer \" prefix.");
}

if (!string.IsNullOrWhiteSpace(request.ActorToken) && string.IsNullOrWhiteSpace(request.ActorTokenType))
{
throw new CustomTokenExchangeException("actor_token_type is required when actor_token is provided.");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using System.Collections.Generic;

namespace Auth0.AspNetCore.Authentication
{
/// <summary>
/// The result of a Custom Token Exchange. Carries the exchanged tokens. This method
/// has no session side-effects — the caller decides what (if anything) to persist.
/// </summary>
public class CustomTokenExchangeResult
{
/// <summary>The access token issued by Auth0.</summary>
public string AccessToken { get; set; } = null!;

/// <summary>The ID token, when an <c>openid</c> scope was granted.</summary>
public string? IdToken { get; set; }

/// <summary>
/// The refresh token, when <c>offline_access</c> was granted. Auth0 suppresses the refresh
/// token in delegation flows (when <c>actor_token</c> is present), so this is often null then.
/// </summary>
public string? RefreshToken { get; set; }

/// <summary>Token lifetime in seconds.</summary>
public int ExpiresIn { get; set; }

/// <summary>The granted scopes, when returned.</summary>
public string? Scope { get; set; }

/// <summary>
/// The <c>act</c> (actor) claim decoded from the returned ID token, present in
/// delegation/impersonation flows (RFC 8693). Null when there is no ID token, no act
/// claim, or the ID token could not be decoded.
/// </summary>
public ActClaim? Act { get; set; }
}

/// <summary>
/// The <c>act</c> (actor) claim from an ID token issued via RFC 8693 delegation. The outermost
/// <see cref="Sub"/> identifies the current actor; nested <see cref="Act"/> values are prior
/// actors in the delegation chain and are informational only (RFC 8693).
/// </summary>
public class ActClaim
{
/// <summary>The subject identifier of the acting party.</summary>
public string? Sub { get; set; }

/// <summary>Nested actor claim representing a delegation chain.</summary>
public ActClaim? Act { get; set; }
}
}
62 changes: 62 additions & 0 deletions src/Auth0.AspNetCore.Authentication/HttpContextExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,68 @@ await FireRefreshFailed(optionsWithAccessToken,
return response.AccessToken;
}

/// <summary>
/// Performs a Custom Token Exchange : exchanges the external token described by
/// <paramref name="request"/> for Auth0 tokens, without a browser redirect. This is the
/// stateless utility — it has <b>no session side-effects</b> (it does not sign the user in or
/// write any cookie); the caller decides what to persist. Use it for delegation/impersonation
/// and agent-identity scenarios.
/// </summary>
/// <param name="context">The current <see cref="HttpContext"/>.</param>
/// <param name="request">The exchange request (subject token + type, and optional audience,
/// scope, actor token pair, organization).</param>
/// <param name="scheme">The Auth0 authentication scheme. Defaults to <see cref="Auth0Constants.AuthenticationScheme"/>.</param>
/// <returns>The exchanged tokens.</returns>
/// <exception cref="CustomTokenExchangeException">
/// Thrown when the request fails client-side validation, or when the token endpoint rejects
/// the exchange.
/// </exception>
public static async Task<CustomTokenExchangeResult> CustomTokenExchangeAsync(this HttpContext context, CustomTokenExchangeRequest request, string? scheme = null)
{
scheme ??= Auth0Constants.AuthenticationScheme;

CustomTokenExchangeRequestValidator.Validate(request);

var options = context.RequestServices.GetRequiredService<IOptionsSnapshot<Auth0WebAppOptions>>().Get(scheme);

var httpClient = options.Backchannel ?? context.RequestServices.GetRequiredService<IHttpClientFactory>().CreateClient();
Comment thread
kailash-b marked this conversation as resolved.
var tokenClient = new TokenClient(httpClient);
var resolvedDomain = context.GetResolvedDomain();

var result = await tokenClient.ExchangeCustomToken(
options,
request.SubjectToken,
request.SubjectTokenType,
request.Audience,
request.Scope,
request.ActorToken,
request.ActorTokenType,
request.Organization,
Comment thread
kailash-b marked this conversation as resolved.
resolvedDomain).ConfigureAwait(false);

if (!result.IsSuccess)
{
throw new CustomTokenExchangeException(result.StatusCode, result.Error, result.ErrorDescription);
}

var response = result.Response!;

if (!string.IsNullOrWhiteSpace(request.Organization))
{
OrganizationClaimValidator.Validate(response.IdToken, request.Organization!);
}

return new CustomTokenExchangeResult
{
AccessToken = response.AccessToken,
IdToken = response.IdToken,
RefreshToken = response.RefreshToken,
ExpiresIn = response.ExpiresIn,
Scope = response.Scope,
Act = ActClaimReader.TryRead(response.IdToken)
};
}

/// <summary>
/// Retrieves the resolved domain from the <see cref="HttpContext.Items"/> collection.
/// </summary>
Expand Down
Loading
Loading