L'authentification avec Firebase dans une application KMP

L’authentification est une étape essentielle dans de nombreuses applications, c’est pourquoi c’est une des premières fonctionnalités que l’on peut vouloir mettre en place. Pour faire cela, on pense naturellement à utiliser les services Firebase qui offrent une solution puissante pour l’authentification des utilisateurs. Mais qu’en est-il lorsque l’on développe une application mobile avec Kotlin Multiplatform ?

Pour rappel Kotlin Multiplatform, aussi appelé KMP (et non plus KMM), est un framework qui permet de partager du code entre différentes plateformes et notamment entre Android et iOS. Il favorise la réutilisation de la logique métier tout en gardant le développement des interfaces natif. Cela permet donc de garder les avantages et les performances du natif tout en bénéficiant des avantages du code partagé.

Pendant mes recherches je me suis aperçue que Firebase ne gère pas nativement l’intégration de ses services avec des applications KMP. Cependant, il existe une bibliothèque créée et maintenue par GitLive du nom de Firebase Kotlin SDK, que l’on peut retrouver sur le site officiel Firebase Open Source.

L’API de Firebase Kotlin SDK est similaire à celle de Firebase Android SDK Kotlin Extensions mais supporte aussi les projets multiplateformes, ce qui permet d’utiliser Firebase directement dans le code partagé entre les plateformes iOS, Android, Desktop ou Web.

Cependant, avant d’adopter cette bibliothèque, il faut savoir si elle correspond à votre besoin, notamment en prenant en compte que cette dernière est toujours en cours de développement et que certains services Firebase ne sont pas encore complètement supportés, comme vous pouvez le voir sur ce tableau récapitulatif de la couverture d’API proposé par Firebase SDK Kotlin.

Pourcentage de couverture de Firebase SDK Kotlin sur les différents services Firebase.
Pourcentage de couverture de Firebase SDK Kotlin sur les différents services Firebase.

Dans cet article, nous explorerons comment j’ai intégré et utilisé les services d'authentification de Firebase dans une application KMP avec Firebase Kotlin SDK dans un de mes projets à travers l’ajout d’une authentification en anonyme mais aussi avec le SignIn de Google.

Mise en place de l’environnement

Créer un projet KMP

Commençons par ajouter le plugin Kotlin Multiplatform Mobile disponible dans Préférences/Plugins sur Android Studio ou bien sur le site de Jetbrains.

Une fois le plugin installé, on peut créer un nouveau projet en choisissant Kotlin Multiplatform App. Pour cette démonstration, je nomme le projet KMP Firebase Integration et choisis de mettre com.ippon.kmp_firebase_integration en tant que namespace de l’application. J'ai choisi de garder le nom par défaut des dossiers.

Capture d'écran de la fenêtre de création de projet sur Android Studio

Créer un projet Firebase

Nous aurons aussi besoin de créer un nouveau projet sur Firebase afin de pouvoir intégrer ses services dans notre application. Pour cela, allez dans la console de Firebase et cliquez sur “Ajouter un projet”. Étant donné que nous utilisons une technologie multiplateformes, il faut ajouter deux applications distinctes : une application Android et une application iOS.

Pour l’application Android :

La première étape pour ajouter une application Android sur Firebase est de fournir le nom du package de l’application. Ce qui correspond à l’applicationId que l’on peut retrouver dans le build.gradle. Attention cependant, comme nous sommes ici dans une application KMP, il faut aller chercher l’applicationId qui se trouve dans androidApp/build.gradle.kts, c’est-à-dire, dans notre cas com.ippon.kmp_firebase_integration.android.

Ensuite, nous aurons aussi besoin d’un certificat de signature de débogage SHA-1 car nous souhaitons utiliser les services d’authentification de Google. Pour cela, plusieurs méthodes existent mais pour des questions de simplicité, je vais utiliser la commande signingReport de Gradle dans le terminal d’Android Studio.

./gradlew signingReport

Cela va me générer des informations de signature pour chaque variant de mon application. Voilà les informations qui m'intéressent concernant androidApp :

Capture d'écran de la console Android Studio montrant le SHA-1
Capture d'écran du formulaire pour enregistrer une application Android sur Firebase

Une fois ces informations rentrées, cliquez sur “Enregistrer l’application”.
Téléchargez le fichier google-services.json.

Capture d'écran de Firebase montrant où ajouter le fichier google-services.json

Copiez ce fichier dans le dossier androidApp.

Pour rendre visible le contenu de notre google-services.json nous devons ajouter le plugin Gradle des services Google. Pour cela, il suffit d’ajouter dans le fichier Gradle à la racine du projet kmp-firebase-integration/build.gradle.kts :

