DEV Community

Sewon Ann
Sewon Ann

Posted on

KMP에서 Google 계정으로 Firebase 로그인 구현하기

Kotlin Multiplatform 프로젝트에서 Android/iOS/JVM/JS 네 가지 플랫폼 모두 Google 계정으로 Firebase 로그인을 구현했다. 그 과정에서 겪은 문제들과 해결 방법을 정리한다.

배경

Firebase는 js/iOS/Android SDK를 제공한다. 아직 KMP 를 지원하진 않아, GitLive Firebase SDK가 KMP 지원을 목적으로 개발되었다. 하지만 지원 기능이 아직 완전하지 않다. 특히 JVM 플랫폼에서는 인증 API가 제한적으로 구현되어 있어 Firebase REST API를 직접 호출해야 하는 등의 제약이 있다.

각 플랫폼에서 Google 로그인을 구현한 방식은 다음과 같다:

  • Android: Credential Manager API로 Google ID Token 획득 → GitLive SDK로 Firebase 인증
  • iOS: Swift Google SDK로 Google ID Token 획득 → GitLive SDK로 Firebase 인증
  • JS: Firebase Web SDK의 signInWithPopup으로 한 번에 처리
  • JVM: OAuth 2.0 REST API로 Google ID Token 획득 → Firebase REST API로 Firebase 인증

개념

인증 흐름

Google 계정으로 Firebase 로그인하는 전체 흐름은 다음과 같다:

1. 사용자가 "Google로 로그인" 버튼 클릭
   ↓
2. 플랫폼별 Google 인증 수행
   - Android/iOS/JVM: Google ID Token 획득
   - JS: Firebase Web SDK로 Google 로그인 및 Firebase 인증 완료
   ↓
3. 인증 결과 반환
   - Android/iOS/JVM: GoogleSignInResult.Credential (idToken)
   - JS: GoogleSignInResult.SignedInUser (이미 로그인된 사용자 정보)
   ↓
4. Firebase 인증 처리
   - Android/iOS/JVM: GitLive SDK로 Firebase 인증 (signInWithCredential)
   - JS: 이미 완료됨 (signInWithPopup 내부에서 처리됨)
   ↓
5. 로컬 저장소에 세션 정보 생성
   ↓
6. UI에서 사용자 정보 표시
Enter fullscreen mode Exit fullscreen mode

토큰

인증 과정에서 사용하는 Google ID 토큰과 Firebase ID 토큰의 차이는 다음과 같다:

특징 Google ID Token Firebase ID Token
발급자 Google OAuth 2.0 Firebase Authentication
용도 Firebase에 사용자 신원 증명 Firebase 백엔드와의 통신
형식 JWT (Google 서명) JWT (Firebase 서명)

플랫폼별 차이

플랫폼 Google 인증 방법 Firebase 인증 방법
Android Credential Manager API GitLive SDK (signInWithCredential)
iOS Swift Google SDK GitLive SDK (signInWithCredential)
Web Firebase Web SDK signInWithPopup 내부
JVM OAuth 2.0 REST API Firebase REST API + FirebasePlatform

토큰 관리: Android/iOS/JS에서는 GitLive SDK가 토큰을 자동으로 관리한다. JVM에서는 GitLive SDK의 제약으로 FirebasePlatform에 직접 토큰을 설정해야 한다.

설정

공통

모든 플랫폼에서 GitLive SDK를 사용한다:

implementation("dev.gitlive:firebase-auth:1.12.0")
Enter fullscreen mode Exit fullscreen mode

플랫폼별 의존성

Android:

implementation("androidx.credentials:credentials:1.2.2")
implementation("androidx.credentials:credentials-play-services-auth:1.2.2")
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.0")
Enter fullscreen mode Exit fullscreen mode

iOS (SPM):

  • Firebase iOS SDK
  • Google Sign-In SDK

JVM:

implementation("io.ktor:ktor-client-core:2.3.7")
implementation("io.ktor:ktor-client-cio:2.3.7")
implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
Enter fullscreen mode Exit fullscreen mode

구현

