Authentication is critical for mobile applications. Testing OAuth 2.0 flows and JWT token management ensures secure, seamless user experiences. This guide covers testing authentication flows, token lifecycle, biometric integration, and security best practices.
For comprehensive mobile security strategies beyond authentication, see our Mobile Security Testing guide.
OAuth 2.0 Flows for Mobile
Authorization Code Flow with PKCE
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks, making it essential for mobile apps.
Flow Diagram:
1. App generates code_verifier + code_challenge
2. Redirect to OAuth provider with code_challenge
3. User authenticates
4. Provider redirects with authorization_code
5. App exchanges code + code_verifier for tokens
Android OAuth Implementation
// Using AppAuth library
dependencies {
implementation("net.openid:appauth:0.11.1")
}
class OAuthManager(private val context: Context) {
private val serviceConfig = AuthorizationServiceConfiguration(
Uri.parse("https://accounts.example.com/authorize"),
Uri.parse("https://accounts.example.com/token")
)
fun startAuthFlow(activity: Activity) {
val authRequest = AuthorizationRequest.Builder(
serviceConfig,
CLIENT_ID,
ResponseTypeValues.CODE,
Uri.parse("myapp://oauth-callback")
)
.setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier())
.setScope("openid profile email")
.build()
val authService = AuthorizationService(context)
val intent = authService.getAuthorizationRequestIntent(authRequest)
activity.startActivityForResult(intent, AUTH_REQUEST_CODE)
}
fun handleAuthResponse(data: Intent, callback: (TokenResponse) -> Unit) {
val authResponse = AuthorizationResponse.fromIntent(data)
val authException = AuthorizationException.fromIntent(data)
if (authResponse != null) {
val tokenRequest = authResponse.createTokenExchangeRequest()
AuthorizationService(context).performTokenRequest(tokenRequest) { response, exception ->
if (response != null) {
callback(response)
}
}
}
}
}
Testing OAuth Flow
class OAuthFlowTest {
@Test
fun testAuthorizationCodeExchange() = runTest {
val mockAuthServer = MockWebServer()
mockAuthServer.start()
// Mock token endpoint
mockAuthServer.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""
{
"access_token": "mock_access_token",
"refresh_token": "mock_refresh_token",
"expires_in": 3600,
"token_type": "Bearer"
}
""".trimIndent())
)
val oauthManager = OAuthManager(context)
val result = oauthManager.exchangeAuthCode("mock_auth_code")
assertNotNull(result.accessToken)
assertEquals("Bearer", result.tokenType)
mockAuthServer.shutdown()
}
@Test
fun testInvalidAuthorizationCode() = runTest {
val mockAuthServer = MockWebServer()
mockAuthServer.enqueue(MockResponse()
.setResponseCode(400)
.setBody("""
{
"error": "invalid_grant",
"error_description": "Invalid authorization code"
}
""".trimIndent())
)
val exception = assertThrows<OAuthException> {
oauthManager.exchangeAuthCode("invalid_code")
}
assertEquals("invalid_grant", exception.error)
}
}
JWT Token Testing
Token Structure Validation
data class JWTToken(
val header: Header,
val payload: Payload,
val signature: String
) {
data class Header(
val alg: String,
val typ: String
)
data class Payload(
val sub: String,
val exp: Long,
val iat: Long,
val iss: String,
val aud: String
)
}
class JWTValidator {
fun validate(token: String): Boolean {
val parts = token.split(".")
if (parts.size != 3) return false
val payload = decodePayload(parts[1])
// Check expiration
if (payload.exp * 1000 < System.currentTimeMillis()) {
throw TokenExpiredException()
}
// Verify signature
return verifySignature(parts[0], parts[1], parts[2])
}
}
@Test
fun testJWTValidation() {
val validToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." // Full JWT
val validator = JWTValidator()
assertTrue(validator.validate(validToken))
}
@Test
fun testExpiredToken() {
val expiredToken = generateExpiredToken()
assertThrows<TokenExpiredException> {
validator.validate(expiredToken)
}
}
Token Refresh Flow
Automatic Token Refresh
class TokenManager(private val authService: AuthService) {
private var accessToken: String? = null
private var refreshToken: String? = null
private var expiresAt: Long = 0
suspend fun getValidAccessToken(): String {
if (isTokenExpired()) {
refreshAccessToken()
}
return accessToken ?: throw NoTokenException()
}
private fun isTokenExpired(): Boolean {
return System.currentTimeMillis() >= expiresAt - REFRESH_THRESHOLD
}
private suspend fun refreshAccessToken() {
val response = authService.refreshToken(refreshToken!!)
accessToken = response.accessToken
refreshToken = response.refreshToken
expiresAt = System.currentTimeMillis() + (response.expiresIn * 1000)
}
}
@Test
fun testAutomaticTokenRefresh() = runTest {
val mockServer = MockWebServer()
// Mock refresh endpoint
mockServer.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""
{
"access_token": "new_access_token",
"refresh_token": "new_refresh_token",
"expires_in": 3600
}
""".trimIndent())
)
val tokenManager = TokenManager(authService)
// Set expired token
tokenManager.setToken("expired_token", expiresAt = System.currentTimeMillis() - 1000)
// Should automatically refresh
val validToken = tokenManager.getValidAccessToken()
assertEquals("new_access_token", validToken)
}
Interceptor for Automatic Refresh
class AuthInterceptor(private val tokenManager: TokenManager) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val original = chain.request()
val token = runBlocking {
tokenManager.getValidAccessToken()
}
val authorized = original.newBuilder()
.header("Authorization", "Bearer $token")
.build()
val response = chain.proceed(authorized)
// Handle 401 Unauthorized
if (response.code == 401) {
response.close()
// Force refresh and retry
tokenManager.forceRefresh()
val newToken = runBlocking { tokenManager.getValidAccessToken() }
val retryRequest = original.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
return chain.proceed(retryRequest)
}
return response
}
}
@Test
fun testInterceptorRefreshOn401() = runTest {
val mockServer = MockWebServer()
// First request returns 401
mockServer.enqueue(MockResponse().setResponseCode(401))
// Refresh token request
mockServer.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""{"access_token": "new_token"}""")
)
// Retry succeeds
mockServer.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""{"data": "success"}""")
)
val client = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(tokenManager))
.build()
val request = Request.Builder()
.url(mockServer.url("/api/data"))
.build()
val response = client.newCall(request).execute()
assertEquals(200, response.code)
}
Biometric Authentication Testing
Android Biometric Prompt
class BiometricAuthManager(private val context: FragmentActivity) {
fun authenticate(onSuccess: () -> Unit, onFailure: (String) -> Unit) {
val executor = ContextCompat.getMainExecutor(context)
val biometricPrompt = BiometricPrompt(
context,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onSuccess()
}
override fun onAuthenticationFailed() {
onFailure("Authentication failed")
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onFailure(errString.toString())
}
}
)
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric login")
.setSubtitle("Log in using your biometric credential")
.setNegativeButtonText("Use account password")
.build()
biometricPrompt.authenticate(promptInfo)
}
}
@Test
fun testBiometricSuccess() = runTest {
val biometricManager = BiometricAuthManager(activity)
var authResult: String? = null
biometricManager.authenticate(
onSuccess = { authResult = "success" },
onFailure = { authResult = "failed: $it" }
)
// Simulate biometric success (requires instrumented test with BiometricTestRule)
onView(withId(R.id.biometric_prompt)).perform(click())
assertEquals("success", authResult)
}
Secure Token Storage
Secure token storage is as critical as network security. While certificate pinning protects data in transit, proper token storage prevents unauthorized access at rest.
Android Keystore
class SecureTokenStorage(private val context: Context) {
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
private val cipher: Cipher
get() {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val secretKey = getOrCreateSecretKey()
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
return cipher
}
private fun getOrCreateSecretKey(): SecretKey {
if (!keyStore.containsAlias(KEY_ALIAS)) {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
"AndroidKeyStore"
)
keyGenerator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setUserAuthenticationRequired(true)
.setUserAuthenticationValidityDurationSeconds(30)
.build()
)
keyGenerator.generateKey()
}
return keyStore.getKey(KEY_ALIAS, null) as SecretKey
}
fun saveToken(token: String) {
val cipher = cipher
val encryptedData = cipher.doFinal(token.toByteArray())
val iv = cipher.iv
// Save encrypted data and IV to SharedPreferences
context.getSharedPreferences("auth", Context.MODE_PRIVATE)
.edit()
.putString("token", Base64.encodeToString(encryptedData, Base64.DEFAULT))
.putString("iv", Base64.encodeToString(iv, Base64.DEFAULT))
.apply()
}
fun getToken(): String? {
val prefs = context.getSharedPreferences("auth", Context.MODE_PRIVATE)
val encryptedData = prefs.getString("token", null) ?: return null
val iv = prefs.getString("iv", null) ?: return null
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val secretKey = keyStore.getKey(KEY_ALIAS, null) as SecretKey
cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, Base64.decode(iv, Base64.DEFAULT)))
val decryptedData = cipher.doFinal(Base64.decode(encryptedData, Base64.DEFAULT))
return String(decryptedData)
}
}
@Test
fun testSecureTokenStorage() {
val storage = SecureTokenStorage(context)
val originalToken = "sensitive_access_token"
storage.saveToken(originalToken)
val retrievedToken = storage.getToken()
assertEquals(originalToken, retrievedToken)
}
Best Practices
- Always use PKCE for mobile OAuth - Prevents code interception
- Store tokens securely - Use Android Keystore / iOS Keychain
- Implement automatic token refresh - Seamless user experience
- Test token expiration scenarios - Handle edge cases
- Use biometric authentication where possible - Enhanced security + UX
- Validate JWT claims - exp, iat, iss, aud
- Handle OAuth errors gracefully - Invalid grants, network failures
For a complete overview of modern mobile testing practices, including OAuth testing integration, check our Mobile Testing 2025 guide. Also explore API Testing Mastery for comprehensive API testing strategies.
Conclusion
OAuth 2.0 and JWT testing for mobile requires:
- PKCE flow implementation and testing
- Token lifecycle management (refresh, expiration)
- Secure storage validation
- Biometric authentication integration
- Error handling and edge cases
Proper authentication testing ensures security while maintaining seamless user experience across app lifecycles.