Skip to main content

Android - Authentication Module Interface

The AuthenticationModule interface lets a module replace Q2's login screen with its own. The host shows your fragment on launch, after logout, and on UUX timeout. Your module owns the credential UI and its error copy. Q2 owns everything after the login result is handed back: session cookies, the Q2 token, and the WebView.

host: showAuthenticationFragment()
→ your fragment: Q2Login.login(context, username, password)
→ Q2AuthResult (never throws)
→ your fragment: completeQ2Login(result)
→ host: cookies, q2Token, WebView
Available since

AuthenticationModule and the com.q2.morannon.auth facade shipped in MSDK Android 26.10.0 and are documented as a supported module type from 26.11.0.

Dependencies​

dependencies {
implementation "com.q2.msdk:sdk_interfaces:$q2Version"
implementation "com.q2.morannon:morannon:$q2Version"
}

Use the same $q2Version for both. AppCompat, Fragment, and kotlinx-coroutines arrive transitively.

Registration​

Add an sdk_modules entry to your DevApp's settings.json, and disable native_login:

{
"sdk_modules": [
{
"name": "partner_login",
"classPath": "com.example.login.PartnerAuthModule",
"data": {},
"enabled": true
},
{
"name": "native_login",
"classPath": "com.q2.nativelogin.NativeLoginEntryPoint",
"enabled": false
}
]
}
Exactly one authentication module

The host selects the authentication module by interface. Nothing keys on the name native_login. If two are enabled, the last one registered wins and nothing reports it.

Every registration mistake produces Q2's login screen instead of yours. The host logs the failure and does not throw:

RequirementWhat happens if you miss it
Declare AuthenticationModule directly on the classPath classThe host reads the class's own declared interfaces, so a module that inherits AuthenticationModule from a base class does not register. by delegation is fine.
A public one-argument constructor taking SdkUtils, or getInstance(SdkUtils)The host matches on arity. A default argument compiles to two parameters and does not match.
A working release buildA class R8 stripped fails Class.forName. Debug works and release does not. See Consumer ProGuard Rules.

This shape satisfies all three and stays unit-testable:

class PartnerAuthModule internal constructor(
private val sdkUtils: SdkUtils,
private val auth: Q2Login, // the interface, so a test can pass a fake
) : AuthenticationModule { // declared directly on this class

constructor(sdkUtils: SdkUtils) : this(sdkUtils, Q2Authentication)

override fun showAuthenticationFragment(): Fragment = PartnerLoginFragment()

override fun onLoginFailed(fragment: Fragment?, errorMessage: String) {
// Show errorMessage on your screen.
}
}

The contract​

AuthenticationModule lives in com.q2.sdk_interfaces.authentication. Two members are required. The rest have defaults.

MemberNotes
showAuthenticationFragment()Required. Returns the fragment the host adds in place of the login screen.
onLoginFailed(fragment, message)Required. Fires only for UUX failures after hand-off. Credential rejections never reach it, because they come back to you from Q2Login.
clearCachedCredentials()Override if anything survives a successful login. See below.
logOut(context, onComplete)Override only to end a session held off-device. Call onComplete() on every path, including failure. If you miss it, logout never finishes and the user cannot get back to login.
supportsBiometricsGates the biometrics row in mobile settings. Return a constant true if you implement updateBiometrics and isBiometricsEnabled.
updateBiometrics / isBiometricsEnabledBack the biometrics settings row. See Biometrics.
startStepUpNot called by the host. MFA runs in the WebView.

clearCachedCredentials​

This is the credential-hygiene hook, not logOut. A profile switch keeps the session live and hands it to a different user, so logOut never fires for it.

  • Called on logout, on profile switch, and best-effort when a live session returns to login.
  • Must be idempotent and must not throw. Nothing waits on it.
  • Does not fire part-way through signing in, since your module is likely still using the credential. Clear your cache at the start of every login attempt too.
  • Arrives on an arbitrary thread. Profile switch and UUX logout come in on the WebView's JavaScript bridge thread. Use Q2SessionCredentials (below) and touch no View or Compose state.
private val credentials = Q2SessionCredentials()

override fun clearCachedCredentials() = credentials.clear()

