Hi everyone!
I will be optimizing the solution provided for future reference on this topic in case anybody else runs into the same issue.
I can see that you are having issues in implementingthe On-Behalf-Of (OBO) flow for a machine-to-machine application using Java and the okta-spring-boot-starter library.
[ROOT CAUSE]
The okta-spring-boot-starter library simplifies incoming token validation (resource server configuration) but does not provide built-in support for outgoing token exchange. This is because:
- Token Exchange is not automatic — You must explicitly call Auth0’s token endpoint
- Custom subject token type required — Auth0 needs to know what type of token you’re exchanging (access token, ID token, etc.)
- Action configuration needed — You must create an Auth0 Action to define how the subject token should be processed
SOLUTION
Step 1: Enable Token Exchange in Auth0
Navigate to Auth0 Dashboard → Applications → Your Machine-to-Machine App → Advanced Settings → Grant Types.
Verify that Token Exchange is enabled. If not, enable it.
Step 2: Create a custom subject token type in Auth0 (via Action)
Auth0 requires you to define a custom subject token type so it knows how to process your incoming token.
Create an Auth0 Action:
- Go to Dashboard → Actions → Library → Create Action
- Name it something like
handle-token-exchange
- Add this code:
exports.onExecuteTokenExchange = async (event, api) => {
// Define your custom subject token type
if (event.request.body.subject_token_type === 'urn:custom:subject-token-type') {
// Validate and process the incoming token
api.access.addClaimToToken('custom_claim', 'value');
}
};
- Deploy the Action
- Link it to your API in Dashboard → APIs → Your API → Actions → Token Exchange
Step 3: Implement Token Exchange in Java (Spring Boot)
Create a service that extracts the current JWT and exchanges it for a downstream API token:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;
@Service
public class TokenExchangeService {
@Value("${auth0.domain}")
private String auth0Domain;
@Value("${auth0.client-id}")
private String clientId;
@Value("${auth0.client-secret}")
private String clientSecret;
/**
* Exchange the current user's JWT for a token scoped to a downstream API
*/
public String getOnBehalfOfToken(String downstreamApiAudience) {
// Step 1: Extract the current user's JWT from the security context
JwtAuthenticationToken authentication =
(JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getToken() == null) {
throw new IllegalStateException("No JWT found in security context");
}
String subjectToken = authentication.getToken().getTokenValue();
// Step 2: Exchange the subject token for a downstream API token
WebClient webClient = WebClient.builder()
.baseUrl("https://" + auth0Domain)
.build();
TokenExchangeResponse response = webClient.post()
.uri("/oauth/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
.with("subject_token", subjectToken)
.with("subject_token_type", "urn:custom:subject-token-type") // Custom type defined in Action
.with("client_id", clientId)
.with("client_secret", clientSecret)
.with("audience", downstreamApiAudience)
.with("scope", "read:data write:data")) // Scopes for downstream API
.retrieve()
.bodyToMono(TokenExchangeResponse.class)
.block();
if (response == null || response.getAccessToken() == null) {
throw new RuntimeException("Failed to exchange token");
}
return response.getAccessToken();
}
}
Create a response DTO:
import com.fasterxml.jackson.annotation.JsonProperty;
public class TokenExchangeResponse {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("expires_in")
private Long expiresIn;
// Getters and setters
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public Long getExpiresIn() {
return expiresIn;
}
public void setExpiresIn(Long expiresIn) {
this.expiresIn = expiresIn;
}
}
Step 4: Use the exchanged token to call the downstream API
@Service
public class DownstreamApiClient {
private final TokenExchangeService tokenExchangeService;
private final WebClient webClient;
public DownstreamApiClient(TokenExchangeService tokenExchangeService, WebClient.Builder webClientBuilder) {
this.tokenExchangeService = tokenExchangeService;
this.webClient = webClientBuilder.build();
}
public String callDownstreamApi(String downstreamApiUrl, String downstreamApiAudience) {
// Step 1: Get the OBO token
String oboToken = tokenExchangeService.getOnBehalfOfToken(downstreamApiAudience);
// Step 2: Call the downstream API with the OBO token
return webClient.get()
.uri(downstreamApiUrl)
.header("Authorization", "Bearer " + oboToken)
.retrieve()
.bodyToMono(String.class)
.block();
}
}
Step 5: Configure application properties
Add to application.yml or application.properties:
auth0:
domain: your-auth0-domain.auth0.com
client-id: your-machine-to-machine-client-id
client-secret: your-machine-to-machine-client-secret
okta:
oauth2:
issuer: https://your-auth0-domain.auth0.com/
client-id: your-resource-server-client-id
Kind Regards,
Nik