From d6070584a5f1407c9060c071cca1d2e7ccf51451 Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Tue, 16 Jun 2026 12:59:37 +0530 Subject: [PATCH 1/3] feat: Add refresh token grant support (MRRT) (#242) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .../com/auth0/AuthenticationController.java | 62 +++++++++++ src/main/java/com/auth0/RenewAuthRequest.java | 88 +++++++++++++++ src/main/java/com/auth0/RequestProcessor.java | 50 ++++++++- src/main/java/com/auth0/Tokens.java | 28 +++++ .../auth0/AuthenticationControllerTest.java | 90 ++++++++++++++- .../java/com/auth0/RenewAuthRequestTest.java | 103 ++++++++++++++++++ .../java/com/auth0/RequestProcessorTest.java | 58 ++++++++++ src/test/java/com/auth0/TokensTest.java | 27 +++++ 8 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/auth0/RenewAuthRequest.java create mode 100644 src/test/java/com/auth0/RenewAuthRequestTest.java diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index 62cee5a..e6419f5 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -383,4 +383,66 @@ public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletRes return requestProcessor.buildAuthorizeUrl(request, response, redirectUri, state, nonce); } + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens}, optionally + * targeting a specific audience and/or scope. This exposes Auth0's refresh-token grant, + * enabling Multi-Resource Refresh Token (MRRT) flows where one refresh token can obtain access + * tokens for multiple APIs. + * + *

The application supplies the {@code domain} it stored from {@link Tokens#getDomain()} at + * login. This is required because a refresh can occur outside of an HTTP request, where the + * domain cannot otherwise be resolved. For applications configured with a fixed domain, the + * {@link AuthenticationController#renewAuth(String)} overload may be used instead.

+ * + * @param refreshToken the refresh token to exchange. + * @param domain the Auth0 domain to target. + * @return a {@link RenewAuthRequest} to configure and execute. + */ + public RenewAuthRequest renewAuth(String refreshToken, String domain) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken, domain); + } + + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens} using the + * statically configured domain. See {@link AuthenticationController#renewAuth(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 {@link AuthenticationController#renewAuth(String, String)} + * with the domain instead.

+ * + * @param refreshToken the refresh token to exchange. + * @return a {@link RenewAuthRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public RenewAuthRequest renewAuth(String refreshToken) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken); + } + + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens}, resolving the + * Auth0 domain from the given request via the configured domain or {@code DomainResolver}. + * See {@link AuthenticationController#renewAuth(String, String)} for details. + * + *

This overload works for both a fixed domain and a {@code DomainResolver}, and is convenient + * when refreshing within an active request. Note: a refresh token is bound to + * the domain it was issued for at login; if the resolver resolves the given request to a + * different domain, Auth0 will reject the grant. Use this overload only when the request + * resolves to the same domain as login; otherwise use + * {@link AuthenticationController#renewAuth(String, String)} with the domain stored from + * {@link Tokens#getDomain()} at login.

+ * + * @param refreshToken the refresh token to exchange. + * @param request the current HTTP request, used to resolve the domain. + * @return a {@link RenewAuthRequest} to configure and execute. + */ + public RenewAuthRequest renewAuth(String refreshToken, HttpServletRequest request) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + Validate.notNull(request, "request must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken, request); + } + } diff --git a/src/main/java/com/auth0/RenewAuthRequest.java b/src/main/java/com/auth0/RenewAuthRequest.java new file mode 100644 index 0000000..60895bc --- /dev/null +++ b/src/main/java/com/auth0/RenewAuthRequest.java @@ -0,0 +1,88 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.TokenHolder; +import com.auth0.net.TokenRequest; + +/** + * Class to exchange a refresh token for a new set of {@link Tokens}, optionally targeting a + * specific {@code audience} and/or {@code scope}. This exposes Auth0's refresh-token grant, + * enabling Multi-Resource Refresh Token (MRRT) flows where one refresh token can obtain access + * tokens for multiple APIs. + *

+ * The library remains stateless: the application owns storage of the refresh token, caching of + * the resulting access tokens, and any concurrency control around refresh-token rotation. + *

+ * Obtain an instance via {@link AuthenticationController#renewAuth(String, String)}, + * {@link AuthenticationController#renewAuth(String)}, or + * {@link AuthenticationController#renewAuth(String, jakarta.servlet.http.HttpServletRequest)}. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class RenewAuthRequest { + + private final AuthAPI client; + private final String refreshToken; + private final String domain; + private final String issuer; + private String audience; + private String scope; + + RenewAuthRequest(AuthAPI client, String refreshToken, String domain, String issuer) { + this.client = client; + this.refreshToken = refreshToken; + this.domain = domain; + this.issuer = issuer; + } + + /** + * Sets the audience to request an access token for. When not set, Auth0 uses the default + * audience configured for the application. + *

+ * Note: if the requested audience is not permitted by the application's MRRT policy, Auth0 + * does not error; it returns a token for the default audience instead. Callers must verify + * the {@code aud} claim of the returned access token. + * + * @param audience the audience (API identifier) to request a token for. + * @return this request instance for fluent chaining. + */ + public RenewAuthRequest 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 RenewAuthRequest withScope(String scope) { + this.scope = scope; + return this; + } + + /** + * Executes the refresh-token grant against Auth0 and returns the resulting tokens. + *

+ * The refresh-token grant does not return an ID token, so {@link Tokens#getIdToken()} is + * typically null. When refresh-token rotation is enabled, the returned + * {@link Tokens#getRefreshToken()} is a new refresh token that supersedes the one used here; + * the application is responsible for persisting it. + * + * @return the {@link Tokens} obtained from the grant, including the granted scope. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public Tokens execute() throws Auth0Exception { + TokenRequest request = client.renewAuth(refreshToken); + if (audience != null) { + request.setAudience(audience); + } + if (scope != null) { + request.setScope(scope); + } + TokenHolder holder = request.execute().getBody(); + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), + holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer); + } +} diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index c1e9512..dc10e66 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -170,6 +170,49 @@ AuthAPI createClientForDomain(String domain) { .build(); } + /** + * Builds a {@link RenewAuthRequest} to exchange a refresh token for new tokens against the + * given domain. The domain is supplied explicitly because a refresh can occur outside of an + * HTTP request (e.g. a background refresh), where the {@link DomainProvider} cannot resolve it. + * + * @param refreshToken the refresh token to exchange. + * @param domain the Auth0 domain to target. + * @return a {@link RenewAuthRequest} ready to configure and execute. + */ + RenewAuthRequest buildRenewAuthRequest(String refreshToken, String domain) { + AuthAPI client = createClientForDomain(domain); + String issuer = constructIssuer(domain); + return new RenewAuthRequest(client, refreshToken, domain, issuer); + } + + /** + * Builds a {@link RenewAuthRequest} using the statically configured domain. Only valid when the + * controller was configured with a fixed domain; when a {@link DomainResolver} is in use there + * is no fixed domain to target and the domain must be supplied explicitly. + * + * @param refreshToken the refresh token to exchange. + * @return a {@link RenewAuthRequest} ready to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + RenewAuthRequest buildRenewAuthRequest(String refreshToken) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call renewAuth(refreshToken, domain)."); + } + return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(null)); + } + + /** + * Builds a {@link RenewAuthRequest} resolving the domain from the given request via the + * configured {@link DomainProvider}. Works for both a fixed domain and a {@link DomainResolver}. + * + * @param refreshToken the refresh token to exchange. + * @param request the current HTTP request, used to resolve the domain. + * @return a {@link RenewAuthRequest} ready to configure and execute. + */ + RenewAuthRequest buildRenewAuthRequest(String refreshToken, HttpServletRequest request) { + return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(request)); + } + private Auth0HttpClient getHttpClient() { if (this.httpClient == null) { DefaultHttpClient.Builder httpBuilder = DefaultHttpClient.newBuilder() @@ -450,7 +493,7 @@ private Tokens exchangeCodeForTokens(String authorizationCode, String redirectUr .execute() .getBody(); String originIssuer = constructIssuer(originDomain); - return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn(), originDomain, originIssuer); + return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), originDomain, originIssuer); } /** @@ -470,15 +513,18 @@ private Tokens mergeTokens(Tokens frontChannelTokens, Tokens codeExchangeTokens) String accessToken; String type; Long expiresIn; + String scope; if (codeExchangeTokens.getAccessToken() != null) { accessToken = codeExchangeTokens.getAccessToken(); type = codeExchangeTokens.getType(); expiresIn = codeExchangeTokens.getExpiresIn(); + scope = codeExchangeTokens.getScope(); } else { accessToken = frontChannelTokens.getAccessToken(); type = frontChannelTokens.getType(); expiresIn = frontChannelTokens.getExpiresIn(); + scope = frontChannelTokens.getScope(); } // Prefer ID token from the front-channel @@ -493,7 +539,7 @@ private Tokens mergeTokens(Tokens frontChannelTokens, Tokens codeExchangeTokens) String issuer = frontChannelTokens.getIssuer() != null ? frontChannelTokens.getIssuer() : codeExchangeTokens.getIssuer(); - return new Tokens(accessToken, idToken, refreshToken, type, expiresIn, domain, issuer); + return new Tokens(accessToken, idToken, refreshToken, type, expiresIn, scope, domain, issuer); } private String constructIssuer(String domain) { diff --git a/src/main/java/com/auth0/Tokens.java b/src/main/java/com/auth0/Tokens.java index cd3951d..fa02682 100644 --- a/src/main/java/com/auth0/Tokens.java +++ b/src/main/java/com/auth0/Tokens.java @@ -22,6 +22,7 @@ public class Tokens implements Serializable { private final String refreshToken; private final String type; private final Long expiresIn; + private final String scope; private final String domain; private final String issuer; @@ -49,11 +50,29 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty * @param issuer the issuer URL from the ID token */ public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String domain, String issuer) { + this(accessToken, idToken, refreshToken, type, expiresIn, null, domain, issuer); + } + + /** + * Full constructor including the granted scope. + * + * @param accessToken access token for Auth0 API + * @param idToken identity token with user information + * @param refreshToken refresh token that can be used to request new tokens + * without signing in again + * @param type token type + * @param expiresIn token expiration + * @param scope the scope granted for the access token, or null if not provided + * @param domain the Auth0 domain that issued these tokens + * @param issuer the issuer URL from the ID token + */ + public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String scope, String domain, String issuer) { this.accessToken = accessToken; this.idToken = idToken; this.refreshToken = refreshToken; this.type = type; this.expiresIn = expiresIn; + this.scope = scope; this.domain = domain; this.issuer = issuer; } @@ -103,6 +122,15 @@ public Long getExpiresIn() { return expiresIn; } + /** + * Getter for the scope granted for the Access Token. + * + * @return the granted scope, or null if not provided. + */ + public String getScope() { + return scope; + } + /** * Getter for the Auth0 domain that issued these tokens. diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java index 6af69ac..b123824 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -250,6 +250,94 @@ public void shouldThrowExceptionWhenBuildAuthorizeUrlRedirectUriIsNull() { assertThat(exception.getMessage(), is("redirectUri must not be null")); } + // --- renewAuth Tests --- + + @Test + public void shouldRenewAuthWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class); + when(mockRequestProcessor.buildRenewAuthRequest("refreshToken", DOMAIN)).thenReturn(mockRenewAuthRequest); + + RenewAuthRequest result = controller.renewAuth("refreshToken", DOMAIN); + + assertThat(result, is(mockRenewAuthRequest)); + verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken", DOMAIN); + } + + @Test + public void shouldRenewAuthWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class); + when(mockRequestProcessor.buildRenewAuthRequest("refreshToken")).thenReturn(mockRenewAuthRequest); + + RenewAuthRequest result = controller.renewAuth("refreshToken"); + + assertThat(result, is(mockRenewAuthRequest)); + verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken"); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthRefreshTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth(null, DOMAIN)); + assertThat(exception.getMessage(), is("refreshToken must not be null")); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth("refreshToken", (String) null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenNoArgRenewAuthRefreshTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth(null)); + assertThat(exception.getMessage(), is("refreshToken must not be null")); + } + + @Test + public void shouldRenewAuthWithRequest() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class); + when(mockRequestProcessor.buildRenewAuthRequest("refreshToken", request)).thenReturn(mockRenewAuthRequest); + + RenewAuthRequest result = controller.renewAuth("refreshToken", request); + + assertThat(result, is(mockRenewAuthRequest)); + verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken", request); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthWithRequestRefreshTokenIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth((String) null, request)); + assertThat(exception.getMessage(), is("refreshToken must not be null")); + } + + @Test + public void shouldThrowExceptionWhenRenewAuthRequestIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.renewAuth("refreshToken", (HttpServletRequest) null)); + assertThat(exception.getMessage(), is("request must not be null")); + } + // --- Logging and Telemetry Tests --- @Test @@ -270,8 +358,6 @@ public void shouldDisableTelemetry() { verify(mockRequestProcessor).doNotSendTelemetry(); } - // --- Exception Propagation --- - @Test public void shouldPropagateIdentityVerificationException() throws IdentityVerificationException { AuthenticationController controller = new AuthenticationController(mockRequestProcessor); diff --git a/src/test/java/com/auth0/RenewAuthRequestTest.java b/src/test/java/com/auth0/RenewAuthRequestTest.java new file mode 100644 index 0000000..5f6dbf5 --- /dev/null +++ b/src/test/java/com/auth0/RenewAuthRequestTest.java @@ -0,0 +1,103 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.TokenHolder; +import com.auth0.net.Response; +import com.auth0.net.TokenRequest; +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.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class RenewAuthRequestTest { + + private static final String REFRESH_TOKEN = "refreshToken"; + private static final String DOMAIN = "domain.auth0.com"; + private static final String ISSUER = "https://domain.auth0.com/"; + + @Mock + private AuthAPI mockClient; + @Mock + private TokenRequest mockTokenRequest; + @Mock + private Response mockTokenResponse; + @Mock + private TokenHolder mockTokenHolder; + + @BeforeEach + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + when(mockClient.renewAuth(REFRESH_TOKEN)).thenReturn(mockTokenRequest); + when(mockTokenRequest.execute()).thenReturn(mockTokenResponse); + when(mockTokenResponse.getBody()).thenReturn(mockTokenHolder); + } + + @Test + public void shouldNotSetAudienceOrScopeWhenNotProvided() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.execute(); + + verify(mockTokenRequest, never()).setAudience(org.mockito.ArgumentMatchers.anyString()); + verify(mockTokenRequest, never()).setScope(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void shouldSetAudienceWhenProvided() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.withAudience("https://api.example.com").execute(); + + verify(mockTokenRequest).setAudience("https://api.example.com"); + verify(mockTokenRequest, never()).setScope(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void shouldSetScopeWhenProvided() throws Exception { + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + request.withScope("openid profile").execute(); + + verify(mockTokenRequest).setScope("openid profile"); + verify(mockTokenRequest, never()).setAudience(org.mockito.ArgumentMatchers.anyString()); + } + + @Test + public void shouldMapTokenHolderToTokensIncludingScope() throws Exception { + when(mockTokenHolder.getAccessToken()).thenReturn("newAccessToken"); + when(mockTokenHolder.getIdToken()).thenReturn(null); + when(mockTokenHolder.getRefreshToken()).thenReturn("rotatedRefreshToken"); + when(mockTokenHolder.getTokenType()).thenReturn("Bearer"); + when(mockTokenHolder.getExpiresIn()).thenReturn(86400L); + when(mockTokenHolder.getScope()).thenReturn("openid profile"); + + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + Tokens tokens = request.withAudience("https://api.example.com").withScope("openid profile").execute(); + + assertThat(tokens.getAccessToken(), is("newAccessToken")); + assertThat(tokens.getRefreshToken(), is("rotatedRefreshToken")); + assertThat(tokens.getType(), is("Bearer")); + assertThat(tokens.getExpiresIn(), is(86400L)); + assertThat(tokens.getScope(), is("openid profile")); + assertThat(tokens.getDomain(), is(DOMAIN)); + assertThat(tokens.getIssuer(), is(ISSUER)); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockTokenRequest.execute()).thenThrow(Auth0Exception.class); + + RenewAuthRequest request = new RenewAuthRequest(mockClient, REFRESH_TOKEN, DOMAIN, ISSUER); + + assertThrows(Auth0Exception.class, request::execute); + } +} diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 0851418..a414307 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -147,6 +147,64 @@ public void shouldCreateClientForDomain() { assertThat(result, is(notNullValue())); } + // --- RenewAuth Tests --- + + @Test + public void shouldBuildRenewAuthRequestForExplicitDomain() { + RequestProcessor processor = createDefaultRequestProcessor(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + RenewAuthRequest result = spy.buildRenewAuthRequest("refreshToken", DOMAIN); + + assertThat(result, is(notNullValue())); + verify(spy).createClientForDomain(DOMAIN); + } + + @Test + public void shouldBuildRenewAuthRequestFromStaticDomain() { + RequestProcessor processor = new RequestProcessor.Builder( + new StaticDomainProvider(DOMAIN), + RESPONSE_TYPE_CODE, + CLIENT_ID, + CLIENT_SECRET) + .withJwkProvider(mockJwkProvider) + .build(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + RenewAuthRequest result = spy.buildRenewAuthRequest("refreshToken"); + + assertThat(result, is(notNullValue())); + verify(spy).createClientForDomain(DOMAIN); + } + + @Test + public void shouldThrowOnNoArgRenewAuthWhenUsingResolver() { + RequestProcessor processor = createDefaultRequestProcessor(); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> processor.buildRenewAuthRequest("refreshToken")); + assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); + } + + @Test + public void shouldBuildRenewAuthRequestResolvingDomainFromRequest() { + String resolvedDomain = "resolved-domain.auth0.com"; + when(mockDomainProvider.getDomain(request)).thenReturn(resolvedDomain); + + RequestProcessor processor = createDefaultRequestProcessor(); + RequestProcessor spy = spy(processor); + doReturn(mockAuthAPI).when(spy).createClientForDomain(anyString()); + + RenewAuthRequest result = spy.buildRenewAuthRequest("refreshToken", request); + + assertThat(result, is(notNullValue())); + verify(mockDomainProvider).getDomain(request); + verify(spy).createClientForDomain(resolvedDomain); + } + // --- Logging and Telemetry Tests --- @Test diff --git a/src/test/java/com/auth0/TokensTest.java b/src/test/java/com/auth0/TokensTest.java index c82a0c1..6bcc3d2 100644 --- a/src/test/java/com/auth0/TokensTest.java +++ b/src/test/java/com/auth0/TokensTest.java @@ -31,4 +31,31 @@ public void shouldReturnMissingTokens() { assertThat(tokens.getDomain(), is(nullValue())); assertThat(tokens.getIssuer(), is(nullValue())); } + + @Test + public void shouldHaveNullScopeFromFiveArgConstructor() { + Tokens tokens = new Tokens("accessToken", "idToken", "refreshToken", "bearer", 360000L); + assertThat(tokens.getScope(), is(nullValue())); + } + + @Test + public void shouldHaveNullScopeFromSevenArgConstructor() { + Tokens tokens = new Tokens("accessToken", "idToken", "refreshToken", "bearer", 360000L, "domain.auth0.com", "https://domain.auth0.com/"); + assertThat(tokens.getScope(), is(nullValue())); + assertThat(tokens.getDomain(), is("domain.auth0.com")); + assertThat(tokens.getIssuer(), is("https://domain.auth0.com/")); + } + + @Test + public void shouldReturnScopeFromEightArgConstructor() { + Tokens tokens = new Tokens("accessToken", "idToken", "refreshToken", "bearer", 360000L, "openid profile", "domain.auth0.com", "https://domain.auth0.com/"); + assertThat(tokens.getAccessToken(), is("accessToken")); + assertThat(tokens.getIdToken(), is("idToken")); + assertThat(tokens.getRefreshToken(), is("refreshToken")); + assertThat(tokens.getType(), is("bearer")); + assertThat(tokens.getExpiresIn(), is(360000L)); + assertThat(tokens.getScope(), is("openid profile")); + assertThat(tokens.getDomain(), is("domain.auth0.com")); + assertThat(tokens.getIssuer(), is("https://domain.auth0.com/")); + } } From 38b46b05f66a001710664852e4f8e6383f3cb298 Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Tue, 7 Jul 2026 16:40:46 +0530 Subject: [PATCH 2/3] feat: Add Custom Token Exhange Support (#253) --- build.gradle | 2 +- .../com/auth0/AuthenticationController.java | 82 +++++++ .../auth0/CustomTokenExchangeException.java | 37 +++ src/main/java/com/auth0/RequestProcessor.java | 86 +++++++ .../java/com/auth0/TokenExchangeRequest.java | 144 +++++++++++ .../auth0/AuthenticationControllerTest.java | 90 +++++++ .../java/com/auth0/RequestProcessorTest.java | 225 ++++++++++++++++++ .../com/auth0/TokenExchangeRequestTest.java | 170 +++++++++++++ 8 files changed, 835 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..519cd7c 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,87 @@ 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(); + + 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) { + 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 +436,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..9424ef9 --- /dev/null +++ b/src/main/java/com/auth0/TokenExchangeRequest.java @@ -0,0 +1,144 @@ +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 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 { + + 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 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. + */ + 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."); + } + // 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."); + } + } + + 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..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; @@ -34,6 +38,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"; @@ -205,6 +210,211 @@ 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")); + } + + @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")); + } + + @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 @@ -967,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 new file mode 100644 index 0000000..0408c8b --- /dev/null +++ b/src/test/java/com/auth0/TokenExchangeRequestTest.java @@ -0,0 +1,170 @@ +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 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( + 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 0b1b19e6c4f35abe8f26a7b7916ef00f79ba08e8 Mon Sep 17 00:00:00 2001 From: Tanya Sinha Date: Thu, 9 Jul 2026 16:47:55 +0530 Subject: [PATCH 3/3] Feat: Add Client-Initiated Backchannel Authentication (CIBA) support (#254) --- .../com/auth0/AuthenticationController.java | 88 ++++++++++ .../BackChannelAuthorizationException.java | 69 ++++++++ .../auth0/BackChannelAuthorizeRequest.java | 100 +++++++++++ .../com/auth0/BackChannelTokenRequest.java | 50 ++++++ src/main/java/com/auth0/RequestProcessor.java | 106 +++++++++++ .../auth0/AuthenticationControllerTest.java | 164 ++++++++++++++++++ ...BackChannelAuthorizationExceptionTest.java | 49 ++++++ .../BackChannelAuthorizeRequestTest.java | 118 +++++++++++++ .../auth0/BackChannelTokenRequestTest.java | 62 +++++++ .../java/com/auth0/RequestProcessorTest.java | 157 +++++++++++++++++ 10 files changed, 963 insertions(+) create mode 100644 src/main/java/com/auth0/BackChannelAuthorizationException.java create mode 100644 src/main/java/com/auth0/BackChannelAuthorizeRequest.java create mode 100644 src/main/java/com/auth0/BackChannelTokenRequest.java create mode 100644 src/test/java/com/auth0/BackChannelAuthorizationExceptionTest.java create mode 100644 src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java create mode 100644 src/test/java/com/auth0/BackChannelTokenRequestTest.java diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index f2cb877..6ca85aa 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -7,6 +7,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Map; /** * Base Auth0 Authenticator class. @@ -445,6 +446,93 @@ public RenewAuthRequest renewAuth(String refreshToken, HttpServletRequest reques return requestProcessor.buildRenewAuthRequest(refreshToken, request); } + /** + * Initiates a Client-Initiated + * Backchannel Authentication (CIBA) request. This is the first step of the CIBA flow: it + * asks Auth0 to authenticate a user out-of-band (on their own device) and returns an + * {@code auth_req_id} used to poll for the result via {@link #backChannelPoll(String, String)}. + * + *

The application supplies the {@code domain} to target; store it alongside the returned + * {@code auth_req_id} so the poll step can target the same domain. For applications configured + * with a fixed domain, {@link #backChannelAuthorize(String, String, java.util.Map)} may be used + * instead.

+ * + *

The library remains stateless: the application owns the polling loop, honoring the + * {@code interval} and {@code expires_in} returned by the initiate step.

+ * + * @param scope the requested scope (e.g. {@code "openid profile"}). + * @param bindingMessage the human-readable message displayed to the user on their device. + * @param loginHint a map identifying the user, serialized to the {@code login_hint} JSON. + * Auth0 expects the {@code iss_sub} shape, e.g. + * {@code {"format": "iss_sub", "iss": "https://your-tenant.auth0.com/", + * "sub": "auth0|abc123"}}. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelAuthorizeRequest} to configure and execute. + */ + public BackChannelAuthorizeRequest backChannelAuthorize(String scope, String bindingMessage, Map loginHint, String domain) { + Validate.notNull(scope, "scope must not be null"); + Validate.notNull(bindingMessage, "bindingMessage must not be null"); + Validate.notNull(loginHint, "loginHint must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildBackChannelAuthorizeRequest(scope, bindingMessage, loginHint, domain); + } + + /** + * Initiates a CIBA backchannel authentication request using the statically configured domain. + * See {@link #backChannelAuthorize(String, String, java.util.Map, 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 scope the requested scope. + * @param bindingMessage the human-readable message displayed to the user on their device. + * @param loginHint a map identifying the user, serialized to the {@code login_hint} JSON. + * @return a {@link BackChannelAuthorizeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public BackChannelAuthorizeRequest backChannelAuthorize(String scope, String bindingMessage, Map loginHint) { + Validate.notNull(scope, "scope must not be null"); + Validate.notNull(bindingMessage, "bindingMessage must not be null"); + Validate.notNull(loginHint, "loginHint must not be null"); + return requestProcessor.buildBackChannelAuthorizeRequest(scope, bindingMessage, loginHint); + } + + /** + * Builds a request to poll for the result of a CIBA backchannel authentication request. This is + * the second step of the CIBA flow: the application calls {@link BackChannelTokenRequest#execute()} + * repeatedly (no more frequently than the {@code interval} returned by the authorize step) until + * the user approves (yielding verified {@link Tokens}) or a terminal error occurs. + * + *

The application supplies the {@code domain} it stored at the authorize step, since polling + * commonly happens outside the initiating HTTP request. For applications configured with a fixed + * domain, {@link #backChannelPoll(String)} may be used instead.

+ * + * @param authReqId the {@code auth_req_id} returned from the authorize step. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelTokenRequest} to execute. + */ + public BackChannelTokenRequest backChannelPoll(String authReqId, String domain) { + Validate.notNull(authReqId, "authReqId must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildBackChannelTokenRequest(authReqId, domain); + } + + /** + * Builds a request to poll for the result of a CIBA backchannel authentication request using the + * statically configured domain. See {@link #backChannelPoll(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 authReqId the {@code auth_req_id} returned from the authorize step. + * @return a {@link BackChannelTokenRequest} to execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public BackChannelTokenRequest backChannelPoll(String authReqId) { + Validate.notNull(authReqId, "authReqId must not be null"); + return requestProcessor.buildBackChannelTokenRequest(authReqId); + } + /** * Builds a request to exchange an external {@code subject_token} for a new set of * {@link Tokens} via Custom diff --git a/src/main/java/com/auth0/BackChannelAuthorizationException.java b/src/main/java/com/auth0/BackChannelAuthorizationException.java new file mode 100644 index 0000000..38faa2b --- /dev/null +++ b/src/main/java/com/auth0/BackChannelAuthorizationException.java @@ -0,0 +1,69 @@ +package com.auth0; + +/** + * Represents an error returned while polling the Auth0 token endpoint for CIBA (Client-Initiated + * Backchannel Authentication) login status. The OAuth error codes returned during polling are + * {@code authorization_pending} (user hasn't approved yet — normal, keep polling), {@code + * slow_down} (poll less frequently), {@code expired_token} (the auth_req_id expired), and {@code + * access_denied} (user rejected). + * + *

The non-terminal cases ({@link #isAuthorizationPending()} and {@link #isSlowDown()}) indicate + * the caller should sleep and retry. The terminal cases ({@link #isExpiredToken()} and {@link + * #isAccessDenied()}) indicate the polling loop must stop. + * + *

Note on the type hierarchy: this extends {@link IdentityVerificationException} so a + * single catch clause can cover the whole CIBA poll path, but {@code authorization_pending} and + * {@code slow_down} are polling control-flow signals, not ID token verification failures. + * Callers should branch on the {@code isX()} helpers rather than treating every instance as a + * verification error. + * + * @see AuthenticationController#backChannelPoll(String, String) + */ +@SuppressWarnings("WeakerAccess") +public class BackChannelAuthorizationException extends IdentityVerificationException { + + public static final String AUTHORIZATION_PENDING = "authorization_pending"; + public static final String SLOW_DOWN = "slow_down"; + public static final String EXPIRED_TOKEN = "expired_token"; + public static final String ACCESS_DENIED = "access_denied"; + + BackChannelAuthorizationException(String code, String message) { + this(code, message, null); + } + + BackChannelAuthorizationException(String code, String message, Throwable cause) { + super(code, message, cause); + } + + /** + * @return true if the error is due to the user not yet approving the authentication request. + * The caller should sleep and retry. + */ + public boolean isAuthorizationPending() { + return AUTHORIZATION_PENDING.equals(getCode()); + } + + /** + * @return true if the polling interval should be increased. The caller should back off — the + * common convention is to add 5 seconds to the current interval — and then retry. + */ + public boolean isSlowDown() { + return SLOW_DOWN.equals(getCode()); + } + + /** + * @return true if the {@code auth_req_id} has expired. This is a terminal error; the polling + * loop must stop. + */ + public boolean isExpiredToken() { + return EXPIRED_TOKEN.equals(getCode()); + } + + /** + * @return true if the user rejected the authentication request. This is a terminal error; the + * polling loop must stop. + */ + public boolean isAccessDenied() { + return ACCESS_DENIED.equals(getCode()); + } +} diff --git a/src/main/java/com/auth0/BackChannelAuthorizeRequest.java b/src/main/java/com/auth0/BackChannelAuthorizeRequest.java new file mode 100644 index 0000000..21dd590 --- /dev/null +++ b/src/main/java/com/auth0/BackChannelAuthorizeRequest.java @@ -0,0 +1,100 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.BackChannelAuthorizeResponse; + +import java.util.Map; + +/** + * Class to initiate a Client-Initiated Backchannel Authentication (CIBA) backchannel authorization + * request (POST /bc-authorize). This is the first step of the CIBA flow: the application requests + * authentication from Auth0 for a user identified via login hints, and Auth0 returns an + * {@code auth_req_id} that can be polled to obtain the authentication result. + *

+ * The returned {@link BackChannelAuthorizeResponse} carries: + *

    + *
  • {@code auth_req_id}: a unique identifier for this authentication request, used in the + * subsequent poll step to fetch the result.
  • + *
  • {@code expires_in}: the lifetime of the authentication request in seconds; after this + * period, the auth_req_id is no longer valid.
  • + *
  • {@code interval}: the minimum number of seconds the application should wait between + * consecutive poll requests.
  • + *
+ *

+ * The library remains stateless: the application owns the polling loop, storage of the + * {@code auth_req_id}, and any state associated with the authentication request. See + * {@link AuthenticationController} for the entry point to obtain an instance. + *

+ * Optional parameters ({@code audience}, {@code requested_expiry}) can be configured via + * {@link #withAudience(String)} and {@link #withRequestedExpiry(Integer)}. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class BackChannelAuthorizeRequest { + + private final AuthAPI client; + private final String scope; + private final String bindingMessage; + private final Map loginHint; + private final String domain; + private final String issuer; + private String audience; + private Integer requestedExpiry; + + BackChannelAuthorizeRequest(AuthAPI client, String scope, String bindingMessage, + Map loginHint, String domain, String issuer) { + this.client = client; + this.scope = scope; + this.bindingMessage = bindingMessage; + this.loginHint = loginHint; + this.domain = domain; + this.issuer = issuer; + } + + /** + * Sets the audience (API identifier) for the access token to be obtained during the + * authentication flow. 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 BackChannelAuthorizeRequest withAudience(String audience) { + this.audience = audience; + return this; + } + + /** + * Sets the requested expiry (lifetime) of the authentication request in seconds. This value + * is sent to Auth0 as {@code requested_expiry} and controls how long the {@code auth_req_id} + * remains valid for polling. + * + * @param seconds the requested lifetime of the authentication request. + * @return this request instance for fluent chaining. + */ + public BackChannelAuthorizeRequest withRequestedExpiry(Integer seconds) { + this.requestedExpiry = seconds; + return this; + } + + /** + * Executes the backchannel authorization request against Auth0 and returns the response + * containing the {@code auth_req_id}, expiration, and polling interval. + * + * @return the {@link BackChannelAuthorizeResponse} containing {@code auth_req_id}, + * {@code expires_in}, and {@code interval}. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public BackChannelAuthorizeResponse execute() throws Auth0Exception { + com.auth0.net.Request request; + + if (audience == null && requestedExpiry == null) { + request = client.authorizeBackChannel(scope, bindingMessage, loginHint); + } else { + request = client.authorizeBackChannel(scope, bindingMessage, loginHint, + audience, requestedExpiry); + } + + return request.execute().getBody(); + } +} diff --git a/src/main/java/com/auth0/BackChannelTokenRequest.java b/src/main/java/com/auth0/BackChannelTokenRequest.java new file mode 100644 index 0000000..58ee1ca --- /dev/null +++ b/src/main/java/com/auth0/BackChannelTokenRequest.java @@ -0,0 +1,50 @@ +package com.auth0; + +import com.auth0.exception.Auth0Exception; + +/** + * Class to poll Auth0's token endpoint for the result of a CIBA (Client-Initiated Backchannel + * Authentication) backchannel authentication request. + *

+ * The library remains stateless: the application owns the polling loop. It must call + * {@link #execute()} no more frequently than the {@code interval} returned by the initiate step, + * and handle {@link BackChannelAuthorizationException} to decide whether to keep polling + * ({@code authorization_pending} / {@code slow_down}) or stop ({@code expired_token} / + * {@code access_denied}). + *

+ * On success, the returned ID token is verified (signature, issuer, org claims) just like the + * Custom Token Exchange login path. + *

+ * Obtain an instance via {@link AuthenticationController#backChannelPoll(String, String)}. + */ +@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"}) +public class BackChannelTokenRequest { + + private final RequestProcessor processor; + private final String authReqId; + private final String domain; + private final String issuer; + + BackChannelTokenRequest(RequestProcessor processor, String authReqId, String domain, + String issuer) { + this.processor = processor; + this.authReqId = authReqId; + this.domain = domain; + this.issuer = issuer; + } + + /** + * Polls the Auth0 token endpoint for the result of the backchannel authentication request. + * + * @return the verified {@link Tokens} once the user has approved the request. + * @throws BackChannelAuthorizationException while the request is still pending + * ({@code authorization_pending} / {@code slow_down}) + * or on a terminal poll error ({@code expired_token} + * / {@code access_denied}). + * @throws IdentityVerificationException if the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + public Tokens execute() throws IdentityVerificationException, Auth0Exception { + return processor.executeBackChannelPoll(authReqId, domain, issuer); + } +} diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java index 519cd7c..8dcb591 100644 --- a/src/main/java/com/auth0/RequestProcessor.java +++ b/src/main/java/com/auth0/RequestProcessor.java @@ -2,10 +2,12 @@ import com.auth0.client.LoggingOptions; import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.APIException; import com.auth0.exception.Auth0Exception; import com.auth0.exception.IdTokenValidationException; import com.auth0.exception.PublicKeyProviderException; import com.auth0.jwt.JWT; +import com.auth0.json.auth.BackChannelTokenResponse; import com.auth0.json.auth.TokenHolder; import com.auth0.net.TokenRequest; import com.auth0.jwk.Jwk; @@ -46,6 +48,7 @@ class RequestProcessor { private static final String KEY_RESPONSE_MODE = "response_mode"; private static final String KEY_FORM_POST = "form_post"; private static final String KEY_MAX_AGE = "max_age"; + private static final String CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; private final DomainProvider domainProvider; private final String responseType; @@ -248,6 +251,109 @@ TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subje return buildTokenExchangeRequest(subjectToken, subjectTokenType, domainProvider.getDomain(null), loginSemantics); } + /** + * Builds a {@link BackChannelAuthorizeRequest} to initiate a CIBA backchannel authorization + * against the given domain. The domain is supplied explicitly because the subsequent poll can + * occur outside of the initiating HTTP request, and the application must store the domain + * (alongside the {@code auth_req_id}) to target the same domain when polling. + * + * @param scope the requested scope. + * @param bindingMessage the human-readable message shown to the user on their device. + * @param loginHint the login hint identifying the user, serialized to JSON by the SDK. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelAuthorizeRequest} ready to configure and execute. + */ + BackChannelAuthorizeRequest buildBackChannelAuthorizeRequest(String scope, String bindingMessage, + java.util.Map loginHint, String domain) { + AuthAPI client = createClientForDomain(domain); + String issuer = constructIssuer(domain); + return new BackChannelAuthorizeRequest(client, scope, bindingMessage, loginHint, domain, issuer); + } + + /** + * Builds a {@link BackChannelAuthorizeRequest} using the statically configured domain. Only + * valid when the controller was configured with a fixed domain. + * + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + BackChannelAuthorizeRequest buildBackChannelAuthorizeRequest(String scope, String bindingMessage, + java.util.Map loginHint) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call the backChannelAuthorize overload that accepts a domain."); + } + return buildBackChannelAuthorizeRequest(scope, bindingMessage, loginHint, domainProvider.getDomain(null)); + } + + /** + * Builds a {@link BackChannelTokenRequest} to poll for the result of a CIBA backchannel + * authorization against the given domain. The domain is supplied explicitly because polling + * typically occurs outside of the initiating HTTP request; it must match the domain the + * {@code auth_req_id} was issued for. + * + * @param authReqId the {@code auth_req_id} returned from the authorize step. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelTokenRequest} ready to execute. + */ + BackChannelTokenRequest buildBackChannelTokenRequest(String authReqId, String domain) { + String issuer = constructIssuer(domain); + return new BackChannelTokenRequest(this, authReqId, domain, issuer); + } + + /** + * Builds a {@link BackChannelTokenRequest} using the statically configured domain. Only valid + * when the controller was configured with a fixed domain. + * + * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}. + */ + BackChannelTokenRequest buildBackChannelTokenRequest(String authReqId) { + if (!(domainProvider instanceof StaticDomainProvider)) { + throw new IllegalStateException("A domain is required when using a DomainResolver; call the backChannelPoll overload that accepts a domain."); + } + return buildBackChannelTokenRequest(authReqId, domainProvider.getDomain(null)); + } + + /** + * Polls the Auth0 token endpoint for the result of a CIBA backchannel authentication request. + *

+ * While the user has not yet completed authentication, Auth0 responds with an OAuth error that + * is surfaced here as a {@link BackChannelAuthorizationException}: {@code authorization_pending} + * and {@code slow_down} are non-terminal (the caller should keep polling), while + * {@code expired_token} and {@code access_denied} are terminal. On success the returned ID + * token is verified (reusing the code-flow verification path, including organization-claim + * validation when an organization is configured). + * + * @throws BackChannelAuthorizationException if Auth0 returned a CIBA poll error. + * @throws IdentityVerificationException if the returned ID token fails verification. + * @throws Auth0Exception if the request to the Auth0 server failed. + */ + Tokens executeBackChannelPoll(String authReqId, String domain, String issuer) + throws IdentityVerificationException, Auth0Exception { + BackChannelTokenResponse response; + try { + response = createClientForDomain(domain) + .getBackChannelLoginStatus(authReqId, CIBA_GRANT_TYPE) + .execute() + .getBody(); + } catch (APIException e) { + // Translate the OAuth poll error codes into a typed exception so callers can drive + // their polling loop (pending/slow_down = keep polling; expired/denied = stop). + throw new BackChannelAuthorizationException(e.getError(), e.getDescription(), e); + } + + if (response.getIdToken() == null) { + // CIBA establishes a user session, so an ID token is required on success. + throw new InvalidRequestException(MISSING_ID_TOKEN, "ID Token is missing from the CIBA token response."); + } + try { + verifyIdToken(response.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(response.getAccessToken(), response.getIdToken(), null, + "Bearer", response.getExpiresIn(), response.getScope(), domain, issuer); + } + /** * Performs the Custom Token Exchange grant against Auth0 and returns the resulting tokens. *

diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java index 16a700d..fed0d2d 100644 --- a/src/test/java/com/auth0/AuthenticationControllerTest.java +++ b/src/test/java/com/auth0/AuthenticationControllerTest.java @@ -11,10 +11,13 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Collections; +import java.util.Map; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsNull.notNullValue; +import static org.hamcrest.core.StringContains.containsString; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -428,6 +431,167 @@ public void shouldThrowExceptionWhenLoginWithCustomTokenExchangeSubjectTokenIsNu assertThat(exception.getMessage(), is("subjectToken must not be null")); } + // --- backChannelAuthorize Tests --- + + @Test + public void shouldBackChannelAuthorizeWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelAuthorizeRequest mockRequest = mock(BackChannelAuthorizeRequest.class); + Map loginHint = Collections.singletonMap("format", "iss_sub"); + when(mockRequestProcessor.buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint, DOMAIN)) + .thenReturn(mockRequest); + + BackChannelAuthorizeRequest result = controller.backChannelAuthorize("openid profile", "Approve login", loginHint, DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint, DOMAIN); + } + + @Test + public void shouldBackChannelAuthorizeWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelAuthorizeRequest mockRequest = mock(BackChannelAuthorizeRequest.class); + Map loginHint = Collections.singletonMap("format", "iss_sub"); + when(mockRequestProcessor.buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint)) + .thenReturn(mockRequest); + + BackChannelAuthorizeRequest result = controller.backChannelAuthorize("openid profile", "Approve login", loginHint); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeScopeIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize(null, "Approve login", Collections.emptyMap(), DOMAIN)); + assertThat(exception.getMessage(), is("scope must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeBindingMessageIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize("openid profile", null, Collections.emptyMap(), DOMAIN)); + assertThat(exception.getMessage(), is("bindingMessage must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeLoginHintIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize("openid profile", "Approve login", null, DOMAIN)); + assertThat(exception.getMessage(), is("loginHint must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelAuthorizeDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize("openid profile", "Approve login", Collections.emptyMap(), null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenNoDomainBackChannelAuthorizeScopeIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelAuthorize(null, "Approve login", Collections.emptyMap())); + assertThat(exception.getMessage(), is("scope must not be null")); + } + + @Test + public void shouldPropagateIllegalStateWhenBackChannelAuthorizeWithoutDomainUsesResolver() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + Map loginHint = Collections.singletonMap("format", "iss_sub"); + when(mockRequestProcessor.buildBackChannelAuthorizeRequest("openid profile", "Approve login", loginHint)) + .thenThrow(new IllegalStateException("A domain is required when using a DomainResolver")); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> controller.backChannelAuthorize("openid profile", "Approve login", loginHint)); + assertThat(exception.getMessage(), containsString("A domain is required when using a DomainResolver")); + } + + // --- backChannelPoll Tests --- + + @Test + public void shouldBackChannelPollWithDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelTokenRequest mockRequest = mock(BackChannelTokenRequest.class); + when(mockRequestProcessor.buildBackChannelTokenRequest("auth-req-123", DOMAIN)).thenReturn(mockRequest); + + BackChannelTokenRequest result = controller.backChannelPoll("auth-req-123", DOMAIN); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelTokenRequest("auth-req-123", DOMAIN); + } + + @Test + public void shouldBackChannelPollWithoutDomain() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + BackChannelTokenRequest mockRequest = mock(BackChannelTokenRequest.class); + when(mockRequestProcessor.buildBackChannelTokenRequest("auth-req-123")).thenReturn(mockRequest); + + BackChannelTokenRequest result = controller.backChannelPoll("auth-req-123"); + + assertThat(result, is(mockRequest)); + verify(mockRequestProcessor).buildBackChannelTokenRequest("auth-req-123"); + } + + @Test + public void shouldThrowExceptionWhenBackChannelPollAuthReqIdIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelPoll(null, DOMAIN)); + assertThat(exception.getMessage(), is("authReqId must not be null")); + } + + @Test + public void shouldThrowExceptionWhenBackChannelPollDomainIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelPoll("auth-req-123", (String) null)); + assertThat(exception.getMessage(), is("domain must not be null")); + } + + @Test + public void shouldThrowExceptionWhenNoDomainBackChannelPollAuthReqIdIsNull() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + + NullPointerException exception = assertThrows( + NullPointerException.class, + () -> controller.backChannelPoll(null)); + assertThat(exception.getMessage(), is("authReqId must not be null")); + } + + @Test + public void shouldPropagateIllegalStateWhenBackChannelPollWithoutDomainUsesResolver() { + AuthenticationController controller = new AuthenticationController(mockRequestProcessor); + when(mockRequestProcessor.buildBackChannelTokenRequest("auth-req-123")) + .thenThrow(new IllegalStateException("A domain is required when using a DomainResolver")); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> controller.backChannelPoll("auth-req-123")); + 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/BackChannelAuthorizationExceptionTest.java b/src/test/java/com/auth0/BackChannelAuthorizationExceptionTest.java new file mode 100644 index 0000000..e810182 --- /dev/null +++ b/src/test/java/com/auth0/BackChannelAuthorizationExceptionTest.java @@ -0,0 +1,49 @@ +package com.auth0; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; + +public class BackChannelAuthorizationExceptionTest { + + @Test + public void shouldIdentifyAuthorizationPending() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.AUTHORIZATION_PENDING, "pending"); + assertThat(e.isAuthorizationPending(), is(true)); + assertThat(e.isSlowDown(), is(false)); + assertThat(e.isExpiredToken(), is(false)); + assertThat(e.isAccessDenied(), is(false)); + assertThat(e.getCode(), is("authorization_pending")); + } + + @Test + public void shouldIdentifySlowDown() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.SLOW_DOWN, "slow down"); + assertThat(e.isSlowDown(), is(true)); + assertThat(e.isAuthorizationPending(), is(false)); + } + + @Test + public void shouldIdentifyExpiredToken() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.EXPIRED_TOKEN, "expired"); + assertThat(e.isExpiredToken(), is(true)); + } + + @Test + public void shouldIdentifyAccessDenied() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.ACCESS_DENIED, "denied"); + assertThat(e.isAccessDenied(), is(true)); + } + + @Test + public void shouldBeIdentityVerificationException() { + BackChannelAuthorizationException e = new BackChannelAuthorizationException( + BackChannelAuthorizationException.ACCESS_DENIED, "denied"); + assertThat(e instanceof IdentityVerificationException, is(true)); + } +} diff --git a/src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java b/src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java new file mode 100644 index 0000000..fdb6b5e --- /dev/null +++ b/src/test/java/com/auth0/BackChannelAuthorizeRequestTest.java @@ -0,0 +1,118 @@ +package com.auth0; + +import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.BackChannelAuthorizeResponse; +import com.auth0.net.Request; +import com.auth0.net.Response; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.Map; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BackChannelAuthorizeRequestTest { + + private static final String DOMAIN = "domain.auth0.com"; + private static final String ISSUER = "https://domain.auth0.com/"; + private static final String SCOPE = "openid profile"; + private static final String BINDING_MESSAGE = "Approve login 1234"; + private static final Map LOGIN_HINT = + Collections.singletonMap("format", "iss_sub"); + + @Mock + private AuthAPI mockClient; + @Mock + private Request mockRequest; + @Mock + private Response mockResponse; + @Mock + private BackChannelAuthorizeResponse mockBody; + + @BeforeEach + public void setUp() throws Exception { + MockitoAnnotations.openMocks(this); + when(mockRequest.execute()).thenReturn(mockResponse); + when(mockResponse.getBody()).thenReturn(mockBody); + } + + private BackChannelAuthorizeRequest newRequest() { + return new BackChannelAuthorizeRequest(mockClient, SCOPE, BINDING_MESSAGE, LOGIN_HINT, DOMAIN, ISSUER); + } + + @Test + public void shouldUseThreeArgOverloadWhenNoOptionalsSet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT)).thenReturn(mockRequest); + + BackChannelAuthorizeResponse result = newRequest().execute(); + + assertSame(mockBody, result); + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT); + verify(mockClient, never()).authorizeBackChannel( + eq(SCOPE), eq(BINDING_MESSAGE), eq(LOGIN_HINT), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyInt()); + } + + @Test + public void shouldUseFiveArgOverloadWhenAudienceSet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", null)) + .thenReturn(mockRequest); + + newRequest().withAudience("api").execute(); + + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", null); + } + + @Test + public void shouldUseFiveArgOverloadWhenRequestedExpirySet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, null, 300)) + .thenReturn(mockRequest); + + newRequest().withRequestedExpiry(300).execute(); + + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, null, 300); + } + + @Test + public void shouldPassBothOptionalsWhenSet() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", 120)) + .thenReturn(mockRequest); + + newRequest().withAudience("api").withRequestedExpiry(120).execute(); + + verify(mockClient).authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT, "api", 120); + } + + @Test + public void shouldReturnResponseBody() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT)).thenReturn(mockRequest); + when(mockBody.getAuthReqId()).thenReturn("auth-req-123"); + when(mockBody.getInterval()).thenReturn(5); + when(mockBody.getExpiresIn()).thenReturn(600L); + + BackChannelAuthorizeResponse result = newRequest().execute(); + + assertThat(result.getAuthReqId(), is("auth-req-123")); + assertThat(result.getInterval(), is(5)); + assertThat(result.getExpiresIn(), is(600L)); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockClient.authorizeBackChannel(SCOPE, BINDING_MESSAGE, LOGIN_HINT)).thenReturn(mockRequest); + when(mockRequest.execute()).thenThrow(new Auth0Exception("boom")); + + assertThrows(Auth0Exception.class, () -> newRequest().execute()); + } +} diff --git a/src/test/java/com/auth0/BackChannelTokenRequestTest.java b/src/test/java/com/auth0/BackChannelTokenRequestTest.java new file mode 100644 index 0000000..1b44aff --- /dev/null +++ b/src/test/java/com/auth0/BackChannelTokenRequestTest.java @@ -0,0 +1,62 @@ +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.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +public class BackChannelTokenRequestTest { + + private static final String DOMAIN = "domain.auth0.com"; + private static final String ISSUER = "https://domain.auth0.com/"; + private static final String AUTH_REQ_ID = "auth-req-123"; + + @Mock + private RequestProcessor mockProcessor; + @Mock + private Tokens mockTokens; + + @BeforeEach + public void setUp() { + MockitoAnnotations.openMocks(this); + } + + private BackChannelTokenRequest newRequest() { + return new BackChannelTokenRequest(mockProcessor, AUTH_REQ_ID, DOMAIN, ISSUER); + } + + @Test + public void shouldDelegateToProcessor() throws Exception { + when(mockProcessor.executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER)).thenReturn(mockTokens); + + Tokens result = newRequest().execute(); + + assertSame(mockTokens, result); + verify(mockProcessor).executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER); + } + + @Test + public void shouldPropagatePendingException() throws Exception { + BackChannelAuthorizationException pending = new BackChannelAuthorizationException( + BackChannelAuthorizationException.AUTHORIZATION_PENDING, "pending"); + when(mockProcessor.executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER)).thenThrow(pending); + + BackChannelAuthorizationException thrown = assertThrows( + BackChannelAuthorizationException.class, () -> newRequest().execute()); + org.hamcrest.MatcherAssert.assertThat(thrown.isAuthorizationPending(), org.hamcrest.core.Is.is(true)); + } + + @Test + public void shouldPropagateAuth0Exception() throws Exception { + when(mockProcessor.executeBackChannelPoll(AUTH_REQ_ID, DOMAIN, ISSUER)) + .thenThrow(new Auth0Exception("boom")); + + assertThrows(Auth0Exception.class, () -> newRequest().execute()); + } +} diff --git a/src/test/java/com/auth0/RequestProcessorTest.java b/src/test/java/com/auth0/RequestProcessorTest.java index 7587f2b..d76610e 100644 --- a/src/test/java/com/auth0/RequestProcessorTest.java +++ b/src/test/java/com/auth0/RequestProcessorTest.java @@ -1,12 +1,15 @@ package com.auth0; import com.auth0.client.auth.AuthAPI; +import com.auth0.exception.APIException; import com.auth0.exception.Auth0Exception; +import com.auth0.json.auth.BackChannelTokenResponse; 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.Request; import com.auth0.net.Response; import com.auth0.net.TokenRequest; import com.auth0.net.client.Auth0HttpClient; @@ -415,6 +418,133 @@ public void shouldThrowOnUtilityCustomTokenExchangeWhenOrgClaimMismatches() thro assertThat(exception.getCode(), is("a0.invalid_jwt_error")); } + // --- CIBA Backchannel Poll Tests --- + + @Test + public void shouldTranslateAuthorizationPendingIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("authorization_pending", "User has not yet approved.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("authorization_pending")); + assertThat(exception.isAuthorizationPending(), is(true)); + assertThat(exception.isSlowDown(), is(false)); + } + + @Test + public void shouldTranslateSlowDownIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("slow_down", "Polling too fast.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("slow_down")); + assertThat(exception.isSlowDown(), is(true)); + } + + @Test + public void shouldTranslateExpiredTokenIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("expired_token", "The auth_req_id expired.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("expired_token")); + assertThat(exception.isExpiredToken(), is(true)); + } + + @Test + public void shouldTranslateAccessDeniedIntoBackChannelException() throws Exception { + Request pollRequest = stubPollRequestThatThrows( + apiException("access_denied", "The user rejected the request.")); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + BackChannelAuthorizationException exception = assertThrows( + BackChannelAuthorizationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("access_denied")); + assertThat(exception.isAccessDenied(), is(true)); + } + + @Test + public void shouldThrowMissingIdTokenWhenPollResponseHasNoIdToken() throws Exception { + BackChannelTokenResponse body = mock(BackChannelTokenResponse.class); + when(body.getIdToken()).thenReturn(null); + Request pollRequest = stubPollRequestReturning(body); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + InvalidRequestException exception = assertThrows( + InvalidRequestException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("a0.missing_id_token")); + } + + @Test + public void shouldThrowWhenPollIdTokenVerificationFails() throws Exception { + // Structurally valid JWT with an invalid signature so verification fails. + String fakeJwt = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL3dyb25nLyIsInN1YiI6InVzZXIxMjMiLCJhdWQiOiJ0ZXN0Q2xpZW50SWQiLCJleHAiOjk5OTk5OTk5OTksImlhdCI6MTYwMDAwMDAwMH0.signature"; + BackChannelTokenResponse body = mock(BackChannelTokenResponse.class); + when(body.getIdToken()).thenReturn(fakeJwt); + Request pollRequest = stubPollRequestReturning(body); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + IdentityVerificationException exception = assertThrows( + IdentityVerificationException.class, + () -> spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER)); + assertThat(exception.getCode(), is("a0.invalid_jwt_error")); + } + + @Test + public void shouldReturnVerifiedTokensOnSuccessfulPoll() throws Exception { + String idToken = signedIdToken(null); + BackChannelTokenResponse body = mock(BackChannelTokenResponse.class); + when(body.getIdToken()).thenReturn(idToken); + when(body.getAccessToken()).thenReturn("cibaAccessToken"); + when(body.getExpiresIn()).thenReturn(86400L); + when(body.getScope()).thenReturn("openid profile"); + Request pollRequest = stubPollRequestReturning(body); + + RequestProcessor spy = spy(createDefaultRequestProcessor()); + doReturn(mockAuthAPI).when(spy).createClientForDomain(DOMAIN); + when(mockAuthAPI.getBackChannelLoginStatus("auth-req-123", CIBA_GRANT_TYPE)).thenReturn(pollRequest); + + Tokens tokens = spy.executeBackChannelPoll("auth-req-123", DOMAIN, ISSUER); + + assertThat(tokens, is(notNullValue())); + assertThat(tokens.getIdToken(), is(idToken)); + assertThat(tokens.getAccessToken(), is("cibaAccessToken")); + assertThat(tokens.getType(), is("Bearer")); + assertThat(tokens.getExpiresIn(), is(86400L)); + assertThat(tokens.getScope(), is("openid profile")); + // CIBA never returns a refresh token. + assertThat(tokens.getRefreshToken(), is(nullValue())); + } + // --- Logging and Telemetry Tests --- @Test @@ -1177,6 +1307,33 @@ public void shouldFallbackToDomainProviderWhenSignedCookieTampered() throws Exce // --- Helper Methods --- + private static final String CIBA_GRANT_TYPE = "urn:openid:params:grant-type:ciba"; + + // Builds an APIException carrying the given OAuth error code and description, matching the + // wire shape Auth0 returns for a failed CIBA poll (400 with error/error_description). + private APIException apiException(String error, String description) { + Map body = new HashMap<>(); + body.put("error", error); + body.put("error_description", description); + return new APIException(body, 400); + } + + @SuppressWarnings("unchecked") + private Request stubPollRequestThatThrows(APIException e) throws Exception { + Request pollRequest = mock(Request.class); + when(pollRequest.execute()).thenThrow(e); + return pollRequest; + } + + @SuppressWarnings("unchecked") + private Request stubPollRequestReturning(BackChannelTokenResponse body) throws Exception { + Request pollRequest = mock(Request.class); + Response pollResponse = mock(Response.class); + when(pollRequest.execute()).thenReturn(pollResponse); + when(pollResponse.getBody()).thenReturn(body); + return pollRequest; + } + // 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) {