Q2Login​

Q2Authentication implements Q2Login. Depend on the interface so your login screen can be unit tested with a fake. It is stateless: it caches nothing and holds no credential.

MemberPurpose
login(context, username, password)Password login. Returns Q2AuthResult.
loginWithBiometrics(fragment, username)Prompt, decrypt, and log in. Returns BiometricLoginResult.
rememberedUsers(context)The remembered-user list on this device.
deviceSupportsBiometrics(context)Whether this device could ever do biometrics.
biometricsState(context, username)The BiometricsEnabledState for a user.
enrollInBiometrics(activity, username, password)Enroll after a password login. Returns EnrollmentResult.
unenrollFromBiometrics(context, username)Un-enroll. Idempotent.
isUnderMaintenance(sdkUtils)Whether the FI is in maintenance. Fails open.
preLoginNotice()The FI's advisory notice for above the form, or null. Fails open.
defaultErrorMessage / errorMessage(hydraCode)Q2's localized error copy.
  • No member throws. Failures arrive as a value: Q2AuthResult.Error, false, null, or BiometricsEnabledState.N_A. CancellationException is propagated. Never wrap a call in a catch (e: Exception) that swallows it.
  • com.q2.morannon.auth is the only supported package. Everything else in the morannon artifact is Q2's own wiring, whatever its visibility. The repository layer is marked @MorannonInternalApi and does not compile without an opt-in. Other classes are plain public and the compiler will not stop you. If you need anything outside com.q2.morannon.auth, that is a gap in Q2Login. Report it rather than importing it.

Results​

Q2AuthResult has two arms, Success and Error. Error.message is always display-ready localized copy. Error.kind lets you branch without parsing it. New outcomes arrive as new Kind constants, never new subclasses, so give any when (kind) an else that shows message and allows another attempt.

KindMeaning
GENERICIncludes a wrong password, which the server does not distinguish. This is the most common arm, so whatever else does is what a mistyped password shows. Do not disable submit on it.
SECURITY_BLOCKEDAlready the localized security copy. Do not append to it.
BIOMETRICS_UNENROLLEDThe user is already un-enrolled. Refresh your toggle state and fall back to password.

A forced password change is not an Error. It arrives as a Success with valid session data, and UUX routes it to its password-change flow. Hand it off like any other success.

Biometrics​

You write no cipher or keystore code. In order:

  1. Call deviceSupportsBiometrics once per session.
  2. Call biometricsState for each user you draw. Only ON justifies a biometric login button.
  3. Call loginWithBiometrics, then completeQ2Login with the result.
  4. Call enrollInBiometrics after a password login, while the password is still in hand. Enrolling also remembers the user, so ask first if they did not choose to be remembered.

BiometricLoginResult:

  • Completed(result): treat it like a password login.
  • Cancelled: render nothing. The password field is already there.
  • ReEnrollmentNeeded: the stored credential can never be decrypted again (new fingerprint, changed lock screen) and the user is already un-enrolled. Offer enrollment again after a password login.
  • SystemEnrollmentNeeded: nothing is enrolled in system settings. Your enrollment is untouched.

updateBiometrics(isOn, activity) receives no credential, so turning the switch on only works while the password from a just-completed login is still held. Hold it in Q2SessionCredentials: call remember(username, password) at the start of each login attempt, and read it back with passwordFor(username). With nothing cached, return false. Never prompt for a password from a settings switch.

Hand-off​

Call Fragment.completeQ2Login(result) from com.q2.morannon.auth. Pass the result as given, either arm, unmodified.

viewLifecycleOwner.lifecycleScope.launch {
val result = auth.login(requireContext(), username, password)
completeQ2Login(result)
}
  • Call it from the fragment the host added, not from a child nested inside it. The result travels on parentFragmentManager, and the host listens on the activity's.
  • Hand off failures too, or the host believes a login is still in flight.
  • Do not reshape Success. It is opaque on purpose. A modified body is dropped silently.
  • It is safe to call after the fragment detaches. The hand-off is dropped rather than thrown.

Hand-rolling setFragmentResult gives no error on a wrong bundle key: login appears to succeed and the app sits there. Use completeQ2Login, or postQ2LoginResult from sdk_interfaces if you build the LoginResponse yourself.

