custom-login is a Kotlin Multiplatform library that provides a complete, production-ready authentication module for Android and iOS apps. It ships with all screens, navigation, ViewModels, validation, and error handling already built — you configure the providers you need and optionally replace any UI component with your own.
It is designed to be the standard authentication baseline for any new KMP project: drop it in, wire up Firebase, and have a fully working login system in minutes.
| Platform | Min version | Notes |
|---|---|---|
| Android | API 24 (Android 7.0) | Tested up to API 36 |
| iOS | iOS 15 | Arm64 + Simulator Arm64 |
| Technology | Version | Role |
|---|---|---|
| Kotlin | 2.3.0 | Language |
| Kotlin Multiplatform | 2.3.0 | Shared code for Android + iOS |
| Compose Multiplatform | 1.10.0 | Declarative UI on both platforms |
| Material 3 | (via Compose MP) | Design system |
| Technology | Version | Role |
|---|---|---|
| Firebase Authentication | BOM 33.7.0 | Auth backend |
| GitLive Firebase SDK | 2.4.0 | KMP wrapper for Firebase |
| Google Sign-In (Android) | Credential Manager 1.5.0 | Native Google sign-in on Android |
| Technology | Version | Role |
|---|---|---|
| Koin | 4.1.1 | Dependency injection |
| Navigation Compose | 2.9.1 | In-app navigation |
| Lifecycle ViewModel | 2.9.6 | MVI ViewModels |
| Technology | Version | Role |
|---|---|---|
| Coil | 3.3.0 | Image loading (KMP) |
| kotlinx.serialization | 1.10.0 | Route serialization for navigation |
| kotlinx.coroutines | 1.10.2 | Async / Flow |
| Technology | Version | Role |
|---|---|---|
| kotlin.test | 2.3.0 | Multiplatform unit tests |
| kotlinx-coroutines-test | 1.10.2 | ViewModel / Flow testing |
| Provider | Android | iOS | Method |
|---|---|---|---|
| Email / Password | ✅ | ✅ | Firebase built-in |
| ✅ | ✅ | Credential Manager (Android) / GIDSignIn (iOS) | |
| Apple | — | ✅ | AuthenticationServices (iOS only) |
| GitHub | ✅ | ✅ | Firebase web OAuth |
| Microsoft | ✅ | ✅ | Firebase web OAuth |
| Twitter / X | ✅ | ✅ | Firebase web OAuth |
| ✅ | ✅ | Firebase web OAuth | |
| Phone OTP | ✅ | ✅ | Firebase PhoneAuthProvider |
| Magic Link | ✅ | ✅ | Firebase email link |
All providers are opt-in via LoginLibraryConfig. Disabled providers are not shown in the UI.
| Screen | Description |
|---|---|
| Welcome | Entry point with Login / Register options |
| Login | Email+password sign-in + social providers |
| Register | Account creation with validation |
| Forgot Password | Sends a reset email |
| Reset Password | Confirms new password with the reset code |
| Phone Auth | Phone number entry + SMS OTP verification |
| Magic Link | Passwordless email link sign-in |
| Re-authentication | Confirm identity before sensitive operations |
- Features
- Architecture Overview
- Prerequisites
- Project Setup
- Initialization
- Integrating the Navigation Flow
- Provider Configuration
- iOS Platform Setup
- Customizing the UI — Slots System
- Re-authentication Screen
- AuthRepository Public API
- Error Handling
- Localization
- Module Structure
- Email / Password sign-in and registration
- Social sign-in: Google, Apple, GitHub, Microsoft, Twitter/X, Facebook
- Passwordless: Phone OTP and Magic Link (email)
- Re-authentication screen for sensitive operations
- Password reset (forgot + reset flows)
- Fully customizable UI via a slots system — replace any component without touching the library
- MVI architecture per screen (Action → ViewModel → UiState + Effect)
- Typed error handling via
AuthErrorsealed class - Full localization support (EN, ES, FR, IT, PT)
- Edge-to-edge display with proper insets handling
custom-login/
├── domain/
│ ├── AuthProvider.kt ← Interface for auth backends (Firebase, Supabase, etc.)
│ ├── AuthRepository.kt ← Public API consumed by the host app and ViewModels
│ └── model/ ← AuthResult, AuthError, Credentials, IdentityProvider…
├── data/
│ ├── FirebaseAuthProvider.kt ← Firebase implementation of AuthProvider
│ ├── AuthRepositoryImpl.kt ← Delegates to AuthProvider; reads config
│ └── DataMapper.kt ← Maps Firebase exceptions to typed AuthError
├── di/
│ ├── KoinInitializer.kt ← initLoginKoin() entry point
│ ├── LoginLibraryConfig.kt ← All feature flags and provider configs
│ ├── DataModule.kt
│ └── PresentationModule.kt
└── presentation/
├── screens/ ← One folder per screen (MVI: Action/UiState/Effect/VM/Screen)
├── slots/ ← AuthScreenSlots + per-screen slots data classes
│ └── defaultslots/ ← Default Composable implementations
└── navigation/
└── RootNavGraph.kt ← authRoutesFlow() extension on NavGraphBuilder
Each screen follows the same MVI pattern:
| File | Role |
|---|---|
XxxAction |
All user inputs — sealed interface |
XxxUiState |
Persistent state driving recomposition |
XxxEffect |
One-time events (navigation, snackbars) |
XxxViewModel |
Processes actions, updates state, emits effects |
XxxScreen |
Composable — renders state, forwards actions |
- Firebase project with Authentication enabled and the desired sign-in methods activated in the Firebase console.
- Add
google-services.json(Android) andGoogleService-Info.plist(iOS) to your project. - Koin dependency injection configured in the host app (the library registers its own modules via
initLoginKoin).
settings.gradle.kts — include the module:
include(":custom-login")build.gradle.kts (app / composeApp module):
dependencies {
implementation(project(":custom-login"))
}The library's own dependencies (Firebase, Koin, Compose, etc.) are defined in custom-login/build.gradle.kts and are transitively available.
Call initLoginKoin once at app startup, before any Composable is shown. Pass a LoginLibraryConfig with the providers you want to enable.
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
initLoginKoin(
config = LoginLibraryConfig(
googleSignInConfig = GoogleSignInConfig(
webClientId = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com",
iosClientId = "YOUR_IOS_CLIENT_ID.apps.googleusercontent.com" // optional
),
appleSignInConfig = AppleSignInConfig(), // iOS only
githubEnabled = true,
microsoftEnabled = true,
twitterEnabled = false,
facebookEnabled = false,
phoneEnabled = true,
magicLinkConfig = MagicLinkConfig(
continueUrl = "https://yourapp.page.link/signin",
iosBundleId = "com.yourcompany.yourapp"
)
)
) {
// Optional: additional Koin modules from the host app
androidContext(this@MyApplication)
modules(yourAppModule)
}
}
}fun MainViewController() = ComposeUIViewController {
initLoginKoin(
config = LoginLibraryConfig(
googleSignInConfig = GoogleSignInConfig(
webClientId = "YOUR_WEB_CLIENT_ID.apps.googleusercontent.com",
iosClientId = "YOUR_IOS_CLIENT_ID.apps.googleusercontent.com"
),
appleSignInConfig = AppleSignInConfig(),
githubEnabled = true,
phoneEnabled = true,
)
)
App()
}The library exposes authRoutesFlow, a NavGraphBuilder extension. Add it to your existing NavHost:
@Composable
fun AppNavHost() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "auth") {
authRoutesFlow(
navController = navController,
onNavigateToHome = {
navController.navigate("home") {
popUpTo("auth") { inclusive = true }
}
}
// slots = AuthScreenSlots() ← optional, see Slots section
)
composable("home") { HomeScreen() }
}
}The auth flow contains: Welcome → Login / Register → Forgot Password → Reset Password → Phone Auth → Magic Link. Navigation between them is handled internally.
All provider flags live in LoginLibraryConfig. Providers not configured are simply not shown in the UI.
LoginLibraryConfig(
googleSignInConfig = GoogleSignInConfig(
webClientId = "123456789-abc.apps.googleusercontent.com", // required on both platforms
iosClientId = "123456789-ios.apps.googleusercontent.com" // required for iOS
)
)Get the Web Client ID from the Firebase console → Authentication → Sign-in method → Google → Web SDK configuration.
Android — uses the Credential Manager API. No extra code required. iOS — requires a Swift handler. See Google (iOS).
LoginLibraryConfig(
appleSignInConfig = AppleSignInConfig(
scopes = listOf("email", "name") // default
)
)Apple Sign-In is iOS only. It will not appear as a provider on Android. Requires the Sign in with Apple capability in Xcode and the entitlement in your app. iOS — requires a Swift handler with nonce. See Apple (iOS).
LoginLibraryConfig(githubEnabled = true)Enable GitHub in the Firebase console (Authentication → Sign-in method → GitHub) and provide your GitHub OAuth App credentials there.
Android — handled via Firebase web OAuth (Chrome Custom Tab). No extra code required. iOS — requires a Swift handler. See GitHub / Microsoft / Twitter / Facebook (iOS).
LoginLibraryConfig(microsoftEnabled = true)Enable Microsoft in Firebase console. Android — Firebase web OAuth. No extra code required. iOS — requires a Swift handler. See GitHub / Microsoft / Twitter / Facebook (iOS).
LoginLibraryConfig(twitterEnabled = true)Enable Twitter in Firebase console and add your Twitter API key and secret. Android — Firebase web OAuth. No extra code required. iOS — requires a Swift handler. See GitHub / Microsoft / Twitter / Facebook (iOS).
LoginLibraryConfig(facebookEnabled = true)Enable Facebook in Firebase console. You also need a Facebook Developer App with the correct OAuth redirect URI configured (https://<project-id>.firebaseapp.com/__/auth/handler).
Android — Firebase web OAuth. No extra code required.
iOS — requires a Swift handler. See GitHub / Microsoft / Twitter / Facebook (iOS).
LoginLibraryConfig(phoneEnabled = true) // defaultEnable Phone in Firebase console. The library provides a full Phone Auth screen with country code picker and OTP verification step.
Android — uses Firebase native PhoneAuthProvider with SIM-based instant verification support.
iOS — requires two Swift handlers. See Phone OTP (iOS).
LoginLibraryConfig(
magicLinkConfig = MagicLinkConfig(
continueUrl = "https://yourapp.page.link/signin", // App Link / Universal Link
iosBundleId = "com.yourcompany.yourapp" // required for iOS
)
)Enable Email link (passwordless) in Firebase console.
The flow: user enters email → Firebase sends a link → user taps it → app opens via deep link → call authRepository.signInWithMagicLink(email, link).
You must:
- Set up App Links (Android) or Universal Links (iOS) for
continueUrl. - In the Activity/scene that receives the link, call:
// Android — in Activity.onNewIntent or similar
val link = intent.data?.toString()
if (link != null) {
val email = preferences.getString("pending_magic_link_email", null)
if (email != null) {
authRepository.signInWithMagicLink(email, link)
}
}The library's Kotlin side is complete. iOS providers use a callback pattern: Kotlin suspends the coroutine and waits for Swift to execute the native sign-in and call back with the result.
Set up all handlers before the first Composable renders, typically in AppDelegate or immediately in MainViewController.
import GoogleSignIn
// In AppDelegate.application(_:didFinishLaunchingWithOptions:) or equivalent:
GoogleSignInProviderIOS.companion.signInHandler = { clientId, completion in
guard let clientId = clientId,
let rootVC = UIApplication.shared.connectedScenes
.compactMap({ ($0 as? UIWindowScene)?.keyWindow?.rootViewController })
.first else {
completion(nil)
return
}
let config = GIDConfiguration(clientID: clientId)
GIDSignIn.sharedInstance.configuration = config
GIDSignIn.sharedInstance.signIn(withPresenting: rootVC) { result, error in
guard let user = result?.user, error == nil else {
completion(nil)
return
}
// Combine idToken and accessToken with the "|||accessToken|||" separator
let idToken = user.idToken?.tokenString ?? ""
let accessToken = user.accessToken.tokenString
completion("\(idToken)|||accessToken|||\(accessToken)")
}
}Apple Sign-In requires a cryptographic nonce to prevent replay attacks.
import AuthenticationServices
import CryptoKit
class AppleSignInDelegate: NSObject, ASAuthorizationControllerDelegate,
ASAuthorizationControllerPresentationContextProviding {
private var currentRawNonce: String?
private var completion: ((String?) -> Void)?
func setup() {
AppleSignInProviderIOS.companion.signInHandler = { [weak self] _, completion in
self?.completion = completion
self?.startSignIn()
}
}
private func startSignIn() {
let rawNonce = randomNonceString()
currentRawNonce = rawNonce
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
request.nonce = sha256(rawNonce)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let tokenData = credential.identityToken,
let idToken = String(data: tokenData, encoding: .utf8) else {
completion?(nil); return
}
let rawNonce = currentRawNonce ?? ""
// Pass token with the "|||rawNonce|||" separator
let combined = rawNonce.isEmpty ? idToken : "\(idToken)|||rawNonce|||\(rawNonce)"
completion?(combined)
completion = nil
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
completion?(nil)
completion = nil
}
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.keyWindow }
.first!
}
// Nonce helpers
private func randomNonceString(length: Int = 32) -> String {
let charset = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
var result = ""
var remainingLength = length
while remainingLength > 0 {
var randoms = [UInt8](repeating: 0, count: 16)
SecRandomCopyBytes(kSecRandomDefault, randoms.count, &randoms)
randoms.forEach { random in
if remainingLength == 0 { return }
if random < charset.count { result.append(charset[Int(random)]); remainingLength -= 1 }
}
}
return result
}
private func sha256(_ input: String) -> String {
let data = Data(input.utf8)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
}These four providers share the same pattern. Firebase handles the full OAuth flow from Swift. The Kotlin library only needs to know when it is complete.
Replace "github.com" with "microsoft.com", "twitter.com", or "facebook.com" as needed.
import FirebaseAuth
// GitHub example — call this at app startup
GitHubSignInProviderIOS.companion.signInHandler = { _, completion in
let provider = OAuthProvider(providerID: "github.com")
provider.scopes = ["user:email"]
provider.getCredentialWith(nil) { credential, error in
guard let credential = credential, error == nil else {
completion(nil); return
}
Auth.auth().signIn(with: credential) { _, error in
// Tell Kotlin that Firebase sign-in is complete
completion(error == nil ? "___PLATFORM_AUTH_COMPLETE___" : nil)
}
}
}
// Microsoft
MicrosoftSignInProviderIOS.companion.signInHandler = { _, completion in
let provider = OAuthProvider(providerID: "microsoft.com")
provider.scopes = ["email", "profile"]
// Optional: provider.customParameters = ["tenant": "your-tenant-id"]
provider.getCredentialWith(nil) { credential, error in
guard let credential = credential, error == nil else { completion(nil); return }
Auth.auth().signIn(with: credential) { _, error in
completion(error == nil ? "___PLATFORM_AUTH_COMPLETE___" : nil)
}
}
}
// Twitter
TwitterSignInProviderIOS.companion.signInHandler = { _, completion in
let provider = OAuthProvider(providerID: "twitter.com")
provider.getCredentialWith(nil) { credential, error in
guard let credential = credential, error == nil else { completion(nil); return }
Auth.auth().signIn(with: credential) { _, error in
completion(error == nil ? "___PLATFORM_AUTH_COMPLETE___" : nil)
}
}
}
// Facebook
FacebookSignInProviderIOS.companion.signInHandler = { _, completion in
let provider = OAuthProvider(providerID: "facebook.com")
provider.scopes = ["email", "public_profile"]
provider.getCredentialWith(nil) { credential, error in
guard let credential = credential, error == nil else { completion(nil); return }
Auth.auth().signIn(with: credential) { _, error in
completion(error == nil ? "___PLATFORM_AUTH_COMPLETE___" : nil)
}
}
}The sentinel string
"___PLATFORM_AUTH_COMPLETE___"tells the Kotlin layer that Firebase sign-in was already completed by Swift, so it only needs to refresh the session.
Phone auth requires two handlers — one for sending the code and one for verifying it.
import FirebaseAuth
// Handler 1: send the OTP
PhoneAuthProviderIOS.companion.sendCodeHandler = { phoneNumber, completion in
PhoneAuthProvider.provider().verifyPhoneNumber(phoneNumber, uiDelegate: nil) { verificationId, error in
completion(verificationId) // nil on failure
}
}
// Handler 2: verify the OTP and sign in
PhoneAuthProviderIOS.companion.verifyCodeHandler = { verificationId, smsCode, completion in
let credential = PhoneAuthProvider.provider()
.credential(withVerificationID: verificationId, verificationCode: smsCode)
Auth.auth().signIn(with: credential) { result, error in
completion(result?.user.uid) // nil on failure
}
}Every screen exposes a *ScreenSlots data class. Pass your own Composables for any slot you want to replace; all others fall back to the built-in defaults.
val mySlots = AuthScreenSlots(
login = LoginScreenSlots(
// Replace the header with your own logo
header = {
Image(painter = painterResource(R.drawable.my_logo), contentDescription = null)
},
// Replace the submit button with a branded button
submitButton = { onClick, isLoading, enabled, text ->
MyBrandedButton(onClick = onClick, loading = isLoading, enabled = enabled, label = text)
}
// All other slots use defaults
)
)Then pass mySlots to authRoutesFlow:
authRoutesFlow(
navController = navController,
slots = mySlots,
onNavigateToHome = { /* ... */ }
)| Screen | Replaceable slots |
|---|---|
| Login | header, emailField, passwordField, submitButton, socialProviders, forgotPasswordLink, registerLink, footer |
| Register | header, nameField, emailField, passwordField, confirmPasswordField, termsCheckbox, submitButton, socialProviders, loginLink, logo, footer |
| Forgot Password | header, description, emailField, submitButton, successContent |
| Reset Password | header, description, passwordField, confirmPasswordField, submitButton, successContent |
| Phone Auth | phoneHeader, phoneDescription, phoneField, sendCodeButton, otpHeader, otpDescription, otpField, verifyButton |
| Magic Link | header, description, emailField, submitButton, successContent |
| Re-auth | header, description, emailField, passwordField, errorMessage, submitButton, socialSection |
All submitButton slots share the same signature:
(onClick: () -> Unit, isLoading: Boolean, enabled: Boolean, text: String) -> UnitAll socialProviders slots share the same signature:
(providers: List<IdentityProvider>, loadingProvider: IdentityProvider?, onProviderClick: (IdentityProvider) -> Unit) -> UnitThe library includes a re-authentication screen for sensitive operations (delete account, change email/password). It is not part of the main authRoutesFlow — the host app launches it independently when needed.
// Add to your own NavGraph
composable("reauth") {
ReauthScreen(
slots = mySlots.reauth,
onReauthSuccess = { navController.navigate("delete_account") },
onNavigateBack = { navController.popBackStack() }
)
}Inject AuthRepository anywhere in your app (via Koin) to interact with auth state programmatically.
class MyViewModel(private val authRepository: AuthRepository) : ViewModel() {
// Observe auth state changes
val authState = authRepository.observeAuthState() // Flow<AuthState>
// Check sign-in status
suspend fun checkSession() = authRepository.isSignedIn()
// Get current session (refreshes token)
suspend fun getUser() = authRepository.getCurrentSession() // UserSession?
// Get ID token for backend verification
suspend fun getToken() = authRepository.getIdToken(forceRefresh = false)
// Sign out
suspend fun signOut() = authRepository.signOut()
// Account management
suspend fun deleteAccount() = authRepository.deleteAccount()
suspend fun updateDisplayName(name: String) = authRepository.updateDisplayName(name)
suspend fun updateEmail(email: String) = authRepository.updateEmail(email)
suspend fun updatePassword(pass: String) = authRepository.updatePassword(pass)
suspend fun sendEmailVerification() = authRepository.sendEmailVerification()
// Re-authenticate before sensitive operations
suspend fun reauth(email: String, pass: String) =
authRepository.reauthenticate(Credentials.EmailPassword(email, pass))
// Complete Magic Link sign-in (call from deep link handler)
suspend fun completeMagicLink(email: String, link: String) =
authRepository.signInWithMagicLink(email, link)
}sealed class AuthState {
object Loading : AuthState()
object Unauthenticated : AuthState()
data class Authenticated(val session: UserSession) : AuthState()
data class Error(val error: AuthError) : AuthState()
}All auth operations return typed results — no raw exceptions propagate to the UI layer.
when (val result = authRepository.signIn(credentials)) {
is AuthResult.Success -> { /* result.session: UserSession */ }
is AuthResult.Failure -> {
when (result.error) {
is AuthError.InvalidCredentials -> showError("Wrong email or password")
is AuthError.UserNotFound -> showError("No account with that email")
is AuthError.NetworkError -> showError("Check your connection")
is AuthError.TooManyRequests -> showError("Too many attempts, try later")
else -> showError(result.error.message)
}
}
AuthResult.RequiresEmailVerification -> showError("Please verify your email first")
AuthResult.PasswordResetSent -> showSuccess("Reset email sent")
AuthResult.PasswordResetSuccess -> navigateToLogin()
AuthResult.MagicLinkSent -> showSuccess("Check your inbox")
}| Error | Cause |
|---|---|
InvalidCredentials |
Wrong password or unified credential error |
UserNotFound |
No account with that email |
EmailAlreadyInUse |
Email already registered |
WeakPassword |
Password too short/simple |
InvalidEmail |
Malformed email address |
InvalidResetCode |
Reset link expired or already used |
TooManyRequests |
Rate-limited by Firebase |
UserDisabled |
Account disabled in Firebase console |
OperationNotAllowed |
Sign-in method not enabled in Firebase |
NetworkError |
No connectivity or request timeout |
SessionExpired |
Token expired, user needs to sign in again |
RequiresEmailVerification |
Account exists but email not verified |
PhoneNumberInvalid |
Malformed E.164 phone number |
InvalidVerificationCode |
Wrong or expired SMS OTP |
Unknown |
Unrecognised Firebase error |
The library ships with strings in 5 languages: English (default), Spanish, French, Italian, Portuguese.
String resources are in custom-login/src/commonMain/composeResources/:
values/strings.xml(EN)values-es/strings.xml(ES)values-fr/strings.xml(FR)values-it/strings.xml(IT)values-pt/strings.xml(PT)
The active locale is picked up automatically from the device language. To add a new language, create a new values-xx/strings.xml with all keys from the default values/strings.xml.
custom-login/
└── src/
├── commonMain/ ← All shared Kotlin: domain, data, presentation, DI
│ ├── composeResources/
│ │ └── values[-xx]/ ← String resources (5 locales)
│ └── kotlin/com/apptolast/customlogin/
│ ├── config/ ← GoogleSignInConfig, AppleSignInConfig, MagicLinkConfig
│ ├── data/ ← FirebaseAuthProvider, AuthRepositoryImpl, DataMapper
│ ├── di/ ← KoinInitializer, LoginLibraryConfig, DataModule, PresentationModule
│ ├── domain/ ← AuthProvider, AuthRepository interfaces; model classes
│ ├── presentation/
│ │ ├── navigation/ ← authRoutesFlow, route objects
│ │ ├── screens/ ← login, register, forgotpassword, resetpassword,
│ │ │ phone, magiclink, reauth, welcome
│ │ ├── slots/ ← AuthScreenSlots + per-screen slots
│ │ │ └── defaultslots/ ← Default Composable implementations
│ │ └── util/ ← AuthErrorExt (toStringRes)
│ └── util/ ← Logger (expect/actual), Validators, ValidationError(Ext)
├── androidMain/ ← Android implementations
│ └── kotlin/com/apptolast/customlogin/
│ ├── Platform.android.kt ← getSocialIdToken, phone auth (actual)
│ ├── provider/
│ │ ├── GoogleSignInProviderAndroid.kt ← Credential Manager API
│ │ └── WebOAuthProviderAndroid.kt ← Firebase web OAuth for all others
│ └── util/Logger.android.kt
└── iosMain/ ← iOS implementations
└── kotlin/com/apptolast/customlogin/
├── Platform.ios.kt ← getSocialIdToken, phone auth (actual)
├── provider/
│ ├── GoogleSignInProviderIOS.kt
│ ├── AppleSignInProviderIOS.kt
│ ├── GitHubSignInProviderIOS.kt
│ ├── MicrosoftSignInProviderIOS.kt
│ ├── TwitterSignInProviderIOS.kt
│ └── FacebookSignInProviderIOS.kt
├── data/PhoneAuthProviderIOS.kt
└── util/Logger.ios.kt
composeApp/ ← Sample consumer app (Android + iOS)