From 8a0c9f76f2651c59c056060a070d6cfb6f5e665f Mon Sep 17 00:00:00 2001 From: ichan-mb Date: Sat, 8 Aug 2020 08:36:10 +0700 Subject: [PATCH 1/2] implement Platform SignIn --- Source/Fuse.Auth/Fuse.Auth.unoproj | 7 +- Source/Fuse.Auth/SignInModule.uno | 129 +++++++++ Source/Fuse.Auth/SignInProvider.uno | 267 ++++++++++++++++++ Source/Fuse.Auth/iOS/SignInHelper.h | 15 + Source/Fuse.Auth/iOS/SignInHelper.mm | 110 ++++++++ .../triggers/actions/SignInAction.uno | 157 ++++++++++ 6 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 Source/Fuse.Auth/SignInModule.uno create mode 100644 Source/Fuse.Auth/SignInProvider.uno create mode 100644 Source/Fuse.Auth/iOS/SignInHelper.h create mode 100644 Source/Fuse.Auth/iOS/SignInHelper.mm create mode 100644 Source/Fuse.Auth/triggers/actions/SignInAction.uno diff --git a/Source/Fuse.Auth/Fuse.Auth.unoproj b/Source/Fuse.Auth/Fuse.Auth.unoproj index 9c81e30fd..d51108530 100644 --- a/Source/Fuse.Auth/Fuse.Auth.unoproj +++ b/Source/Fuse.Auth/Fuse.Auth.unoproj @@ -11,12 +11,17 @@ "../Fuse/Fuse.unoproj" ], "Includes": [ - "*" + "*", + "iOS/SignInHelper.h:ObjCHeader:iOS", + "iOS/SignInHelper.mm:ObjCSource:iOS" ], "iOS": { "PList": { "NSFaceIDUsageDescription": "Require access to FaceID for authenticating" }, + "SystemCapabilities": { + "SignInWithApple": true + } } } diff --git a/Source/Fuse.Auth/SignInModule.uno b/Source/Fuse.Auth/SignInModule.uno new file mode 100644 index 000000000..000f74d36 --- /dev/null +++ b/Source/Fuse.Auth/SignInModule.uno @@ -0,0 +1,129 @@ +using Uno; +using Uno.UX; +using Fuse; +using Fuse.Scripting; +using Uno.Threading; + +namespace Fuse +{ + [UXGlobalModule] + /** + Javascript Module for taking Platform SignIn. Platform SignIn is a SignIn mechanism that use `Sign In With Apple` on iOS and `Google SignIn` on Android. + + Platform SignIn is only available on the mobile target platform (iOS and Android). + + You need to add a reference to `"Fuse.Auth"` in your project file to use this feature. + + > For more information on what are the pre-request when implementing `Sign In With Apple` or `Google Sign In`, you can check the documentation on the apple developer website or android developer website + > for iOS add "SystemCapabilities": { "SignInWithApple":true } in the unoproj file. + + ## Example + + The following example shows how to use it: + + ```XML + + + var Auth = require('useJS/Auth'); + + function doSignIn() { + Auth.signIn().then(function(result) { + // result is json object containing these properties : + // email -> user email that has been sign in / sign up + // firstName -> User firstname + // lastName -> User Lastname + // userId -> User uniqe Id + }, function (ex) { + // failed login + }) + } + Auth.hasSignedIn().then(function (result) { + if (result) { + // user has already sign in + } + }) + + module.exports = { + doSignIn + } + + + + When the callback handler is fired for the first time and the result object of `status` property is true, save those logged user information immediately to the server especially on iOS, + > because as stated in the documentation on the apple website, the Sign In With Apple will only send userId informataion the next time user do the authentication again + + */ + public class SignInModule : NativeModule + { + static readonly SignInModule _instance; + + public SignInModule() + { + if (_instance != null) return; + + _instance = this; + Resource.SetGlobalKey(_instance, "FuseJS/Auth"); + AddMember(new NativePromise("hasSignedIn", HasSignedIn)); + AddMember(new NativePromise("signIn", SignIn, Converter)); + } + + static Future HasSignedIn(object[] args) + { + var p = new Promise(); + if defined (iOS) + SignInWithApple.HasSignedIn(p); + if defined (Android) + SignInWithGoogle.HasSignedIn(p); + return p; + } + + static Future SignIn(object[] args) + { + var p = new Promise(); + if defined (iOS) + SignInWithApple.SignIn(p); + if defined (Android) + SignInWithGoogle.SignIn(p); + return p; + } + + static Scripting.Object Converter(Context context, LoginInformation loginInfo) + { + var wrapperObject = context.NewObject(); + wrapperObject["userId"] = loginInfo.CurrentIdentifier; + wrapperObject["firstName"] = loginInfo.FamilyName; + wrapperObject["lastName"] = loginInfo.GivenName; + wrapperObject["email"] = loginInfo.Email; + wrapperObject["user"] = loginInfo.User; + wrapperObject["password"] = loginInfo.Password; + return wrapperObject; + } + } + + internal class LoginInformation + { + public string CurrentIdentifier; + public string FamilyName; + public string GivenName; + public string Email; + public string User; + public string Password; + + public LoginInformation( string currIdentifier, string familyName, string givenName, string email, string user, string password) + { + CurrentIdentifier = currIdentifier; + FamilyName = familyName; + GivenName = givenName; + Email = email; + User = user; + Password = password; + } + } +} \ No newline at end of file diff --git a/Source/Fuse.Auth/SignInProvider.uno b/Source/Fuse.Auth/SignInProvider.uno new file mode 100644 index 000000000..43126fd4b --- /dev/null +++ b/Source/Fuse.Auth/SignInProvider.uno @@ -0,0 +1,267 @@ +using Uno; +using Uno.UX; +using Uno.Threading; +using Uno.Compiler.ExportTargetInterop; +using Android; +using Fuse; +using Fuse.Platform; +using Fuse.Storage; + +namespace Fuse +{ + [Require("Xcode.Framework","AuthenticationServices")] + [Require("Xcode.Framework","Security")] + [ForeignInclude(Language.ObjC, "SignInHelper.h")] + extern(iOS) class SignInWithApple + { + static ObjC.Object Handle; + static SignInWithApple instance; + static Promise _hasSignInPromise; + static Promise _signInPromise; + + static SignInWithApple() + { + if (Handle == null && Fuse.iOSDevice.OperatingSystemVersion.Major >= 13) + Handle = Setup(); + } + + public static void HasSignedIn(Promise promise) + { + if (Fuse.iOSDevice.OperatingSystemVersion.Major >= 13) + { + _hasSignInPromise = promise; + HasSignedIn(Handle, OnResult); + } + else + promise.Resolve(false); + } + + static void OnResult(bool result) + { + _hasSignInPromise.Resolve(result); + } + + public static void SignIn(Action success, Action fail) + { + if (Fuse.iOSDevice.OperatingSystemVersion.Major >= 13) + SignIn(Handle, success, fail); + else + fail("This iOS version is not supported for Sign In With Apple"); + } + + public static void SignIn(Promise promise) + { + if (Fuse.iOSDevice.OperatingSystemVersion.Major >= 13) + { + _signInPromise = promise; + SignIn(Handle, OnLoginSucceed, OnLoginFailed); + } + else + promise.Reject(new Exception("This iOS version is not supported for Sign In With Apple")); + } + + static void OnLoginSucceed() + { + var _userId = IOSUserSettingsImpl.GetStringValue("platformSignIn.userId"); + var _lastName = IOSUserSettingsImpl.GetStringValue("platformSignIn.lastName"); + var _firstName = IOSUserSettingsImpl.GetStringValue("platformSignIn.firstName"); + var _email = IOSUserSettingsImpl.GetStringValue("platformSignIn.email"); + var _user = IOSUserSettingsImpl.GetStringValue("platformSignIn.user"); + var _password = IOSUserSettingsImpl.GetStringValue("platformSignIn.password"); + _signInPromise.Resolve(new LoginInformation(_userId, _lastName, _firstName, _email, _user, _password)); + } + + static void OnLoginFailed(string message) + { + _signInPromise.Reject(new Exception(message)); + } + + [Foreign(Language.ObjC)] + extern(iOS) static void HasSignedIn(ObjC.Object Handle, Action result) + @{ + SignInHelper* helper = (SignInHelper *)Handle; + [helper hasSignedIn:result]; + @} + + [Foreign(Language.ObjC)] + extern(iOS) static ObjC.Object Setup() + @{ + return [[SignInHelper alloc] init]; + @} + + [Foreign(Language.ObjC)] + extern(iOS) static void SignIn(ObjC.Object Handle, Action success, Action fail) + @{ + SignInHelper* helper = (SignInHelper *)Handle; + dispatch_async(dispatch_get_main_queue(), ^{ + [helper handleAppleIDAuthorization:success error:fail]; + }); + @} + } + + [Require("Gradle.Dependency.Implementation", "com.google.android.gms:play-services-auth:17.0.0")] + [ForeignInclude(Language.Java, + "com.google.android.gms.auth.api.signin.GoogleSignInOptions", + "com.google.android.gms.auth.api.signin.GoogleSignIn", + "com.google.android.gms.auth.api.signin.GoogleSignInClient", + "com.google.android.gms.auth.api.signin.GoogleSignInAccount", + "com.google.android.gms.common.api.ApiException", + "com.google.android.gms.tasks.Task", + "android.content.Intent", + "com.fuse.Activity", + "android.content.SharedPreferences", + "android.preference.PreferenceManager" + )] + extern(Android) class SignInWithGoogle + { + static Java.Object Handle; + static SignInWithGoogle instance; + static Action _success; + static Action _fail; + static int RequestCode = 9987; + static Promise _hasSignInPromise; + static Promise _signInPromise; + + static SignInWithGoogle() + { + if (Handle == null) + Handle = Setup(); + } + + public static void HasSignedIn(Promise promise) + { + _hasSignInPromise = promise; + HasSignedIn(Handle, OnResult); + } + + static void OnResult(bool result) + { + _hasSignInPromise.Resolve(result); + } + + public static void SignIn(Action success, Action fail) + { + _success = success; + _fail = fail; + SignIn(MakeIntent(Handle)); + } + + public static void SignIn(Promise promise) + { + _signInPromise = promise; + _success = OnLoginSucceed; + _fail = OnLoginFailed; + SignIn(MakeIntent(Handle)); + } + + static void OnLoginSucceed() + { + var _userId = AndroidUserSettingsImpl.GetStringValue("platformSignIn.userId"); + var _lastName = AndroidUserSettingsImpl.GetStringValue("platformSignIn.lastName"); + var _firstName = AndroidUserSettingsImpl.GetStringValue("platformSignIn.firstName"); + var _email = AndroidUserSettingsImpl.GetStringValue("platformSignIn.email"); + _signInPromise.Resolve(new LoginInformation(_userId, _lastName, _firstName, _email, "", "")); + } + + static void OnLoginFailed(string message) + { + _signInPromise.Reject(new Exception(message)); + } + + [Foreign(Language.Java)] + extern(android) static void SignIn(Java.Object intent) + @{ + int requestCode = @{RequestCode:Get()}; + com.fuse.Activity.getRootActivity().startActivityForResult((Intent)intent, requestCode); + @} + + static void OnResult (int requestCode, Java.Object intent) + { + if (requestCode == RequestCode) + OnResultHandler(requestCode, intent, _success, _fail); + } + + [Foreign(Language.Java)] + extern(android) static void OnResultHandler(int requestCode, Java.Object intent, Action success, Action fail) + @{ + Intent data = (Intent)intent; + Task task = GoogleSignIn.getSignedInAccountFromIntent(data); + try { + GoogleSignInAccount account = task.getResult(ApiException.class); + @{SaveAccount(Java.Object):Call(account)}; + if (success != null) + success.run(); + } catch (ApiException e) { + if (fail != null) + fail.run(e.getMessage()); + } + @} + + [Foreign(Language.Java)] + extern(android) static Java.Object MakeIntent(Java.Object Handle) + @{ + GoogleSignInClient mGoogleSignInClient = (GoogleSignInClient)Handle; + return mGoogleSignInClient.getSignInIntent(); + @} + + [Foreign(Language.Java)] + extern(Android) static void HasSignedIn(Java.Object Handle, Action result) + @{ + + GoogleSignInAccount account = GoogleSignIn.getLastSignedInAccount(Activity.getRootActivity()); + if (account != null) + { + @{SaveAccount(Java.Object):Call(account)}; + if (result != null) + result.run(true); + } + else + { + @{ClearAccount():Call()}; + if (result != null) + result.run(false); + } + @} + + [Foreign(Language.Java)] + extern(Android) static Java.Object Setup() + @{ + com.fuse.Activity.ResultListener l = new com.fuse.Activity.ResultListener() { + @Override public boolean onResult(int requestCode, int resultCode, android.content.Intent data) { + @{OnResult(int,Java.Object):Call(requestCode, data)}; + return false; + } + }; + com.fuse.Activity.subscribeToResults(l); + GoogleSignInOptions gso = new GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .build(); + return GoogleSignIn.getClient(Activity.getRootActivity(), gso); + @} + + [Foreign(Language.Java)] + extern(Android) static void SaveAccount(Java.Object accountData) + @{ + GoogleSignInAccount account = (GoogleSignInAccount)accountData; + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(Activity.getRootActivity()); + SharedPreferences.Editor editor = preferences.edit(); + editor.putString("platformSignIn.email", account.getEmail()); + editor.putString("platformSignIn.firstName", account.getGivenName()); + editor.putString("platformSignIn.lastName", account.getFamilyName()); + editor.putString("platformSignIn.userId", account.getId()); + editor.apply(); + @} + + [Foreign(Language.Java)] + extern(Android) static void ClearAccount() + @{ + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(Activity.getRootActivity()); + SharedPreferences.Editor editor = preferences.edit(); + editor.remove("platformSignIn.email"); + editor.remove("platformSignIn.firstName"); + editor.remove("platformSignIn.lastName"); + editor.remove("platformSignIn.userId"); + editor.apply(); + @} + } +} \ No newline at end of file diff --git a/Source/Fuse.Auth/iOS/SignInHelper.h b/Source/Fuse.Auth/iOS/SignInHelper.h new file mode 100644 index 000000000..d778548a1 --- /dev/null +++ b/Source/Fuse.Auth/iOS/SignInHelper.h @@ -0,0 +1,15 @@ +#import +#import +#import +#import + +@interface SignInHelper: NSObject + +@property (nonatomic, copy) void (^_action_success)(); +@property (nonatomic, copy) void (^_action_error)(NSString* error); + +-(void)handleAppleIDAuthorization:(void (^)())action_success error: (void (^)(NSString*))action_error; + +-(void)hasSignedIn:(void (^)(bool))result; + +@end \ No newline at end of file diff --git a/Source/Fuse.Auth/iOS/SignInHelper.mm b/Source/Fuse.Auth/iOS/SignInHelper.mm new file mode 100644 index 000000000..f4bec1f2b --- /dev/null +++ b/Source/Fuse.Auth/iOS/SignInHelper.mm @@ -0,0 +1,110 @@ +#import "SignInHelper.h" + +@implementation SignInHelper + + +-(void)handleAppleIDAuthorization:(void (^)())action_success error: (void (^)(NSString*))action_error; +{ + + if (@available(iOS 13.0, *)) { + self._action_success = action_success; + self._action_error = action_error; + + ASAuthorizationAppleIDProvider *appleIDProvider = [ASAuthorizationAppleIDProvider new]; + ASAuthorizationAppleIDRequest *request = appleIDProvider.createRequest; + request.requestedScopes = @[ASAuthorizationScopeFullName, ASAuthorizationScopeEmail]; + ASAuthorizationController *controller = [[ASAuthorizationController alloc] initWithAuthorizationRequests:@[request]]; + controller.delegate = self; + controller.presentationContextProvider = self; + [controller performRequests]; + } +} + +-(void)hasSignedIn:(void (^)(bool))result +{ + if (@available(iOS 13.0, *)) { + dispatch_async(dispatch_get_main_queue(), ^{ + ASAuthorizationAppleIDProvider *appleIDProvider = [ASAuthorizationAppleIDProvider new]; + NSString* userId= [[NSUserDefaults standardUserDefaults] valueForKey:@"appleIDCredential.currentIdentifier"]; + if (userId == nil) + { + result(false); + return; + } + [appleIDProvider getCredentialStateForUserID:userId + completion:^(ASAuthorizationAppleIDProviderCredentialState credentialState, NSError *error) { + switch(credentialState) + { + case ASAuthorizationAppleIDProviderCredentialAuthorized: + result(true); + break; + case ASAuthorizationAppleIDProviderCredentialNotFound: + case ASAuthorizationAppleIDProviderCredentialRevoked: + // remove appleId / password credential Information + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"platformSignIn.userId"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"platformSignIn.lastName"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"platformSignIn.firstName"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"platformSignIn.email"]; + + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"platformSignIn.user"]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"platformSignIn.password"]; + result(false); + break; + default: + result(false); + break; + } + }]; + }); + } + result(false); +} + +- (void)authorizationController:(ASAuthorizationController *)controller didCompleteWithError:(NSError *)error +{ + self._action_error(error.localizedDescription); +} + +-(void)authorizationController:(ASAuthorizationController *)controller didCompleteWithAuthorization:(ASAuthorization *)authorization +{ + if ([authorization.credential isKindOfClass:[ASAuthorizationAppleIDCredential class]]) { + ASAuthorizationAppleIDCredential *appleIDCredential = authorization.credential; + NSString *user = appleIDCredential.user; + NSString *familyName = appleIDCredential.fullName.familyName; + NSString *givenName = appleIDCredential.fullName.givenName; + NSString *email = appleIDCredential.email; + // save to UserDefaults + if (user != nil) + [[NSUserDefaults standardUserDefaults] setValue:user forKey:@"platformSignIn.userId"]; + if (familyName != nil) + [[NSUserDefaults standardUserDefaults] setValue:familyName forKey:@"platformSignIn.lastName"]; + if (givenName != nil) + [[NSUserDefaults standardUserDefaults] setValue:givenName forKey:@"platformSignIn.firstName"]; + if (email != nil) + [[NSUserDefaults standardUserDefaults] setValue:email forKey:@"platformSignIn.email"]; + + if (self._action_success != nil) + self._action_success(); + } + if ([authorization.credential isKindOfClass:[ASPasswordCredential class]]) { + ASPasswordCredential *passwordCredential = authorization.credential; + NSString *user = passwordCredential.user; + NSString *password = passwordCredential.password; + + if (user != nil) + [[NSUserDefaults standardUserDefaults] setValue:user forKey:@"platformSignIn.user"]; + if (password != nil) + [[NSUserDefaults standardUserDefaults] setValue:password forKey:@"platformSignIn.password"]; + + if (self._action_success != nil) + self._action_success(); + } +} + +- (ASPresentationAnchor)presentationAnchorForAuthorizationController:(ASAuthorizationController *)controller API_AVAILABLE(ios(13.0)){ + + return [[UIApplication sharedApplication] delegate].window; +} + + +@end \ No newline at end of file diff --git a/Source/Fuse.Auth/triggers/actions/SignInAction.uno b/Source/Fuse.Auth/triggers/actions/SignInAction.uno new file mode 100644 index 000000000..2fa13e287 --- /dev/null +++ b/Source/Fuse.Auth/triggers/actions/SignInAction.uno @@ -0,0 +1,157 @@ +using Uno; +using Uno.UX; + +using Fuse.Storage; +using Fuse.Triggers; +using Fuse.Triggers.Actions; +using Fuse.Scripting; + +namespace Fuse +{ + public class SignInArgs : EventArgs, IScriptEvent + { + bool _status; + string _errorMessage; + string _userId; + string _lastName; + string _firstName; + string _email; + string _user; + string _password; + + public SignInArgs(bool result, string msg) + { + _status = result; + _errorMessage = msg; + ComposeUserDataFromUserSettings(); + } + void IScriptEvent.Serialize(IEventSerializer s) + { + Serialize(s); + } + + void ComposeUserDataFromUserSettings() + { + // retrieve user data that has been saved in NSUserDefaults + if defined(iOS) + { + _userId = IOSUserSettingsImpl.GetStringValue("platformSignIn.userId"); + _lastName = IOSUserSettingsImpl.GetStringValue("platformSignIn.lastName"); + _firstName = IOSUserSettingsImpl.GetStringValue("platformSignIn.firstName"); + _email = IOSUserSettingsImpl.GetStringValue("platformSignIn.email"); + _user = IOSUserSettingsImpl.GetStringValue("platformSignIn.user"); + _password = IOSUserSettingsImpl.GetStringValue("platformSignIn.password"); + } + // retrieve user data that has been saved in SharedPreferences + if defined(Android) + { + _userId = AndroidUserSettingsImpl.GetStringValue("platformSignIn.userId"); + _lastName = AndroidUserSettingsImpl.GetStringValue("platformSignIn.lastName"); + _firstName = AndroidUserSettingsImpl.GetStringValue("platformSignIn.firstName"); + _email = AndroidUserSettingsImpl.GetStringValue("platformSignIn.email"); + } + } + + virtual void Serialize(IEventSerializer s) + { + s.AddBool("status", _status); + s.AddString("errorMessage", _errorMessage); + s.AddString("userId", _userId); + s.AddString("firstName", _firstName); + s.AddString("lastName", _lastName); + s.AddString("email", _email); + s.AddString("user", _user); + s.AddString("password", _password); + } + } + + public delegate void SignInEventHandler(object sender, SignInArgs args); + + /** + This is trigger action for taking Platform SignIn. Platform SignIn is a SignIn mechanism that use `Sign In With Apple` on iOS and `Google SignIn` on Android. + + Platform SignIn is only available on the mobile target platform (iOS and Android). + + You need to add a reference to `"Fuse.Auth"` in your project file to use this feature. + + > For more information on what are the pre-request when implementing `Sign In With Apple` or `Google Sign In`, you can check the documentation on the apple developer website or android developer website + > for iOS add "SystemCapabilities": { "SignInWithApple":true } in the unoproj file. + + ## Example + + The following example shows how to use it: + + ```XML + + + var Observable = require('FuseJS/Observable'); + var status = Observable(); + var statusMessage = Observable(); + + module.exports = { + resultHandler: function(result) { + console.dir(result); + // result is json object containing these properties : + // status -> boolean value indicating whether sign in action success or fail + // email -> user email that has been sign in / sign up + // firstName -> User firstname + // lastName -> User Lastname + // userId -> User uniqe Id + } + } + + + When the callback handler is fired for the first time and the result object of `status` property is true, save those logged user information immediately to the server especially on iOS, + > because as stated in the documentation on the apple website, the Sign In With Apple will only send userId informataion the next time user do the authentication again + */ + public class PlatformSignIn : TriggerAction + { + Node _target; + + /** + Optionally specifies a handler that will be called when this trigger is pulsed. + */ + public event SignInEventHandler Handler; + + extern(!MOBILE) + protected override void Perform(Node n) + { + Fuse.Diagnostics.UserWarning("Platform SignIn is not implemented for this platform", this); + } + + extern(MOBILE) + protected override void Perform(Node n) + { + _target = n; + if defined(Android) + SignInWithGoogle.SignIn(AuthSuccess, AuthFailed); + if defined(iOS) + SignInWithApple.SignIn(AuthSuccess, AuthFailed); + } + + void AuthSuccess() + { + if (Handler != null) + { + var visual = _target.FindByType(); + Handler(visual, new SignInArgs(true, "")); + } + } + + void AuthFailed(string message) + { + if (Handler != null) + { + var visual = _target.FindByType(); + Handler(visual, new SignInArgs(false, message)); + } + } + } +} \ No newline at end of file From 3ea0c2c9cf2f74c04fc2c18da0bf8fb7378a7da3 Mon Sep 17 00:00:00 2001 From: ichan-mb Date: Sat, 8 Aug 2020 09:07:55 +0700 Subject: [PATCH 2/2] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3a2937d4..40c8d190c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Fuse.Auth - Introducing Fuse.Auth, the easiest way to perform user authentication using biometric sensor that reside on the device such as fingerprint or FaceID +- Introducing Platform SignIn. a Sign In mechanism that use `Sign In With Apple` on iOS and `Google SignIn` on Android. There is two API added, `PlatformSignIn` as trigger action and `FuseJS/Auth` as javascript module. # 1.14