Full example​

A complete module and login fragment covering the basic use cases:

  • maintenance mode and the pre-login notice
  • pre-filling the last remembered user
  • password login, then an offer to enroll in biometrics
  • biometric login, including the re-enrollment and cancel cases
  • forgetting a remembered user
  • error handling, and handing every result back to the host
  • the biometrics settings row and credential cleanup

The UI is left to you. The stub functions at the bottom of the fragment are where your views or Compose state go.

PartnerAuthModule.kt
package com.example.login

import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import com.q2.morannon.auth.EnrollmentResult
import com.q2.morannon.auth.Q2Authentication
import com.q2.morannon.auth.Q2Login
import com.q2.morannon.auth.Q2SessionCredentials
import com.q2.sdk_interfaces.SdkUtils
import com.q2.sdk_interfaces.authentication.AuthenticationModule
import com.q2.sdk_interfaces.authentication.BiometricsEnabledState

/**
* State shared by the module and its fragment. An object rather than a fragment argument, because
* the host can recreate the fragment and the module is created once, at app start.
*/
internal object PartnerLoginSession {
val credentials = Q2SessionCredentials()
lateinit var sdkUtils: SdkUtils
}

class PartnerAuthModule internal constructor(
private val sdkUtils: SdkUtils,
private val auth: Q2Login,
) : AuthenticationModule {

constructor(sdkUtils: SdkUtils) : this(sdkUtils, Q2Authentication)

init {
PartnerLoginSession.sdkUtils = sdkUtils
}

private val credentials = PartnerLoginSession.credentials

override fun showAuthenticationFragment(): Fragment = PartnerLoginFragment()

// UUX rejected the session after hand-off. Credential errors never arrive here.
override fun onLoginFailed(fragment: Fragment?, errorMessage: String) {
(fragment as? PartnerLoginFragment)?.showError(errorMessage)
}

// Logout, profile switch, or a return to login. Any thread, so only touch the holder.
override fun clearCachedCredentials() = credentials.clear()

override val supportsBiometrics = true

// Drives the biometrics row in mobile settings, for whoever is signed in.
override suspend fun isBiometricsEnabled(activity: AppCompatActivity): BiometricsEnabledState =
auth.biometricsState(activity, sdkUtils.getCurrentUserName()?.takeIf { it.isNotBlank() })

override suspend fun updateBiometrics(isOn: Boolean, activity: AppCompatActivity): Boolean {
val username = sdkUtils.getCurrentUserName()
if (username.isNullOrBlank()) return false

if (!isOn) {
auth.unenrollFromBiometrics(activity, username)
return false
}

// Only possible while the password from this session's login is still held.
val password = credentials.passwordFor(username) ?: return false
return auth.enrollInBiometrics(activity, username, password) == EnrollmentResult.Enrolled
}
}
PartnerLoginFragment.kt
package com.example.login

import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.q2.morannon.auth.BiometricLoginResult
import com.q2.morannon.auth.EnrollmentResult
import com.q2.morannon.auth.Q2AuthResult
import com.q2.morannon.auth.Q2Authentication
import com.q2.morannon.auth.Q2Login
import com.q2.morannon.auth.completeQ2Login
import com.q2.sdk_interfaces.authentication.BiometricsEnabledState
import kotlinx.coroutines.launch

class PartnerLoginFragment : Fragment() {

private val auth: Q2Login = Q2Authentication
private val credentials = PartnerLoginSession.credentials

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch { prepareScreen() }
}

private suspend fun prepareScreen() {
val context = requireContext()

if (auth.isUnderMaintenance(PartnerLoginSession.sdkUtils)) {
showMaintenance()
return
}
auth.preLoginNotice()?.let(::showNotice)

// Pre-fill the most recent remembered user, and offer biometrics only if it will work.
val lastUser = auth.rememberedUsers(context).list().firstOrNull() ?: return
showRememberedUser(lastUser.username, lastUser.maskedUsername)
showBiometricButton(
auth.deviceSupportsBiometrics(context) &&
auth.biometricsState(context, lastUser.username) == BiometricsEnabledState.ON
)
}