plugins {
	// ...
	id("com.google.gms.google-services") version "4.4.0" apply false
}

Ensuite, dans le fichier androidApp/build.gradle.kts, on ajoute le plugin des services Google et les SDK Firebase que nous aurons besoin, c’est-à-dire celui de l’authentification :

plugins {
    id("com.android.application")

    // Ajouter le plugin Gradle des services Google.
    id("com.google.gms.google-services")
}

dependencies {
    // Importer le BoM Firebase
    implementation(platform("com.google.firebase:firebase-bom:32.6.0"))
    
    // Ajouter les dépendances Firebase nécessaires.
	implementation("com.google.firebase:firebase-auth")
}

Pour l’application iOS :

La première étape pour ajouter une application iOS sur Firebase est de fournir l’ID du bundle Apple. Ouvrez votre projet avec XCode, sélectionnez le projet dans le navigateur de projet à gauche. Sélectionnez ensuite Targets/Général. L’identifiant du bundle se trouve sous Identity, dans notre cas on a com.ippon.kmp-firebase-integration.

Capture d'écran de la fenêtre Identity

Une fois ces informations rentrées, cliquez sur “Enregistrer l’application”.
Téléchargez le fichier GoogleService-Info.plist.

Capture d'écran de Firebase montrant où ajouter le fichier GoogleService-Info.plist

Ensuite, il faut ajouter le SDK Firebase au projet XCode, pour cela accéder à Fichier > Ajouter des packages. Dans la barre de recherche, copiez ce lien https://github.com/firebase/firebase-ios-sdk, sélectionnez le package proposé et cliquez sur “Ajouter”.

Capture d'écran montrant comment ajouter le package

Une fois le téléchargement fini, une pop-up s'affiche pour demander de choisir les bibliothèques Firebase dont nous aurons besoin, c’est-à-dire FirebaseAuth pour l’authentification.

Capture d'écran des choix de packages avec FirebaseAuth coché

Configurer Firebase

Pour pouvoir utiliser l’authentification dans notre projet, nous devons tout d’abord l’initialiser dans la console Firebase. Dans l’onglet à gauche de la console, aller dans Créer > Authentification, et cliquer sur le bouton “Commencer”. On doit ensuite choisir les méthodes d’authentification que nous souhaitons intégrer à notre projet.

Pour ce projet, nous choisirons l’authentification avec un compte anonyme, il suffit simplement de cliquer sur le toggle pour activer cette méthode.

Nous ajouterons aussi l’authentification avec un compte Google. Lorsque nous cliquons sur le toggle “Activer” nous devons rentrer le nom public du projet.

Capture d'écran de la deuxième étape pour ajouter l'authentification avec Google sur Firebase

Après cette configuration, Firebase nous informe que nous devons mettre à jour les fichiers google-services.json et GoogleService-Info.plist.

Implémentation de l’authentification

Authentification avec un compte anonyme

Common

Entrons maintenant dans le vif du sujet en commençant par implémenter l’authentification avec un compte anonyme sur nos applications.
Tout d’abord, dans le build.gradle.kts du module shared, ajouter dans kotlin > sourceset > commonMain.dependencies, la dépendance à la bibliothèque Firebase SDK Kotlin qui nous servira à appeler l’API de Firebase directement dans notre code partagé.

commonMain.dependencies {
    // Firebase SDK Kotlin 
    implementation("dev.gitlive:firebase-auth:1.10.4")
}

Une fois la synchronisation faite, on peut commencer à créer les fonctions dont nous avons besoin. Pour cela, on peut, dans un premier temps, aller dans shared > src > commonMain > kotlin > com.ippon.kmp_firebase_integration et créer un nouveau module que l’on peut nommer service et qui contiendra nos appels aux services de Firebase.

Dans ce module service, nous allons créer une interface AuthenticationInterface dans laquelle on peut déjà ajouter une fonction pour l’authentification anonyme.

suspend fun signInAnonymously()

Ensuite, on crée une classe AuthenticationService qui prend en paramètre l’initialisation de FirebaseAuth afin de rendre notre classe plus facilement testable en donnant la possibilité de mocker ce paramètre. Elle hérite donc de notre interface et contient l’implémentation de notre fonction de connexion anonyme.

import dev.gitlive.firebase.Firebase
import dev.gitlive.firebase.auth.FirebaseAuth
import dev.gitlive.firebase.auth.auth

