Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for MFA using OIDC conformant endpoints #146

Merged
merged 5 commits into from
May 3, 2018
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,32 @@ authentication

> The default scope used is `openid`


#### Login using MFA with One Time Password code

This call requires the client to have the *MFA* Client Grant Type enabled. Check [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it.

When you sign in to a multifactor authentication enabled connection using the `login` method, you receive an error standing that MFA is required for that user along with an `mfa_token` value. Use this value to call `loginWithOTP` and complete the MFA flow passing the One Time Password from the enrolled MFA code generator app.


```java
authentication
.loginWithOTP("the mfa token", "123456")
.start(new BaseCallback<Credentials>() {
@Override
public void onSuccess(Credentials payload) {
//Logged in!
}

@Override
public void onFailure(AuthenticationException error) {
//Error!
}
});
```



#### Passwordless Login

This feature requires your Application to have the *Resource Owner* Legacy Grant Type enabled. Check [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. Note that Passwordless authentication *cannot be used* with the [OIDC Conformant Mode](#oidc-conformant-mode) enabled.
Expand Down
1 change: 1 addition & 0 deletions auth0/src/main/java/com/auth0/android/Auth0.java
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ public boolean isTLS12Enforced() {

/**
* Set whether to enforce TLS 1.2 on devices with API 16-21.
*
* @param enforced whether TLS 1.2 is enforced on devices with API 16-21.
*/
public void setTLS12Enforced(boolean enforced) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@
import com.auth0.android.request.Request;
import com.auth0.android.request.internal.AuthenticationErrorBuilder;
import com.auth0.android.request.internal.GsonProvider;
import com.auth0.android.request.internal.OkHttpClientFactory;
import com.auth0.android.request.internal.RequestFactory;
import com.auth0.android.result.Credentials;
import com.auth0.android.result.DatabaseUser;
import com.auth0.android.result.Delegation;
import com.auth0.android.result.UserProfile;
import com.auth0.android.request.internal.OkHttpClientFactory;
import com.auth0.android.util.Telemetry;
import com.google.gson.Gson;
import com.squareup.okhttp.HttpUrl;
Expand All @@ -54,6 +54,7 @@
import java.util.Map;

import static com.auth0.android.authentication.ParameterBuilder.GRANT_TYPE_AUTHORIZATION_CODE;
import static com.auth0.android.authentication.ParameterBuilder.GRANT_TYPE_MFA_OTP;
import static com.auth0.android.authentication.ParameterBuilder.GRANT_TYPE_PASSWORD;
import static com.auth0.android.authentication.ParameterBuilder.GRANT_TYPE_PASSWORD_REALM;
import static com.auth0.android.authentication.ParameterBuilder.ID_TOKEN_KEY;
Expand Down Expand Up @@ -81,6 +82,8 @@ public class AuthenticationAPIClient {
private static final String OAUTH_CODE_KEY = "code";
private static final String REDIRECT_URI_KEY = "redirect_uri";
private static final String TOKEN_KEY = "token";
private static final String MFA_TOKEN_KEY = "mfa_token";
private static final String ONE_TIME_PASSWORD_KEY = "otp";
private static final String DELEGATION_PATH = "delegation";
private static final String ACCESS_TOKEN_PATH = "access_token";
private static final String SIGN_UP_PATH = "signup";
Expand All @@ -97,7 +100,8 @@ public class AuthenticationAPIClient {
private static final String HEADER_AUTHORIZATION = "Authorization";

private final Auth0 auth0;
@VisibleForTesting final OkHttpClient client;
@VisibleForTesting
final OkHttpClient client;
private final Gson gson;
private final RequestFactory factory;
private final ErrorBuilder<AuthenticationException> authErrorBuilder;
Expand Down Expand Up @@ -235,6 +239,39 @@ public AuthenticationRequest login(@NonNull String usernameOrEmail, @NonNull Str
return loginWithToken(requestParameters);
}

/**
* Log in a user using the One Time Password code after they have received the 'mfa_required' error.
* The MFA token tells the server the username or email, password and realm values sent on the first request.
* Requires your client to have the <b>MFA</b> Grant Type enabled. See <a href="https://auth0.com/docs/clients/client-grant-types">Client Grant Types</a> to learn how to enable it.* Example usage:
* <pre>
* {@code
* client.loginWithOTP("{mfa token}", "{one time password}")
* .start(new BaseCallback<Credentials>() {
* {@literal}Override
* public void onSuccess(Credentials payload) { }
*
* {@literal}Override
* public void onFailure(AuthenticationException error) { }
* });
* }
* </pre>
*
* @param mfaToken the token received in the previous {@link #login(String, String, String)} response.
* @param otp the one time password code provided by the resource owner, typically obtained from an
* MFA application such as Google Authenticator or Guardian.
* @return a request to configure and start that will yield {@link Credentials}
*/
@SuppressWarnings("WeakerAccess")
public AuthenticationRequest loginWithOTP(@NonNull String mfaToken, @NonNull String otp) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't think of a better name right now but it would be good to be consistent on platforms, In swift the counterpart might be defined as login(withOTP:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally we can have a single "login" method that just sets the client_id and the path /oauth/token. But then users will need to know for each grant type what parameters are accepted. That's the reason we now have a new login "with otp" method that helps to construct this request.

So in swift what are you doing, is that "withOTP" like a flag?

Copy link
Member

@cocojoe cocojoe Feb 21, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, so in Swift there are 2 login methods one is /ro the other grant password-realm. In this case if I had login(withOTP otp: String, mfaToken: String)

The auto-complete would show 3 logins and it's based on the params, in Swift the guideline is to create methods that have meaninful names. You could have login(otp: String, token: String) which isn't as informative.

When the user calls this method they would write login(withOTP: "12356", mfaToken: "blah") the otp is used inside the method.

Map<String, Object> parameters = ParameterBuilder.newBuilder()
.setGrantType(GRANT_TYPE_MFA_OTP)
.set(MFA_TOKEN_KEY, mfaToken)
.set(ONE_TIME_PASSWORD_KEY, otp)
.asDictionary();

return loginWithToken(parameters);
}

/**
* Log in a user with a OAuth 'access_token' of a Identity Provider like Facebook or Twitter using <a href="https://auth0.com/docs/api/authentication#social-with-provider-s-access-token">'\oauth\access_token' endpoint</a>
* The default scope used is 'openid'.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,17 +157,23 @@ public boolean isInvalidConfiguration() {

/// When MFA code is required to authenticate
public boolean isMultifactorRequired() {
return "a0.mfa_required".equals(code);
return "mfa_required".equals(code) || "a0.mfa_required".equals(code);
}

/// When MFA is required and the user is not enrolled
public boolean isMultifactorEnrollRequired() {
return "a0.mfa_registration_required".equals(code);
return "a0.mfa_registration_required".equals(code) || "unsupported_challenge_type".equals(code);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error is the specific challenge type is not expected vs enrolment in general. Should we clarify this? As it could be they don't support OTP but do OOB?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

association_required will be the new not-enrolled error code once they release the new API, currently this is unsupported_challenge_type. On the other side, "unsupported_challenge_type" does mean that the challenge is not supported. Because we're not going to support other challenge type than OTP, I don't think that's an error we can provide an alternate path to the user to continue the authentication. I tried to find without success, but I think somewhere in the docs they mention that OTP must be supported at least.

}

/// When the MFA Token used on the login request is malformed or has expired
public boolean isMultifactorTokenInvalid() {
return "expired_token".equals(code) && "mfa_token is expired".equals(description) ||
"invalid_grant".equals(code) && "Malformed mfa_token".equals(description);
}

/// When MFA code sent is invalid or expired
public boolean isMultifactorCodeInvalid() {
return "a0.mfa_invalid_code".equals(code);
return "a0.mfa_invalid_code".equals(code) || "invalid_grant".equals(code) && "Invalid otp_code.".equals(description);
}

/// When password used for SignUp does not match connection's strength requirements.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ public class ParameterBuilder {
public static final String GRANT_TYPE_PASSWORD_REALM = "https://auth0.com/oauth/grant-type/password-realm";
public static final String GRANT_TYPE_JWT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
public static final String GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code";
public static final String GRANT_TYPE_MFA_OTP = "https://auth0.com/oauth/grant-type/mfa-otp";

public static final String SCOPE_OPENID = "openid";
public static final String SCOPE_OFFLINE_ACCESS = "openid offline_access";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
* Represents a delegation request for Auth0 tokens that will yield a new delegation token.
* The delegation response depends on the 'api_type' parameter.
*
* @param <T> type of object that will hold the delegation response. When requesting Auth0s 'id_token' you can
* use {@link Delegation}, otherwise youll need to provide an object that can be created from the JSON
* @param <T> type of object that will hold the delegation response. When requesting Auth0's 'id_token' you can
* use {@link Delegation}, otherwise you'll need to provide an object that can be created from the JSON
* payload or just use {@code Map<String, Object>}
*/
public class DelegationRequest<T> implements Request<T, AuthenticationException> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ public void onFailure(AuthenticationException error) {

/**
* Checks if a non-expired pair of credentials can be obtained from this manager.
*
* @return whether there are valid credentials stored on this manager.
*/
public boolean hasValidCredentials() {
String accessToken = storage.retrieveString(KEY_ACCESS_TOKEN);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,32 @@ public void shouldCreateClientWithContextInfo() throws Exception {
assertThat(client.getBaseURL(), equalTo("https://" + DOMAIN + "/"));
}

@Test
public void shouldLoginWithMFAOTPCode() throws Exception {
mockAPI.willReturnSuccessfulLogin();
final MockAuthenticationCallback<Credentials> callback = new MockAuthenticationCallback<>();

Auth0 auth0 = new Auth0(CLIENT_ID, mockAPI.getDomain(), mockAPI.getDomain());
auth0.setOIDCConformant(true);
AuthenticationAPIClient client = new AuthenticationAPIClient(auth0);
client.loginWithOTP("ey30.the-mfa-token.value", "123456")
.start(callback);
assertThat(callback, hasPayloadOfType(Credentials.class));

final RecordedRequest request = mockAPI.takeRequest();
assertThat(request.getHeader("Accept-Language"), is(getDefaultLocale()));
Map<String, String> body = bodyFromRequest(request);

assertThat(request.getPath(), equalTo("/oauth/token"));
assertThat(body, hasEntry("client_id", CLIENT_ID));
assertThat(body, hasEntry("grant_type", "https://auth0.com/oauth/grant-type/mfa-otp"));
assertThat(body, hasEntry("mfa_token", "ey30.the-mfa-token.value"));
assertThat(body, hasEntry("otp", "123456"));
assertThat(body, not(hasKey("username")));
assertThat(body, not(hasKey("password")));
assertThat(body, not(hasKey("connection")));
}

@Test
public void shouldLoginWithUserAndPassword() throws Exception {
mockAPI.willReturnSuccessfulLogin();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,20 +138,61 @@ public void shouldReturnNullIfMapDoesNotExist() throws Exception {
assertThat(ex4.getValue("key"), is(nullValue()));
}

@Test
public void shouldHaveExpiredMultifactorTokenOnOIDCMode() throws Exception {
values.put(ERROR_KEY, "expired_token");
values.put(ERROR_DESCRIPTION_KEY, "mfa_token is expired");
AuthenticationException ex = new AuthenticationException(values);
assertThat(ex.isMultifactorTokenInvalid(), is(true));
}

@Test
public void shouldHaveMalformedMultifactorTokenOnOIDCMode() throws Exception {
values.put(ERROR_KEY, "invalid_grant");
values.put(ERROR_DESCRIPTION_KEY, "Malformed mfa_token");
AuthenticationException ex = new AuthenticationException(values);
assertThat(ex.isMultifactorTokenInvalid(), is(true));
}

@Test
public void shouldRequireMultifactorOnOIDCMode() throws Exception {
values.put(ERROR_KEY, "mfa_required");
values.put("mfa_token", "some-random-token");
AuthenticationException ex = new AuthenticationException(values);
assertThat(ex.isMultifactorRequired(), is(true));
assertThat((String) ex.getValue("mfa_token"), is("some-random-token"));
}

@Test
public void shouldRequireMultifactor() throws Exception {
values.put(CODE_KEY, "a0.mfa_required");
AuthenticationException ex = new AuthenticationException(values);
assertThat(ex.isMultifactorRequired(), is(true));
}

@Test
public void shouldRequireMultifactorEnrollOnOIDCMode() throws Exception {
values.put(ERROR_KEY, "unsupported_challenge_type");
values.put(ERROR_DESCRIPTION_KEY, "User is not enrolled with guardian");
AuthenticationException ex = new AuthenticationException(values);
assertThat(ex.isMultifactorEnrollRequired(), is(true));
}

@Test
public void shouldRequireMultifactorEnroll() throws Exception {
values.put(CODE_KEY, "a0.mfa_registration_required");
AuthenticationException ex = new AuthenticationException(values);
assertThat(ex.isMultifactorEnrollRequired(), is(true));
}

@Test
public void shouldHaveInvalidMultifactorCodeOnOIDCMode() throws Exception {
values.put(ERROR_KEY, "invalid_grant");
values.put(ERROR_DESCRIPTION_KEY, "Invalid otp_code.");
AuthenticationException ex = new AuthenticationException(values);
assertThat(ex.isMultifactorCodeInvalid(), is(true));
}

@Test
public void shouldHaveInvalidMultifactorCode() throws Exception {
values.put(CODE_KEY, "a0.mfa_invalid_code");
Expand Down
2 changes: 1 addition & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ coverage:
default:
enabled: true
target: 80%
threshold: 10%
threshold: 30%
if_no_uploads: error
changes:
default:
Expand Down