fun onLoginTapped(username: String, password: String) {
viewLifecycleOwner.lifecycleScope.launch {
credentials.clear()
when (val result = auth.login(requireContext(), username, password)) {
is Q2AuthResult.Success -> {
// Hold the password under the server's spelling of the username, for enrollment.
credentials.remember(result.username, password)
offerBiometricEnrollment(result.username, password)
completeQ2Login(result)
}
is Q2AuthResult.Error -> handleError(result)
}
}
}

fun onBiometricLoginTapped(username: String) {
viewLifecycleOwner.lifecycleScope.launch {
when (val outcome = auth.loginWithBiometrics(this@PartnerLoginFragment, username)) {
is BiometricLoginResult.Completed -> when (val result = outcome.result) {
is Q2AuthResult.Success -> completeQ2Login(result)
is Q2AuthResult.Error -> handleError(result)
}
BiometricLoginResult.Cancelled -> Unit
BiometricLoginResult.ReEnrollmentNeeded -> {
showBiometricButton(false)
showError("Log in with your password to turn biometrics back on.")
}
BiometricLoginResult.SystemEnrollmentNeeded -> showBiometricButton(false)
}
}
}

fun onForgetUserTapped(username: String) {
viewLifecycleOwner.lifecycleScope.launch {
auth.rememberedUsers(requireContext()).forget(username)
showBiometricButton(false)
}
}

private suspend fun offerBiometricEnrollment(username: String, password: String) {
val activity = requireActivity() as AppCompatActivity
if (!auth.deviceSupportsBiometrics(activity)) return

val users = auth.rememberedUsers(activity)
val user = users.list().firstOrNull { it.username == username }
if (user?.biometricsEnrolled == true || user?.enrollmentAlreadyOffered == true) return

// Enrolling also remembers the user, so ask for both in one prompt.
if (askToEnableBiometrics()) {
if (auth.enrollInBiometrics(activity, username, password) == EnrollmentResult.SystemEnrollmentNeeded) {
showError("Set up a fingerprint or face unlock in system settings first.")
}
}
users.recordEnrollmentOffered(username)
}

private fun handleError(result: Q2AuthResult.Error) {
// Tell the host this attempt is over.
completeQ2Login(result)

when (result.kind) {
Q2AuthResult.Error.Kind.BIOMETRICS_UNENROLLED -> showBiometricButton(false)
else -> Unit
}
// message is already localized, including for SECURITY_BLOCKED and a wrong password.
showError(result.message)
}

// Your UI. Replace these with your own views or Compose state.
private suspend fun askToEnableBiometrics(): Boolean = false
private fun showMaintenance() {}
private fun showNotice(notice: String) {}
private fun showRememberedUser(username: String, maskedUsername: String) {}
private fun showBiometricButton(visible: Boolean) {}
fun showError(message: String) {}
}

PartnerLoginSession is an object because the host can recreate the fragment, while the module is created once at app start. Both reach the same Q2SessionCredentials, and the module clears it.

isBiometricsEnabled and updateBiometrics read the signed-in user from SdkUtils.getCurrentUserName(), so the settings row follows a profile switch. Turning biometrics on from settings only works while the password from this session's login is still held. After a biometric login or a profile switch, updateBiometrics returns false.

Limitations​

  • APIGuard attestation is asynchronous, so a login fired before it completes can fail once. There is no readiness API. Keep the button enabled and let the user retry.
  • Step-up authentication is not routed through the facade in either direction.
  • The host draws no remembered-user selector and no "save my login ID" control. rememberedUsers is storage only, for a list your screen draws itself. Users remembered by a previous native_login install are visible to it.
  • There are no PIN/passcode, MFA, deep-link, or analytics hooks.

Testing​

  • Unit test the module and its login screen logic with a fake Q2Login. You need no Room, no Context, and no Android. If login logic cannot be tested that way, the surface is wrong.
  • Add an import-boundary test that fails on any com.q2. import outside com.q2.morannon.auth and com.q2.sdk_interfaces. The compiler accepts this mistake, because both artifacts are on the compile classpath.
  • Registration mistakes only show up in a real APK with a real login. Verify a minified release build before submitting.

Learn More​