class AuthenticationService(private val firebaseAuth: FirebaseAuth = Firebase.auth
) : AuthenticationInterface {
    override suspend fun signInAnonymously() {
        firebaseAuth.signInAnonymously()
    }
}

Enfin, on peut créer un nouveau module viewmodel, qui contiendra comme son nom l’indique, notre AuthenticationViewModel. De la même façon, il prendra en paramètre une instance de AuthenticationService afin de pouvoir appeler la fonction d’authentification anonyme.

import com.ippon.kmp_firebase_integration.service.AuthenticationInterface
import com.ippon.kmp_firebase_integration.service.AuthenticationService

class AuthenticationViewModel constructor(
    private val authenticationService: AuthenticationInterface = AuthenticationService(),
) {
    constructor() : this(AuthenticationService())
    private val coroutineScope: CoroutineScope = MainScope() 
    
    fun signInAnonymously() {
        coroutineScope.launch { 
            authenticationService.signInAnonymously()
        }
    }
}

Il ne reste donc plus qu’à appeler cette fonction dans nos views Android et iOS.

Android

Premièrement, dans androidApp > src > main > AndroidManifest.xml, ajoutez la permission d’utiliser internet <uses-permission android:name="android.permission.INTERNET"/>.

Créer une nouvelle fonction AuthenticationView avec l’annotation @Composable qui représente la page d’authentification et dans laquelle on ajoute un bouton “Continuer en invité”. Lorsque l’on clique dessus, cela va déclencher l’appel de la fonction signInAnonymously de notre shared ViewModel dans le LaunchEffect car c’est une fonction asynchrone.

@Composable
fun AuthenticationView(
    authenticationViewModel: AuthenticationViewModel
) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Button(
            onClick = { authenticationViewModel.signInAnonymously()},
            modifier = Modifier
                .padding(12.dp),
            shape = RoundedCornerShape(12.dp),
        ) {
            Text(text = "Continuer en invité")
        }
    }
}

Dans le MainActivity, on ajoute la création de l’instance de l’AuthenticationViewModel que l’on passe en paramètre de notre AuthenticationView. Si on lance ce code on obtient cette vue :

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
	                  val authenticationViewModel = AuthenticationViewModel()
	                  AuthenticationView(authenticationViewModel)
                }
            }
        }
    }
}
Ecran de l'application avec le bouton "Continuer en invité"

iOS

Effacez le contenu du ContentView, pour y ajouter une variable qui crée une instance de notre ViewModel partagé AuthentificationViewModel. Dans son body, on ajoute aussi un bouton qui aura comme label “Continuer en invité” et qui aura comme action d’appeler la fonction signInAnonymously provenant de l'AuthenticationViewModel du module shared. Comme c’est une fonction asynchrone, on doit précéder notre appel par le mot clé await et entourer notre fonction dans une Task et un try/catch.

import SwiftUI
import shared

struct ContentView: View {
    var authenticationViewModel: AuthenticationViewModel = AuthenticationViewModel()

    var body: some View {
        VStack(alignment: .center) {
            Button(action: {
                authenticationViewModel.signInAnonymously()
            }) {
                    Text("Continuer en invité")
                }
        }
    }
}
Ecran de l'application avec le bouton "Continuer en invité"

import SwiftUI

@main
struct iOSApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
	var body: some Scene {
		WindowGroup {
			ContentView()
		}
	}
}

Nous devons aussi mettre à jour l’AppDelegate afin d’y ajouter le module Firebase.

import Foundation
import UIKit
import Firebase

class AppDelegate: NSObject, UIApplicationDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions:
                     [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

        FirebaseApp.configure()
        
        return true
    }
}

Résultat

Et si l’on clique sur notre bouton “Continuer en invité” sur notre application Android ou iOS, on remarque dans la console de Firebase une nouvelle connexion anonyme, ce qui prouve le fonctionnement de notre authentification sur les deux plateformes 🎉.

Capture d'écran de la console d'authentification Firebase montrant un nouvel utilisateur anonyme

Authentification avec un compte Google

Pour effectuer cette authentification, nous aurons besoin de récupérer un idToken et un accessToken de la part de Google et cela ne peut s’effectuer qu’en natif Android et iOS. Voici une représentation schématique du flow:

Schéma représentant l'architecture de l'application KMP

Common

Pour commencer, retournons dans notre code partagé et dans le build.gradle.kts ajoutez la dépendance suivante:

implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")

Ajoutons ensuite dans notre interface la signature de notre fonction qui servira à appeler le service Google et Firebase. Notre fonction prend en paramètres deux tokens, un idToken et un accessToken qui nous serviront à transmettre les crédits Google à Firebase.

