diff --git a/android/build.gradle b/android/build.gradle index eb5f37e..3454fc2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -75,7 +75,7 @@ dependencies { implementation "com.facebook.react:react-native:+" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "id.passage.android:passage:1.7.0" + implementation "id.passage.android:passage:1.8.2" implementation "com.google.code.gson:gson:2.9.0" } diff --git a/android/gradle.properties b/android/gradle.properties index 4d8124d..6bb227e 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -PassageReactNative_kotlinVersion=1.7.0 +PassageReactNative_kotlinVersion=1.8.10 PassageReactNative_minSdkVersion=21 PassageReactNative_targetSdkVersion=31 PassageReactNative_compileSdkVersion=34 diff --git a/android/src/main/java/com/passagereactnative/PassageReactNativeModule.kt b/android/src/main/java/com/passagereactnative/PassageReactNativeModule.kt index 957c85d..5208faf 100644 --- a/android/src/main/java/com/passagereactnative/PassageReactNativeModule.kt +++ b/android/src/main/java/com/passagereactnative/PassageReactNativeModule.kt @@ -409,5 +409,49 @@ class PassageReactNativeModule(reactContext: ReactApplicationContext) : } // endregion + + // Hosted Auth Region + + @ReactMethod + fun hostedAuthStart(promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + passage.hostedAuthStart(); + promise.resolve(null); + } catch (e: Exception) { + var errorCode = "START_HOSTED_AUTH_ERROR" + promise.reject(errorCode, e.message, e); + } + } + } + + @ReactMethod + fun hostedLogout(promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + passage.hostedLogout() + promise.resolve(null); + } catch (e: Exception) { + val error = "HOSTED_LOGOUT_ERROR" + promise.reject(error, e.message, e); + } + } + } + + @ReactMethod + fun hostedAuthFinish(code: String, state: String, promise: Promise) { + CoroutineScope(Dispatchers.IO).launch { + try { + val authResultWithIdToken = passage.hostedAuthFinish(code, state) + val jsonString = Gson().toJson(authResultWithIdToken.first) + promise.resolve(jsonString) + } catch (e: Exception) { + val error = "FINISH_HOSTED_AUTH_ERROR" + promise.reject(error, e.message, e); + } + } + } + + // endregion } diff --git a/ios/PassageReactNative.m b/ios/PassageReactNative.m index 41a5980..aa98a1a 100644 --- a/ios/PassageReactNative.m +++ b/ios/PassageReactNative.m @@ -103,6 +103,13 @@ @interface RCT_EXTERN_MODULE(PassageReactNative, NSObject) withResolver:(RCTPromiseResolveBlock)resolve withRejecter:(RCTPromiseRejectBlock)reject); +RCT_EXTERN_METHOD(hostedAuth:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject); + + +RCT_EXTERN_METHOD(hostedLogout:(RCTPromiseResolveBlock)resolve + withRejecter:(RCTPromiseRejectBlock)reject); + + (BOOL)requiresMainQueueSetup { return NO; diff --git a/ios/PassageReactNative.swift b/ios/PassageReactNative.swift index 6deeeff..92f98db 100644 --- a/ios/PassageReactNative.swift +++ b/ios/PassageReactNative.swift @@ -45,7 +45,7 @@ class PassageReactNative: NSObject { } let authResult = try await passage.registerWithPasskey(identifier: identifier, options: passkeyCreationOptions) resolve(authResult.toJsonString()) - } catch PassageASAuthorizationError.canceled { + } catch RegisterWithPasskeyError.canceled { reject("USER_CANCELLED", "User cancelled interaction", nil) } catch { reject("PASSKEY_ERROR", "\(error)", nil) @@ -67,7 +67,7 @@ class PassageReactNative: NSObject { do { let authResult = try await passage.loginWithPasskey(identifier: identifier) resolve(authResult.toJsonString()) - } catch PassageASAuthorizationError.canceled { + } catch LoginWithPasskeyError.canceled { reject("USER_CANCELLED", "User cancelled interaction", nil) } catch { reject("PASSKEY_ERROR", "\(error)", nil) @@ -134,7 +134,7 @@ class PassageReactNative: NSObject { resolve(authResult.toJsonString()) } catch { var errorCode = "OTP_ERROR" - if case PassageOTPError.exceededAttempts = error { + if case OneTimePasscodeActivateError.exceededAttempts = error { errorCode = "OTP_ACTIVATION_EXCEEDED_ATTEMPTS" } reject(errorCode, "\(error)", nil) @@ -279,10 +279,7 @@ class PassageReactNative: NSObject { ) { Task { do { - guard let appInfo = try await PassageAuth.appInfo() else { - reject("APP_INFO_ERROR", "Error getting app info.", nil) - return - } + let appInfo = try await passage.appInfo() resolve(appInfo.toJsonString()) } catch { reject("APP_INFO_ERROR", "\(error)", nil) @@ -422,5 +419,37 @@ class PassageReactNative: NSObject { } } } + + @objc(hostedAuth:withRejecter:) + func hostedAuth( + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) -> Void { + Task { + do { + let authResult = try await passage.hostedAuth() + resolve(authResult.toJsonString()) + } catch { + let errorCode = "HOSTED_AUTH_ERROR" + reject(errorCode, "\(error)", nil) + } + } + } + + @objc(hostedLogout:withRejecter:) + func hostedLogout( + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + Task { + do { + try await passage.hostedLogout() + resolve(nil) + } catch { + let errorCode = "LOGOUT_HOSTED_AUTH_ERROR" + reject(errorCode, "\(error)", nil) + } + } + } } diff --git a/ios/PassageReactNativeExtensions.swift b/ios/PassageReactNativeExtensions.swift index 9de27bd..4833715 100644 --- a/ios/PassageReactNativeExtensions.swift +++ b/ios/PassageReactNativeExtensions.swift @@ -11,7 +11,6 @@ internal extension AuthResult { func toDictionary() -> [String: Any] { var authResultDict: [String : Any] = [ "authToken": authToken, - "redirectUrl": redirectURL, "refreshToken": refreshToken, "refreshTokenExpiration": refreshTokenExpiration ] @@ -79,19 +78,14 @@ internal extension AppInfo { func toDictionary() -> [String: Any] { var appInfoDict: [String : Any] = [ "allowedIdentifier": allowedIdentifier, - "authFallbackMethod": authFallbackMethod?.rawValue, "authOrigin": authOrigin, "id": id, - "loginURL": loginURL, "name": name, "publicSignup": publicSignup, - "redirectURL": redirectURL, "requiredIdentifier": requiredIdentifier, "requireEmailVerification": requireEmailVerification, "requireIdentifierVerification": requireIdentifierVerification, - "sessionTimeoutLength": sessionTimeoutLength, - "userMetadataSchema": userMetadataSchema?.map { $0.toDictionary() }, - "authMethods": authMethods?.toDictionary(), + "sessionTimeoutLength": sessionTimeoutLength ] return appInfoDict } @@ -102,25 +96,6 @@ internal extension AppInfo { } -internal extension UserMetadataSchema { - - func toDictionary() -> [String: Any] { - var dict: [String : Any] = [ - "fieldName": fieldName, - "friendlyName": friendlyName, - "id": id, - "profile": profile, - "registration": registration, - "type": type - ] - return dict - } - - func toJsonString() -> String { - return dictToJsonString(toDictionary()) - } - -} internal extension AuthMethods { func toDictionary() -> [String: Any] { diff --git a/passage-react-native.podspec b/passage-react-native.podspec index c6a4f34..fafb7ab 100644 --- a/passage-react-native.podspec +++ b/passage-react-native.podspec @@ -18,7 +18,7 @@ Pod::Spec.new do |s| s.dependency "React-Core" - s.dependency 'Passage', '1.6.0' + s.dependency 'Passage', '1.8.1' s.platform = :ios, '16.0' # Don't install the dependencies when we run `pod install` in the old architecture. diff --git a/src/index.tsx b/src/index.tsx index 4b3d947..4488d04 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,5 @@ import { NativeModules, Platform } from 'react-native'; -import { waitForDeepLinkQueryValue } from './utils/waitForDeepLinkQueryValue'; +import { waitForDeepLinkQueryValues } from './utils/waitForDeepLinkQueryValues'; const LINKING_ERROR = `The package 'passage-react-native' doesn't seem to be linked. Make sure: \n\n` + @@ -31,6 +31,9 @@ export enum PassageErrorCode { TokenError = 'TOKEN_ERROR', AppInfoError = 'APP_INFO_ERROR', SocialAuthError = 'SOCIAL_AUTH_ERROR', + StartHostedAuthError = 'START_HOSTED_AUTH_ERROR', + FinisHostedAuthError = 'FINISH_HOSTED_AUTH_ERROR', + LogoutHostedAuthError = 'LOGOUT_HOSTED_AUTH_ERROR', } export class PassageError extends Error { @@ -181,6 +184,8 @@ type VoidMethod = () => Promise; type ChangeEmail = (newEmail: string) => Promise; type ChangePhone = (newPhone: string) => Promise; type GetCurrentUser = () => Promise; +type AuthorizeWithHostedLogin = () => Promise; +type HostedLogout = () => Promise; /** * The Passage class is used to perform authentication and user operations. @@ -415,7 +420,8 @@ class Passage { // The Android native "authorizeWith" method opens a Chrome Tab and returns void. await PassageReactNative.authorizeWith(connection); // Wait for a redirect back into the app with the auth code. - const authCode = await waitForDeepLinkQueryValue('code'); + const authCodeObj = await waitForDeepLinkQueryValues(['code']); + const authCode = authCodeObj.code; authResultJson = await PassageReactNative.finishSocialAuthentication( authCode ); @@ -613,6 +619,54 @@ class Passage { throw new PassageError(error.code, error.message); } }; + + /** + * Authentication Method for Hosted Apps + * If your Passage app is Hosted, use this method to register and log in your user. + * This method will open up a Passage login experience + * @param {string} clientSecret You hosted app's client secret, found in Passage Console's OIDC Settings.n + * @returns {Promise} A data object that includes AuthResult and idToken + * @throws {PassageError} + */ + authorizeWithHostedLogin: AuthorizeWithHostedLogin = + async (): Promise => { + try { + if (Platform.OS === 'ios') { + // The iOS native "hostedAuthStart" method returns an AuthResult directly. + const result = await PassageReactNative.hostedAuth(); + return JSON.parse(result); + } else { + // The Android native "hostedAuthStart" method opens a Chrome Tab and returns void. + await PassageReactNative.hostedAuthStart(); + // Wait for a redirect back into the app with the auth code. + const { code: authCode, state } = await waitForDeepLinkQueryValues([ + 'code', + 'state', + ]); + let result = await PassageReactNative.hostedAuthFinish( + authCode, + state + ); + return JSON.parse(result); + } + } catch (error: any) { + throw new PassageError(error.code, error.message); + } + }; + + /** + * Logout Method for Hosted Apps + * + * If your Passage app is Hosted, use this method to log out your user. This method will briefly open up a web view where it will log out the + */ + + hostedLogout: HostedLogout = async (): Promise => { + try { + await PassageReactNative.hostedLogout(); + } catch (error: any) { + throw new PassageError(error.code, error.message); + } + }; } export default Passage; diff --git a/src/utils/waitForDeepLinkQueryValue.ts b/src/utils/waitForDeepLinkQueryValues.ts similarity index 72% rename from src/utils/waitForDeepLinkQueryValue.ts rename to src/utils/waitForDeepLinkQueryValues.ts index 6945166..0218929 100644 --- a/src/utils/waitForDeepLinkQueryValue.ts +++ b/src/utils/waitForDeepLinkQueryValues.ts @@ -20,23 +20,31 @@ const getUrlParamValue = (url: string, param: string): string | null => { * * Note that this function works best in a "singleTask" Android app. * - * @param parameter The parameter of the value to return from Deep Link. - * @returns string + * @param parameters Array of parameters to return from Deep Link. + * @returns object * @throws */ -export const waitForDeepLinkQueryValue = async (parameter: string) => { +export const waitForDeepLinkQueryValues = async ( + parameters: string[] +): Promise<{ [key: string]: string }> => { return new Promise(async (resolve, reject) => { - // We need to listen for a Deep Link url event, and attempt to extract the parameter + // We need to listen for a Deep Link url event, and attempt to extract the parameters // from the redirect url. const linkingListener = Linking.addEventListener('url', async (event) => { appStateListener.remove(); linkingListener.remove(); const { url } = event; - const value = getUrlParamValue(url, parameter); - if (!value) { - return reject(new Error('Missing query parameter in redirect url.')); + const result: { [key: string]: string } = {}; + for (let parameter of parameters) { + const value = getUrlParamValue(url, parameter); + if (!value) { + return reject( + new Error(`Missing query parameter ${parameter} in redirect url.`) + ); + } + result[parameter] = value; } - resolve(value); + resolve(result); }); // We need to listen for an AppState change event in the case that the user returns to // the app without having been redirected.