
Simplifies authentication flows using Firebase Authentication, offering composable APIs for Google, Apple, and Email/Password providers. Supports extensible configuration and email actions for user management.
Passage is a Kotlin Multiplatform library designed to simplify authentication flows across Android and iOS platforms. Built on Firebase Authentication, Passage abstracts common operations and provides composable APIs to manage authentication using popular providers like Google, Apple, and Email/Password.
Be sure to show your support by starring βοΈ this repository, and feel free to contribute if you're interested!
[!WARNING]
Starting August 25, 2025, email actions will no longer work due to the shutdown of Firebase Dynamic Links.I decided to drop support of these features for two main reasons:
- I attempted to follow the migration guide to move from Dynamic Links to Firebase Hosting, but had no success.
- Firebase recently updated the free plan limit for email link sign-in emails to only 5 per day, which makes development both harder and more expensive.
Other solutions exist that provide similar features to Firebase Dynamic Links..
Alarmee powers notifications in real-world apps:
Passage uses Firebase Authentication as the backbone for secure and reliable user identity management. It abstracts the complexity of integrating with Firebase's SDKs on multiple platforms, providing a unified API for developers.
Passage abstracts the authentication flow into three main components:
In your settings.gradle.kts file, add Maven Central to your repositories:
repositories {
mavenCentral()
}Then add Passage dependency to your module:
libs.versions.toml:[versions]
passage = "1.0.0" // Check latest version
[libraries]
passage = { group = "io.github.tweener", name = "passage", version.ref = "passage" }Then in your module build.gradle.kts add:
dependencies {
implementation(libs.passage)
}build.gradle.kts add:dependencies {
val passage_version = "1.0.0" // Check latest version
implementation("io.github.tweener:passage:$passage_version")
}First, you need to create an instance of Passage for each platform:
π€ Android
Create an instance of PassageAndroid passing an Application-based Context:
val passage: Passage = PassageAndroid(applicationContext = context)π iOS
Create an instance of PassageIos:
val passage: Passage = PassageIos()Provide a list of the desired gatekeepers (authentication providers) to configure:
val gatekeeperConfigurations = listOf(
GoogleGatekeeperConfiguration(
serverClientId = "your-google-server-client-id",
android = GoogleGatekeeperAndroidConfiguration(
useGoogleButtonFlow = true,
filterByAuthorizedAccounts = false,
autoSelectEnabled = true,
maxRetries = 3
)
),
AppleGatekeeperConfiguration(),
EmailPasswordGatekeeperConfiguration
)For example, if you only want to use the Google Gatekeeper, simply provide the GoogleGatekeeperConfiguration like this:
val gatekeeperConfigurations = listOf(
GoogleGatekeeperConfiguration(
serverClientId = "your-google-server-client-id",
android = GoogleGatekeeperAndroidConfiguration(
useGoogleButtonFlow = true,
filterByAuthorizedAccounts = false,
autoSelectEnabled = true,
maxRetries = 3
)
),
)[!IMPORTANT]
Replaceyour-google-server-client-idwith your actual Google serverClientId.
Initialize Passage in your common module entry point:
passage.initialize(gatekeepersConfigurations = gatekeeperConfigurations)[!NOTE]
If your app already uses Firebase, you can pass the existing Firebase instance to Passage to reuse it and prevent reinitializing Firebase unnecessarily:
passage.initialize(
gatekeepersConfigurations = gatekeeperConfigurations,
firebase = Firebase,
)When using Google gatekeeper on Android, you must now call bindToView() in Passage before performing any authentication operations. This ensures that Passage can access the Activity-based context needed to display the Google Sign-In UI.
@Composable
fun MyApp() {
val passage = { inject Passage }
passage.initialize(...)
passage.bindtoView() // <- Add this line when using Google gatekeeper on Android
}Use the provider-specific methods to authenticate users.
Passage#authenticateWithGoogle() authenticates a user via Google Sign-In. If the user does not already exist, a new account will be created automatically.
val result = passage.authenticateWithGoogle()
result.fold(
onSuccess = { entrant -> Log.d("Passage", "Welcome, ${entrant.displayName}") },
onFailure = { error -> Log.e("Passage", "Authentication failed", error) }
)Passage#authenticateWithApple() authenticates a user via Apple Sign-In. If the user does not already exist, a new account will be created automatically.
[!WARNING]
Apple Sign-In only works on iOS. Passage does not handle Apple Sign-In on Android, since this scenario is quite uncommon.
val result = passage.authenticateWithApple()
// Handle result similarlyCreating a user with email & password will automatically authenticate the user upon successful account creation.
val result = passage.createUserWithEmailAndPassword(PassageEmailAuthParams(email, password))
// Handle result similarlyval result = passage.authenticateWithEmailAndPassword(PassageEmailAuthParams(email, password))
// Handle result similarlyYou can send an email with a sign-in link to the user's email adress:
val result = passage.sendSignInLinkToEmail(
params = PassageSignInLinkToEmailParams(
email = userEmail, // Ask the user for its email address
url = "https://passagesample.web.app/action/sign_in_link_email",
iosParams = PassageSignInLinkToEmailIosParams(bundleId = "com.tweener.passage.sample"),
androidParams = PassageSignInLinkToEmailAndroidParams(
packageName = "com.tweener.passage.sample",
installIfNotAvailable = true,
minimumVersion = "1.0",
),
canHandleCodeInApp = true,
)
)
result.fold(
onSuccess = { entrant -> Log.d("Passage", "An email has been sent to the user with a sign-in link.") },
onFailure = { error -> Log.e("Passage", "Couldn't send the email", error) }
)Once the user clicks on this link from the email, it will redirect to your app and automatically sign-in the user:
passage.handleSignInLinkToEmail(email = "{Your email address}", emailLink = it.link)
.onSuccess { entrant = it } // User is sucessfully signed-in
.onFailure { println("Couldn't sign-in the user, error: ${it.message}") }passage.signOut()
passage.reauthenticateWithGoogle()
passage.reauthenticateWithApple()
passage.reauthenticateWithEmailAndPassword(params)You can retrieve the Firebase ID token for the currently authenticated user. This is useful for authenticating requests to your backend server.
val result = passage.getIdToken(forceRefresh = false)
result.fold(
onSuccess = { token -> Log.d("Passage", "ID Token: $token") },
onFailure = { error -> Log.e("Passage", "Failed to get ID token", error) }
)[!NOTE] Set
forceRefresh = trueto force a refresh of the ID token, which is useful when the token might be expired or you need the latest claims.
You may need to send emails to the user for a password reset if the user forgot their password for instance, or for verifying the user's email address when creating an account.
[!IMPORTANT] Passage uses Firebase Hosting domains to send emails containing universal links for authentication flows. You need to configure your app to handle Firebase Hosting links (e.g.,
PROJECT_ID.web.apporPROJECT_ID.firebaseapp.com).
To handle universal links, additional configuration is required for each platform:
In your activity configured to be open when a universal link is clicked:
class MainActivity : ComponentActivity() {
private val passage = providePassage()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleUniversalLink(intent = intent)
// ...
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleUniversalLink(intent = intent)
// ...
}
private fun handleUniversalLink(intent: Intent) {
intent.data?.let {
passage.handleUrl(url = it.toString())
}
}
}Create a class PassageHelper in your iosMain module:
class PassageHelper {
private val passage = providePassage()
fun handle(url: String): Boolean =
passage.handleUrl(url = url)
}Then, in your AppDelegate, add the following lines:
class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
// ...
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
if (PassageHelper().handle(url: url.absoluteString)) {
print("Handled by Passage")
}
return true
}
print("No valid URL in user activity.")
return false
}
}Passage exposes a universalLinkToHandle StateFlow, which you can use to be notified when a new unviersal link has been clicked and validated by Passage:
val link = passage.universalLinkToHandle.collectAsStateWithLifecycle()
LaunchedEffect(link.value) {
link.value?.let {
println("Universal link handled for mode: ${it.mode} with continueUrl: ${it.continueUrl}")
passage.onLinkHandled() // Important: call 'onLinkHandled()' to let Passage know the link has been handled and can update the authentication state
}
}If you want to reinforce authentication, you can send the user an email to verify its email address:
val result = passage.sendEmailVerification(
params = PassageEmailVerificationParams(
url = "https://passagesample.web.app/action/email_verified",
iosParams = PassageEmailVerificationIosParams(bundleId = "com.tweener.passage.sample"),
androidParams = PassageEmailVerificationAndroidParams(
packageName = "com.tweener.passage.sample",
installIfNotAvailable = true,
minimumVersion = "1.0",
),
canHandleCodeInApp = true,
)
)
result.fold(
onSuccess = { entrant -> Log.d("Passage", "An email has been sent to the user to verify its email address.") },
onFailure = { error -> Log.e("Passage", "Couldn't send the email", error) }
)If you want to reinforce authentication, you can send the user an email to verify its email address:
val result = passage.sendPasswordResetEmail(
params = PassageForgotPasswordParams(
email = passage.getCurrentUser()!!.email,
url = "https://passagesample.web.app/action/password_reset",
iosParams = PassageForgotPasswordIosParams(bundleId = "com.tweener.passage.sample"),
androidParams = PassageForgotPasswordAndroidParams(
packageName = "com.tweener.passage.sample",
installIfNotAvailable = true,
minimumVersion = "1.0",
),
canHandleCodeInApp = true,
)
)
result.fold(
onSuccess = { entrant -> Log.d("Passage", "An email has been sent to the user to reset its password.") },
onFailure = { error -> Log.e("Passage", "Couldn't send the email", error) }
)We love your input and welcome any contributions! Please read our contribution guidelines before submitting a pull request.
Passage is licensed under the Apache-2.0.
Passage is a Kotlin Multiplatform library designed to simplify authentication flows across Android and iOS platforms. Built on Firebase Authentication, Passage abstracts common operations and provides composable APIs to manage authentication using popular providers like Google, Apple, and Email/Password.
Be sure to show your support by starring βοΈ this repository, and feel free to contribute if you're interested!
[!WARNING]
Starting August 25, 2025, email actions will no longer work due to the shutdown of Firebase Dynamic Links.I decided to drop support of these features for two main reasons:
- I attempted to follow the migration guide to move from Dynamic Links to Firebase Hosting, but had no success.
- Firebase recently updated the free plan limit for email link sign-in emails to only 5 per day, which makes development both harder and more expensive.
Other solutions exist that provide similar features to Firebase Dynamic Links..
Alarmee powers notifications in real-world apps:
Passage uses Firebase Authentication as the backbone for secure and reliable user identity management. It abstracts the complexity of integrating with Firebase's SDKs on multiple platforms, providing a unified API for developers.
Passage abstracts the authentication flow into three main components:
In your settings.gradle.kts file, add Maven Central to your repositories:
repositories {
mavenCentral()
}Then add Passage dependency to your module:
libs.versions.toml:[versions]
passage = "1.0.0" // Check latest version
[libraries]
passage = { group = "io.github.tweener", name = "passage", version.ref = "passage" }Then in your module build.gradle.kts add:
dependencies {
implementation(libs.passage)
}build.gradle.kts add:dependencies {
val passage_version = "1.0.0" // Check latest version
implementation("io.github.tweener:passage:$passage_version")
}First, you need to create an instance of Passage for each platform:
π€ Android
Create an instance of PassageAndroid passing an Application-based Context:
val passage: Passage = PassageAndroid(applicationContext = context)π iOS
Create an instance of PassageIos:
val passage: Passage = PassageIos()Provide a list of the desired gatekeepers (authentication providers) to configure:
val gatekeeperConfigurations = listOf(
GoogleGatekeeperConfiguration(
serverClientId = "your-google-server-client-id",
android = GoogleGatekeeperAndroidConfiguration(
useGoogleButtonFlow = true,
filterByAuthorizedAccounts = false,
autoSelectEnabled = true,
maxRetries = 3
)
),
AppleGatekeeperConfiguration(),
EmailPasswordGatekeeperConfiguration
)For example, if you only want to use the Google Gatekeeper, simply provide the GoogleGatekeeperConfiguration like this:
val gatekeeperConfigurations = listOf(
GoogleGatekeeperConfiguration(
serverClientId = "your-google-server-client-id",
android = GoogleGatekeeperAndroidConfiguration(
useGoogleButtonFlow = true,
filterByAuthorizedAccounts = false,
autoSelectEnabled = true,
maxRetries = 3
)
),
)[!IMPORTANT]
Replaceyour-google-server-client-idwith your actual Google serverClientId.
Initialize Passage in your common module entry point:
passage.initialize(gatekeepersConfigurations = gatekeeperConfigurations)[!NOTE]
If your app already uses Firebase, you can pass the existing Firebase instance to Passage to reuse it and prevent reinitializing Firebase unnecessarily:
passage.initialize(
gatekeepersConfigurations = gatekeeperConfigurations,
firebase = Firebase,
)When using Google gatekeeper on Android, you must now call bindToView() in Passage before performing any authentication operations. This ensures that Passage can access the Activity-based context needed to display the Google Sign-In UI.
@Composable
fun MyApp() {
val passage = { inject Passage }
passage.initialize(...)
passage.bindtoView() // <- Add this line when using Google gatekeeper on Android
}Use the provider-specific methods to authenticate users.
Passage#authenticateWithGoogle() authenticates a user via Google Sign-In. If the user does not already exist, a new account will be created automatically.
val result = passage.authenticateWithGoogle()
result.fold(
onSuccess = { entrant -> Log.d("Passage", "Welcome, ${entrant.displayName}") },
onFailure = { error -> Log.e("Passage", "Authentication failed", error) }
)Passage#authenticateWithApple() authenticates a user via Apple Sign-In. If the user does not already exist, a new account will be created automatically.
[!WARNING]
Apple Sign-In only works on iOS. Passage does not handle Apple Sign-In on Android, since this scenario is quite uncommon.
val result = passage.authenticateWithApple()
// Handle result similarlyCreating a user with email & password will automatically authenticate the user upon successful account creation.
val result = passage.createUserWithEmailAndPassword(PassageEmailAuthParams(email, password))
// Handle result similarlyval result = passage.authenticateWithEmailAndPassword(PassageEmailAuthParams(email, password))
// Handle result similarlyYou can send an email with a sign-in link to the user's email adress:
val result = passage.sendSignInLinkToEmail(
params = PassageSignInLinkToEmailParams(
email = userEmail, // Ask the user for its email address
url = "https://passagesample.web.app/action/sign_in_link_email",
iosParams = PassageSignInLinkToEmailIosParams(bundleId = "com.tweener.passage.sample"),
androidParams = PassageSignInLinkToEmailAndroidParams(
packageName = "com.tweener.passage.sample",
installIfNotAvailable = true,
minimumVersion = "1.0",
),
canHandleCodeInApp = true,
)
)
result.fold(
onSuccess = { entrant -> Log.d("Passage", "An email has been sent to the user with a sign-in link.") },
onFailure = { error -> Log.e("Passage", "Couldn't send the email", error) }
)Once the user clicks on this link from the email, it will redirect to your app and automatically sign-in the user:
passage.handleSignInLinkToEmail(email = "{Your email address}", emailLink = it.link)
.onSuccess { entrant = it } // User is sucessfully signed-in
.onFailure { println("Couldn't sign-in the user, error: ${it.message}") }passage.signOut()
passage.reauthenticateWithGoogle()
passage.reauthenticateWithApple()
passage.reauthenticateWithEmailAndPassword(params)You can retrieve the Firebase ID token for the currently authenticated user. This is useful for authenticating requests to your backend server.
val result = passage.getIdToken(forceRefresh = false)
result.fold(
onSuccess = { token -> Log.d("Passage", "ID Token: $token") },
onFailure = { error -> Log.e("Passage", "Failed to get ID token", error) }
)[!NOTE] Set
forceRefresh = trueto force a refresh of the ID token, which is useful when the token might be expired or you need the latest claims.
You may need to send emails to the user for a password reset if the user forgot their password for instance, or for verifying the user's email address when creating an account.
[!IMPORTANT] Passage uses Firebase Hosting domains to send emails containing universal links for authentication flows. You need to configure your app to handle Firebase Hosting links (e.g.,
PROJECT_ID.web.apporPROJECT_ID.firebaseapp.com).
To handle universal links, additional configuration is required for each platform:
In your activity configured to be open when a universal link is clicked:
class MainActivity : ComponentActivity() {
private val passage = providePassage()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleUniversalLink(intent = intent)
// ...
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleUniversalLink(intent = intent)
// ...
}
private fun handleUniversalLink(intent: Intent) {
intent.data?.let {
passage.handleUrl(url = it.toString())
}
}
}Create a class PassageHelper in your iosMain module:
class PassageHelper {
private val passage = providePassage()
fun handle(url: String): Boolean =
passage.handleUrl(url = url)
}Then, in your AppDelegate, add the following lines:
class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
// ...
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
if userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL {
if (PassageHelper().handle(url: url.absoluteString)) {
print("Handled by Passage")
}
return true
}
print("No valid URL in user activity.")
return false
}
}Passage exposes a universalLinkToHandle StateFlow, which you can use to be notified when a new unviersal link has been clicked and validated by Passage:
val link = passage.universalLinkToHandle.collectAsStateWithLifecycle()
LaunchedEffect(link.value) {
link.value?.let {
println("Universal link handled for mode: ${it.mode} with continueUrl: ${it.continueUrl}")
passage.onLinkHandled() // Important: call 'onLinkHandled()' to let Passage know the link has been handled and can update the authentication state
}
}If you want to reinforce authentication, you can send the user an email to verify its email address:
val result = passage.sendEmailVerification(
params = PassageEmailVerificationParams(
url = "https://passagesample.web.app/action/email_verified",
iosParams = PassageEmailVerificationIosParams(bundleId = "com.tweener.passage.sample"),
androidParams = PassageEmailVerificationAndroidParams(
packageName = "com.tweener.passage.sample",
installIfNotAvailable = true,
minimumVersion = "1.0",
),
canHandleCodeInApp = true,
)
)
result.fold(
onSuccess = { entrant -> Log.d("Passage", "An email has been sent to the user to verify its email address.") },
onFailure = { error -> Log.e("Passage", "Couldn't send the email", error) }
)If you want to reinforce authentication, you can send the user an email to verify its email address:
val result = passage.sendPasswordResetEmail(
params = PassageForgotPasswordParams(
email = passage.getCurrentUser()!!.email,
url = "https://passagesample.web.app/action/password_reset",
iosParams = PassageForgotPasswordIosParams(bundleId = "com.tweener.passage.sample"),
androidParams = PassageForgotPasswordAndroidParams(
packageName = "com.tweener.passage.sample",
installIfNotAvailable = true,
minimumVersion = "1.0",
),
canHandleCodeInApp = true,
)
)
result.fold(
onSuccess = { entrant -> Log.d("Passage", "An email has been sent to the user to reset its password.") },
onFailure = { error -> Log.e("Passage", "Couldn't send the email", error) }
)We love your input and welcome any contributions! Please read our contribution guidelines before submitting a pull request.
Passage is licensed under the Apache-2.0.