Today a well-researched tutorial by Arnaud Bos on setting up link-based authentication with Firebase Authentication. Cloud technologies can be challenging, but Arnaud has carefully pieced together all the information you need, and he's pleased to share the results with you.
Arnaud is an experienced independent contractor, very nice and approachable. Don't hesitate to get in touch with him!
If you too like Arnaud, you'd like to write about ClojureDart here, let us know!
I recently had to set up password-less authentication (aka. "magic link") for a ClojureDart + Flutter app I'm developing, using Firebase Authentication.
Given the testimonials on the Firebase website, one would expect it to be a breeze. It wasn't. Fortunately, Clojure, ClojureDart and Flutter had nothing to do with it.
The problem lies with how cumbersome the procedure is to find out how to put the pieces together when they are scattered all over the Firebase Authentication and Firebase Dynamic Links documentation.
So let's try to make it easier for the next person.
Note: This article is focused on setting up password-less authentication with Firebase for Android. The setup for iOS is similar, but I haven't tested it yet, so I'm not going to cover it here.
First and foremost, follow the ClojureDart setup instructions at https://github.com/Tensegritics/ClojureDart
You need a UI to interact with Firebase Authentication. A simple one will do.
Install this pretty spinner package:
flutter pub add flutter_spinkit
The UI itself consist in routing the user to a content page if it is already signed in.
(ns cljd.firebase-auth.main
(:require ["package:flutter/material.dart" :as m]
[cljd.flutter :as f]
[cljd.firebase-auth.home :refer [home]]
[cljd.firebase-auth.login :refer [login]]))
(def app-state (atom {}))
(defn main []
(f/run
:watch [{:keys [user]} app-state]
:bind {:app-state app-state}
(m/MaterialApp
.title "Welcome to Flutter"
.theme (m/ThemeData .primarySwatch m.Colors/pink))
.home
(m/Scaffold
.appBar (m/AppBar
.title (m/Text "Welcome to ClojureDart")))
.body
m/Center
(cond
user home
:else login)))
A "content" page:
(ns cljd.firebase-auth.home
(:require ["package:flutter/material.dart" :as m]))
(def home
(m/Text "Let's get coding!"
.style (m/TextStyle
.color m/Colors.red
.fontSize 32.0)))
And a sign in page in case the user is not signed in.
(ns cljd.firebase-auth.login
(:require ["package:flutter/material.dart" :as m]
["package:flutter_spinkit/flutter_spinkit.dart" :as sk]
[cljd.flutter :as f]))
(defn login-button
[& {:keys []
{:keys [on-tap]} :actions}]
(f/widget
:context ctx
:get [:app-state]
:watch [{:keys [auth-status]} app-state]
:padding {:horizontal 25}
:height 65
:width ##Inf
(m/FilledButton
.onPressed (fn [] (when (not= auth-status :registering)
(do
(.unfocus (m/FocusScope.of ctx))
(on-tap))))
.style (m/FilledButton.styleFrom
.backgroundColor m/Colors.redAccent.shade700
.shape (m/RoundedRectangleBorder
.borderRadius (m/BorderRadius.circular 5))))
(condp = auth-status
:registering (sk/SpinKitThreeBounce
.color m/Colors.white
.size 30.0)
(m/Text "Sign In"
.style (m/TextStyle
.color m/Colors.white
.fontWeight m/FontWeight.bold
.fontSize 16)))))
(defn login-textfield
[& {:keys [controller hint-text obscure-text]}]
(f/widget
:padding {:horizontal 25}
:height 65
:width ##Inf
(m/TextField
.controller controller
.keyboardType m/TextInputType.emailAddress
.obscureText obscure-text
.decoration
(m/InputDecoration
.enabledBorder
(m/OutlineInputBorder
.borderSide
(m/BorderSide
.color m/Colors.white))
.focusedBorder (m/OutlineInputBorder
.borderSide (m/BorderSide .color m/Colors.grey.shade400))
.fillColor m/Colors.grey.shade200
.filled true
.hintText hint-text
.hintStyle (m/TextStyle
.color m/Colors.grey.shade500)))))
(def authentication-error
(f/widget
:get [:app-state]
:watch [{:keys [auth-status]} app-state]
:when (and auth-status
(not= auth-status :registering)
(not= auth-status :signed-in))
(m/Text
"Woops!"
.textAlign m/TextAlign.center
.style (m/TextStyle .color m/Colors.red.shade400))))
(def registration
(f/widget
:get [:email-controller
:user-sign-in]
m/Center
(m/Column
.mainAxisAlignment m/MainAxisAlignment.center
.children
[; logo
(m/Icon
m/Icons.lock
.size 70)
(m/SizedBox .height 50)
; welcome back, you've been missed!
(m/Text
"Sign in to continue"
.style (m/TextStyle
.color m/Colors.grey.shade700
.fontSize 16))
(m/SizedBox .height 25)
; username textfield
(login-textfield
:controller email-controller
:hint-text "Email"
:obscure-text false)
(m/SizedBox .height 10)
authentication-error
(m/SizedBox .height 15)
; sign in button
(login-button
:actions {:on-tap user-sign-in})
])))
(def login
(f/widget
:managed [email-controller (m/TextEditingController)]
:let [user-sign-in (fn []
(let [email (.-text email-controller)]
(println email)))]
:bind {:email-controller email-controller
:user-sign-in user-sign-in}
registration))
70% of the coding is done. The remaining 30% will wait until after we set up Firebase.
Firebase is a Google product and the many services it offers (all backed by Google's Cloud Platform) are designed to help you develop your app faster and more efficiently.
Go to the Firebase console and create a project.
Next, you must link your firebase to your app.
(Choose Flutter)
Do as instructed: install the Firebase ClI
https://firebase.google.com/docs/cli#install_the_firebase_cli
and log in
firebase login
Install the FlutterFire CLI
dart pub global activate flutterfire_cli
Configure your local project with the Firebase project.
flutterfire configure --project=cljd-firebase-email-auth
This will create a configuration file containing keys at lib/firebase_options.dart
.
In case you're worried about keys being present in this file, rest assured:
The content of the Firebase config file or object is considered public,
including the app's platform-specific ID (Apple bundle ID or Android package
name) and the Firebase project-specific values, like the API Key, project ID,
Realtime Database URL, and Cloud Storage bucket name. Given this, use
Firebase Security Rules to protect your data and files in Realtime Database,
Cloud Firestore, and Cloud Storage.
Install the Firebase Core package.
flutter pub add firebase_core
Add the lib/firebase_options.dart
file as a dependency in the main
namespace as well as the firebase_core
package:
["package:firebase_core/firebase_core.dart" :as firebase]
And in the run function, initialize Firebase:
(let [^firebase/FirebaseApp app (await
(firebase/Firebase.initializeApp
.options (options/DefaultFirebaseOptions.currentPlatform)))])
Restart your app to check if the Firebase initialization was successful.
While you're at it, add the widget bindings initialization code to the main
function:
["package:flutter/widgets.dart" :as f_widgets]
(f_widgets/WidgetsFlutterBinding.ensureInitialized)
Configure authentication methods to allow sign-in with email and password which is required for password-less authentication.
As a matter of fact, password-less is only password-less in the sense that the user doesn't have to enter a password to sign in, there is still one behind the scenes.
Now, you can follow along the following steps at https://firebase.google.com/docs/auth/flutter/start.
Add the Firebase Authentication dependency.
flutter pub add firebase_auth
Add the package to the main
namespace and initialize an authentication instance using the Firebase app:
["package:firebase_auth/firebase_auth.dart" :as firebase-auth]
(let [...
^firebase-auth/FirebaseAuth auth-instance (firebase-auth/FirebaseAuth.instanceFor .app firebase-app)
]
...)
Register a bg-watcher
to listen to the authentication state changes.
:bg-watcher ([^firebase-auth/User? u (.authStateChanges auth-instance)]
(swap! app-state assoc :user u))
This will update the app-state
atom with the user object whenever the user logs in or
out.
At this point you should not see any changes in the UI because the user is not signed in, and consequently the :user
key in the app-state
atom is still nil
.
Let's implement the password-less sign in functionality.
First, add this dependency:
flutter pub add shared_preferences
You will need it to store the user's email address.
In the main
namespace, add a binding for the SharedPreferences instance:
["package:shared_preferences/shared_preferences.dart" :as prefs]
(let [...
prefs (await (prefs/SharedPreferences.getInstance)))
]
:bind {:app-state app-state
:firebase-app firebase-app
:auth-instance auth-instance
:prefs prefs}
In the login
namespace, require the Firebase Authentication package and change the user-sign-in
function in the login
namespace to the following implementation:
(ns cljd.firebase-auth.login
(:require ["package:flutter/material.dart" :as m]
["package:flutter_spinkit/flutter_spinkit.dart" :as sk]
["package:firebase_auth/firebase_auth.dart" :as firebase-auth]
["package:shared_preferences/shared_preferences.dart" :as prefs]
[cljd.flutter :as f]))
(def ACS (firebase-auth/ActionCodeSettings
.url "https://cljd-firebase-email-auth.firebaseapp.com/finishSignUp"
.handleCodeInApp true
.androidPackageName "com.example.cljd_cljd_firebase_email_auth"
.androidInstallApp true
.androidMinimumVersion "12"
.dynamicLinkDomain "cljdfirebaseemailauth.page.link"
#_acs))
(defn user-sign-in
[app-state auth-instance prefs email]
(try
(swap! app-state assoc :auth-status :registering)
(await
(.sendSignInLinkToEmail auth-instance
.email email
.actionCodeSettings ACS))
(await (.setString prefs "email-key" email))
(swap! app-state assoc :auth-status :email-link-sent)
(catch dynamic e
(swap! app-state assoc :auth-status :send-link-error))))
The ActionCodeSettings
object is required to configure the behavior of the email link.
We'll touch on the .url
and .dynamicLinkDomain
fields later on as it is one of the subtleties that really did bake my noodle at first.
The .androidPackageName
field is the package name of the Android app. com.example
is the default package name prefix for Flutter apps, or for ClojureDart+Flutter apps, IDK.
You can change it if you want, don't forget to reflect those changes in the Firebase "Project settings" as well.
Now, let's call the user-sign-in
function when the user taps the "Sign In" button and reflect in the UI the fact that the email has been sent.
(def verify-email
(f/widget
m/Center
(m/Column
.mainAxisAlignment m/MainAxisAlignment.center
.children
[; logo
(m/Icon
m/Icons.mail_outline
.size 70)
(m/SizedBox .height 50)
; email sent, please confirm
(m/Text
"Verify your email address"
.style (m/TextStyle
.color m/Colors.grey.shade700
.fontSize 16))
])))
(def login
(f/widget
:managed [email-controller (m/TextEditingController)]
:get [:app-state
:auth-instance
:prefs]
:let [user-sign-in (fn []
(let [email (.-text email-controller)]
(user-sign-in app-state auth-instance prefs email)))
email (.getString prefs "email-key")]
:watch [{:keys [auth-status]} app-state]
:bind {:email-controller email-controller
:user-sign-in user-sign-in}
(cond
(or
(= auth-status :email-link-sent)
(and (not auth-status)
(not (nil? email)))) verify-email
:else registration)))
You also need to modify the authentication-error
widget to display the error message when an error occurs during the sign in process.
(def authentication-error
(f/widget
:get [:app-state]
:watch [{:keys [auth-status]} app-state]
:when (and auth-status
(not= auth-status :registering)
(not= auth-status :signed-in))
(m/Text
(condp = auth-status
:email-link-sent "Email link sent. Please check your email."
:invalid-email "Invalid email address"
:send-link-error "Registration failed."
"Woops!")
.textAlign m/TextAlign.center
.style (m/TextStyle .color m/Colors.red.shade400))))
If you try to Sign In with a valid email now, you'll get an error because the ActionCodeSettings
object is not set up correctly.
You need to register the domain you are going to use for the email link (ActionCodeSettings$dynamicLinkDomain
) as well as the default redirect link target (ActionCodeSettings$url
) in the Firebase console.
But what are those links anyways?
In order to use the password-less authentication, you must to set up a dynamic link domain in Firebase. This domain will be used to open the app when the user clicks on a link to this domain on their devices.
In the Firebase console, go to "Dynamic Links". If you are reading this article around its time of writing (April of 2024), you will see a a deprecation notice on Dynamic Links.
For now, you can still continue to use Dynamic Links and worry later, since there is no available alternative in Firebase yet. Here's the relevant part of the deprecation FAQ:
Firebase Authentication currently uses Firebase Dynamic Links to customize Authentication links, but we will provide an update to ensure that this functionality continues working after the Firebase Dynamic Links service is shut down.
Create a new "URL prefix", aka. Domain, using your own custom domain or, as in this example, use one provided by Google.
Don't forget to add this domain to the "Allow list" to prevent unauthorized use of your domain.
This is the domain you will use in the ActionCodeSettings$dynamicLinkDomain
field.
If you don't specify it, by default Firebase will use the first one in the list of the domains you have registered, so we may as well set it up and be explicit now.
As for the ActionCodeSettings$url
field, it actually has nothing to do with your app per say. It is the URL that the user will be redirected to in case the app you are
referring to in in the ActionCodeSettings$androidPackageName
field is not installed on the user's device and it is not available in the Play Store.
Now that you have set up the Dynamic Link domain, you can try to sign in with a valid email address. You should receive an email with a link. Click on the link from your Android device or emulator, you will be redirected to the URL we have mentioned above and see a message like this:
Site Not Found
You can confirm that the redirect works properly by deploying a simple HTML page.
mkdir public
echo 'Clojure rocks!
' > public/index.html
firebase init hosting
# ? What do you want to use as your public directory? public
# ? Configure as a single-page app (rewrite all urls to /index.html)? Yes
# ? Set up automatic builds and deploys with GitHub? No
# ? File public/index.html already exists. Overwrite? No
firebase deploy --only hosting
If you click on the link in the email now, you should see the content of the HTML page.
# To disable hosting
firebase hosting:disable
Another subtlety to be aware of is that the domain cljd-firebase-email-auth.firebaseapp.com
has been automatically added to the list of "Authorized domains" in the Firebase console. If you want to use a custom domain, you will need to add it manually.
As you've seen, opening the email link on your device only opens the "redirect" URL, which is a first step, but not quite where you want to be. It is time to handle the dynamic link in the app itself.
To do this you must add the Firebase Dynamic Links package to the project.
flutter pub add firebase_dynamic_links
In the android/app/src/main/AndroidManifest.xml
, add the following "Intent filter" to the main Activity to instruct Android that this application, and this Activity specifically, can handle opening links from this domain:
android:autoVerify="true">
android:name="android.intent.action.VIEW"/>
android:name="android.intent.category.DEFAULT"/>
android:name="android.intent.category.BROWSABLE"/>
android:scheme="https" android:host="cljdfirebaseemailauth.page.link"/>
There's yet another step to be taken... As stated in
this page in a very small and inconspicuous paragraph:
If you're building an Android app, open the Project settings page of the Firebase
console and make sure you've specified your SHA-1 signing key. If you use App Links,
also specify your SHA-256 key.
Now don't get lost in the documentation, you might get lost, I've got you covered. To get the SHA-256 key, run the following command:
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
Copy and paste the SHA-256 key into your Firebase "Project settings" page.
You can now properly handle the dynamic link in the app.
The [documentation] https://firebase.google.com/docs/dynamic-links/flutter/receive#terminated_state)
states that the getInitialLink
method must be called before listening to the link stream. To do that, prepend the initialLink
future to the link
stream using the stream_transform
package.
dart pub add stream_transform
Next, in the main
namespace, add the Firebase Dynamic Links package and initialize the Dynamic Links instance and create a stream that listens to the dynamic links and only
keep the ones that are sign-in links:
["package:stream_transform/stream_transform.dart" :as st]
["package:firebase_dynamic_links/firebase_dynamic_links.dart" :as firebase-dynlinks]
(let [...
^firebase-dynlinks/FirebaseDynamicLinks dynlinks-instance (firebase-dynlinks/FirebaseDynamicLinks.instanceFor .app firebase-app)
^#/(Stream firebase-dynlinks/PendingDynamicLinkData) initial-link-stream (-> (.getInitialLink dynlinks-instance)
Stream.fromFuture
st/WhereNotNull
.whereNotNull)
^#/(Stream String) sign-in-links (-> (.-onLink dynlinks-instance)
st/Concatenate
(.startWithStream initial-link-stream)
(.map #(-> % .-link str))
(.where #(.isSignInWithEmailLink auth-instance %)))
]
Before the final step, do add an error handling state to the login scene in the login
namespace:
(def authentication-error
...
(condp = auth-status
...
:sign-in-error "Sign-in failed, please retry."
"Woops!")
...)
And finally, sign-in!
:bg-watcher ([^String link sign-in-links]
(try
(let [email (.getString prefs "email-key")]
(await (.signInWithEmailLink auth-instance .email email .emailLink link)))
(catch dynamic e
(swap! app-state assoc :auth-status :sign-in-error))))
Using the signInWithEmailLink
method with the email and a valid sign-in link will authenticate the user, consequently update the authentication state and trigger a signal down the authStateChanges
stream and thanks to our background watcher, will update the :user
key in the app-state
atom.
Implementing the sign out functionality is straightforward and left as an exercise to the reader 😉