From a78c3b2957a068278accbebd5bc01cfabade3d1b Mon Sep 17 00:00:00 2001 From: Jim Anderson Date: Tue, 24 Jan 2023 17:27:39 -0600 Subject: [PATCH] [SDK-3858] Support JWT Client Authentication (#507) * [SDK-3858] Support JWT Client Authentication * rename builder method --- .../java/com/auth0/client/auth/AuthAPI.java | 107 ++++++++----- .../client/auth/ClientAssertionSigner.java | 20 +++ .../client/auth/RSAClientAssertionSigner.java | 102 ++++++++++++ .../ClientAssertionSigningException.java | 26 +++ .../com/auth0/client/auth/AuthAPITest.java | 138 ++++++++++++---- .../auth/RSAClientAssertionSignerTest.java | 151 ++++++++++++++++++ src/test/resources/auth/rsa_private_key.pem | 28 ++++ src/test/resources/auth/rsa_public_key.pem | 9 ++ 8 files changed, 514 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/auth0/client/auth/ClientAssertionSigner.java create mode 100644 src/main/java/com/auth0/client/auth/RSAClientAssertionSigner.java create mode 100644 src/main/java/com/auth0/exception/ClientAssertionSigningException.java create mode 100644 src/test/java/com/auth0/client/auth/RSAClientAssertionSignerTest.java create mode 100644 src/test/resources/auth/rsa_private_key.pem create mode 100644 src/test/resources/auth/rsa_public_key.pem diff --git a/src/main/java/com/auth0/client/auth/AuthAPI.java b/src/main/java/com/auth0/client/auth/AuthAPI.java index e72415f1..8bbf41bc 100644 --- a/src/main/java/com/auth0/client/auth/AuthAPI.java +++ b/src/main/java/com/auth0/client/auth/AuthAPI.java @@ -54,7 +54,8 @@ public class AuthAPI { private static final String KEY_OTP = "otp"; private static final String KEY_REALM = "realm"; private static final String KEY_MFA_TOKEN = "mfa_token"; - + private static final String KEY_CLIENT_ASSERTION = "client_assertion"; + private static final String KEY_CLIENT_ASSERTION_TYPE = "client_assertion_type"; private static final String PATH_OAUTH = "oauth"; private static final String PATH_TOKEN = "token"; private static final String PATH_DBCONNECTIONS = "dbconnections"; @@ -65,6 +66,7 @@ public class AuthAPI { private final Auth0HttpClient client; private final String clientId; private final String clientSecret; + private final ClientAssertionSigner clientAssertionSigner; private final HttpUrl baseUrl; /** @@ -83,7 +85,7 @@ public class AuthAPI { @Deprecated @SuppressWarnings("deprecation") public AuthAPI(String domain, String clientId, String clientSecret, com.auth0.client.HttpOptions options) { - this(domain, clientId, clientSecret, buildNetworkingClient(options)); + this(domain, clientId, clientSecret, null, buildNetworkingClient(options)); } /** @@ -110,7 +112,20 @@ public AuthAPI(String domain, String clientId, String clientSecret) { * @return a Builder for further configuration. */ public static AuthAPI.Builder newBuilder(String domain, String clientId, String clientSecret) { - return new AuthAPI.Builder(domain, clientId, clientSecret); + return new AuthAPI.Builder(domain, clientId).withClientSecret(clientSecret); + } + + /** + * Initialize a new {@link Builder} to configure and create an instance. Use this to construct an instance + * with a client assertion signer used in place of a client secret when calling token APIs. + * + * @param domain the tenant's domain. Must be a non-null valid HTTPS URL. + * @param clientId the application's client ID. + * @param clientAssertionSigner the {@code ClientAssertionSigner} used to create the signed client assertion. + * @return a Builder for further configuration. + */ + public static AuthAPI.Builder newBuilder(String domain, String clientId, ClientAssertionSigner clientAssertionSigner) { + return new AuthAPI.Builder(domain, clientId).withClientAssertionSigner(clientAssertionSigner); } /** @@ -124,7 +139,7 @@ public static AuthAPI.Builder newBuilder(String domain, String clientId) { return new AuthAPI.Builder(domain, clientId); } - private AuthAPI(String domain, String clientId, String clientSecret, Auth0HttpClient httpClient) { + private AuthAPI(String domain, String clientId, String clientSecret, ClientAssertionSigner clientAssertionSigner, Auth0HttpClient httpClient) { Asserts.assertNotNull(domain, "domain"); Asserts.assertNotNull(clientId, "client id"); Asserts.assertNotNull(httpClient, "Http client"); @@ -135,9 +150,11 @@ private AuthAPI(String domain, String clientId, String clientSecret, Auth0HttpCl } this.clientId = clientId; this.clientSecret = clientSecret; + this.clientAssertionSigner = clientAssertionSigner; this.client = httpClient; - } + + /** * Given a set of options, it creates a new instance of the {@link OkHttpClient} * configuring them according to their availability. @@ -487,7 +504,7 @@ public TokenRequest login(String emailOrUsername, char[] password) { request.addParameter(KEY_GRANT_TYPE, "password"); request.addParameter(KEY_USERNAME, emailOrUsername); request.addParameter(KEY_PASSWORD, password); - addSecret(request, true); + addClientAuthentication(request, true); return request; } @@ -555,7 +572,7 @@ public TokenRequest login(String emailOrUsername, char[] password, String realm) request.addParameter(KEY_USERNAME, emailOrUsername); request.addParameter(KEY_PASSWORD, password); request.addParameter(KEY_REALM, realm); - addSecret(request, true); + addClientAuthentication(request, true); return request; } @@ -597,7 +614,7 @@ public TokenRequest exchangePasswordlessOtp(String emailOrPhone, String realm, c request.addParameter(KEY_USERNAME, emailOrPhone); request.addParameter(KEY_REALM, realm); request.addParameter(KEY_OTP, otp); - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -629,7 +646,7 @@ public TokenRequest requestToken(String audience) { request.addParameter(KEY_CLIENT_ID, clientId); request.addParameter(KEY_GRANT_TYPE, "client_credentials"); request.addParameter(KEY_AUDIENCE, audience); - addSecret(request, true); + addClientAuthentication(request, true); return request; } @@ -663,7 +680,7 @@ public Request revokeToken(String refreshToken) { VoidRequest request = new VoidRequest(client, null, url, HttpMethod.POST); request.addParameter(KEY_CLIENT_ID, clientId); request.addParameter(KEY_TOKEN, refreshToken); - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -696,7 +713,7 @@ public TokenRequest renewAuth(String refreshToken) { request.addParameter(KEY_CLIENT_ID, clientId); request.addParameter(KEY_GRANT_TYPE, "refresh_token"); request.addParameter(KEY_REFRESH_TOKEN, refreshToken); - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -816,7 +833,7 @@ public BaseRequest startPasswordlessEmailFlow(String request.addParameter(KEY_CONNECTION, "email"); request.addParameter(KEY_EMAIL, email); request.addParameter("send", type.getType()); - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -857,7 +874,7 @@ public BaseRequest startPasswordlessSmsFlow(String phon request.addParameter(KEY_CLIENT_ID, clientId); request.addParameter(KEY_CONNECTION, "sms"); request.addParameter("phone_number", phoneNumber); - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -892,7 +909,7 @@ public TokenRequest exchangeMfaOtp(String mfaToken, char[] otp) { request.addParameter(KEY_GRANT_TYPE, "http://auth0.com/oauth/grant-type/mfa-otp"); request.addParameter(KEY_MFA_TOKEN, mfaToken); request.addParameter(KEY_OTP, otp); - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -933,7 +950,7 @@ public TokenRequest exchangeMfaOob(String mfaToken, char[] oobCode, char[] bindi request.addParameter("binding_code", bindingCode); } - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -968,7 +985,7 @@ public TokenRequest exchangeMfaRecoveryCode(String mfaToken, char[] recoveryCode request.addParameter(KEY_MFA_TOKEN, mfaToken); request.addParameter("recovery_code", recoveryCode); - addSecret(request, false); + addClientAuthentication(request, false); return request; } @@ -1008,7 +1025,7 @@ public Request mfaChallengeRequest(String mfaToken, String request.addParameter(KEY_MFA_TOKEN, mfaToken); request.addParameter(KEY_CLIENT_ID, clientId); - addSecret(request, false); + addClientAuthentication(request, false); if (Objects.nonNull(challengeType)) { request.addParameter("challenge_type", challengeType); } @@ -1052,7 +1069,7 @@ public Request addOtpAuthenticator(String mfaToken) { request.addParameter("authenticator_types", Collections.singletonList("otp")); request.addParameter(KEY_CLIENT_ID, clientId); - addSecret(request, false); + addClientAuthentication(request, false); request.addHeader("Authorization", "Bearer " + mfaToken); return request; } @@ -1098,7 +1115,7 @@ public Request addOobAuthenticator(String mfaToken, List request, boolean required) { - if (required && Objects.isNull(this.clientSecret)) { - throw new IllegalStateException("A client secret is required for this operation"); + private void addClientAuthentication(BaseRequest request, boolean required) { + if (required && (this.clientSecret == null && this.clientAssertionSigner == null)) { + throw new IllegalStateException("A client secret or client assertion signing key is required for this operation"); } - if (Objects.nonNull(this.clientSecret)) { - request.addParameter(KEY_CLIENT_SECRET, this.clientSecret); + + if (Objects.nonNull(this.clientAssertionSigner)) { + request.addParameter(KEY_CLIENT_ASSERTION, this.clientAssertionSigner.createSignedClientAssertion(clientId, baseUrl.toString(), clientId)); + request.addParameter(KEY_CLIENT_ASSERTION_TYPE, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + } else if (Objects.nonNull(this.clientSecret)) { + request.addParameter(KEY_CLIENT_SECRET, clientSecret); } } + /** * Builder for {@link AuthAPI} API client instances. */ public static class Builder { private final String domain; private final String clientId; - private final String clientSecret; + private String clientSecret; + private ClientAssertionSigner clientAssertionSigner; private Auth0HttpClient httpClient; - /** - * Create a new Builder - * @param domain the domain of the tenant. - * @param clientId the client ID of the Auth0 application. - * @param clientSecret the client secret of the Auth0 application. - */ - public Builder(String domain, String clientId, String clientSecret) { + public Builder(String domain, String clientId) { this.domain = domain; this.clientId = clientId; + this.clientSecret = null; + } + + /** + * Configure the client with a client secret. + * @param clientSecret the client secret of your application. + * @return the builder instance. + */ + public Builder withClientSecret(String clientSecret) { this.clientSecret = clientSecret; + return this; } - public Builder(String domain, String clientId) { - this.domain = domain; - this.clientId = clientId; - this.clientSecret = null; + /** + * Configure the client with a client assertion signer. + * @param clientAssertionSigner the client assertion signer to create the signed client assertion. + * @return the builder instance. + */ + public Builder withClientAssertionSigner(ClientAssertionSigner clientAssertionSigner) { + this.clientAssertionSigner = clientAssertionSigner; + return this; } /** @@ -1214,7 +1245,7 @@ public Builder withHttpClient(Auth0HttpClient httpClient) { * @return the configured {@code AuthAPI} instance. */ public AuthAPI build() { - return new AuthAPI(domain, clientId, clientSecret, + return new AuthAPI(domain, clientId, clientSecret, clientAssertionSigner, Objects.nonNull(httpClient) ? httpClient : DefaultHttpClient.newBuilder().build()); } } diff --git a/src/main/java/com/auth0/client/auth/ClientAssertionSigner.java b/src/main/java/com/auth0/client/auth/ClientAssertionSigner.java new file mode 100644 index 00000000..679c061f --- /dev/null +++ b/src/main/java/com/auth0/client/auth/ClientAssertionSigner.java @@ -0,0 +1,20 @@ +package com.auth0.client.auth; + +/** + * Responsible for creating a signed client assertion used to authenticate to the Authentication API + * + * @see OpenID Connect Core Specification + */ +public interface ClientAssertionSigner { + + /** + * Creates a signed JWT representing a client assertion used to authenticate to the Authentication API. + * + * @param issuer the Issuer. This MUST contain the client_id of the OAuth Client. + * @param audience the audience that identifies the Authorization Server as an intended audience. + * @param subject the Subject. This MUST contain the client_id of the OAuth Client. + + * @return a signed JWT representing the client assertion. + */ + String createSignedClientAssertion(String issuer, String audience, String subject); +} diff --git a/src/main/java/com/auth0/client/auth/RSAClientAssertionSigner.java b/src/main/java/com/auth0/client/auth/RSAClientAssertionSigner.java new file mode 100644 index 00000000..657c69f7 --- /dev/null +++ b/src/main/java/com/auth0/client/auth/RSAClientAssertionSigner.java @@ -0,0 +1,102 @@ +package com.auth0.client.auth; + +import com.auth0.exception.ClientAssertionSigningException; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTCreationException; +import com.auth0.utils.Asserts; +import org.jetbrains.annotations.TestOnly; + +import java.security.interfaces.RSAPrivateKey; +import java.time.Instant; +import java.util.UUID; + +/** + * An implementation of {@linkplain ClientAssertionSigner} for RSA-signed client assertions. + */ +public class RSAClientAssertionSigner implements ClientAssertionSigner { + + private final RSAPrivateKey assertionSigningKey; + private final RSASigningAlgorithm assertionSigningAlgorithm; + + + /** + * Creates a new instance. + * + * @param assertionSigningKey the private key used to sign the assertion. Must not be null. + * @param assertionSigningAlgorithm The RSA algorithm used to sign the assertion. Must not be null. + * + * @see #RSAClientAssertionSigner(RSAPrivateKey) + */ + public RSAClientAssertionSigner(RSAPrivateKey assertionSigningKey, RSASigningAlgorithm assertionSigningAlgorithm) { + Asserts.assertNotNull(assertionSigningKey, "assertion signing key"); + Asserts.assertNotNull(assertionSigningAlgorithm, "assertion signing algorithm"); + + this.assertionSigningKey = assertionSigningKey; + this.assertionSigningAlgorithm = assertionSigningAlgorithm; + } + + /** + * Creates a new instance using the RSA256 signing algorithm. + * + * @param assertionSigningKey the private key used to sign the assertion. Must not be null. + * + * @see #RSAClientAssertionSigner(RSAPrivateKey, RSASigningAlgorithm) + */ + public RSAClientAssertionSigner(RSAPrivateKey assertionSigningKey) { + this(assertionSigningKey, RSASigningAlgorithm.RSA256); + } + + @Override + public String createSignedClientAssertion(String issuer, String audience, String subject) { + Instant now = Instant.now(); + JWTCreator.Builder builder = JWT.create() + .withIssuer(issuer) + .withAudience(audience) + .withSubject(subject) + .withIssuedAt(now) + .withExpiresAt(now.plusSeconds(180)) + .withClaim("jti", UUID.randomUUID().toString()); + + switch (assertionSigningAlgorithm) { + case RSA256: + try { + return builder.sign(Algorithm.RSA256(null, assertionSigningKey)); + } catch (JWTCreationException exception) { + throw new ClientAssertionSigningException("Error creating the JWT used for client assertion using the RSA256 signing algorithm", exception); + } + case RSA384: + try { + return builder.sign(Algorithm.RSA384(null, assertionSigningKey)); + } catch (JWTCreationException exception) { + throw new ClientAssertionSigningException("Error creating the JWT used for client assertion using the RSA384 signing algorithm", exception); + } + default: + throw new ClientAssertionSigningException("Error creating the JWT used for client assertion. Unknown algorithm."); + } + } + + /** + * @return the assertion signing algorithm configured. + */ + @TestOnly + RSASigningAlgorithm getAssertionSigningAlgorithm() { + return this.assertionSigningAlgorithm; + } + + /** + * Represents the RSA algorithms available to sign the client assertion. + */ + public enum RSASigningAlgorithm { + /** + * The RSA 256 algorithm + */ + RSA256, + + /** + * The RSA 384 algorithm + */ + RSA384 + } +} diff --git a/src/main/java/com/auth0/exception/ClientAssertionSigningException.java b/src/main/java/com/auth0/exception/ClientAssertionSigningException.java new file mode 100644 index 00000000..1de21839 --- /dev/null +++ b/src/main/java/com/auth0/exception/ClientAssertionSigningException.java @@ -0,0 +1,26 @@ +package com.auth0.exception; + +/** + * Reqpresents an exception when creating the signed client assertion. + */ +public class ClientAssertionSigningException extends RuntimeException { + + /** + * Create a new instance. + * + * @param message the message of the exception. + */ + public ClientAssertionSigningException(String message) { + super(message); + } + + /** + * Create a new instance. + * + * @param message the message of the exception. + * @param cause the cause of the exception. + */ + public ClientAssertionSigningException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/test/java/com/auth0/client/auth/AuthAPITest.java b/src/test/java/com/auth0/client/auth/AuthAPITest.java index b2403c72..93c4a878 100644 --- a/src/test/java/com/auth0/client/auth/AuthAPITest.java +++ b/src/test/java/com/auth0/client/auth/AuthAPITest.java @@ -46,7 +46,8 @@ public class AuthAPITest { private MockServer server; private AuthAPI api; - private AuthAPI apiNoSecret; + private AuthAPI apiNoClientAuthentication; + @SuppressWarnings("deprecation") @Rule @@ -56,7 +57,7 @@ public class AuthAPITest { public void setUp() throws Exception { server = new MockServer(); api = AuthAPI.newBuilder(server.getBaseUrl(), CLIENT_ID, CLIENT_SECRET).build(); - apiNoSecret = AuthAPI.newBuilder(server.getBaseUrl(), CLIENT_ID).build(); + apiNoClientAuthentication = AuthAPI.newBuilder(server.getBaseUrl(), CLIENT_ID).build(); } @After @@ -134,7 +135,7 @@ public void shouldThrowWhenClientIdIsNull() { @Test public void shouldAcceptNullClientSecret() { - assertThat(AuthAPI.newBuilder(DOMAIN, CLIENT_ID, null).build(), + assertThat(AuthAPI.newBuilder(DOMAIN, CLIENT_ID, (String) null).build(), is(notNullValue())); } @@ -539,9 +540,9 @@ public void shouldCreateLogInWithAuthorizationCodeGrantRequest() throws Exceptio } @Test - public void authorizationCodeGrantRequestRequiresSecret() throws Exception { - IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoSecret.exchangeCode("code", "https://domain.auth0.com/callback")); - assertThat(e.getMessage(), is("A client secret is required for this operation")); + public void authorizationCodeGrantRequestRequiresClientAuthentication() throws Exception { + IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoClientAuthentication.exchangeCode("code", "https://domain.auth0.com/callback")); + assertThat(e.getMessage(), is("A client secret or client assertion signing key is required for this operation")); } @Test @@ -632,10 +633,10 @@ public void shouldCreateLogInWithPasswordGrantRequest() throws Exception { } @Test - public void passwordGrantRequestRequiresSecret() { + public void passwordGrantRequestRequiresClientAuthentication() { @SuppressWarnings("deprecation") - IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoSecret.login("me", "p455w0rd")); - assertThat(e.getMessage(), is("A client secret is required for this operation")); + IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoClientAuthentication.login("me", "p455w0rd")); + assertThat(e.getMessage(), is("A client secret or client assertion signing key is required for this operation")); } @Test @@ -763,10 +764,10 @@ public void shouldCreateLogInWithPasswordRealmGrantRequest() throws Exception { } @Test - public void passwordRealmGrantRequestRequiresSecret() { + public void passwordRealmGrantRequestRequiresClientAuthentication() { @SuppressWarnings("deprecation") - IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoSecret.login("me", "p455w0rd", "realm")); - assertThat(e.getMessage(), is("A client secret is required for this operation")); + IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoClientAuthentication.login("me", "p455w0rd", "realm")); + assertThat(e.getMessage(), is("A client secret or client assertion signing key is required for this operation")); } @Test @@ -840,9 +841,9 @@ public void shouldCreateLogInWithClientCredentialsGrantRequest() throws Exceptio } @Test - public void clientCredentialsGrantRequestRequiresSecret() { - IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoSecret.requestToken("https://myapi.auth0.com/users")); - assertThat(e.getMessage(), is("A client secret is required for this operation")); + public void clientCredentialsGrantRequestRequiresClientAuthentication() { + IllegalStateException e = assertThrows(IllegalStateException.class, () -> apiNoClientAuthentication.requestToken("https://myapi.auth0.com/users")); + assertThat(e.getMessage(), is("A client secret or client assertion signing key is required for this operation")); } // Login with Passwordless @@ -857,8 +858,8 @@ public void shouldCreateStartEmailPasswordlessFlowRequest() throws Exception { } @Test - public void shouldCreateStartEmailPasswordlessFlowRequestWithoutSecret() throws Exception { - Request request = apiNoSecret.startPasswordlessEmailFlow("user@domain.com", + public void shouldCreateStartEmailPasswordlessFlowRequestWithoutClientAuthentication() throws Exception { + Request request = apiNoClientAuthentication.startPasswordlessEmailFlow("user@domain.com", PasswordlessEmailType.CODE); assertThat(request, is(notNullValue())); @@ -949,8 +950,8 @@ public void shouldCreateStartSmsPasswordlessFlowRequest() throws Exception { } @Test - public void shouldCreateStartSmsPasswordlessFlowRequestWithoutSecret() throws Exception { - Request request = apiNoSecret.startPasswordlessSmsFlow("+16511234567"); + public void shouldCreateStartSmsPasswordlessFlowRequestWithoutClientAuthentication() throws Exception { + Request request = apiNoClientAuthentication.startPasswordlessSmsFlow("+16511234567"); assertThat(request, is(notNullValue())); smsPasswordlessFlow(request, false); @@ -1026,8 +1027,8 @@ public void shouldCreateLoginWithPasswordlessCodeRequest() throws Exception { } @Test - public void shouldCreateLoginWithPasswordlessCodeRequestWithoutSecret() throws Exception { - TokenRequest request = apiNoSecret.exchangePasswordlessOtp("+16511234567", "email", "otp".toCharArray()); + public void shouldCreateLoginWithPasswordlessCodeRequestWithoutClientAuthentication() throws Exception { + TokenRequest request = apiNoClientAuthentication.exchangePasswordlessOtp("+16511234567", "email", "otp".toCharArray()); assertThat(request, is(notNullValue())); passwordlessCodeRequest(request, false); @@ -1078,8 +1079,8 @@ public void shouldCreateRevokeTokenRequest() throws Exception { } @Test - public void shouldCreateRevokeTokenRequestWithoutSecret() throws Exception { - Request request = apiNoSecret.revokeToken("2679NfkaBn62e6w5E8zNEzjr"); + public void shouldCreateRevokeTokenRequestWithoutClientAuthentication() throws Exception { + Request request = apiNoClientAuthentication.revokeToken("2679NfkaBn62e6w5E8zNEzjr"); assertThat(request, is(notNullValue())); revokeTokenRequest(request, false); @@ -1124,8 +1125,8 @@ public void shouldCreateRenewTokenRequest() throws Exception { } @Test - public void shouldCreateRenewTokenRequestWithoutSecret() throws Exception { - TokenRequest request = apiNoSecret.renewAuth("ej2E8zNEzjrcSD2edjaE"); + public void shouldCreateRenewTokenRequestWithoutClientAuthentication() throws Exception { + TokenRequest request = apiNoClientAuthentication.renewAuth("ej2E8zNEzjrcSD2edjaE"); assertThat(request, is(notNullValue())); renewTokenRequest(request, false); @@ -1219,8 +1220,8 @@ public void shouldCreateExchangeMfaOtpRequest() throws Exception { } @Test - public void shouldCreateExchangeMfaOtpRequestWithoutSecret() throws Exception { - TokenRequest request = apiNoSecret.exchangeMfaOtp("mfaToken", new char[]{'o','t','p'}); + public void shouldCreateExchangeMfaOtpRequestWithoutClientAuthentication() throws Exception { + TokenRequest request = apiNoClientAuthentication.exchangeMfaOtp("mfaToken", new char[]{'o','t','p'}); assertThat(request, is(notNullValue())); mfaOtpRequest(request, false); @@ -1277,7 +1278,7 @@ public void shouldCreateExchangeMfaOobRequest() throws Exception { @Test public void shouldCreateExchangeMfaOobRequestWithoutSecret() throws Exception { - TokenRequest request = apiNoSecret.exchangeMfaOob("mfaToken", new char[]{'o','o','b'}, new char[]{'b','o','b'}); + TokenRequest request = apiNoClientAuthentication.exchangeMfaOob("mfaToken", new char[]{'o','o','b'}, new char[]{'b','o','b'}); assertThat(request, is(notNullValue())); mfaOobExchangeRequest(request, "bob", false); @@ -1341,7 +1342,7 @@ public void shouldCreateExchangeMfaRecoveryCodeRequest() throws Exception { @Test public void shouldCreateExchangeMfaRecoveryCodeRequestWithoutSecret() throws Exception { - TokenRequest request = apiNoSecret.exchangeMfaRecoveryCode("mfaToken", new char[]{'c','o','d','e'}); + TokenRequest request = apiNoClientAuthentication.exchangeMfaRecoveryCode("mfaToken", new char[]{'c','o','d','e'}); assertThat(request, is(notNullValue())); mfaRecoveryCodeExchangeRequest(request, false); @@ -1493,4 +1494,83 @@ public void challengeRequest() throws Exception { assertThat(response.getBindingMethod(), not(emptyOrNullString())); assertThat(response.getOobCode(), not(emptyOrNullString())); } + + // Client Assertion tests + @Test + public void shouldAddAndPreferClientAuthentication() throws Exception { + AuthAPI authAPI = AuthAPI.newBuilder(server.getBaseUrl(), CLIENT_ID, CLIENT_SECRET) + .withClientAssertionSigner(new TestAssertionSigner("token")) + .build(); + TokenRequest request = authAPI.exchangeCode("code123", "https://domain.auth0.com/callback"); + assertThat(request, is(notNullValue())); + + server.jsonResponse(AUTH_TOKENS, 200); + TokenHolder response = request.execute().getBody(); + RecordedRequest recordedRequest = server.takeRequest(); + + assertThat(recordedRequest, hasMethodAndPath(HttpMethod.POST, "/oauth/token")); + assertThat(recordedRequest, hasHeader("Content-Type", "application/json")); + + Map body = bodyFromRequest(recordedRequest); + assertThat(body, hasEntry("code", "code123")); + assertThat(body, hasEntry("redirect_uri", "https://domain.auth0.com/callback")); + assertThat(body, hasEntry("grant_type", "authorization_code")); + assertThat(body, hasEntry("client_id", CLIENT_ID)); + assertThat(body, not(hasEntry("client_secret", CLIENT_SECRET))); + assertThat(body, hasEntry("client_assertion", "token")); + assertThat(body, hasEntry("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")); + + assertThat(response, is(notNullValue())); + assertThat(response.getAccessToken(), not(emptyOrNullString())); + assertThat(response.getIdToken(), not(emptyOrNullString())); + assertThat(response.getRefreshToken(), not(emptyOrNullString())); + assertThat(response.getTokenType(), not(emptyOrNullString())); + assertThat(response.getExpiresIn(), is(notNullValue())); + } + + @Test + public void shouldNotAddAnyParamsIfNoSecretOrAssertion() throws Exception { + AuthAPI authAPI = AuthAPI.newBuilder(server.getBaseUrl(), CLIENT_ID).build(); + TokenRequest request = authAPI.exchangeCodeWithVerifier("code123", "verifier", "https://domain.auth0.com/callback"); + + assertThat(request, is(notNullValue())); + + server.jsonResponse(AUTH_TOKENS, 200); + TokenHolder response = request.execute().getBody(); + RecordedRequest recordedRequest = server.takeRequest(); + + assertThat(recordedRequest, hasMethodAndPath(HttpMethod.POST, "/oauth/token")); + assertThat(recordedRequest, hasHeader("Content-Type", "application/json")); + + Map body = bodyFromRequest(recordedRequest); + assertThat(body, hasEntry("code", "code123")); + assertThat(body, hasEntry("code_verifier", "verifier")); + assertThat(body, hasEntry("redirect_uri", "https://domain.auth0.com/callback")); + assertThat(body, hasEntry("grant_type", "authorization_code")); + assertThat(body, hasEntry("client_id", CLIENT_ID)); + assertThat(body, not(hasEntry("client_secret", CLIENT_SECRET))); + assertThat(body, not(hasEntry("client_assertion", "token"))); + assertThat(body, not(hasEntry("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"))); + + assertThat(response, is(notNullValue())); + assertThat(response.getAccessToken(), not(emptyOrNullString())); + assertThat(response.getIdToken(), not(emptyOrNullString())); + assertThat(response.getRefreshToken(), not(emptyOrNullString())); + assertThat(response.getTokenType(), not(emptyOrNullString())); + assertThat(response.getExpiresIn(), is(notNullValue())); + } + + static class TestAssertionSigner implements ClientAssertionSigner { + + private final String token; + + public TestAssertionSigner(String token) { + this.token = token; + } + + @Override + public String createSignedClientAssertion(String issuer, String audience, String subject) { + return token; + } + } } diff --git a/src/test/java/com/auth0/client/auth/RSAClientAssertionSignerTest.java b/src/test/java/com/auth0/client/auth/RSAClientAssertionSignerTest.java new file mode 100644 index 00000000..598e0ccb --- /dev/null +++ b/src/test/java/com/auth0/client/auth/RSAClientAssertionSignerTest.java @@ -0,0 +1,151 @@ +package com.auth0.client.auth; + +import com.auth0.exception.ClientAssertionSigningException; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTCreationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import org.junit.Test; + +import java.io.File; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.EncodedKeySpec; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import static org.junit.Assert.assertThrows; + +import static org.mockito.Mockito.mock; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.when; + +public class RSAClientAssertionSignerTest { + + private static final String PRIVATE_KEY_FILE_RSA = "src/test/resources/auth/rsa_private_key.pem"; + private static final String PUBLIC_KEY_FILE_RSA = "src/test/resources/auth/rsa_public_key.pem"; + + @Test + public void defaultsToRS256() { + RSAPrivateKey privateKey = mock(RSAPrivateKey.class); + RSAClientAssertionSigner rsa = new RSAClientAssertionSigner(privateKey); + assertThat(rsa.getAssertionSigningAlgorithm(), is(RSAClientAssertionSigner.RSASigningAlgorithm.RSA256)); + } + + @Test + public void throwsOnNullSigningKey() { + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new RSAClientAssertionSigner(null)); + assertThat(exception.getMessage(), is("'assertion signing key' cannot be null!")); + } + + @Test + public void throwsOnNullSigningAlgorithm() { + RSAPrivateKey privateKey = mock(RSAPrivateKey.class); + IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, () -> new RSAClientAssertionSigner(privateKey, null)); + assertThat(exception.getMessage(), is("'assertion signing algorithm' cannot be null!")); + } + + @Test + public void throwsWhenErrorSigning256() { + JWTCreator.Builder mockBuilder = mock(JWTCreator.Builder.class); + RSAPrivateKey mockPrivateKey = mock(RSAPrivateKey.class); + + when(mockBuilder.sign(Algorithm.RSA256(null,mockPrivateKey))).thenThrow(JWTCreationException.class); + + ClientAssertionSigningException e = assertThrows(ClientAssertionSigningException.class, () -> { + new RSAClientAssertionSigner(mockPrivateKey).createSignedClientAssertion("iss", "aud", "sub"); + }); + + assertThat(e.getMessage(), is("Error creating the JWT used for client assertion using the RSA256 signing algorithm")); + assertThat(e.getCause(), is(instanceOf(JWTCreationException.class))); + } + + @Test + public void throwsWhenErrorSigning384() { + JWTCreator.Builder mockBuilder = mock(JWTCreator.Builder.class); + RSAPrivateKey mockPrivateKey = mock(RSAPrivateKey.class); + + when(mockBuilder.sign(Algorithm.RSA384(null,mockPrivateKey))).thenThrow(JWTCreationException.class); + + ClientAssertionSigningException e = assertThrows(ClientAssertionSigningException.class, () -> { + new RSAClientAssertionSigner(mockPrivateKey, RSAClientAssertionSigner.RSASigningAlgorithm.RSA384).createSignedClientAssertion("iss", "aud", "sub"); + }); + + assertThat(e.getMessage(), is("Error creating the JWT used for client assertion using the RSA384 signing algorithm")); + assertThat(e.getCause(), is(instanceOf(JWTCreationException.class))); + } + + @Test + public void createsVerifiedRSA256SigningAssertion() throws Exception { + KeyPair keyPair = getKeyPair(); + + RSAClientAssertionSigner clientAssertion = new RSAClientAssertionSigner((RSAPrivateKey) keyPair.getPrivate()); + String jwt = clientAssertion.createSignedClientAssertion("issuer", "audience", "subject"); + + DecodedJWT decodedJWT = JWT.require(Algorithm.RSA256((RSAPublicKey) keyPair.getPublic(), null)) + .build() + .verify(jwt); + + assertThat(decodedJWT.getSubject(), is("subject")); + assertThat(decodedJWT.getAudience(), hasItem("audience")); + assertThat(decodedJWT.getIssuer(), is("issuer")); + assertThat(decodedJWT.getExpiresAtAsInstant(), is(decodedJWT.getIssuedAtAsInstant().plusSeconds(180))); + assertThat(decodedJWT.getClaim("jti").asString(), is(notNullValue())); + + + System.out.println(decodedJWT); + } + + @Test + public void createsVerifiedRSA384SigningAssertion() throws Exception { + KeyPair keyPair = getKeyPair(); + + RSAClientAssertionSigner clientAssertion = new RSAClientAssertionSigner((RSAPrivateKey) keyPair.getPrivate(), RSAClientAssertionSigner.RSASigningAlgorithm.RSA384); + String jwt = clientAssertion.createSignedClientAssertion("issuer", "audience", "subject"); + + DecodedJWT decodedJWT = JWT.require(Algorithm.RSA384((RSAPublicKey) keyPair.getPublic(), null)) + .build() + .verify(jwt); + + assertThat(decodedJWT.getSubject(), is("subject")); + assertThat(decodedJWT.getAudience(), hasItem("audience")); + assertThat(decodedJWT.getIssuer(), is("issuer")); + assertThat(decodedJWT.getExpiresAtAsInstant(), is(decodedJWT.getIssuedAtAsInstant().plusSeconds(180))); + assertThat(decodedJWT.getClaim("jti").asString(), is(notNullValue())); + + + System.out.println(decodedJWT); + } + + private KeyPair getKeyPair() throws Exception { + URI fileUri = new File(PRIVATE_KEY_FILE_RSA).toURI(); + String privateKeyContent = new String(Files.readAllBytes(Paths.get(fileUri))); + privateKeyContent = privateKeyContent.replaceAll("\\n", "").replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", ""); + + KeyFactory kf = KeyFactory.getInstance("RSA"); + + PKCS8EncodedKeySpec keySpecPKCS8 = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyContent)); + PrivateKey privateKey = kf.generatePrivate(keySpecPKCS8); + + URI publicKeyFileUri = new File(PUBLIC_KEY_FILE_RSA).toURI(); + String publicKeyContent = new String(Files.readAllBytes(Paths.get(publicKeyFileUri))); + publicKeyContent = publicKeyContent.replaceAll("\\n", "").replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", ""); + + byte[] encodedPublicKey = Base64.getDecoder().decode(publicKeyContent); + + EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedPublicKey); + PublicKey publicKey = kf.generatePublic(keySpec); + + return new KeyPair(publicKey, privateKey); + } +} diff --git a/src/test/resources/auth/rsa_private_key.pem b/src/test/resources/auth/rsa_private_key.pem new file mode 100644 index 00000000..1427e0d5 --- /dev/null +++ b/src/test/resources/auth/rsa_private_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC4ZtdaIrd1BPIJ +tfnF0TjIK5inQAXZ3XlCrUlJdP+XHwIRxdv1FsN12XyMYO/6ymLmo9ryoQeIrsXB +XYqlET3zfAY+diwCb0HEsVvhisthwMU4gZQu6TYW2s9LnXZB5rVtcBK69hcSlA2k +ZudMZWxZcj0L7KMfO2rIvaHw/qaVOE9j0T257Z8Kp2CLF9MUgX0ObhIsdumFRLaL +DvDUmBPr2zuh/34j2XmWwn1yjN/WvGtdfhXW79Ki1S40HcWnygHgLV8sESFKUxxQ +mKvPUTwDOIwLFL5WtE8Mz7N++kgmDcmWMCHc8kcOIu73Ta/3D4imW7VbKgHZo9+K +3ESFE3RjAgMBAAECggEBAJTEIyjMqUT24G2FKiS1TiHvShBkTlQdoR5xvpZMlYbN +tVWxUmrAGqCQ/TIjYnfpnzCDMLhdwT48Ab6mQJw69MfiXwc1PvwX1e9hRscGul36 +ryGPKIVQEBsQG/zc4/L2tZe8ut+qeaK7XuYrPp8bk/X1e9qK5m7j+JpKosNSLgJj +NIbYsBkG2Mlq671irKYj2hVZeaBQmWmZxK4fw0Istz2WfN5nUKUeJhTwpR+JLUg4 +ELYYoB7EO0Cej9UBG30hbgu4RyXA+VbptJ+H042K5QJROUbtnLWuuWosZ5ATldwO +u03dIXL0SH0ao5NcWBzxU4F2sBXZRGP2x/jiSLHcqoECgYEA4qD7mXQpu1b8XO8U +6abpKloJCatSAHzjgdR2eRDRx5PMvloipfwqA77pnbjTUFajqWQgOXsDTCjcdQui +wf5XAaWu+TeAVTytLQbSiTsBhrnoqVrr3RoyDQmdnwHT8aCMouOgcC5thP9vQ8Us +rVdjvRRbnJpg3BeSNimH+u9AHgsCgYEA0EzcbOltCWPHRAY7B3Ge/AKBjBQr86Kv +TdpTlxePBDVIlH+BM6oct2gaSZZoHbqPjbq5v7yf0fKVcXE4bSVgqfDJ/sZQu9Lp +PTeV7wkk0OsAMKk7QukEpPno5q6tOTNnFecpUhVLLlqbfqkB2baYYwLJR3IRzboJ +FQbLY93E8gkCgYB+zlC5VlQbbNqcLXJoImqItgQkkuW5PCgYdwcrSov2ve5r/Acz +FNt1aRdSlx4176R3nXyibQA1Vw+ztiUFowiP9WLoM3PtPZwwe4bGHmwGNHPIfwVG +m+exf9XgKKespYbLhc45tuC08DATnXoYK7O1EnUINSFJRS8cezSI5eHcbQKBgQDC +PgqHXZ2aVftqCc1eAaxaIRQhRmY+CgUjumaczRFGwVFveP9I6Gdi+Kca3DE3F9Pq +PKgejo0SwP5vDT+rOGHN14bmGJUMsX9i4MTmZUZ5s8s3lXh3ysfT+GAhTd6nKrIE +kM3Nh6HWFhROptfc6BNusRh1kX/cspDplK5x8EpJ0QKBgQDWFg6S2je0KtbV5PYe +RultUEe2C0jYMDQx+JYxbPmtcopvZQrFEur3WKVuLy5UAy7EBvwMnZwIG7OOohJb +vkSpADK6VPn9lbqq7O8cTedEHttm6otmLt8ZyEl3hZMaL3hbuRj6ysjmoFKx6CrX +rK0/Ikt5ybqUzKCMJZg2VKGTxg== +-----END PRIVATE KEY----- diff --git a/src/test/resources/auth/rsa_public_key.pem b/src/test/resources/auth/rsa_public_key.pem new file mode 100644 index 00000000..e8d62885 --- /dev/null +++ b/src/test/resources/auth/rsa_public_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuGbXWiK3dQTyCbX5xdE4 +yCuYp0AF2d15Qq1JSXT/lx8CEcXb9RbDddl8jGDv+spi5qPa8qEHiK7FwV2KpRE9 +83wGPnYsAm9BxLFb4YrLYcDFOIGULuk2FtrPS512Qea1bXASuvYXEpQNpGbnTGVs +WXI9C+yjHztqyL2h8P6mlThPY9E9ue2fCqdgixfTFIF9Dm4SLHbphUS2iw7w1JgT +69s7of9+I9l5lsJ9cozf1rxrXX4V1u/SotUuNB3Fp8oB4C1fLBEhSlMcUJirz1E8 +AziMCxS+VrRPDM+zfvpIJg3JljAh3PJHDiLu902v9w+Iplu1WyoB2aPfitxEhRN0 +YwIDAQAB +-----END PUBLIC KEY-----