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
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
}
]
}
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:
| Requirement | What happens if you miss it |
|---|---|
Declare AuthenticationModule directly on the classPath class | The 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 build | A 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.
| Member | Notes |
|---|---|
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. |
supportsBiometrics | Gates the biometrics row in mobile settings. Return a constant true if you implement updateBiometrics and isBiometricsEnabled. |
updateBiometrics / isBiometricsEnabled | Back the biometrics settings row. See Biometrics. |
startStepUp | Not 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 noViewor 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.
| Member | Purpose |
|---|---|
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, orBiometricsEnabledState.N_A.CancellationExceptionis propagated. Never wrap a call in acatch (e: Exception)that swallows it. com.q2.morannon.authis the only supported package. Everything else in themorannonartifact is Q2's own wiring, whatever its visibility. The repository layer is marked@MorannonInternalApiand does not compile without an opt-in. Other classes are plain public and the compiler will not stop you. If you need anything outsidecom.q2.morannon.auth, that is a gap inQ2Login. 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.
Kind | Meaning |
|---|---|
GENERIC | Includes 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_BLOCKED | Already the localized security copy. Do not append to it. |
BIOMETRICS_UNENROLLED | The 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:
- Call
deviceSupportsBiometricsonce per session. - Call
biometricsStatefor each user you draw. OnlyONjustifies a biometric login button. - Call
loginWithBiometrics, thencompleteQ2Loginwith the result. - Call
enrollInBiometricsafter 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.
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
}
}
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.
rememberedUsersis storage only, for a list your screen draws itself. Users remembered by a previousnative_logininstall 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, noContext, 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 outsidecom.q2.morannon.authandcom.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
- Native Login Module: Q2's own login screen, built on the same
Q2Login. - Configuring settings.json
- Consumer ProGuard Rules