Android에서 Google OAuth 2.0 사용해보기

ChoiUS·2025년 8월 7일
0

Android

목록 보기
5/6
post-thumbnail

1. 과정

1. Google Cloud 콘솔 등록

https://developers.google.com/identity/protocols/oauth2/native-app?hl=ko#android
구글 가이드를 참고했으며, 우선 프로젝트를 생성하고 SHA1을 찾았다.
윈도우 기준 아래 명령어를 사용하면 된다.

keytool -list -v -alias androiddebugkey -keystore %USERPROFILE%\.android\debug.keystore

안드로이드 터미널에서는 gradlew를 사용한 명령어도 가능하다.

./gradlew signingReport


여기에 있는 SHA1과 build.gradle에 있는 패키지 명을 입력해서 클라이언트 ID를 받는다.

추가로 웹 클라이언트용 ID도 발급받는다.
Javascript 원본이나 리디렉션 URI는 입력하지 않아도 된다.

2. 라이브러리 추가

implementation("com.google.android.gms:play-services-auth:21.4.0") 
implementation("com.google.android.libraries.identity.googleid:googleid:1.1.1")
implementation("androidx.credentials:credentials:1.5.0")
implementation("androidx.credentials:credentials-play-services-auth:1.5.0")

3. Android 코드 작성

1. GetSignInWithGoogleOption 사용

https://developer.android.com/identity/sign-in/credential-manager-siwg?hl=ko
이 부분은 안드로이드 가이드를 참고했다.

import android.content.Context
import android.util.Log
import androidx.credentials.ClearCredentialStateRequest
import androidx.credentials.CredentialManager
import androidx.credentials.CustomCredential
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetCredentialResponse
import androidx.credentials.exceptions.ClearCredentialException
import androidx.credentials.exceptions.GetCredentialException
import com.choius323.saisai.BuildConfig
import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption
import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential
import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException

object GoogleAccountUtil {
    // 발급받은 웹 클라이언트 ID
    private const val WEB_CLIENT_ID = BuildConfig.GOOGLE_WEB_CLIENT_ID

    suspend fun googleSignIn(
        context: Context,
        onSignedIn: (idToken: String) -> Unit,
        onError: (Exception) -> Unit,
    ) {
        val credentialManager = CredentialManager.create(context)

        // 로그인 기능에 대한 세부 설정을 담는 객체를 생성
        val signInWithGoogleOption: GetSignInWithGoogleOption = GetSignInWithGoogleOption.Builder(
            serverClientId = WEB_CLIENT_ID // 웹 클라이언트 ID 사용
        ).setNonce(generateNonce())
            .build()

        // CredentialManager에게 전달할 최종 자격 증명 요청서를 생성
        val request: GetCredentialRequest = GetCredentialRequest.Builder()
            .addCredentialOption(signInWithGoogleOption)
            .build()

        try {
            val result = credentialManager.getCredential(
                request = request, context = context,
            )
            handleSignInWithGoogleOption(result, onSignedIn, onError)
        } catch (e: GetCredentialException) {
            onError(e)
        }
    }

    private fun handleSignInWithGoogleOption(
        result: GetCredentialResponse,
        onSignedIn: (idToken: String) -> Unit,
        onError: (Exception) -> Unit,
    ) {
        val credential = result.credential

        when (credential) {
            is CustomCredential -> {
                // 미리 선언된 타입이 아니면 예외 처리
                if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
                    try {
                        val googleIdTokenCredential = GoogleIdTokenCredential
                            .createFrom(credential.data)
                        // idToken: String, data: Bundle
                        Log.d(
                            TAG,
                            "GoogleIdToken: ${googleIdTokenCredential.idToken}, data: ${googleIdTokenCredential.data}"
                        )
                        onSignedIn(googleIdTokenCredential.idToken)
                    } catch (e: GoogleIdTokenParsingException) {
                        Log.e(TAG, "Received an invalid google id token response", e)
                        onError(e)
                    }
                } else {
                    val message = "Unexpected type of credential"
                    Log.e(TAG, message)
                    onError(Exception(message))
                }
            }

            else -> {
                val message = "Unexpected type of credential"
                Log.e(TAG, message)
                onError(Exception(message))
            }
        }
    }

    suspend fun googleSignOut(
        context: Context, onSuccess: () -> Unit = {}, onError: (Exception) -> Unit={}
    ) {
        try {
            val credentialManager = CredentialManager.create(context)
            // 사용자 로그인 상태를 캐시(저장)해 둔 자격 증명 제공자(Credential Provider)의 상태를 제거
            credentialManager.clearCredentialState(ClearCredentialStateRequest())
            Log.d("GoogleLogout", "Credential state cleared successfully.")
            onSuccess()
        } catch (e: ClearCredentialException) {
            Log.e("GoogleLogout", "Error clearing credential state", e)
            onError(e)
        }
    }

    // Nonce(Number used once) 랜덤 생성
    private fun generateNonce(length: Int = 16): String {
        val nonceBytes = ByteArray(length) // you can change the length
        SecureRandom().nextBytes(nonceBytes)  // randomise the bytes
        return Base64.encodeToString(nonceBytes, Base64.URL_SAFE)
    }

    private const val TAG = "GoogleAccountUtil"
}

