Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions firebase-auth/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ dependencies {
implementation "androidx.appcompat:appcompat:$appcompatVersion"

implementation project(':play-services-base-core')
implementation project(':play-services-safetynet-core')
implementation project(':play-services-droidguard')
implementation project(':play-services-droidguard-core')
implementation project(':play-services-tasks-ktx')

implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2024, microG Project Team
* SPDX-License-Identifier: Apache-2.0
*/

package org.microg.gms.firebase.auth

import android.content.Context
import android.os.SystemClock
import android.util.Log
import com.android.volley.Request
import com.android.volley.toolbox.JsonObjectRequest
import com.android.volley.toolbox.Volley
import com.google.android.gms.droidguard.DroidGuardClient
import com.google.android.gms.tasks.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import org.microg.gms.safetynet.Attestation
import org.microg.gms.safetynet.SafetyNetPreferences
import org.microg.gms.utils.singleInstanceOf
import org.microg.gms.droidguard.core.DroidGuardPreferences
import java.security.SecureRandom
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

private const val TAG = "GmsFirebaseAppCheck"
private const val SAFETYNET_API_KEY = "AIzaSyDqVnJBjE5ymo--oBJt3On7HQx9xNm1RHA"

/**
* Manages Firebase App Check token acquisition and caching for the
* IdentityToolkitClient.
*
* Flow:
* 1. Build a SafetyNet attestation payload for the calling app
* 2. Obtain a DroidGuard result (hardware-backed attestation)
* 3. Send the attestation request to Google's androidcheck API
* 4. Exchange the resulting JWS for a Firebase App Check token at
* firebaseappcheck.googleapis.com
* 5. Cache the token until it expires
* 6. The cached token is used as the X-Firebase-AppCheck header value
*/
class AppCheckTokenProvider(
private val context: Context,
private val apiKey: String,
/** Numeric Google Cloud project number (e.g. "123456789012") */
private val projectNumber: String?,
/** Firebase App ID (e.g. "1:123456789012:android:abcdef1234567890") */
private val firebaseAppId: String?,
private val packageName: String,
) {
private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
private val random = SecureRandom()

private var cachedToken: String? = null
private var tokenExpiryMs: Long = 0L

/**
* Obtain a cached or fresh Firebase App Check token.
*
* Performs the full SafetyNet attestation + App Check exchange flow
* when no cached token is available or the cached token has expired.
* Returns null when App Check is unavailable or any step fails.
*/
suspend fun getToken(): String? {
if (cachedToken != null && SystemClock.elapsedRealtime() < tokenExpiryMs - 300_000L) {
return cachedToken
}
if (projectNumber == null || firebaseAppId == null) return null
if (!SafetyNetPreferences.isEnabled(context)) return null
if (!DroidGuardPreferences.isAvailable(context)) return null

return try {
val jws = obtainSafetyNetAttestation() ?: return null
val token = exchangeForAppCheckToken(jws) ?: return null
cachedToken = token
token
} catch (e: Exception) {
Log.w(TAG, "Failed to obtain App Check token", e)
null
}
}

/**
* Performs a SafetyNet attestation and returns the JWS result.
*
* Steps:
* 1. Build a SafetyNetData protobuf payload with app identity and nonce
* 2. DroidGuard-process the payload hash for hardware-backed proof
* 3. POST the combined data to Google's androidcheck API
*/
private suspend fun obtainSafetyNetAttestation(): String? {
return try {
val nonce = ByteArray(32).also { random.nextBytes(it) }
val attestation = Attestation(context, packageName)
attestation.buildPayload(nonce)

val data = mapOf("contentBinding" to attestation.payloadHashBase64)
val dg = withContext(Dispatchers.IO) {
DroidGuardClient.getResults(context, "attest", data).await()
}
attestation.setDroidGuardResult(dg)

withContext(Dispatchers.IO) { attestation.attest(SAFETYNET_API_KEY) }
} catch (e: Exception) {
Log.w(TAG, "SafetyNet attestation failed", e)
null
}
}

/**
* Exchange a SafetyNet attestation JWS for a Firebase App Check token.
*
* POST to firebaseappcheck.googleapis.com with the SafetyNet JWS.
* The response contains an attestation_token (JWT) and a TTL.
*/
private suspend fun exchangeForAppCheckToken(safetyNetJws: String): String? {
val appId = firebaseAppId ?: return null
val projectNum = projectNumber ?: return null

val url = (
"https://firebaseappcheck.googleapis.com/v1/" +
"projects/$projectNum/apps/$appId:exchangeSafetyNetToken" +
"?key=$apiKey"
)

return suspendCoroutine { cont ->
queue.add(JsonObjectRequest(
Request.Method.POST, url,
JSONObject().apply {
put("safety_net_token", safetyNetJws)
put("version", 2)
},
{ response ->
val token = response.optString("attestationToken", null)
if (token != null && token.isNotEmpty()) {
val ttl = response.optString("ttl", "3600s")
tokenExpiryMs = SystemClock.elapsedRealtime() + parseTtlSeconds(ttl) * 1000L
Log.d(TAG, "App Check token acquired, TTL: $ttl")
}
cont.resume(token)
},
{ error ->
Log.w(TAG, "App Check exchange failed: ${error.networkResponse?.statusCode} " +
"${error.networkResponse?.data?.decodeToString() ?: error.message}")
cont.resume(null)
}
))
}
}

fun invalidate() {
cachedToken = null
tokenExpiryMs = 0L
}

companion object {
private fun parseTtlSeconds(ttl: String): Long {
return try {
when {
ttl.endsWith("s") -> ttl.dropLast(1).toLong()
ttl.endsWith("m") -> ttl.dropLast(1).toLong() * 60
ttl.endsWith("h") -> ttl.dropLast(1).toLong() * 3600
else -> 3600L
}
} catch (e: NumberFormatException) {
3600L
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,16 +76,19 @@ class FirebaseAuthService : BaseService(TAG, GmsService.FIREBASE_AUTH) {
PackageUtils.getAndCheckCallingPackage(this, request.packageName)
val apiKey = request.extras?.getString(Constants.EXTRA_API_KEY)
val libraryVersion = request.extras?.getString(Constants.EXTRA_LIBRARY_VERSION)
val firebaseAppId = request.extras?.getString(Constants.EXTRA_FIREBASE_APP_ID)
val projectNumber = request.extras?.getString(Constants.EXTRA_PROJECT_NUMBER)
if (apiKey == null) {
callback.onPostInitComplete(CommonStatusCodes.DEVELOPER_ERROR, null, null)
} else {
callback.onPostInitComplete(0, FirebaseAuthServiceImpl(this, lifecycle, request.packageName, libraryVersion, apiKey).asBinder(), null)
callback.onPostInitComplete(0, FirebaseAuthServiceImpl(this, lifecycle, request.packageName, libraryVersion, apiKey, firebaseAppId, projectNumber).asBinder(), null)
}
}
}

class FirebaseAuthServiceImpl(private val context: Context, override val lifecycle: Lifecycle, private val packageName: String, private val libraryVersion: String?, private val apiKey: String) : IFirebaseAuthService.Stub(), LifecycleOwner {
private val client by lazy { IdentityToolkitClient(context, apiKey, packageName, context.packageManager.getCertificates(packageName).firstOrNull()?.digest("SHA1")) }
class FirebaseAuthServiceImpl(private val context: Context, override val lifecycle: Lifecycle, private val packageName: String, private val libraryVersion: String?, private val apiKey: String, private val firebaseAppId: String? = null, private val projectNumber: String? = null) : IFirebaseAuthService.Stub(), LifecycleOwner {
private val appCheckTokenProvider = firebaseAppId?.let { AppCheckTokenProvider(context, apiKey, projectNumber, firebaseAppId, packageName) }
private val client by lazy { IdentityToolkitClient(context, apiKey, packageName, context.packageManager.getCertificates(packageName).firstOrNull()?.digest("SHA1"), appCheckTokenProvider) }
private var authorizedDomain: String? = null

private suspend fun getAuthorizedDomain(): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,45 @@ import kotlin.coroutines.suspendCoroutine

private const val TAG = "GmsFirebaseAuthClient"

class IdentityToolkitClient(context: Context, private val apiKey: String, private val packageName: String? = null, private val certSha1Hash: ByteArray? = null) {
class IdentityToolkitClient(
context: Context,
private val apiKey: String,
private val packageName: String? = null,
private val certSha1Hash: ByteArray? = null,
private val appCheckTokenProvider: AppCheckTokenProvider? = null,
) {
private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }

private var appCheckToken: String? = null

private fun buildRelyingPartyUrl(method: String) = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/$method?key=$apiKey"
private fun buildStsUrl(method: String) = "https://securetoken.googleapis.com/v1/$method?key=$apiKey"

private suspend fun refreshAppCheckToken() {
appCheckToken = appCheckTokenProvider?.getToken()
}

private fun getRequestHeaders(): Map<String, String> = hashMapOf<String, String>().apply {
if (packageName != null) put("X-Android-Package", packageName)
if (certSha1Hash != null) put("X-Android-Cert", certSha1Hash.toHexString().uppercase())
val token = appCheckToken
if (token != null) {
put("X-Firebase-AppCheck", token)
}
}

private suspend fun request(method: String, data: JSONObject): JSONObject = suspendCoroutine { continuation ->
queue.add(object : JsonObjectRequest(POST, buildRelyingPartyUrl(method), data, {
continuation.resume(it)
}, {
Log.d(TAG, "Error: ${it.networkResponse?.data?.decodeToString() ?: it.message}")
continuation.resumeWithException(RuntimeException(it))
}) {
override fun getHeaders(): Map<String, String> = getRequestHeaders()
})
private suspend fun request(method: String, data: JSONObject): JSONObject {
refreshAppCheckToken()
return suspendCoroutine { continuation ->
queue.add(object : JsonObjectRequest(POST, buildRelyingPartyUrl(method), data, {
continuation.resume(it)
}, {
Log.d(TAG, "Error: ${it.networkResponse?.data?.decodeToString() ?: it.message}")
continuation.resumeWithException(RuntimeException(it))
}) {
override fun getHeaders(): Map<String, String> = this@IdentityToolkitClient.getRequestHeaders()
})
}
}

suspend fun createAuthUri(identifier: String? = null, tenantId: String? = null, continueUri: String? = "http://localhost"): JSONObject =
Expand All @@ -63,8 +82,13 @@ class IdentityToolkitClient(context: Context, private val apiKey: String, privat
request("getAccountInfo", JSONObject()
.put("idToken", idToken))

suspend fun getProjectConfig(): JSONObject = suspendCoroutine { continuation ->
queue.add(JsonObjectRequest(GET, buildRelyingPartyUrl("getProjectConfig"), null, { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) }))
suspend fun getProjectConfig(): JSONObject {
refreshAppCheckToken()
return suspendCoroutine { continuation ->
queue.add(JsonObjectRequest(GET, buildRelyingPartyUrl("getProjectConfig"), null, { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) }) {
override fun getHeaders(): Map<String, String> = this@IdentityToolkitClient.getRequestHeaders()
})
}
}

suspend fun getOobConfirmationCode(requestType: String, email: String? = null, newEmail: String? = null, continueUrl: String? = null, idToken: String? = null, iOSBundleId: String? = null, iOSAppStoreId: String? = null, androidMinimumVersion: String? = null, androidInstallApp: Boolean? = null, androidPackageName: String? = null, canHandleCodeInApp: Boolean? = null): JSONObject =
Expand Down Expand Up @@ -132,8 +156,10 @@ class IdentityToolkitClient(context: Context, private val apiKey: String, privat
.put("phoneNumber", phoneNumber)
.put("sessionInfo", sessionInfo))

suspend fun getTokenByRefreshToken(refreshToken: String): JSONObject = suspendCoroutine { continuation ->
queue.add(object : JsonRequest<JSONObject>(POST, buildStsUrl("token"), "grant_type=refresh_token&refresh_token=$refreshToken", { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) }) {
suspend fun getTokenByRefreshToken(refreshToken: String): JSONObject {
refreshAppCheckToken()
return suspendCoroutine { continuation ->
queue.add(object : JsonRequest<JSONObject>(POST, buildStsUrl("token"), "grant_type=refresh_token&refresh_token=$refreshToken", { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) }) {
override fun parseNetworkResponse(response: NetworkResponse): Response<JSONObject> {
return try {
val jsonString = String(response.data, Charset.forName(HttpHeaderParser.parseCharset(response.headers, PROTOCOL_CHARSET)))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@
public class Constants {
public static final String EXTRA_API_KEY = "com.google.firebase.auth.API_KEY";
public static final String EXTRA_LIBRARY_VERSION = "com.google.firebase.auth.LIBRARY_VERSION";
public static final String EXTRA_FIREBASE_APP_ID = "com.google.firebase.auth.FIREBASE_APP_ID";
public static final String EXTRA_PROJECT_NUMBER = "com.google.firebase.auth.PROJECT_NUMBER";
}