diff --git a/build.gradle.kts b/build.gradle.kts index 8466b03..ccd367e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -138,6 +138,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.play.services) testImplementation(libs.kotlinx.coroutines.swing) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.okhttp.mockwebserver) // firebase aars aar(platform(libs.google.firebase.bom)) aar(libs.google.firebase.firestore) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index be3c5f4..a34df17 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,7 @@ kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-t kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } okhttp = { module = "com.squareup.okhttp3:okhttp", version = "3.12.13" } +okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version = "3.12.13" } robolectric-android-all = { module = "org.robolectric:android-all", version = "14-robolectric-10818077" } xerial-sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version = "3.46.1.0" } diff --git a/src/main/java/android/os/AsyncTask.kt b/src/main/java/android/os/AsyncTask.kt index 1ae3a4a..cfecddb 100644 --- a/src/main/java/android/os/AsyncTask.kt +++ b/src/main/java/android/os/AsyncTask.kt @@ -1,6 +1,6 @@ package android.os -import kotlinx.coroutines.Dispatchers +import com.google.firebase.FirebasePlatform import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -19,9 +19,9 @@ abstract class AsyncTask { fun execute(vararg params: Any): AsyncTask { GlobalScope.launch { - withContext(Dispatchers.Main) { onPreExecute() } + withContext(FirebasePlatform.firebasePlatform.mainDispatcher) { onPreExecute() } val result = doInBackground(*params) - withContext(Dispatchers.Main) { onPostExecute(result) } + withContext(FirebasePlatform.firebasePlatform.mainDispatcher) { onPostExecute(result) } } return this } diff --git a/src/main/java/android/os/Handler.kt b/src/main/java/android/os/Handler.kt index b1b261d..fed367b 100644 --- a/src/main/java/android/os/Handler.kt +++ b/src/main/java/android/os/Handler.kt @@ -1,7 +1,6 @@ package android.os -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import com.google.firebase.FirebasePlatform import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -10,14 +9,14 @@ open class Handler(looper: Looper?, callback: Handler.Callback?) { constructor(looper: Looper) : this(looper, null) fun post(runnable: Runnable): Boolean { - GlobalScope.launch(Dispatchers.Main) { + FirebasePlatform.firebasePlatform.mainScope.launch { runnable.run() } return true } fun postDelayed(runnable: Runnable, time: Long): Boolean { - GlobalScope.launch(Dispatchers.Main) { + FirebasePlatform.firebasePlatform.mainScope.launch { delay(time) runnable.run() } diff --git a/src/main/java/com/google/firebase/FirebasePlatform.kt b/src/main/java/com/google/firebase/FirebasePlatform.kt index 4db9c84..e41d87f 100644 --- a/src/main/java/com/google/firebase/FirebasePlatform.kt +++ b/src/main/java/com/google/firebase/FirebasePlatform.kt @@ -1,9 +1,20 @@ package com.google.firebase +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import java.io.File abstract class FirebasePlatform { + open val mainDispatcher: CoroutineDispatcher + get() = Dispatchers.Default + + internal val mainScope: CoroutineScope by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + CoroutineScope(SupervisorJob() + mainDispatcher) + } + companion object { internal lateinit var firebasePlatform: FirebasePlatform diff --git a/src/main/java/com/google/firebase/auth/AuthCredential.java b/src/main/java/com/google/firebase/auth/AuthCredential.java index 39086fd..7b492d6 100644 --- a/src/main/java/com/google/firebase/auth/AuthCredential.java +++ b/src/main/java/com/google/firebase/auth/AuthCredential.java @@ -1,9 +1,75 @@ package com.google.firebase.auth; -import kotlin.NotImplementedError; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +public abstract class AuthCredential { + private final String provider; + private final String idToken; + private final String accessToken; + private final String rawNonce; + + protected AuthCredential(String provider) { + this(provider, null, null, null); + } + + protected AuthCredential(String provider, String idToken, String accessToken, String rawNonce) { + this.provider = requireValue(provider, "provider"); + this.idToken = optionalValue(idToken, "idToken"); + this.accessToken = optionalValue(accessToken, "accessToken"); + this.rawNonce = optionalValue(rawNonce, "rawNonce"); + + if (this.rawNonce != null && this.idToken == null) { + throw new IllegalArgumentException("An ID token is required when a raw nonce is supplied."); + } + } -public class AuthCredential { public String getProvider() { - throw new NotImplementedError(); + return provider; + } + + public String getSignInMethod() { + return provider; + } + + String toIdpPostBody() { + List values = new ArrayList<>(); + values.add(formValue("providerId", provider)); + if (idToken != null) { + values.add(formValue("id_token", idToken)); + } + if (accessToken != null) { + values.add(formValue("access_token", accessToken)); + } + if (rawNonce != null) { + values.add(formValue("nonce", rawNonce)); + } + return String.join("&", values); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{provider='" + provider + "'}"; + } + + static String requireValue(String value, String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be null or blank."); + } + return value; + } + + static String optionalValue(String value, String name) { + if (value != null && value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " must not be blank."); + } + return value; + } + + private static String formValue(String name, String value) { + return URLEncoder.encode(name, StandardCharsets.UTF_8) + "=" + + URLEncoder.encode(value, StandardCharsets.UTF_8); } } diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.kt b/src/main/java/com/google/firebase/auth/FirebaseAuth.kt index 15312ba..bd12e13 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseAuth.kt +++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.kt @@ -12,8 +12,6 @@ import com.google.firebase.FirebasePlatform import com.google.firebase.auth.internal.InternalAuthProvider import com.google.firebase.internal.InternalTokenResult import com.google.firebase.internal.api.FirebaseNoSignedInUserException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.Transient @@ -28,6 +26,7 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.int import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull @@ -65,6 +64,10 @@ data class FirebaseUserImpl internal constructor( override val email: String?, override val photoUrl: String?, override val displayName: String?, + override val phoneNumber: String? = null, + override val isEmailVerified: Boolean = false, + val lastSignInAt: Long = createdAt, + val providerIds: List = emptyList(), @Transient private val urlFactory: UrlFactory = UrlFactory(app) ) : FirebaseUser() { @@ -75,6 +78,9 @@ data class FirebaseUserImpl internal constructor( email: String? = data.getOrElse("email") { null }?.jsonPrimitive?.contentOrNull, photoUrl: String? = data.getOrElse("photoUrl") { null }?.jsonPrimitive?.contentOrNull, displayName: String? = data.getOrElse("displayName") { null }?.jsonPrimitive?.contentOrNull, + phoneNumber: String? = data["phoneNumber"]?.jsonPrimitive?.contentOrNull, + isEmailVerified: Boolean = data["emailVerified"]?.jsonPrimitive?.booleanOrNull ?: false, + existingProviderData: List = emptyList(), urlFactory: UrlFactory = UrlFactory(app) ) : this( app = app, @@ -90,9 +96,22 @@ data class FirebaseUserImpl internal constructor( email = email, photoUrl = photoUrl ?: data["photo_url"]?.jsonPrimitive?.contentOrNull, displayName = displayName ?: data["display_name"]?.jsonPrimitive?.contentOrNull, + phoneNumber = phoneNumber, + isEmailVerified = isEmailVerified, + lastSignInAt = data["lastLoginAt"]?.jsonPrimitive?.longOrNull ?: System.currentTimeMillis(), + providerIds = resolveProviderIds(data, existingProviderData, email, phoneNumber, isAnonymous), urlFactory = urlFactory ) + override val metadata: FirebaseUserMetadata + get() = FirebaseUserMetadata(createdAt, lastSignInAt) + + override val providerData: List + get() = providerIds.map { provider -> UserInfo(displayName, email, phoneNumber, photoUrl, provider, uid) } + + override val providerId: String + get() = "firebase" + val claims: Map by lazy { jsonParser .parseToJsonElement(String(Base64.getUrlDecoder().decode(idToken.split(".")[1]))) @@ -220,6 +239,9 @@ data class FirebaseUserImpl internal constructor( override fun updateProfile(request: UserProfileChangeRequest): Task = FirebaseAuth.getInstance(app).updateProfile(request) + override fun linkWithCredential(credential: AuthCredential): Task = + FirebaseAuth.getInstance(app).linkWithCredential(this, credential) + fun updateProfile( displayName: String?, photoUrl: String? @@ -234,6 +256,39 @@ data class FirebaseUserImpl internal constructor( } } +private fun resolveProviderIds( + data: JsonObject, + existingProviderData: List, + email: String?, + phoneNumber: String?, + isAnonymous: Boolean +): List { + val stored = + data["providerIds"] + ?.jsonArray + ?.mapNotNull { it.jsonPrimitive.contentOrNull } + .orEmpty() + val nested = + data["providerUserInfo"] + ?.jsonArray + ?.mapNotNull { it.jsonObject["providerId"]?.jsonPrimitive?.contentOrNull } + .orEmpty() + val direct = listOfNotNull(data["providerId"]?.jsonPrimitive?.contentOrNull) + val explicit = existingProviderData.map { it.providerId } + stored + nested + direct + val inferred = + if (explicit.isEmpty()) { + when { + isAnonymous -> emptyList() + phoneNumber != null -> listOf("phone") + email != null -> listOf("password") + else -> emptyList() + } + } else { + emptyList() + } + return (explicit + inferred).distinct() +} + class FirebaseAuth constructor( val app: FirebaseApp ) : InternalAuthProvider { @@ -271,20 +326,24 @@ class FirebaseAuth constructor( call: Call, response: Response ) { - if (!response.isSuccessful) { - source.setException( - createAuthInvalidUserException("accounts", request, response) - ) - } else { - if (response.body()?.use { it.string() }?.also { responseBody -> - user = setResult(responseBody) - source.setResult(AuthResult { user }) - } == null - ) { + try { + if (!response.isSuccessful) { source.setException( createAuthInvalidUserException("accounts", request, response) ) + } else { + val responseBody = response.body()?.use { it.string() } + if (responseBody == null) { + source.setException( + FirebaseAuthInvalidUserException("UNKNOWN_ERROR", "accounts API returned an empty response.") + ) + } else { + user = setResult(responseBody) + source.setResult(AuthResult { user }) + } } + } catch (exception: Exception) { + source.setException(exception) } } } @@ -328,7 +387,7 @@ class FirebaseAuth constructor( FirebasePlatform.firebasePlatform.store(app.key, jsonParser.encodeToString(FirebaseUserImpl.serializer(), value)) } - GlobalScope.launch(Dispatchers.Main) { + FirebasePlatform.firebasePlatform.mainScope.launch { if (prev?.uid != value?.uid) { authStateListeners.forEach { l -> l.onAuthStateChanged(this@FirebaseAuth) } } @@ -486,19 +545,23 @@ class FirebaseAuth constructor( request: Request, response: Response ): FirebaseAuthInvalidUserException { - val body = response.body()!!.use { it.string() } - val jsonObject = jsonParser.parseToJsonElement(body).jsonObject + val body = response.body()?.use { it.string() }.orEmpty() + val errorCode = + runCatching { + jsonParser + .parseToJsonElement(body) + .jsonObject["error"] + ?.jsonObject + ?.get("message") + ?.jsonPrimitive + ?.contentOrNull + }.getOrNull() ?: "UNKNOWN_ERROR" return FirebaseAuthInvalidUserException( - jsonObject["error"] - ?.jsonObject - ?.get("message") - ?.jsonPrimitive - ?.contentOrNull - ?: "UNKNOWN_ERROR", - "$action API returned an error, " + - "with url [${request.method()}] ${request.url()} ${request.body()} -- " + - "response [${response.code()}] ${response.message()} $body" + errorCode, + "$errorCode: $action API returned an error, " + + "with endpoint [${request.method()}] ${request.url().encodedPath()} -- " + + "response [${response.code()}] ${response.message()}" ) } @@ -527,7 +590,7 @@ class FirebaseAuth constructor( } // Log.i("FirebaseAuth", "Refreshing access token forceRefresh=$forceRefresh createdAt=${user.createdAt} expiresIn=${user.expiresIn}") val source = TaskCompletionSource() - refreshToken(user, source) { GetTokenResult(it.idToken, user.claims) } + refreshToken(user, source) { GetTokenResult(it.idToken, it.claims) } return source.task } @@ -582,10 +645,24 @@ class FirebaseAuth constructor( val responseBody = response.body()?.use { it.string() } if (!response.isSuccessful || responseBody == null) { - signOutAndThrowInvalidUserException(responseBody.orEmpty(), "token API returned an error: $body") + signOutAndThrowInvalidUserException( + responseBody.orEmpty(), + "token API returned an error" + ) } else { jsonParser.parseToJsonElement(responseBody).jsonObject.apply { - val newUser = FirebaseUserImpl(app, this, user.isAnonymous, user.email) + val newUser = + FirebaseUserImpl( + app = app, + data = this, + isAnonymous = user.isAnonymous, + email = user.email, + photoUrl = user.photoUrl, + displayName = user.displayName, + phoneNumber = user.phoneNumber, + isEmailVerified = user.isEmailVerified, + existingProviderData = user.providerData + ) if (newUser.claims["aud"] != app.options.projectId) { signOutAndThrowInvalidUserException( newUser.claims.toString(), @@ -674,7 +751,11 @@ class FirebaseAuth constructor( createdAt = prev.createdAt, email = newBody["newEmail"]?.jsonPrimitive?.contentOrNull ?: prev.email, photoUrl = newBody["photoUrl"]?.jsonPrimitive?.contentOrNull ?: prev.photoUrl, - displayName = newBody["displayName"]?.jsonPrimitive?.contentOrNull ?: prev.displayName + displayName = newBody["displayName"]?.jsonPrimitive?.contentOrNull ?: prev.displayName, + phoneNumber = prev.phoneNumber, + isEmailVerified = prev.isEmailVerified, + lastSignInAt = prev.lastSignInAt, + providerIds = prev.providerIds ) } source.setResult(null) @@ -749,7 +830,11 @@ class FirebaseAuth constructor( createdAt = prev.createdAt, email = newBody["newEmail"]?.jsonPrimitive?.contentOrNull ?: prev.email, photoUrl = newBody["photoUrl"]?.jsonPrimitive?.contentOrNull ?: prev.photoUrl, - displayName = newBody["displayName"]?.jsonPrimitive?.contentOrNull ?: prev.displayName + displayName = newBody["displayName"]?.jsonPrimitive?.contentOrNull ?: prev.displayName, + phoneNumber = prev.phoneNumber, + isEmailVerified = prev.isEmailVerified, + lastSignInAt = prev.lastSignInAt, + providerIds = prev.providerIds ) } source.setResult(null) @@ -764,7 +849,7 @@ class FirebaseAuth constructor( override fun addIdTokenListener(listener: com.google.firebase.auth.internal.IdTokenListener) { internalIdTokenListeners.addIfAbsent(listener) - GlobalScope.launch(Dispatchers.Main) { + FirebasePlatform.firebasePlatform.mainScope.launch { listener.onIdTokenChanged(InternalTokenResult(user?.idToken)) } } @@ -776,7 +861,7 @@ class FirebaseAuth constructor( @Synchronized fun addAuthStateListener(listener: AuthStateListener) { authStateListeners.addIfAbsent(listener) - GlobalScope.launch(Dispatchers.Main) { + FirebasePlatform.firebasePlatform.mainScope.launch { listener.onAuthStateChanged(this@FirebaseAuth) } } @@ -798,7 +883,7 @@ class FirebaseAuth constructor( fun addIdTokenListener(listener: IdTokenListener) { idTokenListeners.addIfAbsent(listener) - GlobalScope.launch(Dispatchers.Main) { + FirebasePlatform.firebasePlatform.mainScope.launch { listener.onIdTokenChanged(this@FirebaseAuth) } } @@ -812,7 +897,106 @@ class FirebaseAuth constructor( } fun sendPasswordResetEmail(email: String, settings: ActionCodeSettings?): Task = TODO() - fun signInWithCredential(authCredential: AuthCredential): Task = TODO() + fun signInWithCredential(authCredential: AuthCredential): Task = + enqueueCredentialAuthPost(authCredential, currentIdToken = null) { responseBody -> + FirebaseUserImpl( + app = app, + data = jsonParser.parseToJsonElement(responseBody).jsonObject, + urlFactory = urlFactory + ) + } + + internal fun linkWithCredential( + currentUser: FirebaseUserImpl, + authCredential: AuthCredential + ): Task { + val signedInUser = user + if (signedInUser == null || signedInUser.uid != currentUser.uid) { + return Tasks.forException( + FirebaseAuthInvalidUserException( + "ERROR_USER_MISMATCH", + "The supplied user is not the currently signed-in user." + ) + ) + } + + return enqueueCredentialAuthPost(authCredential, currentIdToken = signedInUser.idToken) { responseBody -> + val data = jsonParser.parseToJsonElement(responseBody).jsonObject + val linkedUser = + FirebaseUserImpl( + app = app, + data = data, + isAnonymous = false, + email = data["email"]?.jsonPrimitive?.contentOrNull ?: signedInUser.email, + photoUrl = data["photoUrl"]?.jsonPrimitive?.contentOrNull ?: signedInUser.photoUrl, + displayName = data["displayName"]?.jsonPrimitive?.contentOrNull ?: signedInUser.displayName, + phoneNumber = data["phoneNumber"]?.jsonPrimitive?.contentOrNull ?: signedInUser.phoneNumber, + isEmailVerified = data["emailVerified"]?.jsonPrimitive?.booleanOrNull ?: signedInUser.isEmailVerified, + existingProviderData = signedInUser.providerData, + urlFactory = urlFactory + ) + if (linkedUser.uid != signedInUser.uid) { + throw FirebaseAuthInvalidUserException( + "ERROR_USER_MISMATCH", + "The linked credential returned a different Firebase user." + ) + } + linkedUser + } + } + + private fun enqueueCredentialAuthPost( + authCredential: AuthCredential, + currentIdToken: String?, + setResult: (responseBody: String) -> FirebaseUserImpl + ): Task = + if (authCredential is PhoneAuthCredential) { + enqueuePhoneAuthPost(authCredential, currentIdToken, setResult) + } else { + enqueueIdpAuthPost(authCredential, currentIdToken, setResult) + } + + private fun enqueueIdpAuthPost( + authCredential: AuthCredential, + currentIdToken: String?, + setResult: (responseBody: String) -> FirebaseUserImpl + ): Task { + val values = + linkedMapOf( + "postBody" to JsonPrimitive(authCredential.toIdpPostBody()), + "requestUri" to JsonPrimitive("http://localhost"), + "returnIdpCredential" to JsonPrimitive(true), + "returnSecureToken" to JsonPrimitive(true) + ) + if (currentIdToken != null) { + values["idToken"] = JsonPrimitive(currentIdToken) + } + return enqueueAuthPost( + url = "identitytoolkit.googleapis.com/v1/accounts:signInWithIdp", + body = RequestBody.create(json, JsonObject(values).toString()), + setResult = setResult + ).task + } + + private fun enqueuePhoneAuthPost( + credential: PhoneAuthCredential, + currentIdToken: String?, + setResult: (responseBody: String) -> FirebaseUserImpl + ): Task { + val values = + linkedMapOf( + "sessionInfo" to JsonPrimitive(credential.verificationId), + "code" to JsonPrimitive(credential.smsCode) + ) + if (currentIdToken != null) { + values["idToken"] = JsonPrimitive(currentIdToken) + } + return enqueueAuthPost( + url = "identitytoolkit.googleapis.com/v1/accounts:signInWithPhoneNumber", + body = RequestBody.create(json, JsonObject(values).toString()), + setResult = setResult + ).task + } fun checkActionCode(code: String): Task = TODO() fun confirmPasswordReset( diff --git a/src/main/java/com/google/firebase/auth/FirebaseUser.kt b/src/main/java/com/google/firebase/auth/FirebaseUser.kt index 63b3e93..2e8b49c 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseUser.kt +++ b/src/main/java/com/google/firebase/auth/FirebaseUser.kt @@ -24,14 +24,14 @@ abstract class FirebaseUser { abstract fun updateProfile(request: UserProfileChangeRequest): Task - val phoneNumber: String get() = TODO() - val isEmailVerified: Boolean get() = TODO() - val metadata: FirebaseUserMetadata get() = TODO() + abstract val phoneNumber: String? + abstract val isEmailVerified: Boolean + abstract val metadata: FirebaseUserMetadata val multiFactor: MultiFactor get() = TODO() - val providerData: List get() = TODO() - val providerId: String get() = TODO() + abstract val providerData: List + abstract val providerId: String - fun linkWithCredential(credential: AuthCredential): Task = TODO() + abstract fun linkWithCredential(credential: AuthCredential): Task fun sendEmailVerification(): Task = TODO() diff --git a/src/main/java/com/google/firebase/auth/GoogleAuthCredential.java b/src/main/java/com/google/firebase/auth/GoogleAuthCredential.java new file mode 100644 index 0000000..9462b39 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/GoogleAuthCredential.java @@ -0,0 +1,10 @@ +package com.google.firebase.auth; + +public final class GoogleAuthCredential extends AuthCredential { + GoogleAuthCredential(String idToken, String accessToken) { + super(GoogleAuthProvider.PROVIDER_ID, idToken, accessToken, null); + if (optionalValue(idToken, "idToken") == null && optionalValue(accessToken, "accessToken") == null) { + throw new IllegalArgumentException("At least one ID token or access token is required."); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/GoogleAuthProvider.java b/src/main/java/com/google/firebase/auth/GoogleAuthProvider.java index 82c26a9..b9f09d9 100644 --- a/src/main/java/com/google/firebase/auth/GoogleAuthProvider.java +++ b/src/main/java/com/google/firebase/auth/GoogleAuthProvider.java @@ -1,9 +1,13 @@ package com.google.firebase.auth; -import kotlin.NotImplementedError; +public final class GoogleAuthProvider { + public static final String PROVIDER_ID = "google.com"; + public static final String GOOGLE_SIGN_IN_METHOD = "google.com"; -public class GoogleAuthProvider { - public static AuthCredential getCredential(String email, String password) { - throw new NotImplementedError(); + private GoogleAuthProvider() { + } + + public static AuthCredential getCredential(String idToken, String accessToken) { + return new GoogleAuthCredential(idToken, accessToken); } } diff --git a/src/main/java/com/google/firebase/auth/OAuthCredential.java b/src/main/java/com/google/firebase/auth/OAuthCredential.java new file mode 100644 index 0000000..eb9fd66 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/OAuthCredential.java @@ -0,0 +1,10 @@ +package com.google.firebase.auth; + +public final class OAuthCredential extends AuthCredential { + OAuthCredential(String provider, String idToken, String accessToken, String rawNonce) { + super(provider, idToken, accessToken, rawNonce); + if (optionalValue(idToken, "idToken") == null && optionalValue(accessToken, "accessToken") == null) { + throw new IllegalArgumentException("At least one ID token or access token is required."); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/OAuthProvider.java b/src/main/java/com/google/firebase/auth/OAuthProvider.java index 787ec6b..b7ac9aa 100644 --- a/src/main/java/com/google/firebase/auth/OAuthProvider.java +++ b/src/main/java/com/google/firebase/auth/OAuthProvider.java @@ -1,52 +1,120 @@ package com.google.firebase.auth; -import kotlin.NotImplementedError; - +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -public class OAuthProvider { +public final class OAuthProvider { + private final String provider; + private final FirebaseAuth auth; + private final List scopes; + private final Map customParameters; public OAuthProvider(Builder builder) { - throw new NotImplementedError(); + provider = builder.provider; + auth = builder.auth; + scopes = Collections.unmodifiableList(new ArrayList<>(builder.scopes)); + customParameters = Collections.unmodifiableMap(new LinkedHashMap<>(builder.customParameters)); + } + + public static AuthCredential getCredential(String providerId, String idToken, String accessToken) { + return new OAuthCredential(providerId, idToken, accessToken, null); } - public static AuthCredential getCredential(String email, String password) { - throw new NotImplementedError(); + public String getProviderId() { + return provider; } - public static class Builder { + public List getScopes() { + return scopes; + } + + public Map getCustomParameters() { + return customParameters; + } + + FirebaseAuth getAuth() { + return auth; + } + + public static final class Builder { + private final String provider; + private final FirebaseAuth auth; + private List scopes = Collections.emptyList(); + private final Map customParameters = new LinkedHashMap<>(); + + private Builder(String provider, FirebaseAuth auth) { + this.provider = AuthCredential.requireValue(provider, "provider"); + if (auth == null) { + throw new IllegalArgumentException("auth must not be null."); + } + this.auth = auth; + } + public Builder setScopes(List scopes) { - throw new NotImplementedError(); + if (scopes == null) { + throw new IllegalArgumentException("scopes must not be null."); + } + this.scopes = new ArrayList<>(scopes); + return this; } + public Builder addCustomParameters(Map customParameters) { - throw new NotImplementedError(); + if (customParameters == null) { + throw new IllegalArgumentException("customParameters must not be null."); + } + this.customParameters.putAll(customParameters); + return this; } + public OAuthProvider build() { - throw new NotImplementedError(); + return new OAuthProvider(this); } } - public static class CredentialBuilder { - public Builder setAccessToken(String accessToken) { - throw new NotImplementedError(); + public static final class CredentialBuilder { + private final String provider; + private String accessToken; + private String idToken; + private String rawNonce; + + private CredentialBuilder(String provider) { + this.provider = AuthCredential.requireValue(provider, "provider"); } - public Builder setIdToken(String idToken) { - throw new NotImplementedError(); + + public CredentialBuilder setAccessToken(String accessToken) { + this.accessToken = AuthCredential.requireValue(accessToken, "accessToken"); + return this; } - public Builder setIdTokenWithRawNonce(String idToken, String rawNonce) { - throw new NotImplementedError(); + + public CredentialBuilder setIdToken(String idToken) { + this.idToken = AuthCredential.requireValue(idToken, "idToken"); + rawNonce = null; + return this; } - public void build() { - throw new NotImplementedError(); + + public CredentialBuilder setIdTokenWithRawNonce(String idToken, String rawNonce) { + this.idToken = AuthCredential.requireValue(idToken, "idToken"); + this.rawNonce = AuthCredential.optionalValue(rawNonce, "rawNonce"); + return this; + } + + public AuthCredential build() { + return new OAuthCredential(provider, idToken, accessToken, rawNonce); } } + public static Builder newBuilder(String provider) { + return new Builder(provider, FirebaseAuth.getInstance()); + } + public static Builder newBuilder(String provider, FirebaseAuth auth) { - throw new NotImplementedError(); + return new Builder(provider, auth); } public static CredentialBuilder newCredentialBuilder(String provider) { - throw new NotImplementedError(); + return new CredentialBuilder(provider); } } diff --git a/src/main/java/com/google/firebase/auth/PhoneAuthProvider.java b/src/main/java/com/google/firebase/auth/PhoneAuthProvider.java index cbe78f9..79132b7 100644 --- a/src/main/java/com/google/firebase/auth/PhoneAuthProvider.java +++ b/src/main/java/com/google/firebase/auth/PhoneAuthProvider.java @@ -7,11 +7,14 @@ import java.util.concurrent.TimeUnit; public class PhoneAuthProvider { - public static PhoneAuthCredential getCredential(String email, String password) { - throw new NotImplementedError(); + public static PhoneAuthCredential getCredential(String verificationId, String smsCode) { + return new PhoneAuthCredential(verificationId, smsCode); } public static PhoneAuthProvider getInstance(FirebaseAuth auth) { - throw new NotImplementedError(); + if (auth == null) { + throw new IllegalArgumentException("auth must not be null."); + } + return new PhoneAuthProvider(); } public void verifyPhoneNumber(String phoneNumber, long timeout, TimeUnit unit, Activity activity, PhoneAuthProvider.OnVerificationStateChangedCallbacks callbacks) { diff --git a/src/main/java/com/google/firebase/auth/Stubs.kt b/src/main/java/com/google/firebase/auth/Stubs.kt index 858e3ea..e17b75a 100644 --- a/src/main/java/com/google/firebase/auth/Stubs.kt +++ b/src/main/java/com/google/firebase/auth/Stubs.kt @@ -20,19 +20,21 @@ class ActionCodeSettings { class FirebaseAuthMultiFactorException(errorCode: String, detailMessage: String) : FirebaseAuthException(errorCode, detailMessage) -class UserInfo { - val displayName: String? get() = TODO() - val email: String get() = TODO() - val phoneNumber: String get() = TODO() - val photoUrl: Uri? get() = TODO() - val providerId: String get() = TODO() - val uid: String get() = TODO() +class UserInfo internal constructor( + val displayName: String?, + val email: String?, + val phoneNumber: String?, + photoUrl: String?, + val providerId: String, + val uid: String +) { + val photoUrl: Uri? = photoUrl?.let(Uri::parse) } -class FirebaseUserMetadata { - val creationTimestamp: Long get() = TODO() - val lastSignInTimestamp: Long get() = TODO() -} +class FirebaseUserMetadata internal constructor( + val creationTimestamp: Long, + val lastSignInTimestamp: Long +) class MultiFactor { val uid: String get() = TODO() @@ -59,8 +61,17 @@ class MultiFactorResolver { fun resolveSignIn(assertion: MultiFactorAssertion): Task = TODO() } -class PhoneAuthCredential : AuthCredential() -class OAuthCredential : AuthCredential() +class PhoneAuthCredential internal constructor( + internal val verificationId: String, + internal val smsCode: String +) : AuthCredential("phone") { + init { + require(verificationId.isNotBlank()) { "verificationId must not be blank" } + require(smsCode.isNotBlank()) { "smsCode must not be blank" } + } + + override fun toString(): String = "PhoneAuthCredential{provider='phone'}" +} interface SignInMethodQueryResult { val signInMethods: List diff --git a/src/test/kotlin/FirebaseAuthCredentialTest.kt b/src/test/kotlin/FirebaseAuthCredentialTest.kt new file mode 100644 index 0000000..4cb0714 --- /dev/null +++ b/src/test/kotlin/FirebaseAuthCredentialTest.kt @@ -0,0 +1,433 @@ +import android.app.Application +import com.google.firebase.Firebase +import com.google.firebase.FirebaseApp +import com.google.firebase.FirebasePlatform +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.FirebaseAuthInvalidUserException +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.OAuthCredential +import com.google.firebase.auth.OAuthProvider +import com.google.firebase.auth.PhoneAuthProvider +import com.google.firebase.initialize +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.Base64 +import java.util.Properties +import java.util.concurrent.CountDownLatch +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +class FirebaseAuthCredentialTest : FirebaseTest() { + private lateinit var server: MockWebServer + private lateinit var auth: FirebaseAuth + private lateinit var platform: RecordingFirebasePlatform + + @Before + fun initializeAuth() { + platform = RecordingFirebasePlatform() + FirebasePlatform.initializeFirebasePlatform(platform) + server = MockWebServer().apply { start() } + auth = FirebaseAuth.getInstance(app).apply { useEmulator(server.hostName, server.port) } + } + + @After + fun stopServer() { + server.shutdown() + platform.close() + } + + @Test + fun `google credential accepts either token and never renders secrets`() { + val credential = GoogleAuthProvider.getCredential("header.payload.signature", null) + + assertEquals("google.com", credential.provider) + assertFalse(credential.toString().contains("header.payload.signature")) + assertThrows(IllegalArgumentException::class.java) { + GoogleAuthProvider.getCredential(null, null) + } + } + + @Test + fun `generic oauth credential builder matches android API and redacts secrets`() { + val credential = + OAuthProvider + .newCredentialBuilder("apple.com") + .setAccessToken("access-secret") + .setIdTokenWithRawNonce("id-secret", "raw-secret") + .build() + + assertTrue(credential is OAuthCredential) + assertEquals("apple.com", credential.provider) + assertFalse(credential.toString().contains("access-secret")) + assertFalse(credential.toString().contains("id-secret")) + assertFalse(credential.toString().contains("raw-secret")) + } + + @Test + fun `phone credential is validated and never renders verification data`() { + val credential = PhoneAuthProvider.getCredential("verification-secret", "123456") + + assertEquals("phone", credential.provider) + assertFalse(credential.toString().contains("verification-secret")) + assertFalse(credential.toString().contains("123456")) + assertThrows(IllegalArgumentException::class.java) { + PhoneAuthProvider.getCredential("", "123456") + } + } + + @Test + fun `sign in with google credential calls signInWithIdp and persists the user`() = + runTest { + server.enqueue(successfulAuthResponse(idToken = "firebase-id-token")) + + val result = + auth + .signInWithCredential(GoogleAuthProvider.getCredential("google-id-token", "google-access-token")) + .await() + + assertEquals("jvm-user", result.user?.uid) + assertEquals("jvm@example.com", auth.currentUser?.email) + assertTrue(auth.currentUser?.isEmailVerified == true) + assertEquals(listOf("google.com"), auth.currentUser?.providerData?.map { it.providerId }) + assertTrue(platform.values.values.single().contains("firebase-id-token")) + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals( + "/identitytoolkit.googleapis.com/v1/accounts:signInWithIdp", + request.requestUrl?.encodedPath() + ) + val json = Json.parseToJsonElement(request.body.readUtf8()).jsonObject + assertEquals("http://localhost", json.getValue("requestUri").jsonPrimitive.content) + assertTrue(json.getValue("returnSecureToken").jsonPrimitive.content.toBoolean()) + val postBody = decodeForm(json.getValue("postBody").jsonPrimitive.content) + assertEquals("google.com", postBody["providerId"]) + assertEquals("google-id-token", postBody["id_token"]) + assertEquals("google-access-token", postBody["access_token"]) + } + + @Test + fun `google session is restored from durable storage after app and platform recreation`() = + runTest { + val storageFile = Files.createTempFile("firebase-auth-session", ".properties").toFile() + storageFile.delete() + storageFile.deleteOnExit() + platform.close() + platform = RecordingFirebasePlatform(storageFile) + FirebasePlatform.initializeFirebasePlatform(platform) + auth = FirebaseAuth.getInstance(app).apply { useEmulator(server.hostName, server.port) } + server.enqueue(successfulAuthResponse(idToken = "persisted-id-token")) + auth.signInWithCredential(GoogleAuthProvider.getCredential("google-token", null)).await() + val options = app.options + + FirebaseApp.clearInstancesForTest() + platform.close() + platform = RecordingFirebasePlatform(storageFile) + FirebasePlatform.initializeFirebasePlatform(platform) + val recreatedApp = Firebase.initialize(Application(), options) + auth = FirebaseAuth.getInstance(recreatedApp).apply { useEmulator(server.hostName, server.port) } + + assertEquals("jvm-user", auth.currentUser?.uid) + assertEquals("jvm@example.com", auth.currentUser?.email) + assertEquals(listOf("google.com"), auth.currentUser?.providerData?.map { it.providerId }) + assertTrue(platform.values.values.single().contains("persisted-id-token")) + } + + @Test + fun `forced token refresh exchanges the refresh token and persists the result`() = + runTest { + server.enqueue(successfulAuthResponse(idToken = "cached-token")) + auth.signInWithCredential(GoogleAuthProvider.getCredential("google-token", null)).await() + server.takeRequest() + val refreshedToken = firebaseToken() + server.enqueue(refreshResponse(refreshedToken)) + + val result = auth.currentUser!!.getIdToken(true).await() + + assertEquals(refreshedToken, result.token) + val request = server.takeRequest() + assertEquals("/securetoken.googleapis.com/v1/token", request.requestUrl?.encodedPath()) + val body = Json.parseToJsonElement(request.body.readUtf8()).jsonObject + assertEquals("refresh-token", body.getValue("refresh_token").jsonPrimitive.content) + assertTrue(platform.values.values.single().contains(refreshedToken)) + } + + @Test + fun `expired token refreshes even when force refresh is false`() = + runTest { + server.enqueue(successfulAuthResponse(idToken = "expired-token", expiresIn = 0)) + auth.signInWithCredential(GoogleAuthProvider.getCredential("google-token", null)).await() + server.takeRequest() + val refreshedToken = firebaseToken() + server.enqueue(refreshResponse(refreshedToken)) + + val result = auth.currentUser!!.getIdToken(false).await() + + assertEquals(refreshedToken, result.token) + assertEquals("/securetoken.googleapis.com/v1/token", server.takeRequest().requestUrl?.encodedPath()) + } + + @Test + fun `link credential sends current token and atomically replaces the session`() = + runTest { + server.enqueue(successfulAuthResponse(idToken = "original-token")) + auth.signInWithCredential(GoogleAuthProvider.getCredential("google-token", null)).await() + server.takeRequest() + + server.enqueue(successfulAuthResponse(idToken = "linked-token", providerId = "apple.com")) + val linked = + auth.currentUser!! + .linkWithCredential( + OAuthProvider + .newCredentialBuilder("apple.com") + .setIdTokenWithRawNonce("apple-token", "nonce-value") + .build() + ).await() + + assertEquals("jvm-user", linked.user?.uid) + assertEquals(listOf("google.com", "apple.com"), linked.user?.providerData?.map { it.providerId }) + assertTrue(platform.values.values.single().contains("linked-token")) + val requestJson = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + assertEquals("original-token", requestJson.getValue("idToken").jsonPrimitive.content) + val postBody = decodeForm(requestJson.getValue("postBody").jsonPrimitive.content) + assertEquals("apple.com", postBody["providerId"]) + assertEquals("apple-token", postBody["id_token"]) + assertEquals("nonce-value", postBody["nonce"]) + } + + @Test + fun `phone credential uses phone endpoint for sign in and linking`() = + runTest { + server.enqueue(successfulAuthResponse(idToken = "phone-token", providerId = "phone")) + + auth.signInWithCredential(PhoneAuthProvider.getCredential("phone-session", "123456")).await() + + val signIn = server.takeRequest() + assertEquals("/identitytoolkit.googleapis.com/v1/accounts:signInWithPhoneNumber", signIn.requestUrl?.encodedPath()) + val signInJson = Json.parseToJsonElement(signIn.body.readUtf8()).jsonObject + assertEquals("phone-session", signInJson.getValue("sessionInfo").jsonPrimitive.content) + assertEquals("123456", signInJson.getValue("code").jsonPrimitive.content) + + server.enqueue(successfulAuthResponse(idToken = "linked-phone-token", providerId = "phone")) + auth.currentUser!!.linkWithCredential(PhoneAuthProvider.getCredential("link-session", "654321")).await() + + val linkJson = Json.parseToJsonElement(server.takeRequest().body.readUtf8()).jsonObject + assertEquals("phone-token", linkJson.getValue("idToken").jsonPrimitive.content) + assertEquals("link-session", linkJson.getValue("sessionInfo").jsonPrimitive.content) + assertEquals("654321", linkJson.getValue("code").jsonPrimitive.content) + } + + @Test + fun `failed link preserves the current session`() = + runTest { + server.enqueue(successfulAuthResponse(idToken = "original-token")) + auth.signInWithCredential(GoogleAuthProvider.getCredential("google-token", null)).await() + server.takeRequest() + + server.enqueue( + MockResponse() + .setResponseCode(400) + .setHeader("Content-Type", "application/json") + .setBody("""{"error":{"message":"FEDERATED_USER_ID_ALREADY_LINKED"}}""") + ) + + val exception = + assertThrows(FirebaseAuthInvalidUserException::class.java) { + kotlinx.coroutines.runBlocking { + auth.currentUser!! + .linkWithCredential( + OAuthProvider.newCredentialBuilder("github.com").setAccessToken("github-token").build() + ).await() + } + } + assertEquals("jvm-user", auth.currentUser?.uid) + assertTrue(platform.values.values.single().contains("original-token")) + assertTrue(exception.message.orEmpty().contains("FEDERATED_USER_ID_ALREADY_LINKED")) + assertFalse(exception.message.orEmpty().contains("original-token")) + assertFalse(exception.message.orEmpty().contains("github-token")) + assertFalse(exception.message.orEmpty().contains(app.options.apiKey)) + } + + @Test + fun `link response for a different user is rejected without changing the session`() = + runTest { + server.enqueue(successfulAuthResponse(idToken = "original-token")) + auth.signInWithCredential(GoogleAuthProvider.getCredential("google-token", null)).await() + server.takeRequest() + + server.enqueue(successfulAuthResponse(idToken = "other-token", uid = "other-user")) + + assertThrows(FirebaseAuthInvalidUserException::class.java) { + kotlinx.coroutines.runBlocking { + auth.currentUser!! + .linkWithCredential( + OAuthProvider.newCredentialBuilder("github.com").setAccessToken("github-token").build() + ).await() + } + } + assertEquals("jvm-user", auth.currentUser?.uid) + assertTrue(platform.values.values.single().contains("original-token")) + assertFalse(platform.values.values.single().contains("other-token")) + } + + @Test + fun `credential sign in notifies auth state and id token listeners`() = + runTest { + val authStateChanged = CountDownLatch(1) + val idTokenChanged = CountDownLatch(1) + val callbackThreads = mutableListOf() + val authListener = + object : FirebaseAuth.AuthStateListener { + override fun onAuthStateChanged(auth: FirebaseAuth) { + if (auth.currentUser?.uid == "jvm-user") { + callbackThreads += Thread.currentThread().name + authStateChanged.countDown() + } + } + } + val tokenListener = + object : FirebaseAuth.IdTokenListener { + override fun onIdTokenChanged(auth: FirebaseAuth) { + if (auth.currentUser?.uid == "jvm-user") { + callbackThreads += Thread.currentThread().name + idTokenChanged.countDown() + } + } + } + auth.addAuthStateListener(authListener) + auth.addIdTokenListener(tokenListener) + server.enqueue(successfulAuthResponse(idToken = "listener-token")) + + auth.signInWithCredential(GoogleAuthProvider.getCredential("google-token", null)).await() + + assertTrue(authStateChanged.await(5, TimeUnit.SECONDS)) + assertTrue(idTokenChanged.await(5, TimeUnit.SECONDS)) + assertEquals(2, callbackThreads.size) + assertTrue(callbackThreads.all { it.startsWith("firebase-platform-main") }) + auth.removeAuthStateListener(authListener) + auth.removeIdTokenListener(tokenListener) + } + + private fun successfulAuthResponse( + idToken: String, + uid: String = "jvm-user", + providerId: String = "google.com", + expiresIn: Int = 3600 + ) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody( + """ + { + "localId": "$uid", + "idToken": "$idToken", + "refreshToken": "refresh-token", + "expiresIn": "$expiresIn", + "email": "jvm@example.com", + "emailVerified": true, + "providerId": "$providerId" + } + """.trimIndent() + ) + + private fun refreshResponse(idToken: String) = + MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody( + """ + { + "user_id": "jvm-user", + "id_token": "$idToken", + "refresh_token": "rotated-refresh-token", + "expires_in": "3600" + } + """.trimIndent() + ) + + private fun firebaseToken(): String { + val payload = Base64.getUrlEncoder().withoutPadding().encodeToString("{\"aud\":\"fir-java-sdk\"}".toByteArray()) + return "header.$payload.signature" + } + + private fun decodeForm(value: String): Map = + value + .split('&') + .associate { part -> + val (key, encodedValue) = part.split('=', limit = 2) + key to URLDecoder.decode(encodedValue, StandardCharsets.UTF_8.name()) + } + + private class RecordingFirebasePlatform( + private val storageFile: File? = null + ) : FirebasePlatform() { + private val mainExecutor: ExecutorService = + Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "firebase-platform-main") } + + override val mainDispatcher = mainExecutor.asCoroutineDispatcher() + + val values = + mutableMapOf().apply { + storageFile + ?.takeIf(File::isFile) + ?.inputStream() + ?.use { input -> + Properties().apply { load(input) }.forEach { key, value -> put(key.toString(), value.toString()) } + } + } + + override fun store( + key: String, + value: String + ) { + values[key] = value + persist() + } + + override fun retrieve(key: String): String? = values[key] + + override fun clear(key: String) { + values.remove(key) + persist() + } + + override fun log(msg: String) = Unit + + override fun getDatabasePath(name: String): File = File("./build/$name") + + private fun persist() { + val file = storageFile ?: return + file.parentFile?.mkdirs() + file.outputStream().use { output -> + Properties() + .apply { + this@RecordingFirebasePlatform.values.forEach { (key, value) -> setProperty(key, value) } + }.store(output, null) + } + } + + fun close() { + mainDispatcher.close() + mainExecutor.shutdownNow() + } + } +}