suspend fun signInWithGoogle(idToken: String?, accessToken: String?)

On ajoute ensuite son implémentation dans AuthenticationService, où l’on fait appel au GoogleAuthProvider en lui donnant en paramètre nos deux tokens que l’on va récupérer par la suite. Ainsi, on obtient notre credential Google que l’on peut donc donner à la fonction signInWithCredential de Firebase dans le but de compléter notre authentification.

import dev.gitlive.firebase.auth.GoogleAuthProvider

override suspend fun signInWithGoogle(idToken: String?, accessToken: String?) {
    val credential = GoogleAuthProvider.credential(idToken, accessToken)
        firebaseAuth.signInWithCredential(credential)
    }

N’oublions pas de mettre à jour notre ViewModel:

fun signInWithGoogle(idToken: String?, accessToken: String?) {
    coroutineScope.launch {
        authenticationService.signInWithGoogle(idToken = idToken, accessToken = accessToken)
    }
}

On cherche maintenant à récupérer nos tokens Google afin de pouvoir les passer en paramètre de notre fonction d’authentification.

Android

Pour créer notre client d’authentification Google, nous devons ajouter cette dépendance dans notre build.gradle.kts :

// Google authentication
implementation("com.google.android.gms:play-services-auth:20.4.1")

Ensuite, nous devons créer une variable que nous appellerons gso qui contiendra notre GoogleSignInOptions et sa configuration. On utilisera la configuration par défaut pour l’authentification avec Google avec l’option GoogleSignInOptions.DEFAULT_SIGN_IN.

val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN)
        .requestIdToken("86184358628-u14g08iq6mnje0qo76ke6tg0l69k75k4.apps.googleusercontent.com")
        .requestEmail()
        .build()

Pour ce qui est du paramètre de requestIdToken, nous aurons besoin de récupérer l’ID client de notre projet, nous pouvons le retrouver, soit dans notre google-services.json ou bien dans la console Google Cloud de notre projet.

Capture d'écran du fichier google-services.json montrant l'ID client

Capture d'écran de la console Google Cloud montrant l'ID client

Puis, dans notre AuthenticationView récupérer le contexte en tant qu’Activity afin de passer cette variable activity et notre gso en tant que paramètre de la fonction GoogleSignIn.getClient afin de pouvoir créer notre client Google.

val activity = LocalContext.current as Activity
var googleSignInClient = GoogleSignIn.getClient(activity, gso)

Une fois que l’on a fini ces configurations, on va pouvoir ajouter un launcher pour lancer une nouvelle activité qui permettra à l’application de lancer la pop-up Google qui permet à l’utilisateur de choisir l’adresse mail Google avec laquelle il souhaite se connecter.

var googleIdToken by rememberSaveable { mutableStateOf("") }

val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    try {
        val task = GoogleSignIn.getSignedInAccountFromIntent(result.data)
        
        // Google Sign In was successful, authenticate with Firebase
        val account = task.getResult(ApiException::class.java)!!
        authenticationViewModel.signInWithGoogle(account.idToken.toString(), null)
    } catch (e: ApiException) {
        // Google Sign In failed, update UI appropriately
        Log.w("TAG", "Google sign in failed", e)
    }
}

Une fois cette étape réalisée, on récupère le résultat qui représente les informations du compte Google sélectionné, mais ce qui nous intéresse ici c’est d’extraire le idToken. Que l’on va stocker dans une variable afin de pouvoir la passer en paramètre de notre fonction d’authentification.

On peut créer un nouveau Composable pour contenir notre button Google personnalisé.

@Composable
fun GoogleSignInButton(
    onClick: () -> Unit
) {
    Button(
        onClick = onClick,
        shape = RoundedCornerShape(12.dp),
        border = BorderStroke(1.dp, Color.LightGray),
        colors = ButtonDefaults.buttonColors(
            containerColor = Color.White,
        ),
    ) {
        Image(
            painter = painterResource(id = R.drawable.ic_google_logo),
            contentDescription = "Logo Google"
        )
        GoogleSignInText()
    }
}

@Composable
fun GoogleSignInText() {
    Text(
        text = "Continuer avec Google",
        color = Color.Gray,
        fontSize = 14.sp,
        modifier = Modifier.padding(4.dp),
    )
}

Et l’ajouter dans notre AuthenticationView en intégrant dans le onClick l’appel qui lancera notre launcher.

Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Button(
            onClick = { authenticationViewModel.signInAnonymously() },
            modifier = Modifier
                .padding(12.dp),
            shape = RoundedCornerShape(12.dp),
        ) {
            Text(text = "Continuer en invité")
        }
        GoogleSignInButton(onClick = {
            val signInIntent = googleSignInClient.signInIntent
            launcher.launch(signInIntent)
        })
    }
