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

  1. Always use PKCE for mobile OAuth - Prevents code interception
  2. Store tokens securely - Use Android Keystore / iOS Keychain
  3. Implement automatic token refresh - Seamless user experience
  4. Test token expiration scenarios - Handle edge cases
  5. Use biometric authentication where possible - Enhanced security + UX
  6. Validate JWT claims - exp, iat, iss, aud
  7. 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.