Repository: EnsembleUI/ensemble
Pull request #1891: Firebase custom token auth
Author: login: Umair-Manzoor-47
Created: 2025-02-26T21:40:16Z | Merged: 2025-03-11T10:07:36Z

Github Ticket : https://github.com/EnsembleUI/ensemble/issues/1703

1. Send credentials to backend. 
2. onComplete auth from backend send token to this `signInWithCustomToken` Action.
3. it Signs in Existing User if already exists or Creates a new one if User doesn't exists already.
4. User is now Authenticated and Signed in on firebase.

#### Attributes:
1. **token** :  Custom `jwtToken` that will be sent from backend minted with firebase admin SDK
2. **onComplete** : `Action` on signed in successfully. 
3. **onError** : `Action` on error on sign in.

#### Returns under event.data:

- Authenticated User as `user`
- firebase return token as `idToken`  

**Note**: firebase user `uid` is accessible under `event.data.user.id`

```
    signInWithCustomToken:
      token: {custom jwtToken} 
      onAuthenticated:
        executeCode:
          body: |
            ensemble.storage.userAuthId = event.data.idToken;
            console.log("ensemble.storage.userAuthId");
          onComplete:
            executeActionGroup:
              actions:
                - showToast:
                    message: ${ensemble.storage.userAuthId}


```



Diff:
diff --git a/modules/auth/lib/auth_module.dart b/modules/auth/lib/auth_module.dart
index 27f3a007f..d0f2df84d 100644
--- a/modules/auth/lib/auth_module.dart
+++ b/modules/auth/lib/auth_module.dart
@@ -8,6 +8,7 @@ import 'package:ensemble_auth/connect/widget/connect_with_google.dart';
 import 'package:ensemble_auth/connect/widget/connect_with_microsoft.dart';
 import 'package:ensemble_auth/signin/auth_manager.dart';
 import 'package:ensemble_auth/signin/sign_in_anonymous.dart';
+import 'package:ensemble_auth/signin/sign_in_with_custom_token.dart';
 import 'package:ensemble_auth/signin/widget/sign_in_with_apple.dart';
 // import 'package:ensemble_auth/signin/widget/sign_in_with_auth0.dart';
 import 'package:ensemble_auth/signin/widget/sign_in_with_google.dart';
@@ -26,5 +27,6 @@ class AuthModuleImpl implements AuthModule {
     GetIt.I.registerSingleton<TokenManager>(TokenManagerImpl());
     GetIt.I.registerFactory<OAuthController>(() => OAuthControllerImpl());
     GetIt.I.registerFactory<SignInAnonymous>(() => SignInAnonymousImpl());
+    GetIt.I.registerFactory<SignInWithCustomToken>(() => SignInWithCustomTokenImpl());
   }
 }
diff --git a/modules/auth/lib/signin/auth_manager.dart b/modules/auth/lib/signin/auth_manager.dart
index 69c63d98f..694545633 100644
--- a/modules/auth/lib/signin/auth_manager.dart
+++ b/modules/auth/lib/signin/auth_manager.dart
@@ -116,14 +116,8 @@ class AuthManager with UserAuthentication {
         print('Sign in anonymous failed');
         return null;
       }
-      Future<void> updateCurrentUser(BuildContext context, User newUser) async {
-        await StorageManager()
-            .writeToSystemStorage(UserAuthentication._idKey, newUser.uid);
-        await StorageManager()
-            .writeToSystemStorage(UserAuthentication._isAnonymous, true);
-      }
 
-      updateCurrentUser(context, user);
+      updateCurrentFirebaseUser(context, user);
 
       return user.isAnonymous;
     } catch (e) {
@@ -132,6 +126,37 @@ class AuthManager with UserAuthentication {
     }
   }
 