Ecran de l'application avec l'ajout du bouton "Continuer avec Google"

Pop-up Google permettant de choisir une adresse mail pour l'authentification

iOS

Pour commencer, nous devons ajouter une nouvelle dépendances dans notre projet Xcode, en allant dans File > Add Packages… et en cherchant dans la barre de recherche https://github.com/google/GoogleSignIn-iOS, ne pas oublier d’ajouter aussi l’extension GoogleSignInSwift car nous utilisons SwiftUI.

Capture d'écran de l'ajout des packages GoogleSignIn

Dans Info.plist ajoutez les informations concernant l’identifiant de notre client iOS:

<key>GIDClientID</key>
<string>YOUR_IOS_CLIENT_ID</string>
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>YOUR_DOT_REVERSED_IOS_CLIENT_ID</string>
    </array>
  </dict>
</array>

Remplacer les id clients par ceux correspondant, que l’on retrouve dans GoogleService-Info.

Capture d'écran du GoogleService-Info montrant le clientID et le reversedID

On peut maintenant passer au code, en commençant par ajouter l’import qui nous servira à appeler la fonction de récupération de token Google:

import GoogleSignIn

Ensuite, on crée une fonction pour gérer nos appels, dans laquelle on va stocker dans la variable presentingViewController notre rootViewController afin de pouvoir le passer en paramètre de la méthode GIDSignIn.sharedInstance.signIn. Puis récupérer les tokens qui nous intéressent dans signInResult.user, c’est-à-dire, idToken et accessToken, que l’on passe donc en paramètre de notre fonction d’authentification.

func handleSignInButton(presentingViewController: UIViewController?) {
    if (presentingViewController == nil) {
        return
    }
    
    GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController!) { signInResult, error in
        guard error == nil else { return }
        guard let signInResult = signInResult else { return }

        signInResult.user.refreshTokensIfNeeded { user, error in
            guard error == nil else { return }
            guard let user = user else { return }

            let idToken = user.idToken
            let accessToken = user.accessToken
            authenticationViewModel.signInWithGoogle(idToken: idToken?.tokenString, accessToken: accessToken.tokenString)
        }
    }
}

On créer une struct pour contenir notre button Google personnalisé.

struct CustomGoogleButton: View {
    var action: (() -> Void)?

    var body: some View {
        ZStack(){
            Button{
                self.action?()
            }label: {
                HStack {
                    Image("IcGoogleLogo")
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(width: 20, height: 20)
                        .scaledToFit()
                        .clipped()
                    Text("Continuer avec Google")
                    .padding()
                }.frame(height: 20)
                    .padding()
                    .overlay(
                        RoundedRectangle(cornerRadius: 10)
                            .stroke(.gray, lineWidth: 1)
                    )
            }
        }
    }
}
Ecran de l'application avec l'ajout du bouton "Continuer avec Google"

Enfin, on ajoute dans notre body notre button Google en lui donnant en paramètre la méthode qu’il appellera lors du clique.

var body: some View {
    VStack(alignment: .center) {
        Button(action: {
              authenticationViewModel.signInAnonymously()
        }) {
            Text("Continuer en invité")
        }
        .onOpenURL { url in
                  GIDSignIn.sharedInstance.handle(url)
                }
        CustomGoogleButton(action: {
            let presentingViewController = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.windows.first?.rootViewController
            handleSignInButton(presentingViewController: presentingViewController)
        })     
    }
}
Pop-up d'Apple pour demander l'autorisation d'ouvrir la pop-up de Google

Pop-up Google permettant de choisir une adresse mail pour l'authentification

Résultat

Pour constater le bon fonctionnement de notre authentification, on peut se rendre sur la console de Firebase dans l’onglet Authentification et voir que l’on s’est bien connecté avec un compte anonyme et avec un compte Google 🎉.

Capture d'écran de la console d'authentification Firebase montrant une connexion avec Google

Conclusion

Voici donc une approche envisageable si vous souhaitez intégrer les services Firebase dans une application KMP grâce à la bibliothèque Firebase Kotlin SDK. Il est possible que d'autres solutions émergent, voire qu'un support officiel de la part de Firebase soit proposé (sait-on jamais) au vue de la communauté très active autour de ce nouveau framework prometteur qu'est KMP.
Vous pouvez retrouver le projet GitLab ici.