Mobile Development

Mobile Authentication Patterns

TOKEN

Pola autentikasi mobile — biometric, OAuth 2.0 + PKCE, token storage, session management, social login, dan security best practices

📋 Daftar Isi
  1. Autentikasi Mobile
  2. OAuth 2.0 + PKCE
  3. Token Storage
  4. Biometric Authentication
  5. Session Management
  6. Social Login
  7. Security Best Practices
  8. Quiz Pemahaman

1. Autentikasi Mobile

Autentikasi mobile memiliki tantangan unik: perangkat bisa hilang/dicuri, jaringan tidak stabil, dan user menginginkan login yang cepat. Artikel ini membahas pola autentikasi modern untuk mobile.

Mobile Auth Flow
🔑
User Login
Credential
Biometric
🔐
Auth Server
OAuth 2.0 / OIDC
Token issuance
💾
Token Storage
Secure storage
Keychain / Keystore

2. OAuth 2.0 + PKCE

PKCE (Proof Key for Code Exchange) adalah ekstensi OAuth 2.0 yang wajib untuk mobile apps. PKCE mencegah serangan authorization code interception.

Kotlin — OAuth PKCE Flow
class OAuthManager(
    private val clientId: String,
    private val redirectUri: String,
    private val authEndpoint: String,
    private val tokenEndpoint: String,
) {
    // Generate PKCE parameters
    fun generatePKCE(): PKCEParams {
        // Code verifier: random string 43-128 chars
        val codeVerifier = generateRandomString(128)

        // Code challenge: SHA256 of verifier, base64url encoded
        val codeChallenge = base64UrlEncode(
            sha256(codeVerifier.toByteArray())
        )

        return PKCEParams(
            codeVerifier = codeVerifier,
            codeChallenge = codeChallenge,
            codeChallengeMethod = "S256"
        )
    }

    // Step 1: Build authorization URL
    fun buildAuthUrl(pkce: PKCEParams): String {
        return Uri.parse(authEndpoint).buildUpon()
            .appendQueryParameter("response_type", "code")
            .appendQueryParameter("client_id", clientId)
            .appendQueryParameter("redirect_uri", redirectUri)
            .appendQueryParameter("code_challenge", pkce.codeChallenge)
            .appendQueryParameter("code_challenge_method", pkce.codeChallengeMethod)
            .appendQueryParameter("state", generateRandomString(32))
            .appendQueryParameter("scope", "openid profile email")
            .build().toString()
    }

    // Step 2: Exchange code for token
    suspend fun exchangeCode(
        authorizationCode: String,
        codeVerifier: String
    ): TokenResponse {
        return httpClient.post(tokenEndpoint) {
            parameter("grant_type", "authorization_code")
            parameter("code", authorizationCode)
            parameter("redirect_uri", redirectUri)
            parameter("client_id", clientId)
            parameter("code_verifier", codeVerifier)  // PKCE!
        }.body()
    }
}
Swift — ASWebAuthenticationSession (iOS)
import AuthenticationServices

class OAuthService {
    func login() async throws -> TokenResponse {
        let pkce = generatePKCE()
        let authURL = buildAuthURL(pkce: pkce)

        // Present system auth sheet
        let code = try await withCheckedThrowingContinuation { cont in
            let session = ASWebAuthenticationSession(
                url: authURL,
                callbackURLScheme: "myapp"
            ) { callbackURL, error in
                if let error { cont.resume(throwing: error); return }
                guard let code = callbackURL?.queryValue("code") else {
                    cont.resume(throwing: AuthError.noCode)
                    return
                }
                cont.resume(returning: code)
            }
            session.presentationContextProvider = self
            session.prefersEphemeralWebBrowserSession = true
            session.start()
        }

        // Exchange code for tokens
        return try await exchangeCode(code: code, verifier: pkce.codeVerifier)
    }
}

3. Token Storage

Kotlin — Android Secure Storage
// Menggunakan EncryptedSharedPreferences
class SecureTokenStorage(context: Context) {
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()