+  Future<String?> signInWithCustomToken(
+      BuildContext context,
+      {required String jwtToken}
+  ) async {
+    try {
+      customFirebaseApp ??= await _initializeFirebaseSignIn();
+      final _auth = FirebaseAuth.instanceFor(app: customFirebaseApp!);
+
+      UserCredential userCredential = await _auth.signInWithCustomToken(jwtToken);
+      User? user = userCredential.user;
+      if (user == null) {
+        print('Sign in with custom firebase auth token failed');
+        return null;
+      }
+      String? firebaseToken = await user.getIdToken();
+
+      updateCurrentFirebaseUser(context, user);
+      return firebaseToken;
+    } catch (e) {
+      print(e.toString());
+      return null;
+    }
+  }
+
+  Future<void> updateCurrentFirebaseUser(BuildContext context, User newUser) async {
+    await StorageManager()
+        .writeToSystemStorage(UserAuthentication._idKey, newUser.uid);
+    await StorageManager()
+        .writeToSystemStorage(UserAuthentication._isAnonymous, newUser.isAnonymous);
+  }
+
   Future<String?> _signInLocally(BuildContext context,
       {required AuthenticatedUser user, AuthToken? token}) async {
     // update the current user
diff --git a/modules/auth/lib/signin/sign_in_with_custom_token.dart b/modules/auth/lib/signin/sign_in_with_custom_token.dart
new file mode 100644
index 000000000..041945051
--- /dev/null
+++ b/modules/auth/lib/signin/sign_in_with_custom_token.dart
@@ -0,0 +1,26 @@
+import 'package:ensemble/framework/action.dart';
+import 'package:ensemble/framework/event.dart';
+import 'package:ensemble/framework/stub/auth_context_manager.dart';
+import 'package:ensemble/screen_controller.dart';
+import 'package:ensemble/widget/stub_widgets.dart';
+import 'package:ensemble_auth/signin/auth_manager.dart';
+import 'package:flutter/material.dart';
+
+class SignInWithCustomTokenImpl implements SignInWithCustomToken {
+  @override
+  Future<void> signInWithCustomToken(BuildContext context,
+      {required SignInWithCustomTokenAction action}) async {
+    final idToken = await AuthManager().signInWithCustomToken(context, jwtToken: action.jwtToken!);
+    if (idToken != null) {
+      if (action.onAuthenticated != null) {
+        AuthenticatedUser? currentUser = AuthManager().getCurrentUser();
+        ScreenController().executeAction(context, action.onAuthenticated!,
+            event: EnsembleEvent(null, data: {'user': currentUser, 'idToken': idToken}));
+      }
+    } else {
+      if (action.onError != null) {
+        ScreenController().executeAction(context, action.onError!);
+      }
+    }
+  }
+}
diff --git a/modules/ensemble/lib/framework/action.dart b/modules/ensemble/lib/framework/action.dart
index 440e08502..3c8b6b6ff 100644
--- a/modules/ensemble/lib/framework/action.dart
+++ b/modules/ensemble/lib/framework/action.dart
@@ -930,6 +930,28 @@ class SignInAnonymousAction extends EnsembleAction {
   }
 }
 
