-
Notifications
You must be signed in to change notification settings - Fork 40
feat: Adds Custom Token Exchange support #258
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } | ||
| } |
44 changes: 44 additions & 0 deletions
44
src/Auth0.AspNetCore.Authentication/CustomTokenExchangeException.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}"; | ||
| } | ||
| } | ||
| } |
43 changes: 43 additions & 0 deletions
43
src/Auth0.AspNetCore.Authentication/CustomTokenExchangeRequest.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; } | ||
| } | ||
| } |
34 changes: 34 additions & 0 deletions
34
src/Auth0.AspNetCore.Authentication/CustomTokenExchangeRequestValidator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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."); | ||
| } | ||
| } | ||
| } | ||
| } |
50 changes: 50 additions & 0 deletions
50
src/Auth0.AspNetCore.Authentication/CustomTokenExchangeResult.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.