    private val prefs = EncryptedSharedPreferences.create(
        context,
        "secure_tokens",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )

    fun saveTokens(accessToken: String, refreshToken: String) {
        prefs.edit()
            .putString("access_token", accessToken)
            .putString("refresh_token", refreshToken)
            .putLong("saved_at", System.currentTimeMillis())
            .apply()
    }

    fun getAccessToken(): String? = prefs.getString("access_token", null)
    fun getRefreshToken(): String? = prefs.getString("refresh_token", null)

    fun clearTokens() {
        prefs.edit().clear().apply()
    }
}
Swift — iOS Keychain
import Security

class KeychainService {
    func save(key: String, value: String) throws {
        guard let data = value.data(using: .utf8) else { return }

        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
        ]

        SecItemDelete(query as CFDictionary)  // Remove existing
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }

    func get(key: String) -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]

        var result: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &result)

        guard status == errSecSuccess, let data = result as? Data else {
            return nil
        }
        return String(data: data, encoding: .utf8)
    }

    func delete(key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
        ]
        SecItemDelete(query as CFDictionary)
    }
}

3.1 Perbandingan Storage

StorageEnkripsiPlatformAman?
SharedPreferences / UserDefaultsTidakKeduanya❌ Tidak
EncryptedSharedPreferencesAES-256Android✅ Ya
KeychainHardware-backediOS✅ Ya
Flutter Secure StoragePlatform-levelKeduanya✅ Ya
SQLite (unencrypted)TidakKeduanya❌ Tidak
SQLCipherAES-256Keduanya✅ Ya
⚠️ Jangan Simpan Token di SharedPreferences Biasa

SharedPreferences dan UserDefaults TIDAK terenkripsi. Siapa pun dengan akses device bisa membaca token. Selalu gunakan Keychain (iOS) atau EncryptedSharedPreferences (Android).

4. Biometric Authentication

Kotlin — BiometricPrompt (Android)
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricManager

class BiometricAuthManager(private val activity: FragmentActivity) {

    fun isAvailable(): Boolean {
        val biometricManager = BiometricManager.from(activity)
        return biometricManager.canAuthenticate(
            BiometricManager.Authenticators.BIOMETRIC_STRONG
        ) == BiometricManager.BIOMETRIC_SUCCESS
    }

    fun authenticate(
        onSuccess: () -> Unit,
        onError: (String) -> Unit
    ) {
        val executor = ContextCompat.getMainExecutor(activity)

        val callback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                onSuccess()
            }

            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                onError(errString.toString())
            }
        }

        val promptInfo = BiometricPrompt.PromptInfo.Builder()
            .setTitle("Autentikasi")
            .setSubtitle("Verifikasi identitas Anda")
            .setNegativeButtonText("Batal")
            .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
            .build()

        BiometricPrompt(activity, executor, callback)
            .authenticate(promptInfo)
    }
}
Swift — LAContext (iOS)
import LocalAuthentication

class BiometricService {
    enum BiometricType {
        case faceID, touchID, none
    }

    var biometricType: BiometricType {
        let context = LAContext()
        var error: NSError?
        guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
            return .none
        }
        return context.biometryType == .faceID ? .faceID : .touchID
    }

    func authenticate(reason: String) async throws -> Bool {
        let context = LAContext()
        return try await context.evaluatePolicy(
            .deviceOwnerAuthenticationWithBiometrics,
            localizedReason: reason
        )
    }
}

// Penggunaan
let biometric = BiometricService()
do {
    let success = try await biometric.authenticate(reason: "Login ke MyApp")
    if success {
        // Load token dari Keychain, auto-login
        let token = keychain.get(key: "access_token")
    }
} catch {
    // Fallback ke password
    showPasswordLogin()
}

5. Session Management

Dart — Token Refresh Interceptor
import 'package:dio/dio.dart';

class AuthInterceptor extends Interceptor {
  final Dio dio;
  final SecureStorage storage;
  bool _isRefreshing = false;
  final _queue = >[];

