From 7050876596a17c4c473d2a7fc44c795ed4b74a52 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Fri, 3 Jul 2026 16:12:03 +0530 Subject: [PATCH 1/4] feat: Add Custom Token Exhange Support --- build.gradle | 2 +- .../com/auth0/AuthenticationController.java | 82 ++++++++++ .../auth0/CustomTokenExchangeException.java | 37 +++++ src/main/java/com/auth0/RequestProcessor.java | 90 ++++++++++ .../java/com/auth0/TokenExchangeRequest.java | 132 +++++++++++++++ .../auth0/AuthenticationControllerTest.java | 90 ++++++++++ .../java/com/auth0/RequestProcessorTest.java | 45 +++++ .../com/auth0/TokenExchangeRequestTest.java | 154 ++++++++++++++++++ 8 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/auth0/CustomTokenExchangeException.java create mode 100644 src/main/java/com/auth0/TokenExchangeRequest.java create mode 100644 src/test/java/com/auth0/TokenExchangeRequestTest.java diff --git a/build.gradle b/build.gradle index 11648f1..a5be0b0 100644 --- a/build.gradle +++ b/build.gradle @@ -61,7 +61,7 @@ dependencies { implementation 'com.google.guava:guava:32.0.1-jre' implementation 'commons-codec:commons-codec:1.22.0' - api 'com.auth0:auth0:3.5.1' + api 'com.auth0:auth0:3.10.0' api 'com.auth0:java-jwt:4.5.0' api 'com.auth0:jwks-rsa:0.24.1' diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index e6419f5..f2cb877 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -445,4 +445,86 @@ public RenewAuthRequest renewAuth(String refreshToken, HttpServletRequest reques return requestProcessor.buildRenewAuthRequest(refreshToken, request); } + /** + * Builds a request to exchange an external {@code subject_token} for a new set of + * {@link Tokens} via Custom + * Token Exchange, without login semantics. The returned tokens are not verified beyond the + * exchange itself, making this suitable for obtaining tokens for a downstream API. + * + *

The application supplies the {@code domain} to target. This is required because a token + * exchange can occur outside of an HTTP request, where the domain cannot otherwise be resolved. + * For applications configured with a fixed domain, the + * {@link AuthenticationController#customTokenExchange(String, String)} overload may be used + * instead.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @param domain the Auth0 domain to target. + * @return a {@link TokenExchangeRequest} to configure and execute. + */ + public TokenExchangeRequest customTokenExchange(String subjectToken, String subjectTokenType, String domain) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, domain, false); + } + + /** + * Builds a Custom Token Exchange request using the statically configured domain. See + * {@link AuthenticationController#customTokenExchange(String, String, String)} for details. + * + *

This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @return a {@link TokenExchangeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public TokenExchangeRequest customTokenExchange(String subjectToken, String subjectTokenType) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, false); + } + + /** + * Builds a request to exchange an external {@code subject_token} for a login-ready set of + * {@link Tokens} via Custom + * Token Exchange. Unlike {@link #customTokenExchange(String, String, String)}, the returned + * ID token is verified (including {@code org_id}/{@code org_name} claims when an organization is + * configured), yielding tokens suitable for establishing an application session. + * + *

The application supplies the {@code domain} to target; see + * {@link #customTokenExchange(String, String, String)} for why.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @param domain the Auth0 domain to target. + * @return a {@link TokenExchangeRequest} to configure and execute. + */ + public TokenExchangeRequest loginWithCustomTokenExchange(String subjectToken, String subjectTokenType, String domain) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, domain, true); + } + + /** + * Builds a login-shaped Custom Token Exchange request using the statically configured domain. + * See {@link #loginWithCustomTokenExchange(String, String, String)} for details. + * + *

This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.

+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @return a {@link TokenExchangeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public TokenExchangeRequest loginWithCustomTokenExchange(String subjectToken, String subjectTokenType) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, true); + } + } diff --git a/src/main/java/com/auth0/CustomTokenExchangeException.java b/src/main/java/com/auth0/CustomTokenExchangeException.java new file mode 100644 index 0000000..9996d10 --- /dev/null +++ b/src/main/java/com/auth0/CustomTokenExchangeException.java @@ -0,0 +1,37 @@ +package com.auth0; + +/** + * Represents a client-side validation error raised before performing a Custom Token Exchange + * request against the Auth0 Authentication API. These are thrown for malformed inputs (an empty or + * {@code "Bearer "}-prefixed {@code subject_token}, or a {@code subject_token_type} that is not a + * valid URI) so the caller fails fast without a network round-trip. + * + * @see AuthenticationController#customTokenExchange(String, String) + * @see AuthenticationController#loginWithCustomTokenExchange(String, String) + */ +@SuppressWarnings("WeakerAccess") +public class CustomTokenExchangeException extends IdentityVerificationException { + + static final String INVALID_TOKEN_FORMAT = "a0.cte_invalid_token_format"; + static final String INVALID_TOKEN_TYPE_URI = "a0.cte_invalid_token_type_uri"; + + CustomTokenExchangeException(String code, String message) { + super(code, message, null); + } + + /** + * @return true if the error is due to an empty, whitespace-only, or {@code "Bearer "}-prefixed + * token value. + */ + public boolean isInvalidTokenFormat() { + return INVALID_TOKEN_FORMAT.equals(getCode()); + } + + /** + * @return true if the error is due to a {@code subject_token_type} value that is not a valid + * URI. + */ + public boolean isInvalidTokenTypeUri() { + return INVALID_TOKEN_TYPE_URI.equals(getCode()); + } +} diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index dc10e66..7baa05d 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -7,6 +7,7 @@ import com.auth0.exception.PublicKeyProviderException; import com.auth0.jwt.JWT; import com.auth0.json.auth.TokenHolder; +import com.auth0.net.TokenRequest; import com.auth0.jwk.Jwk; import com.auth0.jwk.JwkException; import com.auth0.jwk.JwkProvider; @@ -213,6 +214,91 @@ RenewAuthRequest buildRenewAuthRequest(String refreshToken, HttpServletRequest r return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(request)); } + /** + * Builds a {@link TokenExchangeRequest} to exchange an external {@code subject_token} for Auth0 + * tokens against the given domain. The domain is supplied explicitly because a token exchange + * can occur outside of an HTTP request, where the {@link DomainProvider} cannot resolve it. + * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the (customer-defined) URI describing the subject token. + * @param domain the Auth0 domain to target. + * @param loginSemantics whether to verify the returned ID token (incl. organization claims). + * @return a {@link TokenExchangeRequest} ready to configure and execute. + */ + TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, String domain, boolean loginSemantics) { + String issuer = constructIssuer(domain); + return new TokenExchangeRequest(this, subjectToken, subjectTokenType, domain, issuer, loginSemantics, this.organization); + } + + /** + * Builds a {@link TokenExchangeRequest} using the statically configured domain. Only valid when + * the controller was configured with a fixed domain; when a {@link DomainResolver} is in use the + * domain must be supplied explicitly. + * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the (customer-defined) URI describing the subject token. + * @param loginSemantics whether to verify the returned ID token (incl. organization claims). + * @return a {@link TokenExchangeRequest} ready to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, boolean loginSemantics) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call the customTokenExchange overload that accepts a domain."); + } + return buildTokenExchangeRequest(subjectToken, subjectTokenType, domainProvider.getDomain(null), loginSemantics); + } + + /** + * Performs the Custom Token Exchange grant against Auth0 and returns the resulting tokens. + *

+ * The request is built via {@link AuthAPI#exchangeToken(String, String)} (RFC 8693 token-exchange + * grant), which also applies client authentication. When {@code loginSemantics} is true the + * returned ID token is verified (reusing the code-flow verification path, including + * organization-claim validation). + * + * @throws IdentityVerificationException if the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + Tokens executeCustomTokenExchange(String subjectToken, String subjectTokenType, String audience, + String scope, String organization, String domain, String issuer, + boolean loginSemantics) throws IdentityVerificationException, Auth0Exception { + TokenRequest request = createClientForDomain(domain).exchangeToken(subjectToken, subjectTokenType); + + if (audience != null) { + request.setAudience(audience); + } + if (scope != null) { + request.setScope(scope); + } + if (organization != null) { + request.addParameter("organization", organization); + } + + TokenHolder holder = request.execute().getBody(); + + // The login path establishes a user session, so an ID token is required. Fail clearly if the + // exchange returned none (typically because the 'openid' scope was not requested), mirroring + // the code flow's MISSING_ID_TOKEN behavior instead of returning an unverified success. + if (loginSemantics && holder.getIdToken() == null) { + throw new InvalidRequestException(MISSING_ID_TOKEN, + "ID Token is missing from the token exchange response. Request the 'openid' scope when using loginWithCustomTokenExchange."); + } + + // Verify the returned ID token on the login path; also verify (for org_id/org_name claim + // validation) on the utility path whenever an organization is in play. + boolean shouldVerify = loginSemantics || organization != null; + if (shouldVerify && holder.getIdToken() != null) { + try { + verifyIdToken(holder.getIdToken(), issuer, domain, null, organization); + } catch (IdTokenValidationException e) { + throw new IdentityVerificationException(JWT_VERIFICATION_ERROR, "An error occurred while trying to verify the ID Token.", e); + } + } + + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), + holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer); + } + private Auth0HttpClient getHttpClient() { if (this.httpClient == null) { DefaultHttpClient.Builder httpBuilder = DefaultHttpClient.newBuilder() @@ -354,6 +440,10 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse * - HS256: uses client secret */ private void verifyIdToken(String idToken, String issuer, String domain, String nonce) throws IdTokenValidationException { + verifyIdToken(idToken, issuer, domain, nonce, organization); + } + + private void verifyIdToken(String idToken, String issuer, String domain, String nonce, String organization) throws IdTokenValidationException { SignatureVerifier sigVerifier = buildSignatureVerifier(idToken, domain); IdTokenVerifier.Builder verifierBuilder = IdTokenVerifier.init(issuer, clientId, sigVerifier); diff --git a/src/main/java/com/auth0/TokenExchangeRequest.java b/src/main/java/com/auth0/TokenExchangeRequest.java new file mode 100644 index 0000000..e06d813 --- /dev/null +++ b/src/main/java/com/auth0/TokenExchangeRequest.java @@ -0,0 +1,132 @@ +package com.auth0; + +import com.auth0.exception.Auth0Exception; + +import java.net.URI; +import java.net.URISyntaxException; + +/** + * Class to perform a Custom + * Token Exchange: exchanging an external {@code subject_token} for Auth0 {@link Tokens} via the + * RFC 8693 grant {@code urn:ietf:params:oauth:grant-type:token-exchange}, optionally targeting an + * {@code audience}, {@code scope}, and/or {@code organization}. + *

+ * The library remains stateless: the application owns storage of the returned tokens. + *

+ * Obtain an instance via {@link AuthenticationController#customTokenExchange(String, String)} / + * {@link AuthenticationController#loginWithCustomTokenExchange(String, String)} (and their + * domain-qualified overloads). The {@code loginWith*} variants additionally verify the returned ID + * token (including {@code org_id}/{@code org_name} claims when an organization is configured); + * the utility variant returns the raw exchanged tokens. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class TokenExchangeRequest { + + private final RequestProcessor processor; + private final String subjectToken; + private final String subjectTokenType; + private final String domain; + private final String issuer; + private final boolean loginSemantics; + + private String audience; + private String scope; + private String organization; + + TokenExchangeRequest(RequestProcessor processor, String subjectToken, String subjectTokenType, + String domain, String issuer, boolean loginSemantics, String organization) { + this.processor = processor; + this.subjectToken = subjectToken; + this.subjectTokenType = subjectTokenType; + this.domain = domain; + this.issuer = issuer; + this.loginSemantics = loginSemantics; + this.organization = organization; + } + + /** + * Sets the audience (API identifier) to request an access token for. When not set, Auth0 uses + * the default audience configured for the application. + * + * @param audience the audience to request a token for. + * @return this request instance for fluent chaining. + */ + public TokenExchangeRequest withAudience(String audience) { + this.audience = audience; + return this; + } + + /** + * Sets the scope to request for the access token. + * + * @param scope the requested scope. + * @return this request instance for fluent chaining. + */ + public TokenExchangeRequest withScope(String scope) { + this.scope = scope; + return this; + } + + /** + * Sets the organization to associate with the exchange, overriding any client-level default + * configured on the {@link AuthenticationController}. When set, the returned ID token's + * {@code org_id}/{@code org_name} claim is validated against this value on the + * {@code loginWith*} path. + * + * @param organization the organization ID or name. + * @return this request instance for fluent chaining. + */ + public TokenExchangeRequest withOrganization(String organization) { + this.organization = organization; + return this; + } + + /** + * Executes the token exchange against Auth0 and returns the resulting tokens. + * + * @return the {@link Tokens} obtained from the exchange, including the granted scope. + * @throws CustomTokenExchangeException if the request fails client-side validation. + * @throws IdentityVerificationException if the exchange fails or, on the {@code loginWith*} + * path, the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public Tokens execute() throws IdentityVerificationException, Auth0Exception { + validate(); + return processor.executeCustomTokenExchange( + subjectToken, subjectTokenType, audience, scope, organization, + domain, issuer, loginSemantics); + } + + private void validate() throws CustomTokenExchangeException { + assertValidTokenFormat(subjectToken); + assertValidTokenTypeUri(subjectTokenType); + } + + private void assertValidTokenFormat(String token) throws CustomTokenExchangeException { + if (token == null || token.trim().isEmpty()) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, + "The subject token must not be empty."); + } + if (token.regionMatches(true, 0, "Bearer ", 0, 7)) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, + "The subject token must not carry a \"Bearer \" prefix."); + } + } + + private void assertValidTokenTypeUri(String tokenType) throws CustomTokenExchangeException { + if (tokenType == null || tokenType.trim().isEmpty()) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI, + "The subject token type must be a valid URI."); + } + try { + URI uri = new URI(tokenType); + if (!uri.isAbsolute()) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI, + "The subject token type must be an absolute URI."); + } + } catch (URISyntaxException e) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI, + "The subject token type must be a valid URI."); + } + } +} diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java index b123824..16a700d 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -338,6 +338,96 @@ public void shouldThrowExceptionWhenRenewAuthRequestIsNull() { assertThat(exception.getMessage(), is("request must not be null")); } + // --- customTokenExchange Tests --- + + @Test + public void shouldCustomTokenExchangeWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.customTokenExchange("subjectToken", "custom:token", DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false); + } + + @Test + public void shouldCustomTokenExchangeWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", false)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.customTokenExchange("subjectToken", "custom:token"); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", false); + } + + @Test + public void shouldLoginWithCustomTokenExchangeWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.loginWithCustomTokenExchange("subjectToken", "custom:token", DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true); + } + + @Test + public void shouldLoginWithCustomTokenExchangeWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class); + when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", true)).thenReturn(mockRequest); + + TokenExchangeRequest result = controller.loginWithCustomTokenExchange("subjectToken", "custom:token"); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", true); + } + + @Test + public void shouldThrowExceptionWhenCustomTokenExchangeSubjectTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.customTokenExchange(null, "custom:token", DOMAIN)); + assertThat(exception.getMessage(), is("subjectToken must not be null")); + } + + @Test + public void shouldThrowExceptionWhenCustomTokenExchangeSubjectTokenTypeIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.customTokenExchange("subjectToken", null, DOMAIN)); + assertThat(exception.getMessage(), is("subjectTokenType must not be null")); + } + + @Test + public void shouldThrowExceptionWhenCustomTokenExchangeDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.customTokenExchange("subjectToken", "custom:token", null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenLoginWithCustomTokenExchangeSubjectTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.loginWithCustomTokenExchange(null, "custom:token")); + assertThat(exception.getMessage(), is("subjectToken must not be null")); + } + // --- Logging and Telemetry Tests --- @Test diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index a414307..a952294 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -205,6 +205,51 @@ public void shouldBuildRenewAuthRequestResolvingDomainFromRequest() { verify(spy).createClientForDomain(resolvedDomain); } + // --- Custom Token Exchange Tests --- + + @Test + public void shouldBuildTokenExchangeRequestForExplicitDomain() { + RequestProcessor processor = createDefaultRequestProcessor(); + + TokenExchangeRequest result = processor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false); + + assertThat(result, is(notNullValue())); + } + + @Test + public void shouldBuildLoginTokenExchangeRequestForExplicitDomain() { + RequestProcessor processor = createDefaultRequestProcessor(); + + TokenExchangeRequest result = processor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true); + + assertThat(result, is(notNullValue())); + } + + @Test + public void shouldBuildTokenExchangeRequestFromStaticDomain() { + RequestProcessor processor = new RequestProcessor.Builder( + new StaticDomainProvider(DOMAIN), + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) + .build(); + + TokenExchangeRequest result = processor.buildTokenExchangeRequest("subjectToken", "custom:token", false); + + assertThat(result, is(notNullValue())); + } + + @Test + public void shouldThrowOnNoDomainTokenExchangeWhenUsingResolver() { + RequestProcessor processor = createDefaultRequestProcessor(); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> processor.buildTokenExchangeRequest("subjectToken", "custom:token", false)); + assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); + } + // --- Logging and Telemetry Tests --- @Test diff --git a/src/test/java/com/auth0/TokenExchangeRequestTest.java b/src/test/java/com/auth0/TokenExchangeRequestTest.java new file mode 100644 index 0000000..ba463c7 --- /dev/null +++ b/src/test/java/com/auth0/TokenExchangeRequestTest.java @@ -0,0 +1,154 @@ +package com.auth0; + +import com.auth0.exception.Auth0Exception; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +public class TokenExchangeRequestTest { + + private static final String DOMAIN = "test-domain.auth0.com"; + private static final String ISSUER = "https://test-domain.auth0.com/"; + private static final String SUBJECT_TOKEN = "ext-token"; + private static final String SUBJECT_TOKEN_TYPE = "custom:legacy-token"; + + @Mock + private RequestProcessor mockProcessor; + @Mock + private Tokens mockTokens; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + private TokenExchangeRequest newRequest(String subjectToken, String subjectTokenType, boolean loginSemantics) { + return new TokenExchangeRequest(mockProcessor, subjectToken, subjectTokenType, DOMAIN, ISSUER, loginSemantics, null); + } + + @Test + public void shouldDelegateToProcessorWithConfiguredParameters() throws Exception { + when(mockProcessor.executeCustomTokenExchange( + eq(SUBJECT_TOKEN), eq(SUBJECT_TOKEN_TYPE), eq("api"), eq("read:foo"), eq("org_1"), + eq(DOMAIN), eq(ISSUER), eq(false))).thenReturn(mockTokens); + + Tokens result = newRequest(SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, false) + .withAudience("api") + .withScope("read:foo") + .withOrganization("org_1") + .execute(); + + assertSame(mockTokens, result); + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, "api", "read:foo", "org_1", DOMAIN, ISSUER, false); + } + + @Test + public void shouldPassNullOptionalParametersWhenNotSet() throws Exception { + when(mockProcessor.executeCustomTokenExchange( + any(), any(), isNull(), isNull(), isNull(), any(), any(), eq(true))).thenReturn(mockTokens); + + newRequest(SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, true).execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, null, null, null, DOMAIN, ISSUER, true); + } + + @Test + public void shouldUseClientLevelOrganizationDefault() throws Exception { + TokenExchangeRequest request = new TokenExchangeRequest( + mockProcessor, SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, DOMAIN, ISSUER, true, "org_default"); + when(mockProcessor.executeCustomTokenExchange( + any(), any(), any(), any(), eq("org_default"), any(), any(), anyBoolean())).thenReturn(mockTokens); + + request.execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, null, null, "org_default", DOMAIN, ISSUER, true); + } + + @Test + public void shouldOverrideClientLevelOrganization() throws Exception { + TokenExchangeRequest request = new TokenExchangeRequest( + mockProcessor, SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, DOMAIN, ISSUER, true, "org_default"); + when(mockProcessor.executeCustomTokenExchange( + any(), any(), any(), any(), eq("org_override"), any(), any(), anyBoolean())).thenReturn(mockTokens); + + request.withOrganization("org_override").execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, null, null, "org_override", DOMAIN, ISSUER, true); + } + + @Test + public void shouldThrowOnEmptySubjectToken() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(" ", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + + @Test + public void shouldThrowOnBearerPrefixedSubjectToken() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest("Bearer ext-token", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + + @Test + public void shouldThrowOnNonUriSubjectTokenType() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(SUBJECT_TOKEN, "not a uri", false).execute()); + assertThat(exception.isInvalidTokenTypeUri(), is(true)); + } + + @Test + public void shouldThrowOnRelativeUriSubjectTokenType() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(SUBJECT_TOKEN, "legacy-token", false).execute()); + assertThat(exception.isInvalidTokenTypeUri(), is(true)); + } + + @Test + public void shouldAcceptCustomSchemeSubjectTokenType() throws Exception { + when(mockProcessor.executeCustomTokenExchange(any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + .thenReturn(mockTokens); + + newRequest(SUBJECT_TOKEN, "urn:partner:session", false).execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, "urn:partner:session", null, null, null, DOMAIN, ISSUER, false); + } + + @Test + public void shouldNotValidateBeforeReservedNamespaceSubjectTokenType() throws Exception { + // Reserved urn:ietf:* namespaces are accepted client-side; the server enforces rejection. + when(mockProcessor.executeCustomTokenExchange(any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + .thenReturn(mockTokens); + + newRequest(SUBJECT_TOKEN, "urn:ietf:params:oauth:token-type:id_token", false).execute(); + + verify(mockProcessor).executeCustomTokenExchange( + SUBJECT_TOKEN, "urn:ietf:params:oauth:token-type:id_token", null, null, null, DOMAIN, ISSUER, false); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockProcessor.executeCustomTokenExchange(any(), any(), any(), any(), any(), any(), any(), anyBoolean())) + .thenThrow(new Auth0Exception("boom")); + + assertThrows(Auth0Exception.class, + () -> newRequest(SUBJECT_TOKEN, SUBJECT_TOKEN_TYPE, false).execute()); + } +} From f8352e3bda5c18a9e8d7537a72a61437bf6c2e49 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Mon, 6 Jul 2026 11:29:38 +0530 Subject: [PATCH 2/4] Add Tests --- .../java/com/auth0/RequestProcessorTest.java | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index a952294..fa0a7fa 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -34,6 +34,7 @@ public class RequestProcessorTest { private static final String DOMAIN = "test-domain.auth0.com"; + private static final String ISSUER = "https://test-domain.auth0.com/"; private static final String CLIENT_ID = "testClientId"; private static final String CLIENT_SECRET = "testClientSecret"; private static final String RESPONSE_TYPE_CODE = "code"; @@ -250,6 +251,108 @@ public void shouldThrowOnNoDomainTokenExchangeWhenUsingResolver() { assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); } + @Test + public void shouldExecuteCustomTokenExchangeViaAuthApiAndReturnTokens() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getAccessToken()).thenReturn("cteAccessToken"); + when(mockTokenHolder.getRefreshToken()).thenReturn("cteRefreshToken"); + when(mockTokenHolder.getTokenType()).thenReturn("Bearer"); + when(mockTokenHolder.getExpiresIn()).thenReturn(3600L); + when(mockTokenHolder.getScope()).thenReturn("openid profile"); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + Tokens tokens = spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, null, null, DOMAIN, ISSUER, false); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getAccessToken(), is("cteAccessToken")); + assertThat(tokens.getRefreshToken(), is("cteRefreshToken")); + assertThat(tokens.getType(), is("Bearer")); + assertThat(tokens.getExpiresIn(), is(3600L)); + verify(mockAuthAPI).exchangeToken("subjectToken", "custom:token"); + } + + @Test + public void shouldForwardAudienceScopeAndOrganizationOnCustomTokenExchange() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(null); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + spy.executeCustomTokenExchange( + "subjectToken", "custom:token", "https://api/", "openid profile", null, + DOMAIN, ISSUER, false); + + verify(mockTokenRequest).setAudience("https://api/"); + verify(mockTokenRequest).setScope("openid profile"); + verify(mockTokenRequest, never()).addParameter(eq("organization"), any()); + } + + @Test + public void shouldPassOrganizationOnCustomTokenExchangeWhenProvided() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + // Utility path with an organization set still returns tokens; ID token verification is + // skipped here because the holder has no ID token. + when(mockTokenHolder.getIdToken()).thenReturn(null); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, null, "org_123", + DOMAIN, ISSUER, false); + + verify(mockTokenRequest).addParameter("organization", "org_123"); + } + + @Test + public void shouldThrowMissingIdTokenOnLoginCustomTokenExchangeWhenNoIdTokenReturned() throws Exception { + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(null); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, null, null, + DOMAIN, ISSUER, true)); + assertThat(exception.getCode(), is("a0.missing_id_token")); + } + + @Test + public void shouldThrowOnLoginCustomTokenExchangeWhenIdTokenVerificationFails() throws Exception { + // Structurally valid JWT with an invalid signature so RS256 verification fails. + String fakeJwt = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dyb25nLyIsInN1YiI6InVzZXIxMjMiLCJhdWQiOiJ0ZXN0Q2xpZW50SWQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTYwMDAwMDAwMH0.signature"; + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(fakeJwt); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + IdentityVerificationException exception = assertThrows( + IdentityVerificationException.class, + () -> spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", null, + DOMAIN, ISSUER, true)); + assertThat(exception.getCode(), is("a0.invalid_jwt_error")); + } + // --- Logging and Telemetry Tests --- @Test From 7156fe1385c4bfc300cf7d28e97399537deeebe8 Mon Sep 17 00:00:00 2001 From: tanya732 Date: Tue, 7 Jul 2026 13:04:32 +0530 Subject: [PATCH 3/4] Added tests --- .../java/com/auth0/TokenExchangeRequest.java | 24 ++++-- .../java/com/auth0/RequestProcessorTest.java | 77 +++++++++++++++++++ .../com/auth0/TokenExchangeRequestTest.java | 16 ++++ 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/auth0/TokenExchangeRequest.java b/src/main/java/com/auth0/TokenExchangeRequest.java index e06d813..9424ef9 100644 --- a/src/main/java/com/auth0/TokenExchangeRequest.java +++ b/src/main/java/com/auth0/TokenExchangeRequest.java @@ -15,9 +15,11 @@ *

* Obtain an instance via {@link AuthenticationController#customTokenExchange(String, String)} / * {@link AuthenticationController#loginWithCustomTokenExchange(String, String)} (and their - * domain-qualified overloads). The {@code loginWith*} variants additionally verify the returned ID - * token (including {@code org_id}/{@code org_name} claims when an organization is configured); - * the utility variant returns the raw exchanged tokens. + * domain-qualified overloads). The {@code loginWith*} variants always verify the returned ID token. + * The utility variant returns the raw exchanged tokens without verification, except when an + * organization is configured: to validate the {@code org_id}/{@code org_name} claim it must verify + * the ID token that carries it, so a returned ID token is fully verified on either path whenever an + * organization is in play. See {@link #withOrganization(String)}. */ @SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) public class TokenExchangeRequest { @@ -69,9 +71,13 @@ public TokenExchangeRequest withScope(String scope) { /** * Sets the organization to associate with the exchange, overriding any client-level default - * configured on the {@link AuthenticationController}. When set, the returned ID token's - * {@code org_id}/{@code org_name} claim is validated against this value on the - * {@code loginWith*} path. + * configured on the {@link AuthenticationController}. When an organization is set, the returned + * ID token's {@code org_id}/{@code org_name} claim is validated against this value on both the + * {@code loginWith*} and the utility path (whenever the exchange returns an ID token). Because + * an organization claim cannot be trusted without verifying the token that carries it, the + * utility path performs full ID-token verification (signature, issuer, expiry) in this case, so + * an {@link IdentityVerificationException} may be thrown even when only an access token was + * wanted. * * @param organization the organization ID or name. * @return this request instance for fluent chaining. @@ -107,6 +113,12 @@ private void assertValidTokenFormat(String token) throws CustomTokenExchangeExce throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, "The subject token must not be empty."); } + // Reject surrounding whitespace so a stray space/newline can't slip a malformed token + // through (and so a leading space doesn't hide a "Bearer " prefix from the check below). + if (!token.equals(token.trim())) { + throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, + "The subject token must not contain leading or trailing whitespace."); + } if (token.regionMatches(true, 0, "Bearer ", 0, 7)) { throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT, "The subject token must not carry a \"Bearer \" prefix."); diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index fa0a7fa..7587f2b 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -4,6 +4,9 @@ import com.auth0.exception.Auth0Exception; import com.auth0.json.auth.TokenHolder; import com.auth0.jwk.JwkProvider; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; import com.auth0.net.Response; import com.auth0.net.TokenRequest; import com.auth0.net.client.Auth0HttpClient; @@ -18,6 +21,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -353,6 +357,64 @@ public void shouldThrowOnLoginCustomTokenExchangeWhenIdTokenVerificationFails() assertThat(exception.getCode(), is("a0.invalid_jwt_error")); } + @Test + public void shouldReturnVerifiedTokensOnLoginCustomTokenExchangeWhenIdTokenValid() throws Exception { + String idToken = signedIdToken(null); + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenHolder.getAccessToken()).thenReturn("cteAccessToken"); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + Tokens tokens = spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", null, DOMAIN, ISSUER, true); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getIdToken(), is(idToken)); + assertThat(tokens.getAccessToken(), is("cteAccessToken")); + } + + @Test + public void shouldReturnTokensOnUtilityCustomTokenExchangeWhenOrgClaimMatches() throws Exception { + String idToken = signedIdToken("org_123"); + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + when(mockTokenHolder.getAccessToken()).thenReturn("cteAccessToken"); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + Tokens tokens = spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", "org_123", DOMAIN, ISSUER, false); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getAccessToken(), is("cteAccessToken")); + verify(mockTokenRequest).addParameter("organization", "org_123"); + } + + @Test + public void shouldThrowOnUtilityCustomTokenExchangeWhenOrgClaimMismatches() throws Exception { + String idToken = signedIdToken("org_other"); + when(mockAuthAPI.exchangeToken("subjectToken", "custom:token")).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + when(mockTokenHolder.getIdToken()).thenReturn(idToken); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + + IdentityVerificationException exception = assertThrows( + IdentityVerificationException.class, + () -> spy.executeCustomTokenExchange( + "subjectToken", "custom:token", null, "openid", "org_123", DOMAIN, ISSUER, false)); + assertThat(exception.getCode(), is("a0.invalid_jwt_error")); + } + // --- Logging and Telemetry Tests --- @Test @@ -1115,6 +1177,21 @@ public void shouldFallbackToDomainProviderWhenSignedCookieTampered() throws Exce // --- Helper Methods --- + // Builds an HS256 ID token signed with CLIENT_SECRET so the RS256/HS256-aware verifier accepts + // it (HS256 path uses the client secret). Pass a non-null orgId to include an org_id claim. + private String signedIdToken(String orgId) { + JWTCreator.Builder builder = JWT.create() + .withIssuer(ISSUER) + .withSubject("user123") + .withAudience(CLIENT_ID) + .withIssuedAt(new Date(System.currentTimeMillis() - 1000)) + .withExpiresAt(new Date(System.currentTimeMillis() + 3600_000)); + if (orgId != null) { + builder.withClaim("org_id", orgId); + } + return builder.sign(Algorithm.HMAC256(CLIENT_SECRET)); + } + private RequestProcessor createDefaultRequestProcessor() { return new RequestProcessor.Builder( mockDomainProvider, diff --git a/src/test/java/com/auth0/TokenExchangeRequestTest.java b/src/test/java/com/auth0/TokenExchangeRequestTest.java index ba463c7..0408c8b 100644 --- a/src/test/java/com/auth0/TokenExchangeRequestTest.java +++ b/src/test/java/com/auth0/TokenExchangeRequestTest.java @@ -104,6 +104,22 @@ public void shouldThrowOnBearerPrefixedSubjectToken() { assertThat(exception.isInvalidTokenFormat(), is(true)); } + @Test + public void shouldThrowOnSubjectTokenWithSurroundingWhitespace() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(" ext-token\n", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + + @Test + public void shouldThrowOnSubjectTokenWithLeadingSpaceBeforeBearerPrefix() { + CustomTokenExchangeException exception = assertThrows( + CustomTokenExchangeException.class, + () -> newRequest(" Bearer ext-token", SUBJECT_TOKEN_TYPE, false).execute()); + assertThat(exception.isInvalidTokenFormat(), is(true)); + } + @Test public void shouldThrowOnNonUriSubjectTokenType() { CustomTokenExchangeException exception = assertThrows( From a28b0ecc7c1078470b2edfc1013b0fc04f772dcb Mon Sep 17 00:00:00 2001 From: tanya732 Date: Tue, 7 Jul 2026 14:25:57 +0530 Subject: [PATCH 4/4] Minor refactoring --- src/main/java/com/auth0/RequestProcessor.java | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index 7baa05d..519cd7c 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -276,18 +276,14 @@ Tokens executeCustomTokenExchange(String subjectToken, String subjectTokenType, TokenHolder holder = request.execute().getBody(); - // The login path establishes a user session, so an ID token is required. Fail clearly if the - // exchange returned none (typically because the 'openid' scope was not requested), mirroring - // the code flow's MISSING_ID_TOKEN behavior instead of returning an unverified success. - if (loginSemantics && holder.getIdToken() == null) { - throw new InvalidRequestException(MISSING_ID_TOKEN, - "ID Token is missing from the token exchange response. Request the 'openid' scope when using loginWithCustomTokenExchange."); - } - - // Verify the returned ID token on the login path; also verify (for org_id/org_name claim - // validation) on the utility path whenever an organization is in play. - boolean shouldVerify = loginSemantics || organization != null; - if (shouldVerify && holder.getIdToken() != null) { + if (holder.getIdToken() == null) { + // The login path establishes a user session, so an ID token is required. + if (loginSemantics) { + throw new InvalidRequestException(MISSING_ID_TOKEN, "ID Token is missing from the token exchange response."); + } + } else if (loginSemantics || organization != null) { + // Verify the returned ID token on the login path; also verify (for org_id/org_name claim + // validation) on the utility path whenever an organization is in play. try { verifyIdToken(holder.getIdToken(), issuer, domain, null, organization); } catch (IdTokenValidationException e) {