diff --git a/auth0-api-java/src/main/java/com/auth0/models/AuthenticationContext.java b/auth0-api-java/src/main/java/com/auth0/models/AuthenticationContext.java index a3daf6c..118ad28 100644 --- a/auth0-api-java/src/main/java/com/auth0/models/AuthenticationContext.java +++ b/auth0-api-java/src/main/java/com/auth0/models/AuthenticationContext.java @@ -1,5 +1,8 @@ package com.auth0.models; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; public class AuthenticationContext { @@ -12,4 +15,62 @@ public AuthenticationContext(Map claims) { public Map getClaims() { return claims; } + + /** + * Returns the current actor from the RFC 8693 {@code act} claim (the top-level {@code act.sub}). + * + *

For a token issued via On-Behalf-Of token exchange, this identifies the party that + * performed the exchange. Per RFC 8693 §4.1, this is the only actor that should be used for + * access control decisions. + * + * @return the current actor identifier, or {@code null} if the token has no {@code act} claim + */ + public String getActor() { + Object act = claims.get("act"); + if (act instanceof Map) { + Object sub = ((Map) act).get("sub"); + if (sub instanceof String) { + return (String) sub; + } + } + return null; + } + + /** + * Returns the prior actors in the RFC 8693 delegation chain, ordered from the most recent + * (nearest to the current actor) to the original. + * + *

These are the actors nested inside the {@code act} claim. Per RFC 8693 §4.1 they are + * informational only and MUST NOT be used for access control decisions; use them for audit + * logging only. + * + * @return an unmodifiable list of prior actor identifiers, or an empty list if there are none + */ + public List getPriorActors() { + List priorActors = new ArrayList<>(); + Object node = claims.get("act"); + while (node instanceof Map) { + node = ((Map) node).get("act"); + if (node instanceof Map) { + Object sub = ((Map) node).get("sub"); + if (sub instanceof String) { + priorActors.add((String) sub); + } + } + } + return Collections.unmodifiableList(priorActors); + } + + /** + * Returns the organization identifier from the {@code org_id} claim, if present. + * + *

Organization membership and RBAC policies are enforced by Auth0 when the token is issued; + * this accessor simply exposes the preserved organization context for the caller to read. + * + * @return the {@code org_id} claim value, or {@code null} if the token is not organization-bound + */ + public String getOrganizationId() { + Object orgId = claims.get("org_id"); + return (orgId instanceof String) ? (String) orgId : null; + } } diff --git a/auth0-api-java/src/test/java/com/auth0/models/AuthenticationContextTest.java b/auth0-api-java/src/test/java/com/auth0/models/AuthenticationContextTest.java new file mode 100644 index 0000000..f7fa7eb --- /dev/null +++ b/auth0-api-java/src/test/java/com/auth0/models/AuthenticationContextTest.java @@ -0,0 +1,115 @@ +package com.auth0.models; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +public class AuthenticationContextTest { + + @Test + public void testGetActorReturnsNullWhenNoActClaim() { + AuthenticationContext context = new AuthenticationContext(new HashMap<>()); + + assertNull(context.getActor()); + assertTrue(context.getPriorActors().isEmpty()); + } + + @Test + public void testGetActorReturnsTopLevelSubForSingleExchange() { + Map act = new LinkedHashMap<>(); + act.put("sub", "mcp_server_client_id"); + Map claims = new HashMap<>(); + claims.put("act", act); + + AuthenticationContext context = new AuthenticationContext(claims); + + assertEquals("mcp_server_client_id", context.getActor()); + assertTrue(context.getPriorActors().isEmpty()); + } + + @Test + public void testGetPriorActorsReturnsNestedActorsForChainedExchange() { + Map spa = new LinkedHashMap<>(); + spa.put("sub", "spa_client_id"); + Map mcp1 = new LinkedHashMap<>(); + mcp1.put("sub", "mcp_server_1_client_id"); + mcp1.put("act", spa); + Map mcp2 = new LinkedHashMap<>(); + mcp2.put("sub", "mcp_server_2_client_id"); + mcp2.put("act", mcp1); + Map claims = new HashMap<>(); + claims.put("act", mcp2); + + AuthenticationContext context = new AuthenticationContext(claims); + + assertEquals("mcp_server_2_client_id", context.getActor()); + assertEquals( + Arrays.asList("mcp_server_1_client_id", "spa_client_id"), + context.getPriorActors()); + } + + @Test + public void testGetActorReturnsNullWhenActIsNotAMap() { + Map claims = new HashMap<>(); + claims.put("act", "not-an-object"); + + AuthenticationContext context = new AuthenticationContext(claims); + + assertNull(context.getActor()); + assertTrue(context.getPriorActors().isEmpty()); + } + + @Test + public void testGetActorReturnsNullWhenActMissingSub() { + Map spa = new LinkedHashMap<>(); + spa.put("sub", "spa_client_id"); + Map act = new LinkedHashMap<>(); + act.put("act", spa); + Map claims = new HashMap<>(); + claims.put("act", act); + + AuthenticationContext context = new AuthenticationContext(claims); + + assertNull(context.getActor()); + assertEquals(Arrays.asList("spa_client_id"), context.getPriorActors()); + } + + @Test(expected = UnsupportedOperationException.class) + public void testGetPriorActorsReturnsUnmodifiableList() { + Map nested = new LinkedHashMap<>(); + nested.put("sub", "b"); + Map act = new LinkedHashMap<>(); + act.put("sub", "a"); + act.put("act", nested); + Map claims = new HashMap<>(); + claims.put("act", act); + + AuthenticationContext context = new AuthenticationContext(claims); + List priors = context.getPriorActors(); + + priors.add("mutate"); + } + + @Test + public void testGetOrganizationIdReturnsOrgIdWhenPresent() { + Map claims = new HashMap<>(); + claims.put("org_id", "org_123"); + + AuthenticationContext context = new AuthenticationContext(claims); + + assertEquals("org_123", context.getOrganizationId()); + } + + @Test + public void testGetOrganizationIdReturnsNullWhenAbsent() { + AuthenticationContext context = new AuthenticationContext(new HashMap<>()); + + assertNull(context.getOrganizationId()); + } +} diff --git a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java index 294a3a5..57ef9f6 100644 --- a/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java +++ b/auth0-springboot-api-playground/src/main/java/com/auth0/playground/ProfileController.java @@ -56,4 +56,29 @@ public ResponseEntity> mcdProtectedEndpoint(Authentication a return ResponseEntity.ok(response); } + + /** + * On-Behalf-Of (RFC 8693) endpoint — inspects the actor claim of a token issued + * via token exchange. + *

+ * {@code getActor()} returns the current actor ({@code act.sub}), the only actor to use + * for access control per RFC 8693 §4.1. {@code getPriorActors()} returns the delegation + * chain for audit logging only. {@code getOrganizationId()} exposes the preserved + * {@code org_id} for organization-bound tokens. + *

+ */ + @GetMapping("/on-behalf-of") + public ResponseEntity> onBehalfOfEndpoint(Authentication authentication) { + Map response = new LinkedHashMap<>(); + response.put("user", authentication.getName()); + + if (authentication instanceof Auth0AuthenticationToken) { + Auth0AuthenticationToken auth0Token = (Auth0AuthenticationToken) authentication; + response.put("currentActor", auth0Token.getActor()); + response.put("priorActors", auth0Token.getPriorActors()); + response.put("organizationId", auth0Token.getOrganizationId()); + } + + return ResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/auth0-springboot-api/EXAMPLES.md b/auth0-springboot-api/EXAMPLES.md index fb08388..318876d 100644 --- a/auth0-springboot-api/EXAMPLES.md +++ b/auth0-springboot-api/EXAMPLES.md @@ -280,6 +280,68 @@ public class AdminController { } ``` +## On-Behalf-Of Token Exchange (RFC 8693) + +When a token is issued via [On-Behalf-Of token exchange](https://datatracker.ietf.org/doc/html/rfc8693), it carries an `act` (actor) claim identifying the client that performed the exchange, and — for chained exchanges — the prior actors in the delegation chain. As a resource server, this SDK does not perform the exchange; it exposes helpers on `Auth0AuthenticationToken` so you can inspect the actor claim on a validated token: + +- `getActor()` — the **current actor** (`act.sub`), the client that performed the exchange. Per [RFC 8693 §4.1](https://datatracker.ietf.org/doc/html/rfc8693#section-4.1), this is the **only** actor you should use for access control decisions. Returns `null` for a direct (non-exchanged) token. +- `getPriorActors()` — the **prior actors** in the delegation chain, ordered from most recent to original. These are **informational only** (audit/logging) and MUST NOT be used for access control per RFC 8693 §4.1. Returns an empty list when there are none. + +### Inspecting the Actor Claim + +```java +@RestController +@RequestMapping("/api") +public class OnBehalfOfController { + + private static final Set AUTHORIZED_ACTORS = Set.of("mcp_server_client_id"); + + @GetMapping("/on-behalf-of") + public ResponseEntity> onBehalfOf(Authentication authentication) { + if (authentication instanceof Auth0AuthenticationToken auth0Token) { + String currentActor = auth0Token.getActor(); // "act.sub", or null for a direct token + + // Use ONLY the current actor for authorization (RFC 8693 §4.1) + if (currentActor != null && !AUTHORIZED_ACTORS.contains(currentActor)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("error", "unauthorized_actor")); + } + + return ResponseEntity.ok(Map.of( + // The user the request is being made on behalf of + "user", String.valueOf(auth0Token.getPrincipal()), // "sub" claim + // The current actor — safe for access control decisions + "currentActor", String.valueOf(currentActor), + // Prior actors — audit/logging only, never for access control + "priorActors", auth0Token.getPriorActors() + )); + } + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } +} +``` + +For a chained exchange (`user → SPA → MCP Server 1 → MCP Server 2 → downstream API`), `getActor()` returns `mcp_server_2_client_id` and `getPriorActors()` returns `["mcp_server_1_client_id", "spa_client_id"]`. + +### Organizations + +When the subject token is organization-bound, On-Behalf-Of exchange preserves the organization context (`org_id`) on the issued token. Membership and RBAC are enforced by Auth0 at issuance; the resource server simply reads the claim via `getOrganizationId()`: + +```java +@GetMapping("/org-context") +public ResponseEntity> orgContext(Authentication authentication) { + if (authentication instanceof Auth0AuthenticationToken auth0Token) { + return ResponseEntity.ok(Map.of( + "user", String.valueOf(auth0Token.getPrincipal()), + "organizationId", String.valueOf(auth0Token.getOrganizationId()) // "org_id", or null + )); + } + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); +} +``` + ## Multiple Custom Domains (MCD) Multiple Custom Domains (MCD) support enables a single API application to accept access tokens issued by multiple domains associated with the same Auth0 tenant, including the canonical domain and its custom domains. diff --git a/auth0-springboot-api/README.md b/auth0-springboot-api/README.md index 84320fa..609cddd 100644 --- a/auth0-springboot-api/README.md +++ b/auth0-springboot-api/README.md @@ -240,6 +240,16 @@ auth0: audience: "https://your-api-identifier" ``` +## On-Behalf-Of Token Exchange (RFC 8693) + +For tokens issued via [On-Behalf-Of token exchange](https://datatracker.ietf.org/doc/html/rfc8693), `Auth0AuthenticationToken` exposes helpers to inspect the `act` (actor) claim: + +- `getActor()` — the current actor (`act.sub`), the client that performed the exchange. Per [RFC 8693 §4.1](https://datatracker.ietf.org/doc/html/rfc8693#section-4.1), this is the only actor to use for access control decisions. +- `getPriorActors()` — the delegation chain, for audit/logging only (never for access control). +- `getOrganizationId()` — the preserved `org_id` for organization-bound tokens. + +See [EXAMPLES.md](./EXAMPLES.md#on-behalf-of-token-exchange-rfc-8693) for full examples. + ## Extensibility ### Custom Cache Implementation diff --git a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationToken.java b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationToken.java index 8e62a8b..c1ee73c 100644 --- a/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationToken.java +++ b/auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationToken.java @@ -114,4 +114,39 @@ public Set getScopes() { public Object getClaim(String claimName) { return authenticationContext.getClaims().get(claimName); } + + /** + * Returns the current actor from the RFC 8693 {@code act} claim (the top-level {@code act.sub}). + * + *

For a token issued via On-Behalf-Of token exchange, this identifies the party that performed + * the exchange. Per RFC 8693 §4.1, this is the only actor that should be used for access control + * decisions. + * + * @return the current actor identifier, or {@code null} if the token has no {@code act} claim + */ + public String getActor() { + return authenticationContext.getActor(); + } + + /** + * Returns the prior actors in the RFC 8693 delegation chain, ordered from the most recent to the + * original. + * + *

These are informational only and MUST NOT be used for access control decisions per RFC 8693 + * §4.1; use them for audit logging only. + * + * @return an unmodifiable list of prior actor identifiers, or an empty list if there are none + */ + public List getPriorActors() { + return authenticationContext.getPriorActors(); + } + + /** + * Returns the organization identifier from the {@code org_id} claim, if present. + * + * @return the {@code org_id} claim value, or {@code null} if the token is not organization-bound + */ + public String getOrganizationId() { + return authenticationContext.getOrganizationId(); + } } diff --git a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationTokenTest.java b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationTokenTest.java index eec1072..8d93482 100644 --- a/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationTokenTest.java +++ b/auth0-springboot-api/src/test/java/com/auth0/spring/boot/Auth0AuthenticationTokenTest.java @@ -195,4 +195,46 @@ void createAuthorities_shouldHandleWhitespace_aroundScopes() { assertTrue(authorities.contains(new SimpleGrantedAuthority("SCOPE_read:users"))); assertTrue(authorities.contains(new SimpleGrantedAuthority("SCOPE_write:users"))); } + + @Test + @DisplayName("Should delegate getActor to the authentication context") + void getActor_shouldDelegateToContext() { + AuthenticationContext context = mock(AuthenticationContext.class); + Map claims = new HashMap<>(); + claims.put("sub", "auth0|123456789"); + when(context.getClaims()).thenReturn(claims); + when(context.getActor()).thenReturn("mcp_server_client_id"); + + Auth0AuthenticationToken token = new Auth0AuthenticationToken(context); + + assertEquals("mcp_server_client_id", token.getActor()); + } + + @Test + @DisplayName("Should delegate getPriorActors to the authentication context") + void getPriorActors_shouldDelegateToContext() { + AuthenticationContext context = mock(AuthenticationContext.class); + Map claims = new HashMap<>(); + claims.put("sub", "auth0|123456789"); + when(context.getClaims()).thenReturn(claims); + when(context.getPriorActors()).thenReturn(List.of("mcp_server_1_client_id", "spa_client_id")); + + Auth0AuthenticationToken token = new Auth0AuthenticationToken(context); + + assertEquals(List.of("mcp_server_1_client_id", "spa_client_id"), token.getPriorActors()); + } + + @Test + @DisplayName("Should delegate getOrganizationId to the authentication context") + void getOrganizationId_shouldDelegateToContext() { + AuthenticationContext context = mock(AuthenticationContext.class); + Map claims = new HashMap<>(); + claims.put("sub", "auth0|123456789"); + when(context.getClaims()).thenReturn(claims); + when(context.getOrganizationId()).thenReturn("org_123"); + + Auth0AuthenticationToken token = new Auth0AuthenticationToken(context); + + assertEquals("org_123", token.getOrganizationId()); + } }