이 방식을 사용하면 이렇게 나온다.

2. GetGoogleIdOption 사용

suspend fun googleSignIn(
    context: Context,
    onSignedIn: () -> Unit,
    onError: (Exception) -> Unit,
) {
    val credentialManager = CredentialManager.create(context)

    // 로그인 기능에 대한 세부 설정을 담는 객체를 생성
    val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder()
        .setFilterByAuthorizedAccounts(false) //과거에 이 앱에 로그인을 허용한 적이 있는 계정만 사용 여부
        .setServerClientId(WEB_CLIENT_ID) // 웹 클라이언트 ID
        .setAutoSelectEnabled(true) // 계정 자동 선택 여부
        .setNonce(generateNonce())
        .build()

    // CredentialManager에게 전달할 최종 자격 증명 요청서를 생성
    val request: GetCredentialRequest = GetCredentialRequest.Builder()
        .addCredentialOption(googleIdOption)
        .build()

    try {
        val result = credentialManager.getCredential(
            request = request, context = context,
        )
        handleSignIn(result, onSignedIn, onError)
    } catch (e: GetCredentialException) {
        onError(e)
    }
}

private fun handleSignIn(
    result: GetCredentialResponse,
    onSignedIn: () -> Unit,
    onError: (Exception) -> Unit,
) {
    val credential = result.credential
    val responseJson: String

    when (credential) {

        // Passkey credential
        is PublicKeyCredential -> {
            responseJson = credential.authenticationResponseJson
            Log.d( TAG, "Credential response: $responseJson")
        }

        // Password credential
        is PasswordCredential -> {
            val username = credential.id
            val password = credential.password
            Log.d( TAG, "Username: $username, Password: $password")
        }

        // GoogleIdToken credential
        is CustomCredential -> {
            if (credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
                try {
                    val googleIdTokenCredential = GoogleIdTokenCredential
                        .createFrom(credential.data)
                    Log.d(
                         TAG,
                        "Google ID token: ${googleIdTokenCredential.idToken} data: ${googleIdTokenCredential.data}"
                    )
                } catch (e: GoogleIdTokenParsingException) {
                    Log.e( TAG, "Received an invalid google id token response", e)
                }
            } else {
                // Catch any unrecognized custom credential type here.
                Log.e( TAG, "Unexpected type of credential")
            }
        }

        else -> {
            // Catch any unrecognized credential type here.
            Log.e( TAG, "Unexpected type of credential")
        }
    }
    
    /*
    googleSignOut, generateNonce 생략
    */
}

Option과 credential 분기를 제외하면 크게 다르지 않다.

이 이후에는 tokenId를 서버에 전송해서 서버가 검증을 거친 뒤 다시 클라이언트와 통신(JWT 등)하는 방법을 권장한다.

2. 트러블슈팅

1. 키 저장소 파일이 존재하지 않음

SHA1을 발급하기 위해 keytool 명령어를 사용하는데, keytool 오류: java.lang.Exception: 키 저장소 파일이 존재하지 않음이 계속 발생했다.
처음에는 ~\.android\debug.keystore로 경로를 지정했는데, 이게 windows 환경에서는 안 되는 것 같았고, 경로를 C:\Users\사용자이름\.android\debug.keystore로 직접 지정해서 확인했다.
그 뒤에는 gradlew에 있는 기능을 알게 되어서 이후에는 편하게 사용 중이다.

2. BAD_AUTHENTICATION, GetCredentialCancellationException

androidx.credentials.exceptions.GetCredentialCancellationException: [16] Account reauth failed.
[RequestTokenManager] getToken() -> BAD_AUTHENTICATION. App: com.choius323.saisai, Service: oauth2:openid

aypn: Long live credential not available.

로그인을 시도할 때 위와 같은 오류가 계속 발생했다.
그래서 SHA1 재입력, 빌드 초기화, 프로젝트 재생성 등 정말 다양한 시도를 해봤다.
그러다가 AI와 한참의 시도 끝에 앱 삭제 후 설치 및 재부팅을 했을 때 성공했다.
Gemini 말로는 SHA1와 같은 값을 새로 적용했을 때는 재설치 하는 게 좋다는데, 아무튼 한참 걸렸다...

3. 웹 클라이언트 ID 사용

안드로이드 앱을 개발하고 있는 상황이어서, 당연히 안드로이드 ID만 필요하다고 생각해서 이 키를 코드에 넣고 사용했다.
하지만, 안드로이드 ID는 다른 곳에서 이용하고 코드에서 직접 사용하는 것은 웹 ID였다.
사실 코드에도 적혀있지만, serverClientId, setServerClientId 처럼 안드로이드 ID를 적으라고 써있진 않았다.
조금 더 알아보니 안드로이드 ID는 로그인 후 기기에 있는 PlayStore의 서비스에 접근해서 검증을 거치는 용도로 사용되기 때문에, 코드에 직접 사용할 필요 없이 자동으로 사용되는 값이었다.
실제로 소셜 로그인 해결방법 중 하나는 에뮬레이터이면 PlayStore를 지원하는지 확인하고, 있다면 PlayStore 캐시를 지워보는 것이었다.

profile
사람을 위한 개발자

0개의 댓글