  AuthInterceptor({required this.dio, required this.storage});

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) async {
    final token = await storage.getAccessToken();
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      // Token expired
      if (!_isRefreshing) {
        _isRefreshing = true;
        try {
          final refreshToken = await storage.getRefreshToken();
          final response = await dio.post('/auth/refresh', data: {
            'refresh_token': refreshToken,
          });
          await storage.saveTokens(
            accessToken: response.data['access_token'],
            refreshToken: response.data['refresh_token'],
          );
          // Retry original request
          err.requestOptions.headers['Authorization'] =
              'Bearer ${response.data['access_token']}';
          final retryResponse = await dio.fetch(err.requestOptions);
          handler.resolve(retryResponse);
        } catch (e) {
          // Refresh failed, force logout
          await storage.clearTokens();
          navigatorKey.currentState?.pushReplacementNamed('/login');
          handler.reject(err);
        } finally {
          _isRefreshing = false;
          // Process queued requests
          for (var completer in _queue) {
            completer.complete();
          }
          _queue.clear();
        }
      } else {
        // Wait for refresh to complete
        final completer = Completer();
        _queue.add(completer);
        await completer.future;
        // Retry with new token
        final token = await storage.getAccessToken();
        err.requestOptions.headers['Authorization'] = 'Bearer $token';
        final retryResponse = await dio.fetch(err.requestOptions);
        handler.resolve(retryResponse);
      }
    } else {
      handler.next(err);
    }
  }
}

5.1 Token Lifecycle

TokenDurasiGunakan Untuk
Access Token15-60 menitAutentikasi API request
Refresh Token7-30 hariMendapatkan access token baru
ID Token (OIDC)15-60 menitIdentitas user (claims)
Session TokenVariesServer-side session

6. Social Login

Dart — Google Sign-In
import 'package:google_sign_in/google_sign_in.dart';

final GoogleSignIn _google = GoogleSignIn(
  scopes: ['email', 'profile'],
);

Future signInWithGoogle() async {
  try {
    final account = await _google.signIn();
    if (account == null) return; // User cancelled

    final auth = await account.authentication;
    final idToken = auth.idToken;
    final accessToken = auth.accessToken;

    // Send tokens to your backend for verification
    final response = await apiClient.post('/auth/google', data: {
      'id_token': idToken,
      'access_token': accessToken,
    });

    // Save server-issued tokens
    await secureStorage.saveTokens(
      accessToken: response.data['access_token'],
      refreshToken: response.data['refresh_token'],
    );

    navigateToHome();
  } catch (e) {
    showError('Login gagal: $e');
  }
}

7. Security Best Practices

PraktikMengapa
Gunakan PKCE untuk OAuthCegah code interception
Simpan token di Keychain/KeystoreEnkripsi hardware-backed
Implement token refreshUser tidak perlu login ulang
Biometric sebagai second factorTambah layer keamanan
Certificate pinningCegah MITM attack
Root/jailbreak detectionCegah running di compromised device
Auto-logout setelah idleCegah unauthorized access

Quiz Pemahaman

Pertanyaan 1: Apa fungsi PKCE di OAuth 2.0?

a) Mengenkripsi password
b) Mencegah authorization code interception
c) Menyimpan token
d) Generate API key

Pertanyaan 2: Di mana menyimpan token di iOS secara aman?

a) UserDefaults
b) File system
c) Keychain
d) NSCache

Pertanyaan 3: Durasi tipikal access token?

a) 1 tahun
b) 30 hari
c) 15-60 menit
d) 1 detik

Pertanyaan 4: Apa yang terjadi saat refresh token gagal?

a) App crash
b) Retry terus
c) Force logout user
d) Ignore error

Pertanyaan 5: Komponen Android apa untuk autentikasi biometric?

a) FingerprintManager
b) BiometricPrompt
c) KeyguardManager
d) SecurityManager
← SebelumnyaKembali ke Beranda Selanjutnya →Lihat Kategori
🔍 Zoom
100%
🎨 Tema