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

Improve authenticated flow of the Credentials Manager #519

Merged
merged 6 commits into from
Nov 10, 2021

Conversation

lbalmaceda
Copy link
Contributor

@lbalmaceda lbalmaceda commented Sep 29, 2021

Changes

The requireAuthentication method takes an Activity instance to use as context to launch the "lock screen" intent. When this is a subclass of ComponentActivity, we can make use of the new Activity Results API to launch that intent instead. This is the recommended way now that startActivityForResult has been deprecated.

When that's the case, the launch and result handling happen internally without the developer having to invoke the checkAuthenticationResult method to "complete" the flow.

This PR does NOT include breaking changes.

References

See #513

Testing

Added unit tests and manually tested the new flow.

Existing implementations would still receive a call to their activities' onActivityResult method, but with a request code value different than the one requested by our SDK, passed by the dev in the requireAuthentication call. Developers should ignore it because the request code is not a match. Yet, we were already handling this case internally and checking for the request code in these lines anyway, so distracted developers are covered.

I did notice that if the requireAuthentication method was called after the activity is resumed/started, then the following runtime exception is raised:

Caused by: java.lang.IllegalStateException: LifecycleOwner com.auth0.androidsample.LoginActivity@494936b is attempting to register while current state is STARTED. LifecycleOwners must call register before they are STARTED.

So I added a check (and tests) to prevent using this use case with Activity Results if the activity passed as context is already started.

@lbalmaceda lbalmaceda marked this pull request as ready for review October 28, 2021 14:22
@lbalmaceda lbalmaceda requested a review from a team as a code owner October 28, 2021 14:22
@@ -63,12 +68,13 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT

/**
* Require the user to authenticate using the configured LockScreen before accessing the credentials.
* This feature is disabled by default and will only work if the device is running on Android version 21 or up and if the user
* has configured a secure LockScreen (PIN, Pattern, Password or Fingerprint).
* This method MUST be called in [Activity.onCreate]. This feature is disabled by default and will
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's OK if they don't. I'm defaulting to the old scenario anyways, but it's better to lead them to use the new way for future compatibility & easier deprecation reasons.

@@ -84,25 +90,42 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
): Boolean {
require(!(requestCode < 1 || requestCode > 255)) { "Request code must be a value between 1 and 255." }
val kManager = activity.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
authIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) kManager.createConfirmDeviceCredentialIntent(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

the min SDK is already 21, no reason to keep these checks around

* prevent that exception while still falling back to the old "startActivityForResult" flow. That's in
* case devs are invoking this method in places other than the Activity's "OnCreate" method.
*/
if (activity is ComponentActivity && !activity.lifecycle.currentState.isAtLeast(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

If the context is a subclass of ComponentActivity and not yet STARTED, the new path will be used. Otherwise, legacy scenario.

// Activity is "created"
val activityController = Robolectric.buildActivity(
ComponentActivity::class.java
).create()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

activity is so far "created"

)

// Activity is "started" so pending ActivityResults are dispatched
activityController.start()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we then instruct it to move to the "started" state, that releases the activity results that we assert

activityController.start()
// Trigger the prompt for credentials
manager.getCredentials(callback)
verify(activity, never()).startActivityForResult(any(), anyInt())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we test that legacy path is not used

ComponentActivity::class.java
).create()
val activity = Mockito.spy(activityController.get())
val canceledResult = ActivityResult(Activity.RESULT_CANCELED, null)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

similar to the above test, but now we test that the user "canceled" the auth request

ComponentActivity::class.java
).create()
val activity = Mockito.spy(activityController.get())
val successfulResult = ActivityResult(Activity.RESULT_OK, null)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

user will succeed to prove their identity

Activity::class.java
).create().start().resume().get()
ComponentActivity::class.java
).create().resume().start().get()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

proves that if using a ComponentActivity that is already started, it doesn't throw the exception because we are handling that and falling back to the legacy use case (which is tested below).

val activity = Mockito.spy(
Robolectric.buildActivity(
Activity::class.java
).create().get()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The rest of the tests onwards change this line. From starting from an activity that was "resumed" to start with an activity that is "created".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
3 participants