From cc1ed092801cb689d192cc2c84d62b0491c8106d Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 9 Jul 2026 11:29:36 +0530 Subject: [PATCH 1/2] Feat: Add CIBA Support --- .../com/auth0/AuthenticationController.java | 84 +++++++++++++ .../BackChannelAuthorizationException.java | 63 ++++++++++ .../auth0/BackChannelAuthorizeRequest.java | 100 +++++++++++++++ .../com/auth0/BackChannelTokenRequest.java | 50 ++++++++ src/main/java/com/auth0/RequestProcessor.java | 106 ++++++++++++++++ ...BackChannelAuthorizationExceptionTest.java | 49 ++++++++ .../BackChannelAuthorizeRequestTest.java | 118 ++++++++++++++++++ .../auth0/BackChannelTokenRequestTest.java | 62 +++++++++ 8 files changed, 632 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..9e9a542 100644 --- a/src/main/java/com/auth0/AuthenticationController.java +++ b/src/main/java/com/auth0/AuthenticationController.java @@ -445,6 +445,90 @@ 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. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelAuthorizeRequest} to configure and execute. + */ + public BackChannelAuthorizeRequest backChannelAuthorize(String scope, String bindingMessage, java.util.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, java.util.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..effd4eb --- /dev/null +++ b/src/main/java/com/auth0/BackChannelAuthorizationException.java @@ -0,0 +1,63 @@ +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. + * + * @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 sleep longer and + * 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/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()); + } +} From 8f5c7fd1a3f43b5a214a9b65ee3e4558afda689c Mon Sep 17 00:00:00 2001 From: tanya732 Date: Thu, 9 Jul 2026 16:43:43 +0530 Subject: [PATCH 2/2] Add tests --- .../com/auth0/AuthenticationController.java | 8 +- .../BackChannelAuthorizationException.java | 10 +- .../auth0/AuthenticationControllerTest.java | 164 ++++++++++++++++++ .../java/com/auth0/RequestProcessorTest.java | 157 +++++++++++++++++ 4 files changed, 335 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index 9e9a542..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. @@ -462,10 +463,13 @@ public RenewAuthRequest renewAuth(String refreshToken, HttpServletRequest reques * @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, java.util.Map loginHint, String domain) { + 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"); @@ -486,7 +490,7 @@ public BackChannelAuthorizeRequest backChannelAuthorize(String scope, String bin * @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, java.util.Map loginHint) { + 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"); diff --git a/src/main/java/com/auth0/BackChannelAuthorizationException.java b/src/main/java/com/auth0/BackChannelAuthorizationException.java index effd4eb..38faa2b 100644 --- a/src/main/java/com/auth0/BackChannelAuthorizationException.java +++ b/src/main/java/com/auth0/BackChannelAuthorizationException.java @@ -11,6 +11,12 @@ * 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") @@ -38,8 +44,8 @@ public boolean isAuthorizationPending() { } /** - * @return true if the polling interval should be increased. The caller should sleep longer and - * retry. + * @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()); 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/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) {