diff --git a/build.gradle b/build.gradle index 11648f1..a5be0b0 100644 --- a/build.gradle +++ b/build.gradle @@ -61,7 +61,7 @@ dependencies { implementation 'com.google.guava:guava:32.0.1-jre' implementation 'commons-codec:commons-codec:1.22.0' - api 'com.auth0:auth0:3.5.1' + api 'com.auth0:auth0:3.10.0' api 'com.auth0:java-jwt:4.5.0' api 'com.auth0:jwks-rsa:0.24.1' diff --git a/src/main/java/com/auth0/AuthenticationController.java b/src/main/java/com/auth0/AuthenticationController.java index 62cee5a..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. @@ -383,4 +384,235 @@ public AuthorizeUrl buildAuthorizeUrl(HttpServletRequest request, HttpServletRes return requestProcessor.buildAuthorizeUrl(request, response, redirectUri, state, nonce); } + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens}, optionally + * targeting a specific audience and/or scope. This exposes Auth0's refresh-token grant, + * enabling Multi-Resource Refresh Token (MRRT) flows where one refresh token can obtain access + * tokens for multiple APIs. + * + *
The application supplies the {@code domain} it stored from {@link Tokens#getDomain()} at + * login. This is required because a refresh can occur outside of an HTTP request, where the + * domain cannot otherwise be resolved. For applications configured with a fixed domain, the + * {@link AuthenticationController#renewAuth(String)} overload may be used instead.
+ * + * @param refreshToken the refresh token to exchange. + * @param domain the Auth0 domain to target. + * @return a {@link RenewAuthRequest} to configure and execute. + */ + public RenewAuthRequest renewAuth(String refreshToken, String domain) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken, domain); + } + + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens} using the + * statically configured domain. See {@link AuthenticationController#renewAuth(String, String)} + * for details. + * + *This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call {@link AuthenticationController#renewAuth(String, String)} + * with the domain instead.
+ * + * @param refreshToken the refresh token to exchange. + * @return a {@link RenewAuthRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public RenewAuthRequest renewAuth(String refreshToken) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken); + } + + /** + * Builds a request to exchange a refresh token for a new set of {@link Tokens}, resolving the + * Auth0 domain from the given request via the configured domain or {@code DomainResolver}. + * See {@link AuthenticationController#renewAuth(String, String)} for details. + * + *This overload works for both a fixed domain and a {@code DomainResolver}, and is convenient + * when refreshing within an active request. Note: a refresh token is bound to + * the domain it was issued for at login; if the resolver resolves the given request to a + * different domain, Auth0 will reject the grant. Use this overload only when the request + * resolves to the same domain as login; otherwise use + * {@link AuthenticationController#renewAuth(String, String)} with the domain stored from + * {@link Tokens#getDomain()} at login.
+ * + * @param refreshToken the refresh token to exchange. + * @param request the current HTTP request, used to resolve the domain. + * @return a {@link RenewAuthRequest} to configure and execute. + */ + public RenewAuthRequest renewAuth(String refreshToken, HttpServletRequest request) { + Validate.notNull(refreshToken, "refreshToken must not be null"); + Validate.notNull(request, "request must not be null"); + return requestProcessor.buildRenewAuthRequest(refreshToken, request); + } + + /** + * Initiates a Client-Initiated + * Backchannel Authentication (CIBA) request. This is the first step of the CIBA flow: it + * asks Auth0 to authenticate a user out-of-band (on their own device) and returns an + * {@code auth_req_id} used to poll for the result via {@link #backChannelPoll(String, String)}. + * + *The application supplies the {@code domain} to target; store it alongside the returned + * {@code auth_req_id} so the poll step can target the same domain. For applications configured + * with a fixed domain, {@link #backChannelAuthorize(String, String, java.util.Map)} may be used + * instead.
+ * + *The library remains stateless: the application owns the polling loop, honoring the + * {@code interval} and {@code expires_in} returned by the initiate step.
+ * + * @param scope the requested scope (e.g. {@code "openid profile"}). + * @param bindingMessage the human-readable message displayed to the user on their device. + * @param loginHint a map identifying the user, serialized to the {@code login_hint} JSON. + * Auth0 expects the {@code iss_sub} shape, e.g. + * {@code {"format": "iss_sub", "iss": "https://your-tenant.auth0.com/", + * "sub": "auth0|abc123"}}. + * @param domain the Auth0 domain to target. + * @return a {@link BackChannelAuthorizeRequest} to configure and execute. + */ + public BackChannelAuthorizeRequest backChannelAuthorize(String scope, String bindingMessage, MapThis 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, MapThe 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 + * Token Exchange, without login semantics. The returned tokens are not verified beyond the + * exchange itself, making this suitable for obtaining tokens for a downstream API. + * + *The application supplies the {@code domain} to target. This is required because a token + * exchange can occur outside of an HTTP request, where the domain cannot otherwise be resolved. + * For applications configured with a fixed domain, the + * {@link AuthenticationController#customTokenExchange(String, String)} overload may be used + * instead.
+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @param domain the Auth0 domain to target. + * @return a {@link TokenExchangeRequest} to configure and execute. + */ + public TokenExchangeRequest customTokenExchange(String subjectToken, String subjectTokenType, String domain) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, domain, false); + } + + /** + * Builds a Custom Token Exchange request using the statically configured domain. See + * {@link AuthenticationController#customTokenExchange(String, String, String)} for details. + * + *This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.
+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @return a {@link TokenExchangeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public TokenExchangeRequest customTokenExchange(String subjectToken, String subjectTokenType) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, false); + } + + /** + * Builds a request to exchange an external {@code subject_token} for a login-ready set of + * {@link Tokens} via Custom + * Token Exchange. Unlike {@link #customTokenExchange(String, String, String)}, the returned + * ID token is verified (including {@code org_id}/{@code org_name} claims when an organization is + * configured), yielding tokens suitable for establishing an application session. + * + *The application supplies the {@code domain} to target; see + * {@link #customTokenExchange(String, String, String)} for why.
+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @param domain the Auth0 domain to target. + * @return a {@link TokenExchangeRequest} to configure and execute. + */ + public TokenExchangeRequest loginWithCustomTokenExchange(String subjectToken, String subjectTokenType, String domain) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + Validate.notNull(domain, "domain must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, domain, true); + } + + /** + * Builds a login-shaped Custom Token Exchange request using the statically configured domain. + * See {@link #loginWithCustomTokenExchange(String, String, String)} for details. + * + *This overload is only valid when the controller was configured with a fixed domain. When a + * {@code DomainResolver} is in use, call the overload that accepts a domain.
+ * + * @param subjectToken the external token to exchange. + * @param subjectTokenType the customer-defined URI describing the subject token. + * @return a {@link TokenExchangeRequest} to configure and execute. + * @throws IllegalStateException if the controller was configured with a {@code DomainResolver}. + */ + public TokenExchangeRequest loginWithCustomTokenExchange(String subjectToken, String subjectTokenType) { + Validate.notNull(subjectToken, "subjectToken must not be null"); + Validate.notNull(subjectTokenType, "subjectTokenType must not be null"); + return requestProcessor.buildTokenExchangeRequest(subjectToken, subjectTokenType, true); + } + } diff --git a/src/main/java/com/auth0/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: + *
+ * 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
+ * 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/CustomTokenExchangeException.java b/src/main/java/com/auth0/CustomTokenExchangeException.java
new file mode 100644
index 0000000..9996d10
--- /dev/null
+++ b/src/main/java/com/auth0/CustomTokenExchangeException.java
@@ -0,0 +1,37 @@
+package com.auth0;
+
+/**
+ * Represents a client-side validation error raised before performing a Custom Token Exchange
+ * request against the Auth0 Authentication API. These are thrown for malformed inputs (an empty or
+ * {@code "Bearer "}-prefixed {@code subject_token}, or a {@code subject_token_type} that is not a
+ * valid URI) so the caller fails fast without a network round-trip.
+ *
+ * @see AuthenticationController#customTokenExchange(String, String)
+ * @see AuthenticationController#loginWithCustomTokenExchange(String, String)
+ */
+@SuppressWarnings("WeakerAccess")
+public class CustomTokenExchangeException extends IdentityVerificationException {
+
+ static final String INVALID_TOKEN_FORMAT = "a0.cte_invalid_token_format";
+ static final String INVALID_TOKEN_TYPE_URI = "a0.cte_invalid_token_type_uri";
+
+ CustomTokenExchangeException(String code, String message) {
+ super(code, message, null);
+ }
+
+ /**
+ * @return true if the error is due to an empty, whitespace-only, or {@code "Bearer "}-prefixed
+ * token value.
+ */
+ public boolean isInvalidTokenFormat() {
+ return INVALID_TOKEN_FORMAT.equals(getCode());
+ }
+
+ /**
+ * @return true if the error is due to a {@code subject_token_type} value that is not a valid
+ * URI.
+ */
+ public boolean isInvalidTokenTypeUri() {
+ return INVALID_TOKEN_TYPE_URI.equals(getCode());
+ }
+}
diff --git a/src/main/java/com/auth0/RenewAuthRequest.java b/src/main/java/com/auth0/RenewAuthRequest.java
new file mode 100644
index 0000000..60895bc
--- /dev/null
+++ b/src/main/java/com/auth0/RenewAuthRequest.java
@@ -0,0 +1,88 @@
+package com.auth0;
+
+import com.auth0.client.auth.AuthAPI;
+import com.auth0.exception.Auth0Exception;
+import com.auth0.json.auth.TokenHolder;
+import com.auth0.net.TokenRequest;
+
+/**
+ * Class to exchange a refresh token for a new set of {@link Tokens}, optionally targeting a
+ * specific {@code audience} and/or {@code scope}. This exposes Auth0's refresh-token grant,
+ * enabling Multi-Resource Refresh Token (MRRT) flows where one refresh token can obtain access
+ * tokens for multiple APIs.
+ *
+ * The library remains stateless: the application owns storage of the refresh token, caching of
+ * the resulting access tokens, and any concurrency control around refresh-token rotation.
+ *
+ * Obtain an instance via {@link AuthenticationController#renewAuth(String, String)},
+ * {@link AuthenticationController#renewAuth(String)}, or
+ * {@link AuthenticationController#renewAuth(String, jakarta.servlet.http.HttpServletRequest)}.
+ */
+@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"})
+public class RenewAuthRequest {
+
+ private final AuthAPI client;
+ private final String refreshToken;
+ private final String domain;
+ private final String issuer;
+ private String audience;
+ private String scope;
+
+ RenewAuthRequest(AuthAPI client, String refreshToken, String domain, String issuer) {
+ this.client = client;
+ this.refreshToken = refreshToken;
+ this.domain = domain;
+ this.issuer = issuer;
+ }
+
+ /**
+ * Sets the audience to request an access token for. When not set, Auth0 uses the default
+ * audience configured for the application.
+ *
+ * Note: if the requested audience is not permitted by the application's MRRT policy, Auth0
+ * does not error; it returns a token for the default audience instead. Callers must verify
+ * the {@code aud} claim of the returned access token.
+ *
+ * @param audience the audience (API identifier) to request a token for.
+ * @return this request instance for fluent chaining.
+ */
+ public RenewAuthRequest withAudience(String audience) {
+ this.audience = audience;
+ return this;
+ }
+
+ /**
+ * Sets the scope to request for the access token.
+ *
+ * @param scope the requested scope.
+ * @return this request instance for fluent chaining.
+ */
+ public RenewAuthRequest withScope(String scope) {
+ this.scope = scope;
+ return this;
+ }
+
+ /**
+ * Executes the refresh-token grant against Auth0 and returns the resulting tokens.
+ *
+ * The refresh-token grant does not return an ID token, so {@link Tokens#getIdToken()} is
+ * typically null. When refresh-token rotation is enabled, the returned
+ * {@link Tokens#getRefreshToken()} is a new refresh token that supersedes the one used here;
+ * the application is responsible for persisting it.
+ *
+ * @return the {@link Tokens} obtained from the grant, including the granted scope.
+ * @throws Auth0Exception if the request to the Auth0 server failed.
+ */
+ public Tokens execute() throws Auth0Exception {
+ TokenRequest request = client.renewAuth(refreshToken);
+ if (audience != null) {
+ request.setAudience(audience);
+ }
+ if (scope != null) {
+ request.setScope(scope);
+ }
+ TokenHolder holder = request.execute().getBody();
+ return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(),
+ holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer);
+ }
+}
diff --git a/src/main/java/com/auth0/RequestProcessor.java b/src/main/java/com/auth0/RequestProcessor.java
index c1e9512..8dcb591 100644
--- a/src/main/java/com/auth0/RequestProcessor.java
+++ b/src/main/java/com/auth0/RequestProcessor.java
@@ -2,11 +2,14 @@
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;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.JwkProvider;
@@ -45,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;
@@ -170,6 +174,233 @@ AuthAPI createClientForDomain(String domain) {
.build();
}
+ /**
+ * Builds a {@link RenewAuthRequest} to exchange a refresh token for new tokens against the
+ * given domain. The domain is supplied explicitly because a refresh can occur outside of an
+ * HTTP request (e.g. a background refresh), where the {@link DomainProvider} cannot resolve it.
+ *
+ * @param refreshToken the refresh token to exchange.
+ * @param domain the Auth0 domain to target.
+ * @return a {@link RenewAuthRequest} ready to configure and execute.
+ */
+ RenewAuthRequest buildRenewAuthRequest(String refreshToken, String domain) {
+ AuthAPI client = createClientForDomain(domain);
+ String issuer = constructIssuer(domain);
+ return new RenewAuthRequest(client, refreshToken, domain, issuer);
+ }
+
+ /**
+ * Builds a {@link RenewAuthRequest} using the statically configured domain. Only valid when the
+ * controller was configured with a fixed domain; when a {@link DomainResolver} is in use there
+ * is no fixed domain to target and the domain must be supplied explicitly.
+ *
+ * @param refreshToken the refresh token to exchange.
+ * @return a {@link RenewAuthRequest} ready to configure and execute.
+ * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}.
+ */
+ RenewAuthRequest buildRenewAuthRequest(String refreshToken) {
+ if (!(domainProvider instanceof StaticDomainProvider)) {
+ throw new IllegalStateException("A domain is required when using a DomainResolver; call renewAuth(refreshToken, domain).");
+ }
+ return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(null));
+ }
+
+ /**
+ * Builds a {@link RenewAuthRequest} resolving the domain from the given request via the
+ * configured {@link DomainProvider}. Works for both a fixed domain and a {@link DomainResolver}.
+ *
+ * @param refreshToken the refresh token to exchange.
+ * @param request the current HTTP request, used to resolve the domain.
+ * @return a {@link RenewAuthRequest} ready to configure and execute.
+ */
+ RenewAuthRequest buildRenewAuthRequest(String refreshToken, HttpServletRequest request) {
+ return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(request));
+ }
+
+ /**
+ * Builds a {@link TokenExchangeRequest} to exchange an external {@code subject_token} for Auth0
+ * tokens against the given domain. The domain is supplied explicitly because a token exchange
+ * can occur outside of an HTTP request, where the {@link DomainProvider} cannot resolve it.
+ *
+ * @param subjectToken the external token to exchange.
+ * @param subjectTokenType the (customer-defined) URI describing the subject token.
+ * @param domain the Auth0 domain to target.
+ * @param loginSemantics whether to verify the returned ID token (incl. organization claims).
+ * @return a {@link TokenExchangeRequest} ready to configure and execute.
+ */
+ TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, String domain, boolean loginSemantics) {
+ String issuer = constructIssuer(domain);
+ return new TokenExchangeRequest(this, subjectToken, subjectTokenType, domain, issuer, loginSemantics, this.organization);
+ }
+
+ /**
+ * Builds a {@link TokenExchangeRequest} using the statically configured domain. Only valid when
+ * the controller was configured with a fixed domain; when a {@link DomainResolver} is in use the
+ * domain must be supplied explicitly.
+ *
+ * @param subjectToken the external token to exchange.
+ * @param subjectTokenType the (customer-defined) URI describing the subject token.
+ * @param loginSemantics whether to verify the returned ID token (incl. organization claims).
+ * @return a {@link TokenExchangeRequest} ready to configure and execute.
+ * @throws IllegalStateException if the controller was configured with a {@link DomainResolver}.
+ */
+ TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, boolean loginSemantics) {
+ if (!(domainProvider instanceof StaticDomainProvider)) {
+ throw new IllegalStateException("A domain is required when using a DomainResolver; call the customTokenExchange overload that accepts a domain.");
+ }
+ return buildTokenExchangeRequest(subjectToken, subjectTokenType, domainProvider.getDomain(null), loginSemantics);
+ }
+
+ /**
+ * 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
+ * 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.
+ *
+ * The request is built via {@link AuthAPI#exchangeToken(String, String)} (RFC 8693 token-exchange
+ * grant), which also applies client authentication. When {@code loginSemantics} is true the
+ * returned ID token is verified (reusing the code-flow verification path, including
+ * organization-claim validation).
+ *
+ * @throws IdentityVerificationException if the returned ID token fails verification.
+ * @throws Auth0Exception if the request to the Auth0 server failed.
+ */
+ Tokens executeCustomTokenExchange(String subjectToken, String subjectTokenType, String audience,
+ String scope, String organization, String domain, String issuer,
+ boolean loginSemantics) throws IdentityVerificationException, Auth0Exception {
+ TokenRequest request = createClientForDomain(domain).exchangeToken(subjectToken, subjectTokenType);
+
+ if (audience != null) {
+ request.setAudience(audience);
+ }
+ if (scope != null) {
+ request.setScope(scope);
+ }
+ if (organization != null) {
+ request.addParameter("organization", organization);
+ }
+
+ TokenHolder holder = request.execute().getBody();
+
+ if (holder.getIdToken() == null) {
+ // The login path establishes a user session, so an ID token is required.
+ if (loginSemantics) {
+ throw new InvalidRequestException(MISSING_ID_TOKEN, "ID Token is missing from the token exchange response.");
+ }
+ } else if (loginSemantics || organization != null) {
+ // Verify the returned ID token on the login path; also verify (for org_id/org_name claim
+ // validation) on the utility path whenever an organization is in play.
+ try {
+ verifyIdToken(holder.getIdToken(), issuer, domain, null, organization);
+ } catch (IdTokenValidationException e) {
+ throw new IdentityVerificationException(JWT_VERIFICATION_ERROR, "An error occurred while trying to verify the ID Token.", e);
+ }
+ }
+
+ return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(),
+ holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), domain, issuer);
+ }
+
private Auth0HttpClient getHttpClient() {
if (this.httpClient == null) {
DefaultHttpClient.Builder httpBuilder = DefaultHttpClient.newBuilder()
@@ -311,6 +542,10 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse
* - HS256: uses client secret
*/
private void verifyIdToken(String idToken, String issuer, String domain, String nonce) throws IdTokenValidationException {
+ verifyIdToken(idToken, issuer, domain, nonce, organization);
+ }
+
+ private void verifyIdToken(String idToken, String issuer, String domain, String nonce, String organization) throws IdTokenValidationException {
SignatureVerifier sigVerifier = buildSignatureVerifier(idToken, domain);
IdTokenVerifier.Builder verifierBuilder = IdTokenVerifier.init(issuer, clientId, sigVerifier);
@@ -450,7 +685,7 @@ private Tokens exchangeCodeForTokens(String authorizationCode, String redirectUr
.execute()
.getBody();
String originIssuer = constructIssuer(originDomain);
- return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn(), originDomain, originIssuer);
+ return new Tokens(holder.getAccessToken(), holder.getIdToken(), holder.getRefreshToken(), holder.getTokenType(), holder.getExpiresIn(), holder.getScope(), originDomain, originIssuer);
}
/**
@@ -470,15 +705,18 @@ private Tokens mergeTokens(Tokens frontChannelTokens, Tokens codeExchangeTokens)
String accessToken;
String type;
Long expiresIn;
+ String scope;
if (codeExchangeTokens.getAccessToken() != null) {
accessToken = codeExchangeTokens.getAccessToken();
type = codeExchangeTokens.getType();
expiresIn = codeExchangeTokens.getExpiresIn();
+ scope = codeExchangeTokens.getScope();
} else {
accessToken = frontChannelTokens.getAccessToken();
type = frontChannelTokens.getType();
expiresIn = frontChannelTokens.getExpiresIn();
+ scope = frontChannelTokens.getScope();
}
// Prefer ID token from the front-channel
@@ -493,7 +731,7 @@ private Tokens mergeTokens(Tokens frontChannelTokens, Tokens codeExchangeTokens)
String issuer = frontChannelTokens.getIssuer() != null ? frontChannelTokens.getIssuer()
: codeExchangeTokens.getIssuer();
- return new Tokens(accessToken, idToken, refreshToken, type, expiresIn, domain, issuer);
+ return new Tokens(accessToken, idToken, refreshToken, type, expiresIn, scope, domain, issuer);
}
private String constructIssuer(String domain) {
diff --git a/src/main/java/com/auth0/TokenExchangeRequest.java b/src/main/java/com/auth0/TokenExchangeRequest.java
new file mode 100644
index 0000000..9424ef9
--- /dev/null
+++ b/src/main/java/com/auth0/TokenExchangeRequest.java
@@ -0,0 +1,144 @@
+package com.auth0;
+
+import com.auth0.exception.Auth0Exception;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+
+/**
+ * Class to perform a Custom
+ * Token Exchange: exchanging an external {@code subject_token} for Auth0 {@link Tokens} via the
+ * RFC 8693 grant {@code urn:ietf:params:oauth:grant-type:token-exchange}, optionally targeting an
+ * {@code audience}, {@code scope}, and/or {@code organization}.
+ *
+ * The library remains stateless: the application owns storage of the returned tokens.
+ *
+ * Obtain an instance via {@link AuthenticationController#customTokenExchange(String, String)} /
+ * {@link AuthenticationController#loginWithCustomTokenExchange(String, String)} (and their
+ * domain-qualified overloads). The {@code loginWith*} variants always verify the returned ID token.
+ * The utility variant returns the raw exchanged tokens without verification, except when an
+ * organization is configured: to validate the {@code org_id}/{@code org_name} claim it must verify
+ * the ID token that carries it, so a returned ID token is fully verified on either path whenever an
+ * organization is in play. See {@link #withOrganization(String)}.
+ */
+@SuppressWarnings({"UnusedReturnValue", "WeakerAccess", "unused"})
+public class TokenExchangeRequest {
+
+ private final RequestProcessor processor;
+ private final String subjectToken;
+ private final String subjectTokenType;
+ private final String domain;
+ private final String issuer;
+ private final boolean loginSemantics;
+
+ private String audience;
+ private String scope;
+ private String organization;
+
+ TokenExchangeRequest(RequestProcessor processor, String subjectToken, String subjectTokenType,
+ String domain, String issuer, boolean loginSemantics, String organization) {
+ this.processor = processor;
+ this.subjectToken = subjectToken;
+ this.subjectTokenType = subjectTokenType;
+ this.domain = domain;
+ this.issuer = issuer;
+ this.loginSemantics = loginSemantics;
+ this.organization = organization;
+ }
+
+ /**
+ * Sets the audience (API identifier) to request an access token for. When not set, Auth0 uses
+ * the default audience configured for the application.
+ *
+ * @param audience the audience to request a token for.
+ * @return this request instance for fluent chaining.
+ */
+ public TokenExchangeRequest withAudience(String audience) {
+ this.audience = audience;
+ return this;
+ }
+
+ /**
+ * Sets the scope to request for the access token.
+ *
+ * @param scope the requested scope.
+ * @return this request instance for fluent chaining.
+ */
+ public TokenExchangeRequest withScope(String scope) {
+ this.scope = scope;
+ return this;
+ }
+
+ /**
+ * Sets the organization to associate with the exchange, overriding any client-level default
+ * configured on the {@link AuthenticationController}. When an organization is set, the returned
+ * ID token's {@code org_id}/{@code org_name} claim is validated against this value on both the
+ * {@code loginWith*} and the utility path (whenever the exchange returns an ID token). Because
+ * an organization claim cannot be trusted without verifying the token that carries it, the
+ * utility path performs full ID-token verification (signature, issuer, expiry) in this case, so
+ * an {@link IdentityVerificationException} may be thrown even when only an access token was
+ * wanted.
+ *
+ * @param organization the organization ID or name.
+ * @return this request instance for fluent chaining.
+ */
+ public TokenExchangeRequest withOrganization(String organization) {
+ this.organization = organization;
+ return this;
+ }
+
+ /**
+ * Executes the token exchange against Auth0 and returns the resulting tokens.
+ *
+ * @return the {@link Tokens} obtained from the exchange, including the granted scope.
+ * @throws CustomTokenExchangeException if the request fails client-side validation.
+ * @throws IdentityVerificationException if the exchange fails or, on the {@code loginWith*}
+ * path, the returned ID token fails verification.
+ * @throws Auth0Exception if the request to the Auth0 server failed.
+ */
+ public Tokens execute() throws IdentityVerificationException, Auth0Exception {
+ validate();
+ return processor.executeCustomTokenExchange(
+ subjectToken, subjectTokenType, audience, scope, organization,
+ domain, issuer, loginSemantics);
+ }
+
+ private void validate() throws CustomTokenExchangeException {
+ assertValidTokenFormat(subjectToken);
+ assertValidTokenTypeUri(subjectTokenType);
+ }
+
+ private void assertValidTokenFormat(String token) throws CustomTokenExchangeException {
+ if (token == null || token.trim().isEmpty()) {
+ throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT,
+ "The subject token must not be empty.");
+ }
+ // Reject surrounding whitespace so a stray space/newline can't slip a malformed token
+ // through (and so a leading space doesn't hide a "Bearer " prefix from the check below).
+ if (!token.equals(token.trim())) {
+ throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT,
+ "The subject token must not contain leading or trailing whitespace.");
+ }
+ if (token.regionMatches(true, 0, "Bearer ", 0, 7)) {
+ throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_FORMAT,
+ "The subject token must not carry a \"Bearer \" prefix.");
+ }
+ }
+
+ private void assertValidTokenTypeUri(String tokenType) throws CustomTokenExchangeException {
+ if (tokenType == null || tokenType.trim().isEmpty()) {
+ throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI,
+ "The subject token type must be a valid URI.");
+ }
+ try {
+ URI uri = new URI(tokenType);
+ if (!uri.isAbsolute()) {
+ throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI,
+ "The subject token type must be an absolute URI.");
+ }
+ } catch (URISyntaxException e) {
+ throw new CustomTokenExchangeException(CustomTokenExchangeException.INVALID_TOKEN_TYPE_URI,
+ "The subject token type must be a valid URI.");
+ }
+ }
+}
diff --git a/src/main/java/com/auth0/Tokens.java b/src/main/java/com/auth0/Tokens.java
index cd3951d..fa02682 100644
--- a/src/main/java/com/auth0/Tokens.java
+++ b/src/main/java/com/auth0/Tokens.java
@@ -22,6 +22,7 @@ public class Tokens implements Serializable {
private final String refreshToken;
private final String type;
private final Long expiresIn;
+ private final String scope;
private final String domain;
private final String issuer;
@@ -49,11 +50,29 @@ public Tokens(String accessToken, String idToken, String refreshToken, String ty
* @param issuer the issuer URL from the ID token
*/
public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String domain, String issuer) {
+ this(accessToken, idToken, refreshToken, type, expiresIn, null, domain, issuer);
+ }
+
+ /**
+ * Full constructor including the granted scope.
+ *
+ * @param accessToken access token for Auth0 API
+ * @param idToken identity token with user information
+ * @param refreshToken refresh token that can be used to request new tokens
+ * without signing in again
+ * @param type token type
+ * @param expiresIn token expiration
+ * @param scope the scope granted for the access token, or null if not provided
+ * @param domain the Auth0 domain that issued these tokens
+ * @param issuer the issuer URL from the ID token
+ */
+ public Tokens(String accessToken, String idToken, String refreshToken, String type, Long expiresIn, String scope, String domain, String issuer) {
this.accessToken = accessToken;
this.idToken = idToken;
this.refreshToken = refreshToken;
this.type = type;
this.expiresIn = expiresIn;
+ this.scope = scope;
this.domain = domain;
this.issuer = issuer;
}
@@ -103,6 +122,15 @@ public Long getExpiresIn() {
return expiresIn;
}
+ /**
+ * Getter for the scope granted for the Access Token.
+ *
+ * @return the granted scope, or null if not provided.
+ */
+ public String getScope() {
+ return scope;
+ }
+
/**
* Getter for the Auth0 domain that issued these tokens.
diff --git a/src/test/java/com/auth0/AuthenticationControllerTest.java b/src/test/java/com/auth0/AuthenticationControllerTest.java
index 6af69ac..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.*;
@@ -250,6 +253,345 @@ public void shouldThrowExceptionWhenBuildAuthorizeUrlRedirectUriIsNull() {
assertThat(exception.getMessage(), is("redirectUri must not be null"));
}
+ // --- renewAuth Tests ---
+
+ @Test
+ public void shouldRenewAuthWithDomain() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class);
+ when(mockRequestProcessor.buildRenewAuthRequest("refreshToken", DOMAIN)).thenReturn(mockRenewAuthRequest);
+
+ RenewAuthRequest result = controller.renewAuth("refreshToken", DOMAIN);
+
+ assertThat(result, is(mockRenewAuthRequest));
+ verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken", DOMAIN);
+ }
+
+ @Test
+ public void shouldRenewAuthWithoutDomain() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class);
+ when(mockRequestProcessor.buildRenewAuthRequest("refreshToken")).thenReturn(mockRenewAuthRequest);
+
+ RenewAuthRequest result = controller.renewAuth("refreshToken");
+
+ assertThat(result, is(mockRenewAuthRequest));
+ verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken");
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenRenewAuthRefreshTokenIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.renewAuth(null, DOMAIN));
+ assertThat(exception.getMessage(), is("refreshToken must not be null"));
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenRenewAuthDomainIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.renewAuth("refreshToken", (String) null));
+ assertThat(exception.getMessage(), is("domain must not be null"));
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenNoArgRenewAuthRefreshTokenIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.renewAuth(null));
+ assertThat(exception.getMessage(), is("refreshToken must not be null"));
+ }
+
+ @Test
+ public void shouldRenewAuthWithRequest() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ RenewAuthRequest mockRenewAuthRequest = mock(RenewAuthRequest.class);
+ when(mockRequestProcessor.buildRenewAuthRequest("refreshToken", request)).thenReturn(mockRenewAuthRequest);
+
+ RenewAuthRequest result = controller.renewAuth("refreshToken", request);
+
+ assertThat(result, is(mockRenewAuthRequest));
+ verify(mockRequestProcessor).buildRenewAuthRequest("refreshToken", request);
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenRenewAuthWithRequestRefreshTokenIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.renewAuth((String) null, request));
+ assertThat(exception.getMessage(), is("refreshToken must not be null"));
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenRenewAuthRequestIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.renewAuth("refreshToken", (HttpServletRequest) null));
+ assertThat(exception.getMessage(), is("request must not be null"));
+ }
+
+ // --- customTokenExchange Tests ---
+
+ @Test
+ public void shouldCustomTokenExchangeWithDomain() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class);
+ when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false)).thenReturn(mockRequest);
+
+ TokenExchangeRequest result = controller.customTokenExchange("subjectToken", "custom:token", DOMAIN);
+
+ assertThat(result, is(mockRequest));
+ verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, false);
+ }
+
+ @Test
+ public void shouldCustomTokenExchangeWithoutDomain() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class);
+ when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", false)).thenReturn(mockRequest);
+
+ TokenExchangeRequest result = controller.customTokenExchange("subjectToken", "custom:token");
+
+ assertThat(result, is(mockRequest));
+ verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", false);
+ }
+
+ @Test
+ public void shouldLoginWithCustomTokenExchangeWithDomain() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class);
+ when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true)).thenReturn(mockRequest);
+
+ TokenExchangeRequest result = controller.loginWithCustomTokenExchange("subjectToken", "custom:token", DOMAIN);
+
+ assertThat(result, is(mockRequest));
+ verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", DOMAIN, true);
+ }
+
+ @Test
+ public void shouldLoginWithCustomTokenExchangeWithoutDomain() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ TokenExchangeRequest mockRequest = mock(TokenExchangeRequest.class);
+ when(mockRequestProcessor.buildTokenExchangeRequest("subjectToken", "custom:token", true)).thenReturn(mockRequest);
+
+ TokenExchangeRequest result = controller.loginWithCustomTokenExchange("subjectToken", "custom:token");
+
+ assertThat(result, is(mockRequest));
+ verify(mockRequestProcessor).buildTokenExchangeRequest("subjectToken", "custom:token", true);
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenCustomTokenExchangeSubjectTokenIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.customTokenExchange(null, "custom:token", DOMAIN));
+ assertThat(exception.getMessage(), is("subjectToken must not be null"));
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenCustomTokenExchangeSubjectTokenTypeIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.customTokenExchange("subjectToken", null, DOMAIN));
+ assertThat(exception.getMessage(), is("subjectTokenType must not be null"));
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenCustomTokenExchangeDomainIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.customTokenExchange("subjectToken", "custom:token", null));
+ assertThat(exception.getMessage(), is("domain must not be null"));
+ }
+
+ @Test
+ public void shouldThrowExceptionWhenLoginWithCustomTokenExchangeSubjectTokenIsNull() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+
+ NullPointerException exception = assertThrows(
+ NullPointerException.class,
+ () -> controller.loginWithCustomTokenExchange(null, "custom:token"));
+ assertThat(exception.getMessage(), is("subjectToken must not be null"));
+ }
+
+ // --- backChannelAuthorize Tests ---
+
+ @Test
+ public void shouldBackChannelAuthorizeWithDomain() {
+ AuthenticationController controller = new AuthenticationController(mockRequestProcessor);
+ BackChannelAuthorizeRequest mockRequest = mock(BackChannelAuthorizeRequest.class);
+ Map