각 플랫폼에서 Google ID Token을 획득하는 방법이 다르므로, GoogleAuthenticator 인터페이스로 추상화했다.

interface GoogleAuthenticator {
    suspend fun signInWithGoogle(): Result<GoogleSignInResult>
}

sealed class GoogleSignInResult {
    data class Credential(val idToken: String, val accessToken: String?) : GoogleSignInResult()
    data class SignedInUser(val user: User) : GoogleSignInResult()
}
Enter fullscreen mode Exit fullscreen mode

Android

Google ID Token 획득 (Credential Manager API):

val googleIdOption = GetGoogleIdOption.Builder()
    .setServerClientId(YOUR_WEB_CLIENT_ID)
    .setFilterByAuthorizedAccounts(false)
    .build()

val result = credentialManager.getCredential(activity, request)
val idToken = GoogleIdTokenCredential.createFrom(result.credential).idToken
Enter fullscreen mode Exit fullscreen mode

Firebase 인증:

val credential = FirebaseGoogleAuthProvider.credential(idToken, null)
FirebaseAuth.auth.signInWithCredential(credential)
Enter fullscreen mode Exit fullscreen mode

iOS

Swift와 Kotlin 간 통신을 위해 GoogleSignInProvider 인터페이스를 정의했다. Swift가 이 인터페이스를 구현하고 Kotlin에서 호출하는 방식이다.

Kotlin 인터페이스 정의:

// Swift가 구현할 인터페이스
interface GoogleSignInProvider {
    fun getGoogleCredential(
        onSuccess: (idToken: String, accessToken: String?) -> Unit,
        onFailure: (String) -> Unit
    )
}

// Swift 구현체를 저장할 변수
private var googleSignInProvider: GoogleSignInProvider? = null

// Swift에서 호출하여 provider를 등록
fun setGoogleSignInProvider(provider: GoogleSignInProvider) {
    googleSignInProvider = provider
}

// 등록된 provider 반환
fun getGoogleSignInProvider(): GoogleSignInProvider? = googleSignInProvider
Enter fullscreen mode Exit fullscreen mode

Swift 구현:

