모바일 OAuth 2.0 + PKCE: 앱에서 안전한 인증 구현 완전 가이드

모바일

OAuth2PKCE모바일 인증iOS 인증Android 인증

이 글은 누구를 위한 것인가

  • 모바일 앱에 소셜 로그인이나 OAuth 인증을 구현해야 하는 개발자
  • Implicit Flow를 아직 쓰고 있거나, 앱 내 WebView로 로그인을 처리하는 팀
  • 토큰을 어디에, 어떻게 저장해야 안전한지 혼란스러운 엔지니어

들어가며

모바일 앱에서 OAuth 2.0을 구현할 때 흔히 저지르는 실수가 있다. 웹에서 쓰는 Implicit Flow를 그대로 쓰거나, WebView 안에서 로그인 페이지를 열거나, 토큰을 UserDefaults에 저장하는 것이다. 이 모두 보안 취약점이다.

RFC 8252 "OAuth 2.0 for Native Apps"와 PKCE(Proof Key for Code Exchange)가 모바일의 표준이다. 복잡해 보이지만 구조를 이해하면 단순하다.

이 글은 bluefoxdev.kr의 모바일 보안 인증 가이드 를 참고하고, PKCE 실전 구현 관점에서 확장하여 작성했습니다.


1. 왜 PKCE인가

1.1 Authorization Code Flow의 위험

[일반 Authorization Code Flow - 앱에서 위험한 이유]

앱 → 브라우저에서 인증 URL 오픈
브라우저 → 인증 서버에서 code 발급
인증 서버 → 앱으로 code 리다이렉트 (custom scheme)
앱 → code로 token 교환

위험: 악의적인 앱이 동일한 custom scheme 등록 가능
→ code 가로채기 → 토큰 탈취

1.2 PKCE가 해결하는 방법

[PKCE Flow]

1. 앱: code_verifier(랜덤 문자열) 생성
2. 앱: code_challenge = SHA256(code_verifier)
3. 앱 → 인증 서버: code_challenge 포함해서 인증 요청
4. 브라우저: 인증 후 code 발급
5. 악의적인 앱이 code를 가로채도...
   code + code_verifier 없으면 토큰 교환 불가!
6. 정상 앱 → 인증 서버: code + code_verifier로 토큰 교환

2. iOS 구현

2.1 ASWebAuthenticationSession 사용

import AuthenticationServices
import CryptoKit

class OAuthManager: NSObject {
    
    // PKCE 파라미터 생성
    private func generatePKCE() -> (verifier: String, challenge: String) {
        // code_verifier: 43~128자의 랜덤 URL-safe 문자열
        var buffer = [UInt8](repeating: 0, count: 64)
        _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer)
        let verifier = Data(buffer).base64URLEncodedString()
        
        // code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
        let data = Data(verifier.utf8)
        let hashed = SHA256.hash(data: data)
        let challenge = Data(hashed).base64URLEncodedString()
        
        return (verifier, challenge)
    }
    
    func startOAuthFlow(
        authorizationURL: URL,
        clientID: String,
        redirectURI: String,
        scopes: [String],
        completion: @escaping (Result<TokenResponse, Error>) -> Void
    ) {
        let (verifier, challenge) = generatePKCE()
        
        // state: CSRF 방지
        let state = UUID().uuidString
        
        var components = URLComponents(url: authorizationURL, resolvingAgainstBaseURL: false)!
        components.queryItems = [
            URLQueryItem(name: "response_type", value: "code"),
            URLQueryItem(name: "client_id", value: clientID),
            URLQueryItem(name: "redirect_uri", value: redirectURI),
            URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
            URLQueryItem(name: "code_challenge", value: challenge),
            URLQueryItem(name: "code_challenge_method", value: "S256"),
            URLQueryItem(name: "state", value: state),
        ]
        
        // verifier를 메모리에 임시 저장 (세션 중)
        let storedVerifier = verifier
        let storedState = state
        
        // ASWebAuthenticationSession: 시스템 브라우저 사용 (앱 내 WebView 아님)
        let session = ASWebAuthenticationSession(
            url: components.url!,
            callbackURLScheme: "myapp"
        ) { callbackURL, error in
            guard let callbackURL, error == nil else {
                completion(.failure(error ?? OAuthError.cancelled))
                return
            }
            
            let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)
            let code = components?.queryItems?.first(where: { $0.name == "code" })?.value
            let returnedState = components?.queryItems?.first(where: { $0.name == "state" })?.value
            
            // state 검증 (CSRF 방지)
            guard returnedState == storedState, let code else {
                completion(.failure(OAuthError.stateMismatch))
                return
            }
            
            // 토큰 교환
            Task {
                do {
                    let token = try await self.exchangeCodeForToken(
                        code: code,
                        verifier: storedVerifier,
                        redirectURI: redirectURI
                    )
                    completion(.success(token))
                } catch {
                    completion(.failure(error))
                }
            }
        }
        
        session.presentationContextProvider = self
        session.prefersEphemeralWebBrowserSession = false // 쿠키 공유로 SSO 지원
        session.start()
    }
    
    private func exchangeCodeForToken(
        code: String,
        verifier: String,
        redirectURI: String
    ) async throws -> TokenResponse {
        var request = URLRequest(url: tokenEndpoint)
        request.httpMethod = "POST"
        request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        
        let body = [
            "grant_type=authorization_code",
            "code=\(code)",
            "redirect_uri=\(redirectURI)",
            "client_id=\(clientID)",
            "code_verifier=\(verifier)",
        ].joined(separator: "&")
        
        request.httpBody = body.data(using: .utf8)
        
        let (data, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
            throw OAuthError.tokenExchangeFailed
        }
        
        return try JSONDecoder().decode(TokenResponse.self, from: data)
    }
}

