Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
82 changes: 82 additions & 0 deletions src/main/java/com/auth0/AuthenticationController.java
Original file line number Diff line number Diff line change
Expand Up @@ -445,4 +445,86 @@ public RenewAuthRequest renewAuth(String refreshToken, HttpServletRequest reques
return requestProcessor.buildRenewAuthRequest(refreshToken, request);
}

/**
* Builds a request to exchange an external {@code subject_token} for a new set of
* {@link Tokens} via <a href="https://auth0.com/docs/authenticate/custom-token-exchange">Custom
* Token Exchange</a>, without login semantics. The returned tokens are not verified beyond the
* exchange itself, making this suitable for obtaining tokens for a downstream API.
*
* <p>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.</p>
*
* @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.
*
* <p>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.</p>
*
* @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 <a href="https://auth0.com/docs/authenticate/custom-token-exchange">Custom
* Token Exchange</a>. 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.
*
* <p>The application supplies the {@code domain} to target; see
* {@link #customTokenExchange(String, String, String)} for why.</p>
*
* @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.
*
* <p>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.</p>
*
* @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);
}

}
37 changes: 37 additions & 0 deletions src/main/java/com/auth0/CustomTokenExchangeException.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
86 changes: 86 additions & 0 deletions src/main/java/com/auth0/RequestProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.auth0.exception.PublicKeyProviderException;
import com.auth0.jwt.JWT;
import com.auth0.json.auth.TokenHolder;
import com.auth0.net.TokenRequest;
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.JwkProvider;
Expand Down Expand Up @@ -213,6 +214,87 @@ RenewAuthRequest buildRenewAuthRequest(String refreshToken, HttpServletRequest r
return buildRenewAuthRequest(refreshToken, domainProvider.getDomain(request));
}

/**
* Builds a {@link TokenExchangeRequest} to exchange an external {@code subject_token} for Auth0
* tokens against the given domain. The domain is supplied explicitly because a token exchange
* can occur outside of an HTTP request, where the {@link DomainProvider} cannot resolve it.
*
* @param subjectToken the external token to exchange.
* @param subjectTokenType the (customer-defined) URI describing the subject token.
* @param domain the Auth0 domain to target.
* @param loginSemantics whether to verify the returned ID token (incl. organization claims).
* @return a {@link TokenExchangeRequest} ready to configure and execute.
*/
TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, String domain, boolean loginSemantics) {
String issuer = constructIssuer(domain);
return new TokenExchangeRequest(this, subjectToken, subjectTokenType, domain, issuer, loginSemantics, this.organization);
}

/**
* Builds a {@link TokenExchangeRequest} using the statically configured domain. Only valid when
* the controller was configured with a fixed domain; when a {@link DomainResolver} is in use the
* domain must be supplied explicitly.
*
* @param subjectToken the external token to exchange.
* @param subjectTokenType the (customer-defined) URI describing the subject token.
* @param loginSemantics whether to verify the returned ID token (incl. organization claims).
* @return a {@link TokenExchangeRequest} ready to configure and execute.
* @throws IllegalStateException if the controller was configured with a {@link DomainResolver}.
*/
TokenExchangeRequest buildTokenExchangeRequest(String subjectToken, String subjectTokenType, boolean loginSemantics) {
if (!(domainProvider instanceof StaticDomainProvider)) {
throw new IllegalStateException("A domain is required when using a DomainResolver; call the customTokenExchange overload that accepts a domain.");
}
return buildTokenExchangeRequest(subjectToken, subjectTokenType, domainProvider.getDomain(null), loginSemantics);
}

/**
* Performs the Custom Token Exchange grant against Auth0 and returns the resulting tokens.
* <p>
* 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()
Expand Down Expand Up @@ -354,6 +436,10 @@ private Tokens getVerifiedTokens(HttpServletRequest request, HttpServletResponse
* - HS256: uses client secret
*/
private void verifyIdToken(String idToken, String issuer, String domain, String nonce) throws IdTokenValidationException {
verifyIdToken(idToken, issuer, domain, nonce, organization);
}

private void verifyIdToken(String idToken, String issuer, String domain, String nonce, String organization) throws IdTokenValidationException {
SignatureVerifier sigVerifier = buildSignatureVerifier(idToken, domain);

IdTokenVerifier.Builder verifierBuilder = IdTokenVerifier.init(issuer, clientId, sigVerifier);
Expand Down
144 changes: 144 additions & 0 deletions src/main/java/com/auth0/TokenExchangeRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package com.auth0;

import com.auth0.exception.Auth0Exception;

import java.net.URI;
import java.net.URISyntaxException;

/**
* Class to perform a <a href="https://auth0.com/docs/authenticate/custom-token-exchange">Custom
* Token Exchange</a>: 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}.
* <p>
* The library remains stateless: the application owns storage of the returned tokens.
* <p>
* 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.");
}
}
}
Loading