class IOSGoogleSignInProvider: NSObject, GoogleSignInProvider {
    func getGoogleCredential(
        onSuccess: (String, String?) -> Void,
        onFailure: (String) -> Void
    ) {
        GIDSignIn.sharedInstance.signIn(withPresenting: topVC) { result, error in
            guard let idToken = result?.user.idToken?.tokenString else {
                onFailure("Google ID Token 획득 실패")
                return
            }
            onSuccess(idToken, nil)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Swift 초기화 코드:

import Firebase
import GoogleSignIn

// 앱 시작 시 Firebase 초기화
FirebaseApp.configure()

// GoogleSignInProvider 등록
let provider = IOSGoogleSignInProvider()
setGoogleSignInProvider(provider)
Enter fullscreen mode Exit fullscreen mode

Kotlin에서 Swift 코드 호출:

// IosGoogleAuthenticator.kt
class IosGoogleAuthenticator : GoogleAuthenticator {
    override suspend fun signInWithGoogle(): Result<GoogleSignInResult.Credential> {
        return suspendCancellableCoroutine { continuation ->
            // Swift 객체를 호출해서 idToken 획득
            getGoogleSignInProvider()?.getGoogleCredential(
                onSuccess = { idToken, accessToken ->
                    continuation.resume(Result.success(GoogleSignInResult.Credential(idToken, accessToken)))
                },
                onFailure = { error ->
                    continuation.resume(Result.failure(Exception(error)))
                }
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Firebase 인증 (GitLive SDK):

// FirebaseAuthRepository.ios.kt
override suspend fun signInWithCredential(
    idToken: String,
    accessToken: String?,
): Result<User> {
    return try {
        val credential = FirebaseGoogleAuthProvider.credential(idToken, accessToken)
        val authResult = FirebaseAuth.auth.signInWithCredential(credential)
        val user = authResult.user.toDomainUser()
        Result.success(user)
    } catch (e: Exception) {
        Result.failure(e)
    }
}
Enter fullscreen mode Exit fullscreen mode

Web

Firebase Web SDK의 signInWithPopup:

val authModule = js("require('firebase/auth')")
val firebaseAuth: dynamic = authModule.getAuth()
val provider = js("new authModule.GoogleAuthProvider()")

val resultPromise: Promise<dynamic> = authModule.signInWithPopup(firebaseAuth, provider)
resultPromise.then(
    onFulfilled = { result: dynamic ->
        val firebaseUser: dynamic = result.user
        val user = User(
            uid = firebaseUser.uid as String,
            email = firebaseUser.email as? String,
            displayName = firebaseUser.displayName as? String,
            photoUrl = firebaseUser.photoURL as? String,
            emailVerified = firebaseUser.emailVerified as? Boolean ?: false,
            provider = "google.com",
            createdAt = null,
            lastLoginAt = null,
        )
        // 이미 Firebase에 로그인됨
        Result.success(GoogleSignInResult.SignedInUser(user))
    }
)
Enter fullscreen mode Exit fullscreen mode

JVM

OAuth 2.0 Authorization Code Flow:

// 로컬 콜백 서버 시작
val port = findAvailablePort(8080)
val callbackServer = OAuthCallbackServer(port)

// OAuth 2.0 인증 URL 생성 및 브라우저 열기
val state = UUID.randomUUID().toString()
val authUrl = "https://accounts.google.com/o/oauth2/v2/auth?client_id=$CLIENT_ID&redirect_uri=http://127.0.0.1:$port/callback&response_type=code&scope=openid email profile&state=$state"
Desktop.getDesktop().browse(URI(authUrl))

// 콜백 대기 (최대 5분)
val authCode = withTimeoutOrNull(300_000L) {
    callbackServer.waitForCallback()
}

// Authorization Code를 ID Token으로 교환
val response = httpClient.post("https://oauth2.googleapis.com/token") {
    parameter("code", authCode)
    parameter("client_id", CLIENT_ID)
    parameter("redirect_uri", "http://127.0.0.1:$port/callback")
    parameter("grant_type", "authorization_code")
}
val googleIdToken = response.body<TokenResponse>().idToken
Enter fullscreen mode Exit fullscreen mode

Firebase REST API로 Firebase ID Token 교환:

val response = httpClient.post("https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdToken") {
    setBody(
        """
        {
            "idToken": "$googleIdToken",
            "requestUri": "http://localhost",
            "returnSecureToken": true
        }
        """.trimIndent()
    )
}
val firebaseToken = response.body<FirebaseTokenResponse>()
Enter fullscreen mode Exit fullscreen mode

FirebasePlatform에 직접 설정:

val userJson = """
{
    "isAnonymous": false,
    "uid": "${userInfo.sub}",
    "idToken": "$firebaseToken",
    "email": ${userInfo.email?.let { "\"$it\"" } ?: "null"},
    "photoUrl": ${userInfo.picture?.let { "\"$it\"" } ?: "null"},
    "displayName": ${userInfo.name?.let { "\"$it\"" } ?: "null"}
}
""".trimIndent()

val key = "com.google.firebase.auth.FIREBASE_USER"
platform.store(key, userJson)
Enter fullscreen mode Exit fullscreen mode

주의: 이 방법은 GitLive SDK 내부 구현에 의존적이므로 SDK 업데이트 시 동작하지 않을 수 있다.


정리

Firebase SDK, GitLive SDK, 각 플랫폼의 Google 인증 SDK를 조합하여 KMP 프로젝트에서 네 가지 플랫폼 모두 Firebase 인증을 구현했다. 하지만 JVM 플랫폼에서는 GitLive SDK의 제약으로 Firebase REST API를 직접 호출하고 FirebasePlatform에 토큰을 설정하는 등의 우회 방법이 필요했다.

각 플랫폼별로 다른 SDK와 API를 사용하지만, GoogleAuthenticator 인터페이스로 플랫폼별 구현을 추상화하여 공통 비즈니스 로직과 UI 계층을 Kotlin으로 공유할 수 있었다.

Top comments (0)