2.2 토큰 안전 저장 (Keychain)

import Security

class TokenStorage {
    private let service = "com.myapp.auth"
    
    func save(token: String, forKey key: String) throws {
        let data = token.data(using: .utf8)!
        
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
            kSecValueData as String: data,
            // 기기 잠금 해제 시에만 접근 가능
            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
        ]
        
        // 기존 값 삭제 후 저장
        SecItemDelete(query as CFDictionary)
        
        let status = SecItemAdd(query as CFDictionary, nil)
        guard status == errSecSuccess else {
            throw KeychainError.saveFailed(status)
        }
    }
    
    func load(forKey key: String) throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            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(forKey key: String) {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,
        ]
        SecItemDelete(query as CFDictionary)
    }
}

3. Android 구현

3.1 AppAuth-Android 사용

// build.gradle.kts
dependencies {
    implementation("net.openid:appauth:0.11.1")
}
class OAuthActivity : AppCompatActivity() {
    
    private lateinit var authService: AuthorizationService
    private val RC_AUTH = 100
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        authService = AuthorizationService(this)
    }
    
    fun startOAuthFlow() {
        val serviceConfig = AuthorizationServiceConfiguration(
            Uri.parse("https://auth.example.com/authorize"),
            Uri.parse("https://auth.example.com/token")
        )
        
        val request = AuthorizationRequest.Builder(
            serviceConfig,
            "client_id",
            ResponseTypeValues.CODE,
            Uri.parse("com.myapp://callback")
        )
            .setScope("openid profile email")
            .setCodeVerifier() // PKCE 자동 처리 (AppAuth가 관리)
            .build()
        
        val intent = authService.getAuthorizationRequestIntent(request)
        startActivityForResult(intent, RC_AUTH)
    }
    
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        
        if (requestCode == RC_AUTH) {
            val response = AuthorizationResponse.fromIntent(data!!)
            val exception = AuthorizationException.fromIntent(data)
            
            if (response != null) {
                exchangeCodeForTokens(response)
            } else {
                Log.e("OAuth", "Authorization failed: ${exception?.message}")
            }
        }
    }
    
    private fun exchangeCodeForTokens(response: AuthorizationResponse) {
        val tokenRequest = response.createTokenExchangeRequest()
        
        authService.performTokenRequest(tokenRequest) { tokenResponse, exception ->
            if (tokenResponse != null) {
                // 토큰 저장
                saveTokens(tokenResponse)
            } else {
                Log.e("OAuth", "Token exchange failed: ${exception?.message}")
            }
        }
    }
    
    override fun onDestroy() {
        super.onDestroy()
        authService.dispose()
    }
}

3.2 토큰 안전 저장 (EncryptedSharedPreferences)

import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

class TokenStorage(context: Context) {
    
    private val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build()
    
    private val prefs = EncryptedSharedPreferences.create(
        context,
        "auth_tokens",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
    )
    
    fun saveAccessToken(token: String) {
        prefs.edit().putString("access_token", token).apply()
    }
    
    fun getAccessToken(): String? {
        return prefs.getString("access_token", null)
    }
    
    fun saveRefreshToken(token: String) {
        prefs.edit().putString("refresh_token", token).apply()
    }
    
    fun getRefreshToken(): String? {
        return prefs.getString("refresh_token", null)
    }
    
    fun clearAll() {
        prefs.edit().clear().apply()
    }
}

4. Refresh Token 처리 패턴

// iOS: 토큰 갱신 인터셉터 패턴
actor TokenRefresher {
    private var refreshTask: Task<String, Error>?
    
    func validAccessToken() async throws -> String {
        // 저장된 토큰 만료 확인
        if let token = tokenStorage.accessToken, !token.isExpired {
            return token.value
        }
        
        // 이미 갱신 중이면 기다림 (중복 갱신 방지)
        if let existingTask = refreshTask {
            return try await existingTask.value
        }
        
        let task = Task<String, Error> {
            defer { refreshTask = nil }
            
            guard let refreshToken = tokenStorage.refreshToken else {
                throw OAuthError.noRefreshToken
            }
            
            let newToken = try await oauthManager.refresh(token: refreshToken)
            tokenStorage.save(newToken)
            return newToken.accessToken
        }
        
        refreshTask = task
        return try await task.value
    }
}

5. 보안 체크리스트

모바일 OAuth 구현 보안 체크리스트:

인증 흐름:
□ Authorization Code Flow + PKCE 사용
□ WebView 대신 시스템 브라우저 사용 (ASWebAuthenticationSession / Custom Tab)
□ Implicit Flow 사용 안 함 (deprecated)
□ state 파라미터로 CSRF 방지

토큰 저장:
□ iOS: Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly)
□ Android: EncryptedSharedPreferences
□ UserDefaults/SharedPreferences에 저장 안 함
□ 로컬 로그에 토큰 출력 안 함

토큰 관리:
□ Access Token 만료 시간 짧게 (1시간 이하)
□ Refresh Token으로 자동 갱신
□ 앱 삭제 시 Keychain 데이터 삭제 여부 확인
□ 계정 로그아웃 시 토큰 즉시 revoke

마무리

모바일 OAuth 구현의 핵심은 세 가지다.

  1. PKCE: code 가로채기 공격 방어, 반드시 적용
  2. 시스템 브라우저: WebView는 키로거 공격에 취약, ASWebAuthenticationSession/Custom Tab 사용
  3. Keychain/EncryptedSharedPreferences: 토큰은 반드시 암호화 저장

AppAuth 라이브러리(iOS/Android 공식 지원)를 쓰면 PKCE, state, 코드 교환을 자동으로 처리해준다. 직접 구현보다 훨씬 안전하고 검증된 방법이다.