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
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -12,4 +15,62 @@ public AuthenticationContext(Map<String, Object> claims) {
public Map<String, Object> getClaims() {
return claims;
}

/**
* Returns the current actor from the RFC 8693 {@code act} claim (the top-level {@code act.sub}).
*
* <p>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.
*
* <p>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<String> getPriorActors() {
List<String> 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.
*
* <p>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;
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> act = new LinkedHashMap<>();
act.put("sub", "mcp_server_client_id");
Map<String, Object> 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<String, Object> spa = new LinkedHashMap<>();
spa.put("sub", "spa_client_id");
Map<String, Object> mcp1 = new LinkedHashMap<>();
mcp1.put("sub", "mcp_server_1_client_id");
mcp1.put("act", spa);
Map<String, Object> mcp2 = new LinkedHashMap<>();
mcp2.put("sub", "mcp_server_2_client_id");
mcp2.put("act", mcp1);
Map<String, Object> 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<String, Object> 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<String, Object> spa = new LinkedHashMap<>();
spa.put("sub", "spa_client_id");
Map<String, Object> act = new LinkedHashMap<>();
act.put("act", spa);
Map<String, Object> 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<String, Object> nested = new LinkedHashMap<>();
nested.put("sub", "b");
Map<String, Object> act = new LinkedHashMap<>();
act.put("sub", "a");
act.put("act", nested);
Map<String, Object> claims = new HashMap<>();
claims.put("act", act);

AuthenticationContext context = new AuthenticationContext(claims);
List<String> priors = context.getPriorActors();

priors.add("mutate");
}

@Test
public void testGetOrganizationIdReturnsOrgIdWhenPresent() {
Map<String, Object> 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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,29 @@ public ResponseEntity<Map<String, Object>> mcdProtectedEndpoint(Authentication a

return ResponseEntity.ok(response);
}

/**
* On-Behalf-Of (RFC 8693) endpoint — inspects the actor claim of a token issued
* via token exchange.
* <p>
* {@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.
* </p>
*/
@GetMapping("/on-behalf-of")
public ResponseEntity<Map<String, Object>> onBehalfOfEndpoint(Authentication authentication) {
Map<String, Object> 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);
}
}
62 changes: 62 additions & 0 deletions auth0-springboot-api/EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> AUTHORIZED_ACTORS = Set.of("mcp_server_client_id");

@GetMapping("/on-behalf-of")
public ResponseEntity<Map<String, Object>> 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<Map<String, Object>> 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.
Expand Down
10 changes: 10 additions & 0 deletions auth0-springboot-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
* {@code ROLE_USER} authority is assigned.
*/
public class Auth0AuthenticationToken extends AbstractAuthenticationToken {
private final AuthenticationContext authenticationContext;

Check warning on line 22 in auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationToken.java

View workflow job for this annotation

GitHub Actions / gradle

no comment
private final String principal;

Check warning on line 23 in auth0-springboot-api/src/main/java/com/auth0/spring/boot/Auth0AuthenticationToken.java

View workflow job for this annotation

GitHub Actions / gradle

no comment

/**
* Constructs a new {@code Auth0AuthenticationToken} from the given {@link AuthenticationContext}.
Expand Down Expand Up @@ -114,4 +114,39 @@
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}).
*
* <p>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.
*
* <p>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<String> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> 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<String, Object> 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<String, Object> 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());
}
}
Loading