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) {