+class SignInWithCustomTokenAction extends EnsembleAction {
+  final EnsembleAction? onAuthenticated;
+  final EnsembleAction? onError;
+  final String? jwtToken;
+
+  SignInWithCustomTokenAction({
+    super.initiator,
+    super.inputs,
+    required this.onAuthenticated,
+    required this.onError,
+    this.jwtToken,
+  });
+
+  factory SignInWithCustomTokenAction.fromYaml({Map? payload}) {
+    return SignInWithCustomTokenAction(
+      onAuthenticated: EnsembleAction.from(payload?['onAuthenticated']),
+      onError: EnsembleAction.from(payload?['onError']),
+      jwtToken: payload?['token'],
+    );
+  }
+}
+
 class ClearKeychain extends EnsembleAction {
   ClearKeychain({
     required this.key,
@@ -1038,6 +1060,7 @@ enum ActionType {
   setLocale,
   clearLocale,
   signInAnonymous,
+  signInWithCustomToken,
   handleDeeplink,
   createDeeplink,
   verifySignIn,
@@ -1170,6 +1193,8 @@ abstract class EnsembleAction {
       return FileUploadAction.fromYaml(payload: payload);
     } else if (actionType == ActionType.signInAnonymous) {
       return SignInAnonymousAction.fromYaml(payload: payload);
+    } else if (actionType == ActionType.signInWithCustomToken) {
+      return SignInWithCustomTokenAction.fromYaml(payload: payload);
     } else if (actionType == ActionType.pickFiles) {
       return FilePickerAction.fromYaml(payload: payload);
     } else if (actionType == ActionType.openUrl) {
diff --git a/modules/ensemble/lib/module/auth_module.dart b/modules/ensemble/lib/module/auth_module.dart
index d187ef3e7..f638cf0e0 100644
--- a/modules/ensemble/lib/module/auth_module.dart
+++ b/modules/ensemble/lib/module/auth_module.dart
@@ -17,6 +17,7 @@ class AuthModuleStub implements AuthModule {
     GetIt.I.registerSingleton<TokenManager>(TokenManagerStub());
     GetIt.I.registerFactory<OAuthController>(() => OAuthControllerStub());
     GetIt.I.registerFactory<SignInAnonymous>(() => SignInAnonymousStub());
+    GetIt.I.registerFactory<SignInWithCustomToken>(() => SignInWithCustomTokenStub());
     // note that we don't inject AuthContextManagerStub(), since its presence
     // will prevent data_context to load
   }
diff --git a/modules/ensemble/lib/screen_controller.dart b/modules/ensemble/lib/screen_controller.dart
index 51bafc04d..17e741c3b 100644
--- a/modules/ensemble/lib/screen_controller.dart
+++ b/modules/ensemble/lib/screen_controller.dart
@@ -405,6 +405,8 @@ class ScreenController {
       GetIt.I<FileManager>().pickFiles(context, action, scopeManager);
     } else if (action is SignInAnonymousAction) {
       GetIt.I<SignInAnonymous>().signInAnonymously(context, action: action);
+    } else if (action is SignInWithCustomTokenAction) {
+      GetIt.I<SignInWithCustomToken>().signInWithCustomToken(context, action: action);
     } else if (action is WalletConnectAction) {
       //  TODO store session:  WalletConnectSession? session = await sessionStorage.getSession();
 
diff --git a/modules/ensemble/lib/widget/stub_widgets.dart b/modules/ensemble/lib/widget/stub_widgets.dart
index 397e905c4..d07951b95 100644
--- a/modules/ensemble/lib/widget/stub_widgets.dart
+++ b/modules/ensemble/lib/widget/stub_widgets.dart
@@ -68,3 +68,17 @@ class SignInAnonymousStub implements SignInAnonymous {
         "Auth is not enabled. Please review the Ensemble documentation.");
   }
 }
+
+abstract class SignInWithCustomToken {
+  Future<void> signInWithCustomToken(BuildContext context,
+      {required SignInWithCustomTokenAction action});
+}
+
+class SignInWithCustomTokenStub implements SignInWithCustomToken {
+  @override
+  Future<void> signInWithCustomToken(BuildContext context,
+      {required SignInWithCustomTokenAction action}) {
+    throw ConfigError(
+        "Auth is not enabled. Please review the Ensemble documentation.");
+  }
+}
\ No newline at end of file


Reviews:
user: login: sharjeelyunus
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-02T18:40:13Z
user: login: TheNoumanDev
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-03-03T00:33:54Z
user: login: TheNoumanDev
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-09T23:11:18Z
user: login: TheNoumanDev
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-09T23:11:27Z
user: login: TheNoumanDev
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-10T17:05:46Z

Review comments:
diff_hunk: @@ -0,0 +1,26 @@
+import 'package:ensemble/framework/action.dart';
+import 'package:ensemble/framework/event.dart';
+import 'package:ensemble/framework/stub/auth_context_manager.dart';
+import 'package:ensemble/screen_controller.dart';
+import 'package:ensemble/widget/stub_widgets.dart';
+import 'package:ensemble_auth/signin/auth_manager.dart';
+import 'package:flutter/material.dart';
+
+class SignInWithCustomTokenImpl implements SignInWithCustomToken {
+  @override
+  Future<void> signInWithCustomToken(BuildContext context,
+      {required SignInWithCustomTokenAction action}) async {
+    final token = await AuthManager().signInWithCustomToken(context,idToken: action.idToken!);
+    if (token != null) {
+      if (action.onAuthenticated != null) {
+        AuthenticatedUser? currentUser = AuthManager().getCurrentUser();
+        ScreenController().executeAction(context, action.onAuthenticated!,
+            event: EnsembleEvent(null, data: {'user': currentUser, 'token': token}));
path: modules/auth/lib/signin/sign_in_with_custom_token.dart
user: login: sharjeelyunus
body: please expose `token` as `idToken` to be consistent with other auth actions
created_at: 2025-03-02T18:40:13Z
author_association: MEMBER
diff_hunk: @@ -132,6 +132,36 @@ class AuthManager with UserAuthentication {
     }
   }
 
+  Future<String?> signInWithCustomToken(
+      BuildContext context,
+      {required String idToken}
+  ) async {
+    try {
+      customFirebaseApp ??= await _initializeFirebaseSignIn();
+      final _auth = FirebaseAuth.instanceFor(app: customFirebaseApp!);
+
+      UserCredential userCredential = await _auth.signInWithCustomToken(idToken);
path: modules/auth/lib/signin/auth_manager.dart
user: login: TheNoumanDev
body: in `UserCredential ` we are getting `AuthCredential` and `AdditionalUserInfo` as well along `user` info, so can you please also get them if they are not null and store it in [`data`](https://github.com/EnsembleUI/ensemble/blob/3375794044d3e1f7951d353f6594ce6767ce99ca/modules/ensemble/lib/framework/stub/auth_context_manager.dart#L126) property of `AuthenticatedUser`.
created_at: 2025-03-03T00:33:28Z
author_association: MEMBER
diff_hunk: @@ -132,6 +132,36 @@ class AuthManager with UserAuthentication {
     }
   }
 
+  Future<String?> signInWithCustomToken(
+      BuildContext context,
+      {required String idToken}
+  ) async {
+    try {
+      customFirebaseApp ??= await _initializeFirebaseSignIn();
+      final _auth = FirebaseAuth.instanceFor(app: customFirebaseApp!);
+
+      UserCredential userCredential = await _auth.signInWithCustomToken(idToken);
+      User? user = userCredential.user;
+      if (user == null) {
+        print('Sign in with custom firebase auth token failed');
+        return null;
+      }
+      String? firebaseToken = await user.getIdToken();
+      Future<void> updateCurrentUser(BuildContext context, User newUser) async {
path: modules/auth/lib/signin/auth_manager.dart
user: login: TheNoumanDev
body: Before merging, please create a common method for `updateCurrentUser` as the same method is being used in `signInAnonymously`
Plus, if possible, add a line description of some complex code so that it would be easy for someone to understand in the future
created_at: 2025-03-09T23:11:18Z
author_association: MEMBER

PR comments:
user: login: amin-nas
created_at: 2025-02-27T20:10:51Z
body: @Umair-Manzoor-47 let's rename this to `signInWithCustomToken`
Also, we need to have the id token available in `event.data.user`. Since we will be calling secured cloud functions, we need to pass this token in the header. This token should be available for all methods of firebase auth supported by Ensemble.
author_association: CONTRIBUTOR
user: login: Umair-Manzoor-47
created_at: 2025-02-28T11:41:47Z
body: @amin-nas 
For clarification 
`event.data.user` must have `tokenId` sent from backend? 
OR `UUID` that is sent from **firebase** after user is **authenticated** and signed in. 
Now `event.data.user` already has **UUID**. 
author_association: CONTRIBUTOR
user: login: sharjeelyunus
created_at: 2025-03-10T02:33:38Z
body: @Umair-Manzoor-47 can you confirm what type of token user has to provide to use this action? Will it be a identity token? Or a JWT token?
author_association: MEMBER
user: login: Umair-Manzoor-47
created_at: 2025-03-10T10:12:55Z
body: @sharjeelyunus 
It will be `JWT` token but it must be minted with some `firebase attributes` required for auth. 
as mentioned here: [Firebase Custom JWT token](https://firebase.google.com/docs/auth/admin/create-custom-tokens#java_1)
author_association: CONTRIBUTOR
user: login: sharjeelyunus
created_at: 2025-03-10T10:35:25Z
body: then why are we asking user to provide `idToken` in the edl?
author_association: MEMBER