diff --git a/.circleci/config.yml b/.circleci/config.yml index 4237dfd0..dd074b9d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -16,7 +16,7 @@ executors: macos: macos: - xcode: "16.3" + xcode: 26.4.1 resource_class: m4pro.medium jobs: @@ -34,10 +34,16 @@ jobs: - gradle- - run: - name: Run Android Unit and Integration Tests + name: Setup Node and install dependencies command: | - cd packages/react-native-webcrypto-bridge/android - ./gradlew testDebugUnitTest --info + npm i -g yarn@1.22.22 + yarn install --frozen-lockfile + + - run: + name: Run React Native Platform Android Tests + command: | + cd packages/react-native-platform/android + ./gradlew testDebugUnitTest -PnewArchEnabled=true --info - save_cache: key: gradle-{{ checksum "packages/react-native-webcrypto-bridge/android/build.gradle" }} @@ -93,11 +99,17 @@ jobs: - gradle-rn-platform-{{ checksum "packages/react-native-platform/android/build.gradle" }} - gradle-rn-platform- + - run: + name: Setup Node and install dependencies + command: | + npm i -g yarn@1.22.22 + yarn install --frozen-lockfile + - run: name: Run React Native Platform Android Tests command: | cd packages/react-native-platform/android - ./gradlew testDebugUnitTest --info + ./gradlew testDebugUnitTest -PnewArchEnabled=true --info - save_cache: key: gradle-rn-platform-{{ checksum "packages/react-native-platform/android/build.gradle" }} @@ -147,4 +159,4 @@ workflows: - test-rn-webcrypto-android - test-rn-webcrypto-ios - test-rn-platform-android - - test-rn-platform-ios + # - test-rn-platform-ios diff --git a/.gitignore b/.gitignore index 75674aa5..930baf58 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ docs/api/* out/ build dist +.build # Debug diff --git a/e2e/apps/react-native-oidc/app.config.ts b/e2e/apps/react-native-oidc/app.config.ts index 2c4f6b7e..97e47afc 100644 --- a/e2e/apps/react-native-oidc/app.config.ts +++ b/e2e/apps/react-native-oidc/app.config.ts @@ -5,7 +5,7 @@ import envModule from '@repo/env'; envModule.setEnvironmentVarsFromTestEnv(__dirname); const env: any = {}; // List of environment variables made available to the app -['ISSUER', 'NATIVE_CLIENT_ID', 'USE_DPOP'].forEach((key) => { +['ISSUER', 'NATIVE_CLIENT_ID', 'NATIVE_REDIRECT_URI', 'USE_DPOP'].forEach((key) => { if (!process.env[key]) { console.warn(`Environment variable ${key} should be set for development. See README.md`); } @@ -26,6 +26,12 @@ export default ({ config }: ConfigContext) => ({ "bundleIdentifier": "com.anonymous.reporeactnativeoidc" }, scheme: "com.oktapreview.jperreault-test", + autolinking: { + searchPaths: [ + "../../node_modules", + "../../packages" + ] + }, intentFilters: [ { action: "VIEW", diff --git a/e2e/apps/react-native-oidc/app/callback.tsx b/e2e/apps/react-native-oidc/app/callback.tsx new file mode 100644 index 00000000..d91c85a4 --- /dev/null +++ b/e2e/apps/react-native-oidc/app/callback.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useRouter, useLocalSearchParams } from 'expo-router'; +import { ActivityIndicator, StyleSheet } from 'react-native'; +import { ThemedView } from '@/components/ThemedView'; +import { ThemedText } from '@/components/ThemedText'; +import { useAuth } from '@/hooks/useAuth'; + + +export default function CallbackScreen() { + const router = useRouter(); + const searchParams = useLocalSearchParams>(); + const { handleAuthFlowExchange } = useAuth(); + + useEffect(() => { + const handleExchange = async () => { + const params = { ...searchParams }; + await handleAuthFlowExchange(new URLSearchParams(params)); + router.replace('/'); + } + + handleExchange(); + }, [router, searchParams, handleAuthFlowExchange]); + + return ( + + + Processing login... + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, +}); diff --git a/e2e/apps/react-native-oidc/auth.ts b/e2e/apps/react-native-oidc/auth.ts index 44848d0b..d64985de 100644 --- a/e2e/apps/react-native-oidc/auth.ts +++ b/e2e/apps/react-native-oidc/auth.ts @@ -1,14 +1,19 @@ -import { fetch as expoFetch, FetchResponse } from 'expo/fetch'; import Constants from 'expo-constants'; +import { Platform } from 'react-native'; import { OAuth2Client } from '@okta/auth-foundation/core'; +import { + AuthorizationCodeFlow, + SessionLogoutFlow, + AuthTransaction, + Credential, + openAuthSession +} from '@okta/react-native-platform'; export const client = new OAuth2Client({ baseURL: Constants?.expoConfig?.extra?.env.ISSUER, clientId: Constants?.expoConfig?.extra?.env.NATIVE_CLIENT_ID, - // TODO: skip OIDC to avoid PK import errors scopes: ['openid', 'email', 'profile', 'offline_access'], - // scopes: ['offline_access'], dpop: false, fetchImpl: async (input: string | URL | Request, init?: RequestInit) => { // const { body, ...rest } = { body: undefined, ...init }; @@ -24,3 +29,60 @@ export const client = new OAuth2Client({ return response; } }); + +export const flow = new AuthorizationCodeFlow(client, { + redirectUri: Constants?.expoConfig?.extra?.env.NATIVE_REDIRECT_URI +}); + +export async function handleAuthFlowCallback (params: string | URLSearchParams) { + try { + const { token, context } = await flow.resume(params); + console.log('token', token); + console.log('context', context); + const credential = await Credential.store(token); + return credential.id; + } + catch (err) { + console.log('here 3'); + console.log(err, (err as Error)?.stack); + throw err; + } + finally { + flow.reset(); + } +} + +export async function performSignIn () { + try { + console.log('here 1') + const uri = await flow.start(); + + // @ts-ignore + const transaction = new AuthTransaction(flow.context); + await transaction.save(); + console.log('here 2.5 - transaction saved') + const result = await openAuthSession(uri.href, flow.redirectUri); + console.log('result: ', result) + + if (result.type === 'success') { + if (['ios', 'macos'].includes(Platform.OS)) { + return await handleAuthFlowCallback(result.url); + } + } + + // TODO: handle this + console.log('[WARNING] auth did not complete') + } + catch (err) { + console.log('here 3'); + console.log(err, (err as Error)?.stack); + throw err; + } +} + +export async function performSignOut () { + const isOIDC = client.configuration.scopes.includes('openid'); + + // TODO: implement oidc logout + await (await Credential.getDefault())?.revoke(); +} diff --git a/e2e/apps/react-native-oidc/components/ExternalLink.tsx b/e2e/apps/react-native-oidc/components/ExternalLink.tsx deleted file mode 100644 index dfbd23ea..00000000 --- a/e2e/apps/react-native-oidc/components/ExternalLink.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Href, Link } from 'expo-router'; -import { openBrowserAsync } from 'expo-web-browser'; -import { type ComponentProps } from 'react'; -import { Platform } from 'react-native'; - -type Props = Omit, 'href'> & { href: Href & string }; - -export function ExternalLink({ href, ...rest }: Props) { - return ( - { - if (Platform.OS !== 'web') { - // Prevent the default behavior of linking to the default browser on native. - event.preventDefault(); - // Open the link in an in-app browser. - await openBrowserAsync(href); - } - }} - /> - ); -} diff --git a/e2e/apps/react-native-oidc/hooks/useAuth.ts b/e2e/apps/react-native-oidc/hooks/useAuth.ts index f30a671e..0bdcf0f2 100644 --- a/e2e/apps/react-native-oidc/hooks/useAuth.ts +++ b/e2e/apps/react-native-oidc/hooks/useAuth.ts @@ -1,65 +1,15 @@ import { useCallback } from 'react'; -import { useRouter, type Router } from 'expo-router'; -import { Platform } from 'react-native'; -import { openAuthSessionAsync } from 'expo-web-browser'; -// import { AuthorizationCodeFlow, SessionLogoutFlow, AuthTransaction } from '@okta/oauth2-flows'; -// import { Credential } from '@okta/auth-foundation/core'; -import { - AuthorizationCodeFlow, - SessionLogoutFlow, - AuthTransaction, - Credential -} from '@okta/react-native-platform'; -import { client } from '@/auth'; +import { useRouter, type Router, usePathname } from 'expo-router'; +import { performSignIn, performSignOut, handleAuthFlowCallback } from '@/auth'; -async function performSignIn () { - try { - console.log('here 1') - // Platform-specific redirect URI - iOS uses single slash, Android uses double slash - const redirectUri = Platform.OS === 'ios' - ? 'com.oktapreview.jperreault-test:/callback' - : 'com.oktapreview.jperreault-test://callback'; - - // TODO: move to env - const flow = new AuthorizationCodeFlow(client, { - redirectUri - }); - - console.log('here 1') - const uri = await flow.start(); - - // @ts-ignore - const transaction = new AuthTransaction(flow.context); - await transaction.save(); - const result = await openAuthSessionAsync(uri.href, redirectUri); - console.log('result: ', result) - // @ts-ignore - const { token, context } = await flow.resume(result.url); - console.log('token', token); - console.log('context', context); - const credential = await Credential.store(token); - return credential.id; - } - catch (err) { - console.log('here 3'); - console.log(err, (err as Error)?.stack); - throw err; - } -} - -async function performSignOut () { - const isOIDC = client.configuration.scopes.includes('openid'); - - // TODO: implement oidc logout - await (await Credential.getDefault())?.revoke(); -} - export function useAuth () { const router = useRouter(); + const pathname = usePathname(); const signIn = useCallback(async () => { const id = await performSignIn(); + console.log('pathname: ', pathname); return id; }, [router]); @@ -68,5 +18,9 @@ export function useAuth () { router.navigate(redirectTo); }, [router]); - return { signIn, signOut }; + const handleAuthFlowExchange = useCallback(async (params: URLSearchParams) => { + return await handleAuthFlowCallback(params); + }, []) + + return { signIn, signOut, handleAuthFlowExchange }; }; \ No newline at end of file diff --git a/e2e/apps/react-native-oidc/index.js b/e2e/apps/react-native-oidc/index.js index 48f0382e..2d676e19 100644 --- a/e2e/apps/react-native-oidc/index.js +++ b/e2e/apps/react-native-oidc/index.js @@ -2,8 +2,14 @@ import '@expo/metro-runtime'; import 'expo-router/entry'; import { Platform, installWebCryptoPolyfill } from '@okta/react-native-platform'; +import { Linking } from 'react-native'; installWebCryptoPolyfill(); +// Global deeplink debugging - log ALL deeplinks received +Linking.addEventListener('url', ({ url }) => { + console.log('[GLOBAL DEEPLINK RECEIVED]', url); +}); + console.log('Plat', Platform, Platform.TimeCoordinator) console.log("globalThis.crypto", globalThis.crypto); // global.crypto = global.crypto ?? globalThis.crypto; diff --git a/e2e/apps/react-native-oidc/package.json b/e2e/apps/react-native-oidc/package.json index 7811a71c..0183e279 100644 --- a/e2e/apps/react-native-oidc/package.json +++ b/e2e/apps/react-native-oidc/package.json @@ -30,7 +30,6 @@ "expo-status-bar": "~3.0.8", "expo-symbols": "~1.0.7", "expo-system-ui": "~6.0.8", - "expo-web-browser": "~15.0.9", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.5", diff --git a/e2e/apps/token-broker/src/component/TestCallback.tsx b/e2e/apps/token-broker/src/component/TestCallback.tsx new file mode 100644 index 00000000..0e59478c --- /dev/null +++ b/e2e/apps/token-broker/src/component/TestCallback.tsx @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { handleAuthorizationCodeFlowResponse } from '@/auth'; +import { Loading } from './Loading'; + +interface FlowCallbackProps { + loadingElement?: React.ReactElement +} + +// prevents mutiple calls in strict mode +// https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application +let flowResumed = false; + +export const TestCallback: React.FC = ({ loadingElement = () }) => { + const navigate = useNavigate(); + const [callbackError, setCallbackError] = useState(null); + + useEffect(() => { + // prevents mutiple calls in strict mode + if (!flowResumed) { + // handleAuthorizationCodeFlowResponse() + // .then((originalUri) => { + // navigate(originalUri ?? '/', { replace: true }); + // }) + // .catch(err => { + // console.log(err, err.message); + // setCallbackError(err as unknown as Error); + // }); + const url = new URL(window.location.href); + url.pathname = '/login/callback2'; + window.location.replace(url.toString()); + + flowResumed = true; + } + }, []); + + return loadingElement; +}; diff --git a/package.json b/package.json index 0c272b59..34817f0b 100644 --- a/package.json +++ b/package.json @@ -32,18 +32,7 @@ ], "nohoist": [ "@repo/okta-client-js-docs/**", - "**/typedoc", - "**/expo", - "**/expo/**", - "**/expo-*", - "**/expo-*/**", - "**/@expo/**", - "**/@expo/**/**", - "**/@react-navigation/**", - "react", - "react-native", - "react-native-oidc/@okta/*", - "react-native-oidc/@okta/**" + "**/typedoc" ] }, "devEngines": { diff --git a/packages/auth-foundation/src/FetchClient.ts b/packages/auth-foundation/src/FetchClient.ts index 56b5fe5c..50b671a1 100644 --- a/packages/auth-foundation/src/FetchClient.ts +++ b/packages/auth-foundation/src/FetchClient.ts @@ -92,8 +92,6 @@ export class FetchClient extends await this.prepareAcrStepUpRetry(response, request, wwwAuthError); } - // TODO: clear token??? - // super.send() will sign call .authorize() } diff --git a/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts b/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts index a9b56b11..dea2bb6f 100644 --- a/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts +++ b/packages/oauth2-flows/src/AuthorizationCodeFlow/index.ts @@ -102,8 +102,8 @@ export class AuthorizationCodeFlow extends AuthenticationFlow { } /** @internal */ - protected parseAuthorizationCode (url: URL): AuthorizationCodeFlow.RedirectValues | OAuth2ErrorResponse { - const params = url.searchParams; + protected parseAuthorizationCode (url: URL | URLSearchParams): AuthorizationCodeFlow.RedirectValues | OAuth2ErrorResponse { + const params = url instanceof URL ? url.searchParams : url; const error = getSearchParam(params, 'error'); if (error) { @@ -245,14 +245,14 @@ export class AuthorizationCodeFlow extends AuthenticationFlow { * @param redirectUri * @returns */ - async resume (redirectUri?: string): Promise { + async resume (redirectUri: string | URL | URLSearchParams): Promise { this.inProgress = true; let oauthState = ''; try { - const currentUrl = new URL(redirectUri ?? window.location.href); + const callbackUrl = typeof redirectUri === 'string' ? new URL(redirectUri) : redirectUri; - const values = this.parseAuthorizationCode(currentUrl); + const values = this.parseAuthorizationCode(callbackUrl); if (isOAuth2ErrorResponse(values)) { throw new OAuth2Error(values); } diff --git a/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts b/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts index a5b7f7a7..466423f2 100644 --- a/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts +++ b/packages/oauth2-flows/test/spec/AuthorizationCodeFlow.spec.ts @@ -116,7 +116,7 @@ describe('AuthorizationCodeFlow', () => { jest.spyOn(AuthTransaction, 'load').mockResolvedValue(context); }); - it('can process an auth code redirect', async () => { + it('can process an auth code redirect via string', async () => { const redirectUri = new URL(flowParams.redirectUri); redirectUri.searchParams.set('code', code); redirectUri.searchParams.set('state', state); @@ -136,7 +136,45 @@ describe('AuthorizationCodeFlow', () => { expect(spies.removeTransaction).toHaveBeenLastCalledWith(state); }); - it('parses oauth error returned in redirect url', async () => { + it('can process an auth code redirect via URL', async () => { + const redirectUri = new URL(flowParams.redirectUri); + redirectUri.searchParams.set('code', code); + redirectUri.searchParams.set('state', state); + + expect(flow.inProgress).toEqual(false); + await flow.resume(redirectUri); + // cannot assert .inProgress true, jest executes too fast + // events are triggered when .inProgress is set, so if they fire + // the value was toggled during execution + + expect(flow.inProgress).toEqual(false); + expect(spies.start).toHaveBeenCalledTimes(1); + expect(spies.stop).toHaveBeenCalledTimes(1); + expect(spies.exchange).toHaveBeenCalledTimes(1); + expect(spies.exchange).toHaveBeenLastCalledWith(code, context); + expect(spies.removeTransaction).toHaveBeenCalledTimes(1); + expect(spies.removeTransaction).toHaveBeenLastCalledWith(state); + }); + + it('can process an auth code redirect via URLSearchParams', async () => { + const params = new URLSearchParams({ code, state }); + + expect(flow.inProgress).toEqual(false); + await flow.resume(params); + // cannot assert .inProgress true, jest executes too fast + // events are triggered when .inProgress is set, so if they fire + // the value was toggled during execution + + expect(flow.inProgress).toEqual(false); + expect(spies.start).toHaveBeenCalledTimes(1); + expect(spies.stop).toHaveBeenCalledTimes(1); + expect(spies.exchange).toHaveBeenCalledTimes(1); + expect(spies.exchange).toHaveBeenLastCalledWith(code, context); + expect(spies.removeTransaction).toHaveBeenCalledTimes(1); + expect(spies.removeTransaction).toHaveBeenLastCalledWith(state); + }); + + it('parses oauth error returned in redirect url via string', async () => { const oauthError = { error: 'someoautherror', errorDescription: 'someoautherrordesc', @@ -161,6 +199,56 @@ describe('AuthorizationCodeFlow', () => { expect(spies.removeTransaction).not.toHaveBeenCalled(); }); + it('parses oauth error returned in redirect url via URL', async () => { + const oauthError = { + error: 'someoautherror', + errorDescription: 'someoautherrordesc', + errorUri: 'someoautherroruri', + }; + const expectedError = new OAuth2Error('someoautherror'); + + const redirectUri = new URL(flowParams.redirectUri); + redirectUri.searchParams.set('error', oauthError.error); + redirectUri.searchParams.set('error_description', oauthError.errorDescription); + redirectUri.searchParams.set('error_uri', oauthError.errorUri); + + expect(flow.inProgress).toEqual(false); + await expect(flow.resume(redirectUri)).rejects.toThrow(expectedError); + + expect(flow.inProgress).toEqual(false); + expect(spies.start).toHaveBeenCalledTimes(1); + expect(spies.stop).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenLastCalledWith({ error: expectedError }); + expect(spies.exchange).not.toHaveBeenCalled(); + expect(spies.removeTransaction).not.toHaveBeenCalled(); + }); + + it('parses oauth error returned in redirect url via URLSearchParams', async () => { + const oauthError = { + error: 'someoautherror', + errorDescription: 'someoautherrordesc', + errorUri: 'someoautherroruri', + }; + const expectedError = new OAuth2Error('someoautherror'); + + const params = new URLSearchParams(); + params.set('error', oauthError.error); + params.set('error_description', oauthError.errorDescription); + params.set('error_uri', oauthError.errorUri); + + expect(flow.inProgress).toEqual(false); + await expect(flow.resume(params)).rejects.toThrow(expectedError); + + expect(flow.inProgress).toEqual(false); + expect(spies.start).toHaveBeenCalledTimes(1); + expect(spies.stop).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenCalledTimes(1); + expect(spies.error).toHaveBeenLastCalledWith({ error: expectedError }); + expect(spies.exchange).not.toHaveBeenCalled(); + expect(spies.removeTransaction).not.toHaveBeenCalled(); + }); + it('cannot parse redirect url', async () => { const expectedError1 = new AuthenticationFlowError('Failed to parse `code` from redirect url'); const expectedError2 = new AuthenticationFlowError('Failed to parse `state` from redirect url'); diff --git a/packages/react-native-platform/android/build.gradle b/packages/react-native-platform/android/build.gradle index 46391048..dc402f54 100644 --- a/packages/react-native-platform/android/build.gradle +++ b/packages/react-native-platform/android/build.gradle @@ -1,5 +1,6 @@ buildscript { ext.kotlin_version = '2.1.0' + ext.android_gradle_plugin_version = '8.1.0' repositories { google() @@ -7,13 +8,24 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.1.0' + classpath "com.android.tools.build:gradle:$android_gradle_plugin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'com.facebook.react' apply false +} + +def isNewArchitectureEnabled() { + return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" +} + +if (isNewArchitectureEnabled()) { + apply plugin: 'com.facebook.react' +} android { namespace "com.okta.reactnativeplatform" @@ -47,9 +59,7 @@ android { sourceSets { main { - java { - exclude 'com/okta/reactnativeplatform/browserSession/**' - } + java.srcDirs += 'build/generated/source/codegen/java' } } } @@ -57,6 +67,17 @@ android { repositories { google() mavenCentral() + maven { + url "$rootDir/../node_modules/react-native/android" + } +} + +repositories { + google() + mavenCentral() + maven { + url "$rootDir/../node_modules/react-native/android" + } } dependencies { @@ -79,5 +100,3 @@ tasks.withType(Test) { includeTestsMatching 'com.okta.reactnativeplatform.TokenStorageModuleTest' } } - - diff --git a/packages/react-native-platform/android/gradle.properties b/packages/react-native-platform/android/gradle.properties index 3e784c7c..e5f51ebd 100644 --- a/packages/react-native-platform/android/gradle.properties +++ b/packages/react-native-platform/android/gradle.properties @@ -1,2 +1,3 @@ android.useAndroidX=true android.suppressUnsupportedCompileSdk=35 +# newArchEnabled=true \ No newline at end of file diff --git a/packages/react-native-platform/android/settings.gradle b/packages/react-native-platform/android/settings.gradle new file mode 100644 index 00000000..a1a91188 --- /dev/null +++ b/packages/react-native-platform/android/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + + resolutionStrategy { + eachPlugin { + // Apply versions only for standalone builds, not in composite builds + if (gradle.parent == null) { + if (requested.id.id == 'com.android.library') { + useVersion('8.1.0') + } else if (requested.id.id == 'org.jetbrains.kotlin.android') { + useVersion('2.1.0') + } + } + } + } +} + +includeBuild('../../../node_modules/@react-native/gradle-plugin') + +rootProject.name = 'okta-react-native-platform' \ No newline at end of file diff --git a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/OktaReactNativePlatformPackage.kt b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/OktaReactNativePlatformPackage.kt index db1f4769..780f8b34 100644 --- a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/OktaReactNativePlatformPackage.kt +++ b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/OktaReactNativePlatformPackage.kt @@ -1,20 +1,38 @@ package com.okta.reactnativeplatform -import com.facebook.react.ReactPackage +import com.facebook.react.BaseReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.uimanager.ViewManager +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider -class OktaReactNativePlatformPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf( - TokenStorageModule(reactContext) - ) - } +class OktaReactNativePlatformPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + when (name) { + TokenStorageModule.NAME -> TokenStorageModule(reactContext) + BrowserSessionModule.NAME -> BrowserSessionModule(reactContext) + else -> null + } - @Suppress("DEPRECATION") - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return emptyList() + override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { + mapOf( + TokenStorageModule.NAME to ReactModuleInfo( + name = TokenStorageModule.NAME, + className = "com.okta.reactnativeplatform.TokenStorageModule", + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true + ), + BrowserSessionModule.NAME to ReactModuleInfo( + name = BrowserSessionModule.NAME, + className = "com.okta.reactnativeplatform.BrowserSessionModule", + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true + ) + ) } } diff --git a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/browserSession/BrowserSessionModule.kt b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/browserSession/BrowserSessionModule.kt new file mode 100644 index 00000000..542658ec --- /dev/null +++ b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/browserSession/BrowserSessionModule.kt @@ -0,0 +1,92 @@ +package com.okta.reactnativeplatform + +import android.app.Activity +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import com.facebook.react.bridge.* +import com.facebook.react.module.annotations.ReactModule + +/** + * BrowserSessionModule for opening OAuth flows in native browser + * Simply launches CustomTabsIntent - OAuth completion is handled via Linking API on JavaScript side + */ +@ReactModule(name = BrowserSessionModule.NAME) +class BrowserSessionModule(reactContext: ReactApplicationContext) : + NativeBrowserSessionBridgeSpec(reactContext) { + + companion object { + const val NAME = "BrowserSessionBridge" + } + + override fun getName(): String = NAME + + @ReactMethod + override fun openAuthSession( + url: String, + redirectScheme: String, + options: ReadableMap, + promise: Promise + ) { + // Android uses the JavaScript polyfill pattern (openBrowser + Linking API) + // This method is iOS-only but must be declared for TurboModule compatibility + promise.reject( + "platform_not_supported", + "Use openBrowser() on Android - OAuth completion is handled via JavaScript Linking API" + ) + } + + @ReactMethod + override fun openBrowser( + url: String, + options: ReadableMap, + promise: Promise + ) { + try { + val uri = Uri.parse(url) + if (uri.scheme == null || uri.host == null) { + promise.reject("invalid_url", "Invalid URL provided") + return + } + + val activity: Activity? = reactApplicationContext.currentActivity + if (activity == null) { + promise.reject("no_activity", "Current activity is not available") + return + } + + // Extract ephemeralSession option with default value of false + val ephemeralSession = if (options.hasKey("ephemeralSession")) { + options.getBoolean("ephemeralSession") + } else { + false + } + + // Set share state based on ephemeral preference + // ephemeralSession: true = isolated session (no shared cookies/auth) + // ephemeralSession: false = shared with browser (uses existing cookies/auth) + val shareState = if (ephemeralSession) { + CustomTabsIntent.SHARE_STATE_OFF + } else { + CustomTabsIntent.SHARE_STATE_ON + } + + // Launch in CustomTabsIntent (Chrome/default browser) + val customTabsIntent = CustomTabsIntent.Builder() + .setShareState(shareState) + .build() + + customTabsIntent.launchUrl(activity, uri) + + // Resolve immediately - the browser is now open + // OAuth result will come via deeplink + Linking API (JavaScript side) + val result = WritableNativeMap().apply { + putString("type", "opened") + } + promise.resolve(result) + + } catch (e: Exception) { + promise.reject("browser_session_error", "Failed to launch browser", e) + } + } +} + diff --git a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/browserSession/BrowserSessionModule.kt.disabled b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/browserSession/BrowserSessionModule.kt.disabled deleted file mode 100644 index eaeb7c60..00000000 --- a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/browserSession/BrowserSessionModule.kt.disabled +++ /dev/null @@ -1,58 +0,0 @@ -package com.okta.reactnativeplatform - -import android.app.Activity -import android.content.Intent -import android.net.Uri -import androidx.browser.customtabs.CustomTabsIntent -import com.facebook.react.bridge.* -import com.facebook.react.module.annotations.ReactModule - -@ReactModule(name = BrowserSessionModule.NAME) -class BrowserSessionModule(reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { - - companion object { - const val NAME = "BrowserSessionBridge" - } - - override fun getName(): String = NAME - - @ReactMethod - fun launchBrowserSession(url: String, promise: Promise) { - try { - val activity = currentActivity as? Activity - if (activity == null) { - promise.reject("no_activity", "Current activity is not available") - return - } - - val uri = Uri.parse(url) - if (uri.scheme == null || uri.host == null) { - promise.reject("invalid_url", "Invalid URL provided") - return - } - - val customTabsIntent = CustomTabsIntent.Builder() - .setShareState(CustomTabsIntent.SHARE_STATE_ON) - .build() - - customTabsIntent.launchUrl(activity, uri) - promise.resolve(null) - } catch (e: Exception) { - promise.reject("browser_session_error", "Failed to launch browser session", e) - } - } - - @ReactMethod - fun closeBrowserSession(promise: Promise) { - try { - val activity = currentActivity as? Activity - if (activity != null) { - activity.finish() - } - promise.resolve(null) - } catch (e: Exception) { - promise.reject("close_error", "Failed to close browser session", e) - } - } -} diff --git a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/EncryptionManager.kt b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/EncryptionManager.kt index 5b1f615d..4542bc3d 100644 --- a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/EncryptionManager.kt +++ b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/EncryptionManager.kt @@ -1,5 +1,6 @@ package com.okta.reactnativeplatform +import android.annotation.SuppressLint import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 @@ -49,43 +50,206 @@ class EncryptionManager { } /** - * Encrypts plaintext using AES-256-GCM with a random IV. - * Returns Base64-encoded (IV + ciphertext) for storage. + * Cache of key type detection to avoid repeated attempts per app session. + * Null = not yet determined, true = hardware-backed, false = software-backed + */ + private var detectedKeyType: Boolean? = null + + /** + * Deletes the existing master key from the keystore. + * Used when a key becomes unusable or needs to be regenerated. + */ + private fun deleteExistingKey() { + try { + keyStore.deleteEntry(MASTER_KEY_ALIAS) + detectedKeyType = null // Reset cache + } catch (e: Exception) { + } + } + + /** + * Determines the key type by attempting operations. + * Returns true if hardware-backed, false if software-backed. + * This is determined by which path actually works, not by external detection. + */ + private fun determineKeyType(): Boolean { + // Return cached result if already determined + detectedKeyType?.let { + return it + } + + + return try { + // Try hardware path first (no custom IV) + val testCipher = Cipher.getInstance(TRANSFORMATION) + val testKey = getMasterKey() + testCipher.init(Cipher.ENCRYPT_MODE, testKey) + + // If we got here without exception, hardware path works + detectedKeyType = true + true + } catch (hwE: Exception) { + + // Try software path (with custom IV) + try { + val testIV = ByteArray(IV_LENGTH_BYTES) + val testCipher = Cipher.getInstance(TRANSFORMATION) + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, testIV) + val testKey = getMasterKey() + testCipher.init(Cipher.ENCRYPT_MODE, testKey, gcmSpec) + + // If we got here without exception, software path works + detectedKeyType = false + false + } catch (swE: Exception) { + // Default to software-backed to allow retry with fresh key generation + detectedKeyType = false + false + } + } + } + + /** + * Encrypts plaintext using AES-256-GCM with appropriate IV handling. + * Returns Base64-encoded (IV + ciphertext) for storage when applicable. * * @param plaintext The data to encrypt - * @return Base64-encoded string of (IV + ciphertext) + * @return Base64-encoded string of encrypted data * @throws Exception if encryption fails */ fun encryptString(plaintext: String): String { val dataToEncrypt = plaintext.toByteArray(Charsets.UTF_8) - // Generate random IV - val iv = ByteArray(IV_LENGTH_BYTES) - java.security.SecureRandom().nextBytes(iv) + val isHardwareBacked = determineKeyType() - // Get cipher and apply GCM spec with IV - val cipher = Cipher.getInstance(TRANSFORMATION) - val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) - - val secretKey = getMasterKey() - cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec) - - // Encrypt data - val ciphertext = cipher.doFinal(dataToEncrypt) - - // Combine IV + ciphertext and Base64 encode - val encryptedData = iv + ciphertext - return Base64.encodeToString(encryptedData, Base64.NO_WRAP) + return if (isHardwareBacked) { + encryptWithHardwareKey(dataToEncrypt) + } else { + encryptWithSoftwareKey(dataToEncrypt) + } } /** - * Decrypts Base64-encoded (IV + ciphertext) back to plaintext. + * Encrypts using hardware-backed key. + * Hardware keystores generate their own IV. We extract it and prepend to ciphertext. + */ + private fun encryptWithHardwareKey(dataToEncrypt: ByteArray): String { + try { + val cipher = Cipher.getInstance(TRANSFORMATION) + + val secretKey = getMasterKey() + + // Init without custom IV - let hardware keystore manage it + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + // Encrypt data + val ciphertext = cipher.doFinal(dataToEncrypt) + + // Extract the IV that was generated by the hardware keystore + val generatedIV = cipher.iv + + // Combine IV + ciphertext and Base64 encode (same format as software path) + val encryptedData = (generatedIV ?: ByteArray(0)) + ciphertext + + val encoded = Base64.encodeToString(encryptedData, Base64.NO_WRAP) + return encoded + } catch (e: Exception) { + throw Exception("Hardware-backed encryption failed: ${e.message}", e) + } + } + + /** + * Encrypts using software-backed key with caller-provided IV. + * Gives us control over IV generation and storage. + * Only called when key type determination confirms software-backed. + */ + private fun encryptWithSoftwareKey(dataToEncrypt: ByteArray): String { + try { + + // Generate random IV + val iv = ByteArray(IV_LENGTH_BYTES) + java.security.SecureRandom().nextBytes(iv) + + // Get cipher and apply GCM spec with custom IV + val cipher = Cipher.getInstance(TRANSFORMATION) + + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) + + val secretKey = getMasterKey() + + cipher.init(Cipher.ENCRYPT_MODE, secretKey, gcmSpec) + + // Encrypt data + val ciphertext = cipher.doFinal(dataToEncrypt) + + // Combine IV + ciphertext and Base64 encode + val encryptedData = iv + ciphertext + + val encoded = Base64.encodeToString(encryptedData, Base64.NO_WRAP) + return encoded + } catch (e: Exception) { + throw Exception("Software-backed encryption failed: ${e.message}", e) + } + } + + /** + * Decrypts Base64-encoded encrypted data back to plaintext. + * Uses the appropriate decryption method based on key type. * - * @param encryptedString Base64-encoded (IV + ciphertext) + * @param encryptedString Base64-encoded encrypted data * @return Decrypted plaintext * @throws Exception if decryption fails or data is corrupted */ fun decryptString(encryptedString: String): String { + + val isHardwareBacked = determineKeyType() + + return if (isHardwareBacked) { + decryptWithHardwareKey(encryptedString) + } else { + decryptWithSoftwareKey(encryptedString) + } + } + + /** + * Decrypts using hardware-backed key. + * Extracts IV from the beginning of the encrypted data and provides it to the cipher. + */ + private fun decryptWithHardwareKey(encryptedString: String): String { + try { + val encryptedData = Base64.decode(encryptedString, Base64.NO_WRAP) + + // Extract IV and ciphertext + if (encryptedData.size < IV_LENGTH_BYTES) { + throw IllegalArgumentException("Encrypted data too short: missing IV") + } + + val iv = encryptedData.sliceArray(0 until IV_LENGTH_BYTES) + + val ciphertext = encryptedData.sliceArray(IV_LENGTH_BYTES until encryptedData.size) + + // Initialize cipher with the extracted IV + val cipher = Cipher.getInstance(TRANSFORMATION) + + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) + + val secretKey = getMasterKey() + + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec) + + val plaintext = cipher.doFinal(ciphertext) + + val result = String(plaintext, Charsets.UTF_8) + return result + } catch (e: Exception) { + throw Exception("Hardware-backed decryption failed: ${e.message}", e) + } + } + + /** + * Decrypts using software-backed key with caller-managed IV. + */ + private fun decryptWithSoftwareKey(encryptedString: String): String { try { // Decode from Base64 val encryptedData = Base64.decode(encryptedString, Base64.NO_WRAP) @@ -96,20 +260,25 @@ class EncryptionManager { } val iv = encryptedData.sliceArray(0 until IV_LENGTH_BYTES) + val ciphertext = encryptedData.sliceArray(IV_LENGTH_BYTES until encryptedData.size) // Initialize cipher with IV val cipher = Cipher.getInstance(TRANSFORMATION) + val gcmSpec = GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv) val secretKey = getMasterKey() + cipher.init(Cipher.DECRYPT_MODE, secretKey, gcmSpec) // Decrypt val plaintext = cipher.doFinal(ciphertext) - return String(plaintext, Charsets.UTF_8) + + val result = String(plaintext, Charsets.UTF_8) + return result } catch (e: Exception) { - throw Exception("Decryption failed: ${e.message}", e) + throw Exception("Software-backed decryption failed: ${e.message}", e) } } @@ -135,6 +304,7 @@ class EncryptionManager { * * @return Newly generated AES-256 SecretKey */ + @SuppressLint("NewApi") private fun generateMasterKey(): SecretKey { val keyGenerator = KeyGenerator.getInstance(ALGORITHM, KEYSTORE_PROVIDER) @@ -151,21 +321,29 @@ class EncryptionManager { }.build() keyGenerator.init(keySpec) - return keyGenerator.generateKey() + + val key = keyGenerator.generateKey() + return key } catch (e: Exception) { // fallback to software-backed keystore if hardware is not available - val keySpec = KeyGenParameterSpec.Builder( - MASTER_KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ).apply { - setKeySize(KEY_SIZE) - setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - setBlockModes(KeyProperties.BLOCK_MODE_GCM) - setIsStrongBoxBacked(false) - }.build() - - keyGenerator.init(keySpec) - return keyGenerator.generateKey() + try { + val keySpec = KeyGenParameterSpec.Builder( + MASTER_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ).apply { + setKeySize(KEY_SIZE) + setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + setBlockModes(KeyProperties.BLOCK_MODE_GCM) + setIsStrongBoxBacked(false) + }.build() + + keyGenerator.init(keySpec) + + val key = keyGenerator.generateKey() + return key + } catch (fallbackE: Exception) { + throw Exception("Failed to generate encryption key: ${fallbackE.message}", fallbackE) + } } } } diff --git a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/TokenStorageModule.kt b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/TokenStorageModule.kt index e5e8eab1..e744c1b3 100644 --- a/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/TokenStorageModule.kt +++ b/packages/react-native-platform/android/src/main/java/com/okta/reactnativeplatform/tokenStorage/TokenStorageModule.kt @@ -1,5 +1,6 @@ package com.okta.reactnativeplatform +import android.util.Log import com.facebook.react.bridge.* import com.facebook.react.module.annotations.ReactModule import kotlinx.coroutines.CoroutineScope @@ -9,16 +10,23 @@ import kotlinx.coroutines.launch @ReactModule(name = TokenStorageModule.NAME) class TokenStorageModule(reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { + NativeTokenStorageBridgeSpec(reactContext) { companion object { const val NAME = "TokenStorageBridge" } + init { + } + override fun getName(): String = NAME // DataStore provider for encrypted token and metadata storage - private val dataStore = TokenDataStore(reactContext) + private val dataStore = try { + TokenDataStore(reactContext) + } catch (e: Exception) { + throw e + } // CoroutineScope for async DataStore operations // Uses IO dispatcher to avoid blocking main thread @@ -28,19 +36,19 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : // MARK: - Token Operations (Secure Storage) @ReactMethod - fun saveToken(id: String, tokenData: String, promise: Promise) { + override fun saveToken(id: String, tokenData: String, promise: Promise) { scope.launch { try { dataStore.saveToken(id, tokenData) promise.resolve(null) } catch (e: Exception) { - promise.reject("token_save_error", "Failed to save token", e) + promise.reject("token_save_error", "Failed to save token: ${e.message}", e) } } } @ReactMethod - fun getToken(id: String, promise: Promise) { + override fun getToken(id: String, promise: Promise) { scope.launch { try { val token = dataStore.getToken(id) @@ -52,7 +60,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : } @ReactMethod - fun removeToken(id: String, promise: Promise) { + override fun removeToken(id: String, promise: Promise) { scope.launch { try { dataStore.removeToken(id) @@ -64,7 +72,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : } @ReactMethod - fun getAllTokenIds(promise: Promise) { + override fun getAllTokenIds(promise: Promise) { scope.launch { try { val keys = dataStore.getAllTokenIds() @@ -78,7 +86,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : } @ReactMethod - fun clearTokens(promise: Promise) { + override fun clearTokens(promise: Promise) { scope.launch { try { dataStore.clearAllTokens() @@ -92,7 +100,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : // MARK: - Metadata Operations (Regular Storage) @ReactMethod - fun saveMetadata(id: String, metadataData: String, promise: Promise) { + override fun saveMetadata(id: String, metadataData: String, promise: Promise) { scope.launch { try { dataStore.saveMetadata(id, metadataData) @@ -104,7 +112,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : } @ReactMethod - fun getMetadata(id: String, promise: Promise) { + override fun getMetadata(id: String, promise: Promise) { scope.launch { try { val metadata = dataStore.getMetadata(id) @@ -116,7 +124,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : } @ReactMethod - fun removeMetadata(id: String, promise: Promise) { + override fun removeMetadata(id: String, promise: Promise) { scope.launch { try { dataStore.removeMetadata(id) @@ -130,7 +138,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : // MARK: - Default Token ID @ReactMethod - fun setDefaultTokenId(id: String?, promise: Promise) { + override fun setDefaultTokenId(id: String?, promise: Promise) { scope.launch { try { dataStore.setDefaultTokenId(id) @@ -142,7 +150,7 @@ class TokenStorageModule(reactContext: ReactApplicationContext) : } @ReactMethod - fun getDefaultTokenId(promise: Promise) { + override fun getDefaultTokenId(promise: Promise) { scope.launch { try { val id = dataStore.getDefaultTokenId() diff --git a/packages/react-native-platform/android/src/test/kotlin/com/okta/reactnativeplatform/browserSession/BrowserSessionModuleTest.kt.disabled b/packages/react-native-platform/android/src/test/kotlin/com/okta/reactnativeplatform/browserSession/BrowserSessionModuleTest.kt.disabled deleted file mode 100644 index 05d959d7..00000000 --- a/packages/react-native-platform/android/src/test/kotlin/com/okta/reactnativeplatform/browserSession/BrowserSessionModuleTest.kt.disabled +++ /dev/null @@ -1,121 +0,0 @@ -package com.okta.reactnativeplatform - -import android.app.Application -import androidx.test.core.app.ApplicationProvider -import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.Promise -import io.mockk.mockk -import io.mockk.slot -import io.mockk.verify -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import com.google.common.truth.Truth.assertThat - -/** - * Unit tests for BrowserSessionModule. - * Tests the Kotlin implementation of browser session operations. - */ -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [34]) -class BrowserSessionModuleTest { - - private lateinit var module: BrowserSessionModule - private lateinit var context: ReactApplicationContext - - @Before - fun setUp() { - val application = ApplicationProvider.getApplicationContext() - context = ReactApplicationContext(application) - module = BrowserSessionModule(context) - } - - // MARK: - Module Info Tests - - @Test - fun testGetName_shouldReturnBrowserSessionBridge() { - val name = module.name - assertThat(name).isEqualTo("BrowserSessionBridge") - } - - // MARK: - Launch Browser Session Tests - - @Test - fun testLaunchBrowserSession_withValidUrl_shouldResolvePromise() { - val promise = mockk(relaxed = true) - - module.launchBrowserSession("https://example.com", promise) - - // Note: This test may resolve or reject depending on whether an activity is available - // In Robolectric environment, currentActivity may be null - verify { promise.reject("no_activity", "Current activity is not available") } - } - - @Test - fun testLaunchBrowserSession_withInvalidUrl_shouldRejectPromise() { - val promise = mockk(relaxed = true) - val rejectSlot = slot() - - module.launchBrowserSession("not a valid url", promise) - - verify { promise.reject(capture(rejectSlot), any()) } - assertThat(rejectSlot.captured).isEqualTo("invalid_url") - } - - @Test - fun testLaunchBrowserSession_withEmptyUrl_shouldRejectPromise() { - val promise = mockk(relaxed = true) - val rejectSlot = slot() - - module.launchBrowserSession("", promise) - - verify { promise.reject(capture(rejectSlot), any()) } - assertThat(rejectSlot.captured).isEqualTo("invalid_url") - } - - // MARK: - Close Browser Session Tests - - @Test - fun testCloseBrowserSession_shouldResolvePromise() { - val promise = mockk(relaxed = true) - - module.closeBrowserSession(promise) - - verify { promise.resolve(null) } - } - - @Test - fun testCloseBrowserSession_withoutActivity_shouldStillResolve() { - val promise = mockk(relaxed = true) - - module.closeBrowserSession(promise) - - verify { promise.resolve(null) } - } - - // MARK: - Error Handling Tests - - @Test - fun testLaunchBrowserSession_withMalformedUrl_shouldRejectInvalidUrl() { - val promise = mockk(relaxed = true) - val codeSlot = slot() - - module.launchBrowserSession("://invalid", promise) - - // Malformed URL should either be caught or handled gracefully - assertThat(promise).isNotNull() - } - - @Test - fun testLaunchBrowserSession_withSchemeOnly_shouldRejectInvalidUrl() { - val promise = mockk(relaxed = true) - val rejectSlot = slot() - - module.launchBrowserSession("https://", promise) - - verify { promise.reject(capture(rejectSlot), any()) } - assertThat(rejectSlot.captured).isEqualTo("invalid_url") - } -} diff --git a/packages/react-native-platform/ios/Package.swift b/packages/react-native-platform/ios/Package.swift deleted file mode 100644 index 0865371a..00000000 --- a/packages/react-native-platform/ios/Package.swift +++ /dev/null @@ -1,32 +0,0 @@ -// swift-tools-version:5.9 -import PackageDescription - -let package = Package( - name: "RNPlatformBridges", - platforms: [ - .iOS(.v13) - ], - products: [ - .library( - name: "RNTokenStorageBridge", - targets: ["RNTokenStorageBridge"] - ) - ], - dependencies: [], - targets: [ - .target( - name: "RNTokenStorageBridge", - path: "Sources/RNTokenStorageBridge", - exclude: ["TokenStorageBridge.m", "TokenStorageBridge.h"], - publicHeadersPath: "." - ), - .testTarget( - name: "RNTokenStorageBridgeTests", - dependencies: ["RNTokenStorageBridge"], - path: "Tests/RNTokenStorageBridgeTests" - ) - ] -) - - - diff --git a/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.h b/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.h index 51e55c84..4adbff82 100644 --- a/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.h +++ b/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.h @@ -1,13 +1,11 @@ #import -#import -@interface RCT_EXTERN_MODULE(BrowserSessionBridge, NSObject) +#ifdef RCT_NEW_ARCH_ENABLED +#import "RNTokenStorageBridge.h" -RCT_EXTERN_METHOD(launchBrowserSession:(NSString *)url - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(closeBrowserSession:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject) +@interface BrowserSessionBridge : NSObject +#else +@interface BrowserSessionBridge : NSObject +#endif @end diff --git a/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.m b/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.m index 405f1588..edcad56e 100644 --- a/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.m +++ b/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.m @@ -1,5 +1,10 @@ -#import "BrowserSessionBridge.h" +#import -@implementation BrowserSessionBridge +@interface RCT_EXTERN_MODULE(BrowserSessionBridge, NSObject) + +RCT_EXTERN_METHOD(openAuthSession:(NSString *)url + redirectScheme:(NSString *)redirectScheme + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) @end diff --git a/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.swift b/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.swift index e2726bc1..fb6e5d8b 100644 --- a/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.swift +++ b/packages/react-native-platform/ios/Sources/RNBrowserSessionBridge/BrowserSessionBridge.swift @@ -1,95 +1,181 @@ import Foundation +import AuthenticationServices +import React #if os(iOS) -import WebKit import UIKit #endif -// Type aliases for Promise-like callbacks (compatible with React Native) -typealias PromiseResolveBlock = (Any?) -> Void -typealias PromiseRejectBlock = (String, String, Error?) -> Void - #if os(iOS) -@objc(BrowserSessionBridge) -class BrowserSessionBridge: NSObject { - - override init() { - super.init() - print("✅ BrowserSessionBridge initialized!") - } - - @objc - static func requiresMainQueueSetup() -> Bool { - return true // WebView operations must happen on main thread - } - @objc - static func moduleName() -> String! { - return "BrowserSessionBridge" +// Result types matching expo-web-browser +@objc(BrowserSessionResult) +class BrowserSessionResult: NSObject { + @objc var type: String + @objc var url: String? + + init(type: String, url: String? = nil) { + self.type = type + self.url = url + } + + func toDictionary() -> [String: Any] { + var dict: [String: Any] = ["type": type] + if let url = url { + dict["url"] = url } + return dict + } +} - @objc - func constantsToExport() -> [String: Any]! { - return [ - "isAvailable": true - ] +// Main React Native module +@available(iOS 12.0, *) +@objc(BrowserSessionBridge) +class BrowserSessionBridge: NSObject { + + @objc + static func moduleName() -> String! { + return "BrowserSessionBridge" + } + + @objc + static func requiresMainQueueSetup() -> Bool { + return true + } + + private var authSession: ASWebAuthenticationSession? + + @objc(openAuthSession:redirectScheme:options:resolve:reject:) + func openAuthSession( + _ url: String, + redirectScheme: String, + options: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + guard let urlObj = URL(string: url) else { + reject("invalid_url", "Invalid URL provided", nil) + return } - - // MARK: - Browser Session Operations - - @objc(launchBrowserSession:resolve:reject:) - func launchBrowserSession(_ url: String, resolve: @escaping PromiseResolveBlock, reject: @escaping PromiseRejectBlock) { - do { - guard let urlObj = URL(string: url) else { - reject("invalid_url", "Invalid URL provided", nil) - return - } - - DispatchQueue.main.async { - let webViewController = UIViewController() - let webView = WKWebView(frame: webViewController.view.bounds) - webView.load(URLRequest(url: urlObj)) - - webViewController.view.addSubview(webView) - - // Get the key window and present the view controller - if let keyWindow = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first?.windows - .first(where: { $0.isKeyWindow }) { - keyWindow.rootViewController?.present(webViewController, animated: true) { - resolve(nil) - } - } else { - reject("no_window", "Could not find key window", nil) - } - } + + // Extract ephemeralSession option with default value of false + let ephemeralSession = (options["ephemeralSession"] as? NSNumber)?.boolValue ?? false + + DispatchQueue.main.async { [weak self] in + guard let self = self else { + reject("no_window", "BrowserSessionBridge is not available", nil) + return + } + + // Track if promise was already resolved to avoid double-resolve issues + var completed = false + + // Use ASWebAuthenticationSession for OAuth + let authSession = ASWebAuthenticationSession( + url: urlObj, + callbackURLScheme: redirectScheme + ) { [weak self] callbackURL, error in + // Prevent double-resolution + guard !completed else { return } + completed = true + + defer { + // Clean up the reference to allow deallocation + self?.authSession = nil } + + // Session completed or was cancelled + if let error = error { + // Check if it's a cancellation or a real error + if let authError = error as? ASWebAuthenticationSessionError, + authError.code == .canceledLogin { + resolve(BrowserSessionResult(type: "cancel").toDictionary()) + } else { + reject("browser_session_error", error.localizedDescription, error) + } + return + } + + if let callbackURL = callbackURL { + // OAuth flow completed, return the redirect URL + resolve(BrowserSessionResult(type: "success", url: callbackURL.absoluteString).toDictionary()) + } else { + // Shouldn't happen, but handle it + resolve(BrowserSessionResult(type: "cancel").toDictionary()) + } + } + + // Set presentation context provider for iOS 13+ + if #available(iOS 13.0, *) { + authSession.presentationContextProvider = self + authSession.prefersEphemeralWebBrowserSession = ephemeralSession + } + + // Cancel any existing session before starting a new one + if let existingSession = self.authSession { + existingSession.cancel() + } + + // Keep a reference to prevent deallocation + self.authSession = authSession + + // Start the authentication session + if authSession.start() { + } else { + guard !completed else { return } + completed = true + reject("browser_session_error", "Failed to start authentication session", nil) + self.authSession = nil + } } + } +} - @objc(closeBrowserSession:reject:) - func closeBrowserSession(_ resolve: @escaping PromiseResolveBlock, reject: @escaping PromiseRejectBlock) { - DispatchQueue.main.async { - if let presentedViewController = UIApplication.shared.connectedScenes - .compactMap({ $0 as? UIWindowScene }) - .first?.windows - .first(where: { $0.isKeyWindow })?.rootViewController?.presentedViewController { - presentedViewController.dismiss(animated: true) { - resolve(nil) - } - } else { - resolve(nil) // Already closed - } - } +// MARK: - ASWebAuthenticationPresentationContextProviding +@available(iOS 13.0, *) +extension BrowserSessionBridge: ASWebAuthenticationPresentationContextProviding { + @objc + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + // Return the key window as the anchor for presentation + if let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.windows + .first(where: { $0.isKeyWindow }) { + return window + } + + // Fallback to any available window + if let window = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first?.windows + .first { + return window } + + // Last resort - create a new window + return UIWindow() + } } + #else -// Stub for non-iOS platforms (for SPM testing on macOS) + +// Stub for non-iOS platforms @objc(BrowserSessionBridge) class BrowserSessionBridge: NSObject { - @objc static func requiresMainQueueSetup() -> Bool { return false } - @objc static func moduleName() -> String! { return "BrowserSessionBridge" } - @objc func constantsToExport() -> [String: Any]! { return ["isAvailable": false] } + @objc static func moduleName() -> String! { return "BrowserSessionBridge" } + @objc static func requiresMainQueueSetup() -> Bool { return false } + + @objc(openAuthSession:redirectScheme:options:resolve:reject:) + func openAuthSession( + _ url: String, + redirectScheme: String, + options: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + reject("platform_not_supported", "BrowserSessionBridge is only available on iOS", nil) + } } + #endif diff --git a/packages/react-native-platform/ios/Sources/RNTokenStorageBridge/KeychainHelper.swift b/packages/react-native-platform/ios/Sources/RNTokenStorageBridge/KeychainHelper.swift new file mode 100644 index 00000000..6fb3e0e5 --- /dev/null +++ b/packages/react-native-platform/ios/Sources/RNTokenStorageBridge/KeychainHelper.swift @@ -0,0 +1,115 @@ +import Foundation +import Security + +class KeychainHelper { + + static func save( + service: String, + key: String, + value: String, + accessibility: CFString + ) throws { + guard let data = value.data(using: .utf8) else { + throw NSError(domain: "KeychainHelperError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to encode string to UTF-8"]) + } + + // Query for adding new item + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: accessibility + ] + + // First try to delete any existing item (use minimal query) + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + SecItemDelete(deleteQuery as CFDictionary) + + // Now add the new item + let status = SecItemAdd(addQuery as CFDictionary, nil) + guard status == errSecSuccess else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + + static func load(service: String, key: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + return nil + } + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + + guard let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + + return value + } + + static func delete(service: String, key: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } + + static func allKeys(service: String) throws -> [String] { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status == errSecItemNotFound { + return [] + } + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + + guard let items = result as? [[String: Any]] else { + return [] + } + + return items.compactMap { $0[kSecAttrAccount as String] as? String } + } + + static func clearAll(service: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) + } + } +} diff --git a/packages/react-native-platform/ios/Sources/RNTokenStorageBridge/TokenStorageBridge.swift b/packages/react-native-platform/ios/Sources/RNTokenStorageBridge/TokenStorageBridge.swift index 46107f4c..96b4ce9a 100644 --- a/packages/react-native-platform/ios/Sources/RNTokenStorageBridge/TokenStorageBridge.swift +++ b/packages/react-native-platform/ios/Sources/RNTokenStorageBridge/TokenStorageBridge.swift @@ -1,27 +1,13 @@ import Foundation import Security - -// Type aliases for Promise-like callbacks (compatible with React Native) -typealias PromiseResolveBlock = (Any?) -> Void -typealias PromiseRejectBlock = (String, String, Error?) -> Void +import React @objc(TokenStorageBridge) -class TokenStorageBridge: NSObject { +class TokenStorageBridge: NSObject, RCTBridgeModule { override init() { - super.init() - print("✅ TokenStorageBridge initialized!") - // Debug: Print all methods - let methodCount = UnsafeMutablePointer.allocate(capacity: 1) - let methods = class_copyMethodList(type(of: self), methodCount) - print("📋 TokenStorageBridge methods:") - for i in 0.. String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - kSecReturnData as String: true - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess else { - if status == errSecItemNotFound { - return nil - } - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - - guard let data = result as? Data, - let value = String(data: data, encoding: .utf8) else { - return nil - } - - return value - } - - static func delete(service: String, key: String) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key - ] - - // Loop to delete all items matching the query (defensive against duplicates) - while true { - let status = SecItemDelete(query as CFDictionary) - if status == errSecItemNotFound { - break - } else if status != errSecSuccess { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - // If successful, loop again to ensure all items are deleted - } - } - - static func allKeys(service: String) throws -> [String] { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecReturnAttributes as String: true, - kSecMatchLimit as String: kSecMatchLimitAll - ] - - var result: AnyObject? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess else { - if status == errSecItemNotFound { - return [] - } - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - - guard let items = result as? [[String: Any]] else { - return [] - } - - return items.compactMap { $0[kSecAttrAccount as String] as? String } - } - - static func clearAll(service: String) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service - ] - - // SecItemDelete only deletes one item per call, so loop until empty - while true { - let status = SecItemDelete(query as CFDictionary) - if status == errSecItemNotFound { - // No more items to delete - break - } else if status != errSecSuccess { - throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) - } - // If successful, loop again to delete next item + resolve(nil as Any?) } } } diff --git a/packages/react-native-platform/ios/Tests/RNBrowserSessionBridgeTests/BrowserSessionBridgeTests.swift b/packages/react-native-platform/ios/Tests/RNBrowserSessionBridgeTests/BrowserSessionBridgeTests.swift deleted file mode 100644 index 34af341d..00000000 --- a/packages/react-native-platform/ios/Tests/RNBrowserSessionBridgeTests/BrowserSessionBridgeTests.swift +++ /dev/null @@ -1,110 +0,0 @@ -import XCTest -@testable import RNBrowserSessionBridge - -class BrowserSessionBridgeTests: XCTestCase { - - var sut: BrowserSessionBridge! - - override func setUp() { - super.setUp() - sut = BrowserSessionBridge() - } - - override func tearDown() { - super.tearDown() - sut = nil - } - - // MARK: - Initialization Tests - - func testInit_shouldSucceed() { - XCTAssertNotNil(sut) - } - - func testRequiresMainQueueSetup_shouldReturnValue() { - XCTAssertFalse(BrowserSessionBridge.requiresMainQueueSetup()) - } - - func testModuleName_shouldReturnBrowserSessionBridge() { - XCTAssertEqual(BrowserSessionBridge.moduleName(), "BrowserSessionBridge") - } - - func testConstantsToExport_shouldBeNotNil() { - let constants = sut.constantsToExport() - XCTAssertNotNil(constants) - } - - #if os(iOS) - // MARK: - Launch Browser Session Tests (iOS only) - - func testLaunchBrowserSession_withValidUrl_shouldResolve() { - let expectation = XCTestExpectation(description: "Launch browser session") - - sut.launchBrowserSession("https://example.com") { result in - XCTAssertNil(result) - expectation.fulfill() - } reject: { code, message, error in - XCTFail("Should not reject: \(message ?? "unknown error")") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 2.0) - } - - func testLaunchBrowserSession_withInvalidUrl_shouldReject() { - let expectation = XCTestExpectation(description: "Launch with invalid URL") - - sut.launchBrowserSession("not a url") { result in - XCTFail("Should not resolve") - expectation.fulfill() - } reject: { code, message, error in - XCTAssertEqual(code, "invalid_url") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 2.0) - } - - // MARK: - Close Browser Session Tests (iOS only) - - func testCloseBrowserSession_shouldResolve() { - let expectation = XCTestExpectation(description: "Close browser session") - - sut.closeBrowserSession { result in - XCTAssertNil(result) - expectation.fulfill() - } reject: { code, message, error in - XCTFail("Should resolve") - expectation.fulfill() - } - - wait(for: [expectation], timeout: 2.0) - } - - // MARK: - Integration Tests (iOS only) - - func testLaunchAndCloseBrowserSession() { - let launchExpectation = XCTestExpectation(description: "Launch") - let closeExpectation = XCTestExpectation(description: "Close") - - sut.launchBrowserSession("https://example.com") { _ in - launchExpectation.fulfill() - } reject: { _, _, _ in - XCTFail("Launch should not reject") - launchExpectation.fulfill() - } - - wait(for: [launchExpectation], timeout: 2.0) - - sut.closeBrowserSession { _ in - closeExpectation.fulfill() - } reject: { _, _, _ in - XCTFail("Close should not reject") - closeExpectation.fulfill() - } - - wait(for: [closeExpectation], timeout: 2.0) - } - #endif -} - diff --git a/packages/react-native-platform/ios/Tests/RNTokenStorageBridgeTests/KeychainHelperTests.swift b/packages/react-native-platform/ios/Tests/RNTokenStorageBridgeTests/KeychainHelperTests.swift deleted file mode 100644 index c75c62b7..00000000 --- a/packages/react-native-platform/ios/Tests/RNTokenStorageBridgeTests/KeychainHelperTests.swift +++ /dev/null @@ -1,327 +0,0 @@ -import XCTest -import Security -@testable import RNTokenStorageBridge - -class KeychainHelperTests: XCTestCase { - - let testService = "com.okta.test.service" - let testKey = "test-key" - let testValue = "test-value" - - override func setUp() { - super.setUp() - cleanupTestKeychain() - } - - override func tearDown() { - super.tearDown() - cleanupTestKeychain() - } - - // MARK: - Save Tests - - func testSave_shouldStoreValueInKeychain() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - - XCTAssertNoThrow({ - try KeychainHelper.save( - service: self.testService, - key: self.testKey, - value: self.testValue, - accessibility: accessibility - ) - }) - } - - func testSave_withDifferentAccessibility_shouldSucceed() { - let restrictions = [ - kSecAttrAccessibleWhenUnlockedThisDeviceOnly, - kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - ] - - for accessibility in restrictions { - let uniqueKey = "\(testKey)-\(UUID().uuidString)" - XCTAssertNoThrow({ - try KeychainHelper.save( - service: self.testService, - key: uniqueKey, - value: self.testValue, - accessibility: accessibility - ) - }) - } - } - - // MARK: - Load Tests - - func testLoad_savedValue_shouldReturnValue() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - - try? KeychainHelper.save( - service: testService, - key: testKey, - value: testValue, - accessibility: accessibility - ) - - let result = try? KeychainHelper.load(service: testService, key: testKey) - - XCTAssertEqual(result, testValue) - } - - func testLoad_nonExistent_shouldReturnNil() { - let result = try? KeychainHelper.load(service: testService, key: "non-existent") - - XCTAssertNil(result) - } - - func testLoad_afterSaveAndDelete_shouldReturnNil() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - - try? KeychainHelper.save( - service: testService, - key: testKey, - value: testValue, - accessibility: accessibility - ) - - try? KeychainHelper.delete(service: testService, key: testKey) - - let result = try? KeychainHelper.load(service: testService, key: testKey) - - XCTAssertNil(result) - } - - // MARK: - Delete Tests - - func testDelete_savedValue_shouldRemoveValue() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let uniqueKey = "isolated-delete-test-\(UUID().uuidString)" - let testData = "isolated-delete-data" - - // Step 1: Save - do { - try KeychainHelper.save( - service: self.testService, - key: uniqueKey, - value: testData, - accessibility: accessibility - ) - } catch { - XCTFail("Save should not throw: \(error)") - return - } - - // Step 2: Verify save worked - do { - let savedValue = try KeychainHelper.load(service: self.testService, key: uniqueKey) - XCTAssertEqual(savedValue, testData) - } catch { - XCTFail("Load after save should not throw: \(error)") - return - } - - // Step 3: Delete - do { - try KeychainHelper.delete(service: self.testService, key: uniqueKey) - } catch { - XCTFail("Delete should not throw: \(error)") - return - } - - // Step 4: Verify delete worked - do { - let deletedValue = try KeychainHelper.load(service: self.testService, key: uniqueKey) - XCTAssertNil(deletedValue) - } catch { - XCTFail("Load after delete should not throw: \(error)") - return - } - } - - func testDelete_nonExistent_shouldNotThrow() { - XCTAssertNoThrow({ - try KeychainHelper.delete(service: self.testService, key: "non-existent") - }) - } - - func testDelete_multipleItems_shouldDeleteOnlyTarget() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let key1 = "key-1" - let key2 = "key-2" - let value1 = "value-1" - let value2 = "value-2" - - try? KeychainHelper.save(service: testService, key: key1, value: value1, accessibility: accessibility) - try? KeychainHelper.save(service: testService, key: key2, value: value2, accessibility: accessibility) - - try? KeychainHelper.delete(service: testService, key: key1) - - let result1 = try? KeychainHelper.load(service: testService, key: key1) - let result2 = try? KeychainHelper.load(service: testService, key: key2) - - XCTAssertNil(result1) - XCTAssertEqual(result2, value2) - } - - // MARK: - AllKeys Tests - - func testAllKeys_emptyService_shouldReturnEmptyArray() { - let result = try? KeychainHelper.allKeys(service: testService) - - XCTAssertEqual(result, []) - } - - func testAllKeys_withMultipleItems_shouldReturnAllKeys() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let keys = ["key-1", "key-2", "key-3"] - - for key in keys { - try? KeychainHelper.save( - service: testService, - key: key, - value: "value-\(key)", - accessibility: accessibility - ) - } - - let result = try? KeychainHelper.allKeys(service: testService) - - XCTAssertEqual(Set(result ?? []), Set(keys)) - } - - func testAllKeys_afterDelete_shouldNotIncludeDeletedKey() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let keys = ["key-1", "key-2"] - - for key in keys { - try? KeychainHelper.save( - service: testService, - key: key, - value: "value", - accessibility: accessibility - ) - } - - try? KeychainHelper.delete(service: testService, key: "key-1") - - let result = try? KeychainHelper.allKeys(service: testService) - - XCTAssertEqual(result, ["key-2"]) - } - - // MARK: - ClearAll Tests - - func testClearAll_emptyService_shouldNotThrow() { - XCTAssertNoThrow({ - try KeychainHelper.clearAll(service: self.testService) - }) - } - - func testClearAll_withItems_shouldRemoveAllItems() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let keys = ["key-1", "key-2", "key-3"] - - for key in keys { - try? KeychainHelper.save( - service: testService, - key: key, - value: "value", - accessibility: accessibility - ) - } - - try? KeychainHelper.clearAll(service: testService) - - let result = try? KeychainHelper.allKeys(service: testService) - - XCTAssertEqual(result, []) - } - - // MARK: - Data Integrity Tests - - func testSaveAndLoad_preservesData() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let testData = "Complex data with special characters: !@#$%^&*()" - - try? KeychainHelper.save( - service: testService, - key: testKey, - value: testData, - accessibility: accessibility - ) - - let result = try? KeychainHelper.load(service: testService, key: testKey) - - XCTAssertEqual(result, testData) - } - - func testSaveAndLoad_largeData() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - let largeData = String(repeating: "x", count: 10000) - - try? KeychainHelper.save( - service: testService, - key: testKey, - value: largeData, - accessibility: accessibility - ) - - let result = try? KeychainHelper.load(service: testService, key: testKey) - - XCTAssertEqual(result, largeData) - } - - func testSaveAndLoad_emptyString() { - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - - try? KeychainHelper.save( - service: testService, - key: testKey, - value: "", - accessibility: accessibility - ) - - let result = try? KeychainHelper.load(service: testService, key: testKey) - - XCTAssertEqual(result, "") - } - - // MARK: - Service Isolation Tests - - func testDifferentServices_shouldBeIsolated() { - let service1 = "com.okta.service1" - let service2 = "com.okta.service2" - let accessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - - try? KeychainHelper.save( - service: service1, - key: testKey, - value: "value1", - accessibility: accessibility - ) - - try? KeychainHelper.save( - service: service2, - key: testKey, - value: "value2", - accessibility: accessibility - ) - - let result1 = try? KeychainHelper.load(service: service1, key: testKey) - let result2 = try? KeychainHelper.load(service: service2, key: testKey) - - XCTAssertEqual(result1, "value1") - XCTAssertEqual(result2, "value2") - - // Cleanup - try? KeychainHelper.clearAll(service: service1) - try? KeychainHelper.clearAll(service: service2) - } - - // MARK: - Helper Methods - - private func cleanupTestKeychain() { - try? KeychainHelper.clearAll(service: testService) - } -} diff --git a/packages/react-native-platform/ios/Tests/RNTokenStorageBridgeTests/TokenStorageBridgeTests.swift b/packages/react-native-platform/ios/Tests/RNTokenStorageBridgeTests/TokenStorageBridgeTests.swift deleted file mode 100644 index 23f3b09f..00000000 --- a/packages/react-native-platform/ios/Tests/RNTokenStorageBridgeTests/TokenStorageBridgeTests.swift +++ /dev/null @@ -1,377 +0,0 @@ -import XCTest -import Security -@testable import RNTokenStorageBridge - -class TokenStorageBridgeTests: XCTestCase { - - var sut: TokenStorageBridge! - - override func setUp() { - super.setUp() - sut = TokenStorageBridge() - cleanupKeychain() - } - - override func tearDown() { - super.tearDown() - cleanupKeychain() - } - - // MARK: - Token Operations Tests - - func testSaveToken_shouldSucceed() { - let expectation = XCTestExpectation(description: "Save token") - - sut.saveToken("test-id", tokenData: "test-token-data", resolve: { _ in - expectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: 1.0) - } - - func testSaveAndGetToken_shouldReturnSavedToken() { - let saveExpectation = XCTestExpectation(description: "Save token") - let getExpectation = XCTestExpectation(description: "Get token") - var savedTokenValue: String? - - sut.saveToken("test-id", tokenData: "test-token-data", resolve: { _ in - saveExpectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Save should not reject") - saveExpectation.fulfill() - }) - - wait(for: [saveExpectation], timeout: 1.0) - - sut.getToken("test-id", resolve: { token in - savedTokenValue = token as? String - getExpectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Get should not reject") - getExpectation.fulfill() - }) - - wait(for: [getExpectation], timeout: 1.0) - XCTAssertEqual(savedTokenValue, "test-token-data") - } - - func testGetToken_nonExistent_shouldReturnNil() { - let expectation = XCTestExpectation(description: "Get non-existent token") - var result: String? - - sut.getToken("non-existent", resolve: { token in - result = token as? String - expectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: 1.0) - XCTAssertNil(result) - } - - func testRemoveToken_shouldRemoveTokenAndMetadata() { - let saveTokenExp = XCTestExpectation(description: "Save token") - let saveMetaExp = XCTestExpectation(description: "Save metadata") - let removeExp = XCTestExpectation(description: "Remove token") - let getTokenExp = XCTestExpectation(description: "Get removed token") - var retrievedToken: String? - - sut.saveToken("test-id", tokenData: "test-data", resolve: { _ in - saveTokenExp.fulfill() - }, reject: { _, _, _ in - XCTFail("Save token should not reject") - saveTokenExp.fulfill() - }) - - sut.saveMetadata("test-id", metadataData: "test-meta", resolve: { _ in - saveMetaExp.fulfill() - }, reject: { _, _, _ in - XCTFail("Save metadata should not reject") - saveMetaExp.fulfill() - }) - - wait(for: [saveTokenExp, saveMetaExp], timeout: 1.0) - - sut.removeToken("test-id", resolve: { _ in - removeExp.fulfill() - }, reject: { _, _, _ in - XCTFail("Remove should not reject") - removeExp.fulfill() - }) - - wait(for: [removeExp], timeout: 1.0) - - sut.getToken("test-id", resolve: { token in - retrievedToken = token as? String - getTokenExp.fulfill() - }, reject: { _, _, _ in - XCTFail("Get should not reject") - getTokenExp.fulfill() - }) - - wait(for: [getTokenExp], timeout: 1.0) - XCTAssertNil(retrievedToken) - } - - func testGetAllTokenIds_emptyStorage_shouldReturnEmptyArray() { - let expectation = XCTestExpectation(description: "Get all token IDs") - var result: [String]? - - sut.getAllTokenIds({ ids in - result = ids as? [String] - expectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: 1.0) - XCTAssertEqual(result, []) - } - - func testGetAllTokenIds_withMultipleTokens_shouldReturnIds() { - let save1Exp = XCTestExpectation(description: "Save token 1") - let save2Exp = XCTestExpectation(description: "Save token 2") - let getAllExp = XCTestExpectation(description: "Get all IDs") - var result: [String]? - - sut.saveToken("token-1", tokenData: "data-1", resolve: { _ in - save1Exp.fulfill() - }, reject: { _, _, _ in - XCTFail("Save 1 should not reject") - save1Exp.fulfill() - }) - - sut.saveToken("token-2", tokenData: "data-2", resolve: { _ in - save2Exp.fulfill() - }, reject: { _, _, _ in - XCTFail("Save 2 should not reject") - save2Exp.fulfill() - }) - - wait(for: [save1Exp, save2Exp], timeout: 1.0) - - sut.getAllTokenIds({ ids in - result = ids as? [String] - getAllExp.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - getAllExp.fulfill() - }) - - wait(for: [getAllExp], timeout: 1.0) - XCTAssertEqual(Set(result ?? []), Set(["token-1", "token-2"])) - } - - func testClearTokens_shouldRemoveAllTokens() { - let save1Exp = XCTestExpectation(description: "Save token 1") - let save2Exp = XCTestExpectation(description: "Save token 2") - let clearExp = XCTestExpectation(description: "Clear tokens") - let getAllExp = XCTestExpectation(description: "Get all IDs after clear") - var result: [String]? - - sut.saveToken("token-1", tokenData: "data-1", resolve: { _ in - save1Exp.fulfill() - }, reject: { _, _, _ in - save1Exp.fulfill() - }) - - sut.saveToken("token-2", tokenData: "data-2", resolve: { _ in - save2Exp.fulfill() - }, reject: { _, _, _ in - save2Exp.fulfill() - }) - - wait(for: [save1Exp, save2Exp], timeout: 1.0) - - sut.clearTokens({ _ in - clearExp.fulfill() - }, reject: { _, _, _ in - XCTFail("Clear should not reject") - clearExp.fulfill() - }) - - wait(for: [clearExp], timeout: 1.0) - - sut.getAllTokenIds({ ids in - result = ids as? [String] - getAllExp.fulfill() - }, reject: { _, _, _ in - getAllExp.fulfill() - }) - - wait(for: [getAllExp], timeout: 1.0) - XCTAssertEqual(result, []) - } - - // MARK: - Metadata Operations Tests - - func testSaveMetadata_shouldSucceed() { - let expectation = XCTestExpectation(description: "Save metadata") - - sut.saveMetadata("test-id", metadataData: "test-metadata", resolve: { _ in - expectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: 1.0) - } - - func testSaveAndGetMetadata_shouldReturnSavedMetadata() { - let saveExpectation = XCTestExpectation(description: "Save metadata") - let getExpectation = XCTestExpectation(description: "Get metadata") - var savedMetadata: String? - - sut.saveMetadata("test-id", metadataData: "test-metadata", resolve: { _ in - saveExpectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Save should not reject") - saveExpectation.fulfill() - }) - - wait(for: [saveExpectation], timeout: 1.0) - - sut.getMetadata("test-id", resolve: { metadata in - savedMetadata = metadata as? String - getExpectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Get should not reject") - getExpectation.fulfill() - }) - - wait(for: [getExpectation], timeout: 1.0) - XCTAssertEqual(savedMetadata, "test-metadata") - } - - func testGetMetadata_nonExistent_shouldReturnNil() { - let expectation = XCTestExpectation(description: "Get non-existent metadata") - var result: String? - - sut.getMetadata("non-existent", resolve: { metadata in - result = metadata as? String - expectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: 1.0) - XCTAssertNil(result) - } - - func testRemoveMetadata_shouldRemoveMetadata() { - let saveExpectation = XCTestExpectation(description: "Save metadata") - let removeExpectation = XCTestExpectation(description: "Remove metadata") - let getExpectation = XCTestExpectation(description: "Get removed metadata") - var result: String? - - sut.saveMetadata("test-id", metadataData: "test-metadata", resolve: { _ in - saveExpectation.fulfill() - }, reject: { _, _, _ in - saveExpectation.fulfill() - }) - - wait(for: [saveExpectation], timeout: 1.0) - - sut.removeMetadata("test-id", resolve: { _ in - removeExpectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Remove should not reject") - removeExpectation.fulfill() - }) - - wait(for: [removeExpectation], timeout: 1.0) - - sut.getMetadata("test-id", resolve: { metadata in - result = metadata as? String - getExpectation.fulfill() - }, reject: { _, _, _ in - getExpectation.fulfill() - }) - - wait(for: [getExpectation], timeout: 1.0) - XCTAssertNil(result) - } - - // MARK: - Default Token ID Tests - - func testSetDefaultTokenId_shouldSucceed() { - let expectation = XCTestExpectation(description: "Set default token ID") - - sut.setDefaultTokenId("default-id", resolve: { _ in - expectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: 1.0) - } - - func testSetAndGetDefaultTokenId_shouldReturnSavedId() { - let setExpectation = XCTestExpectation(description: "Set default") - let getExpectation = XCTestExpectation(description: "Get default") - var savedId: String? - - sut.setDefaultTokenId("default-id", resolve: { _ in - setExpectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Set should not reject") - setExpectation.fulfill() - }) - - wait(for: [setExpectation], timeout: 1.0) - - sut.getDefaultTokenId({ id in - savedId = id as? String - getExpectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Get should not reject") - getExpectation.fulfill() - }) - - wait(for: [getExpectation], timeout: 1.0) - XCTAssertEqual(savedId, "default-id") - } - - func testGetDefaultTokenId_notSet_shouldReturnNil() { - let expectation = XCTestExpectation(description: "Get default not set") - var result: String? - - sut.getDefaultTokenId({ id in - result = id as? String - expectation.fulfill() - }, reject: { _, _, _ in - XCTFail("Should not reject") - expectation.fulfill() - }) - - wait(for: [expectation], timeout: 1.0) - XCTAssertNil(result) - } - - // MARK: - Helper Methods - - private func cleanupKeychain() { - let services = [ - "com.okta.auth-foundation.tokens", - "com.okta.auth-foundation.metadata", - "com.okta.auth-foundation.default" - ] - - for service in services { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service - ] - SecItemDelete(query as CFDictionary) - } - } -} diff --git a/packages/react-native-platform/package.json b/packages/react-native-platform/package.json index 91dd0639..163a8774 100644 --- a/packages/react-native-platform/package.json +++ b/packages/react-native-platform/package.json @@ -1,6 +1,7 @@ { "name": "@okta/react-native-platform", "version": "0.7.0", + "author": "developer@okta.com", "type": "module", "main": "dist/esm/index.js", "module": "dist/esm/index.js", @@ -15,11 +16,13 @@ "files": [ "./LICENSE", "./dist", - "./ios", + "./ios/Sources", + "./ios/Package.swift", "./android", "./cpp", "*.md", "package.json", + "react-native.config.js", "react-native-platform.podspec" ], "exports": { @@ -77,7 +80,7 @@ } }, "codegenConfig": { - "name": "RNTokenStorageSpec", + "name": "RNOktaPlatformSpecs", "type": "modules", "jsSrcsDir": "src/specs", "android": { diff --git a/packages/react-native-platform/react-native-platform.podspec b/packages/react-native-platform/react-native-platform.podspec index ebbcfa05..76128d7e 100644 --- a/packages/react-native-platform/react-native-platform.podspec +++ b/packages/react-native-platform/react-native-platform.podspec @@ -9,11 +9,12 @@ Pod::Spec.new do |s| s.description = package['description'] || "Okta authentication platform for React Native" s.homepage = "https://github.com/okta/okta-client-javascript" s.license = package['license'] - s.authors = { "Okta" => "jared.perreault@okta.com" } + s.authors = package["author"] s.platforms = { :ios => "13.0" } s.source = { :git => "https://github.com/okta/okta-client-javascript.git", :tag => "v#{s.version}" } - s.source_files = "ios/**/*.{h,m,mm,swift}" + s.source_files = "ios/Sources/**/*.{h,m,mm,swift}" + s.exclude_files = "ios/Tests/**/*" s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', diff --git a/packages/react-native-platform/react-native.config.js b/packages/react-native-platform/react-native.config.js new file mode 100644 index 00000000..f53c6c5a --- /dev/null +++ b/packages/react-native-platform/react-native.config.js @@ -0,0 +1,10 @@ +module.exports = { + dependency: { + platforms: { + android: { + packageImportPath: 'import com.okta.reactnativeplatform.OktaReactNativePlatformPackage;', + packageInstance: 'new OktaReactNativePlatformPackage()', + }, + }, + }, +}; diff --git a/packages/react-native-platform/src/BrowserSession/index.ts b/packages/react-native-platform/src/BrowserSession/index.ts new file mode 100644 index 00000000..c7babf20 --- /dev/null +++ b/packages/react-native-platform/src/BrowserSession/index.ts @@ -0,0 +1,152 @@ +/** + * Browser Session API for React Native + * Opens an OAuth flow in a native browser (Safari on iOS, Chrome on Android) + * Based on the expo-web-browser openAuthSessionAsync API + * + * @packageDocumentation + */ + +import { Linking, Platform } from 'react-native'; +import NativeBrowserSessionBridgeSpec from '../specs/NativeBrowserSessionBridge.ts'; +import type { BrowserSessionResult } from './types.ts'; +import type { BrowserSessionOptions } from '../specs/NativeBrowserSessionBridge.ts'; + +export type * from './types.ts'; +export type { BrowserSessionOptions }; + + +// iOS: Use native ASWebAuthenticationSession which handles OAuth natively +// No Linking listener needed - native session intercepts redirects +async function openAuthSessionIOS ( + url: string, + redirectUri: string, + options: BrowserSessionOptions +): Promise { + // Extract the scheme from redirectUri if it contains :// + // e.g., 'com.example://callback' -> 'com.example' + let redirectScheme = redirectUri; + const schemeMatch = redirectUri.match(/^([^:/]+)/); + if (schemeMatch) { + redirectScheme = schemeMatch[1]; + } + + return await NativeBrowserSessionBridgeSpec.openAuthSession(url, redirectScheme, options); +} + +async function openAuthSessionAndroid ( + url: string, + redirectUri: string, + options: BrowserSessionOptions +): Promise { + + let resolver: (value: BrowserSessionResult) => void; + const deepLinkPromise = new Promise ((resolve) => { + resolver = resolve; + }); + + const subscription = Linking.addEventListener('url', ({ url: deepLinkUrl }) => { + if (deepLinkUrl.startsWith(redirectUri)) { + resolver({ + type: 'success', + url: deepLinkUrl, + }); + } + }); + + // Browser promise - just opens the browser, doesn't wait for result + // Android CustomTabsIntent launches immediately, returns opened status + const browserPromise = NativeBrowserSessionBridgeSpec.openBrowser(url, options) + .then(() => { + // return a promise that never resolves + return new Promise(() => {}); + }) + .catch((error) => { + // If browser fails to open, return cancel + return { type: 'cancel' as const }; + }); + + // TODO: throw if browser fails to open, listen for close + + // If deeplink arrives, return success; if browser closes without redirect, return cancel + try { + return await Promise.race([ + deepLinkPromise, // only resolves when deeplink (redirectUri) is navigated to + browserPromise // only throws when user closes browser window + ]); + } + finally { + subscription.remove(); + } +} + +/** + * Opens an authorization flow in a native browser + * + * Launches a native browser (Safari on iOS, Chrome/CustomTabsIntent on Android) with the provided URL. + * Detects when the user completes the OAuth flow and returns to the app via the redirect URI. + * + * # iOS Behavior + * Uses native ASWebAuthenticationSession which handles the entire OAuth flow natively. + * The session automatically intercepts OAuth redirects and returns control to the app. + * No Linking listener is needed on iOS. + * + * # Android Behavior + * Uses a polyfill pattern combining CustomTabsIntent with Linking API. + * Listens for deeplinks from the OAuth provider and races against browser dismissal. + * + * @param url - The full OAuth authorization URL + * @param redirectUri - The redirect URI scheme (e.g., 'com.example://callback') + * Used to detect when the OAuth flow is complete + * @param options - Configuration options for the browser session: + * - `ephemeralSession`: If true, uses an isolated session without shared cookies/auth + * (Safari on iOS, SHARE_STATE_OFF on Android). Defaults to false for convenience. + * + * @returns A promise that resolves with the result of the browser session + * - `{ type: 'success', url: '...' }` - User completed OAuth, returned with callback URL + * - `{ type: 'cancel' }` - User closed the browser without completing flow + * - `{ type: 'dismiss' }` - Browser was dismissed programmatically + * + * @throws Error with code if the operation fails: + * - 'invalid_url' - The provided URL is malformed + * - 'no_activity' (Android) - Current activity not available + * - 'no_window' (iOS) - Key window not found + * - 'browser_session_error' - Generic native error + * - 'native_module_not_available' - Native module not loaded + * + * @example + * ```typescript + * const redirectUri = 'com.example://callback'; + * + * try { + * const result = await openAuthSession(authUrl, redirectUri, { ephemeralSession: false }); + * if (result.type === 'success') { + * console.log('OAuth successful, code in:', result.url); + * } else { + * console.log('User cancelled'); + * } + * } catch (err) { + * console.error('Failed to open browser session:', err); + * } + * ``` + */ +export async function openAuthSession( + url: string, + redirectUri: string, + options: BrowserSessionOptions = { ephemeralSession: false } +): Promise { + if (!NativeBrowserSessionBridgeSpec) { + throw new Error( + 'BrowserSessionBridge native module is not available. ' + + 'Ensure you are using react-native-platform and have properly linked native dependencies.' + ); + } + + switch (Platform.OS) { + case 'ios': + return openAuthSessionIOS(url, redirectUri, options); + case 'android': + return openAuthSessionAndroid(url, redirectUri, options); + default: + throw new Error('Unsupported target platform'); + } +} diff --git a/packages/react-native-platform/src/BrowserSession/types.ts b/packages/react-native-platform/src/BrowserSession/types.ts new file mode 100644 index 00000000..a56990dc --- /dev/null +++ b/packages/react-native-platform/src/BrowserSession/types.ts @@ -0,0 +1,20 @@ +/** + * Browser session result returned from openAuthSession + * Matches the expo-web-browser API for compatibility + */ +export type BrowserSessionResult = { + type: 'success'; + url: string; +} | { + type: 'cancel' | 'dismiss'; +} + +/** + * Error codes that can be returned when openAuthSession fails + */ +export type BrowserSessionErrorCode = + | 'invalid_url' + | 'no_activity' + | 'no_window' + | 'browser_session_error' + | 'native_module_not_available'; diff --git a/packages/react-native-platform/src/index.ts b/packages/react-native-platform/src/index.ts index 2ab2a3dd..419eec7e 100644 --- a/packages/react-native-platform/src/index.ts +++ b/packages/react-native-platform/src/index.ts @@ -33,4 +33,8 @@ installWebCryptoPolyfill(); // Override TokenStorage to use React Native Storage Bridge import { ReactNativeTokenStorage } from './Credential/TokenStorage.ts'; import { Credential } from '@okta/auth-foundation/core'; + +// Export Browser Session API +export { openAuthSession } from './BrowserSession/index.ts'; +export type { BrowserSessionResult, BrowserSessionErrorCode } from './BrowserSession/types.ts'; Credential.coordinator.tokenStorage = new ReactNativeTokenStorage(); diff --git a/packages/react-native-platform/src/specs/NativeBrowserSessionBridge.ts b/packages/react-native-platform/src/specs/NativeBrowserSessionBridge.ts new file mode 100644 index 00000000..8ebcc299 --- /dev/null +++ b/packages/react-native-platform/src/specs/NativeBrowserSessionBridge.ts @@ -0,0 +1,27 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export type BrowserSessionResult = { + type: 'success'; + url: string; +} | { + type: 'cancel' | 'dismiss'; +} + +export interface BrowserOpenResult { + type: 'opened' | 'error'; +} + +export type BrowserSessionOptions = { + ephemeralSession: boolean; +}; + +export interface Spec extends TurboModule { + // iOS: Launches ASWebAuthenticationSession and waits for OAuth result + openAuthSession(url: string, redirectScheme: string, options: BrowserSessionOptions): Promise; + + // Android: Launches CustomTabsIntent and returns immediately + openBrowser(url: string, options: BrowserSessionOptions): Promise; +} + +export default TurboModuleRegistry.getEnforcing('BrowserSessionBridge'); diff --git a/packages/react-native-platform/test/spec/BrowserSession.spec.ts b/packages/react-native-platform/test/spec/BrowserSession.spec.ts new file mode 100644 index 00000000..e5830b9f --- /dev/null +++ b/packages/react-native-platform/test/spec/BrowserSession.spec.ts @@ -0,0 +1,243 @@ +// Mock react-native before importing the module +jest.mock('react-native', () => ({ + Linking: { + addEventListener: jest.fn(() => ({ + remove: jest.fn(), + })), + }, + Platform: { + OS: 'ios', + }, +})); + +// Mock the native bridge before importing the module +jest.mock('src/specs/NativeBrowserSessionBridge', () => { + return { + __esModule: true, + default: { + openAuthSession: jest.fn(), + openBrowser: jest.fn(), + }, + }; +}); + +import type { BrowserSessionOptions } from 'src/specs/NativeBrowserSessionBridge'; +import NativeBrowserSessionBridgeSpec from 'src/specs/NativeBrowserSessionBridge'; +import { openAuthSession } from 'src/BrowserSession/index'; + +describe('BrowserSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('openAuthSession', () => { + describe('Basic functionality', () => { + it('should be a function', () => { + expect(typeof openAuthSession).toBe('function'); + }); + + it('should accept a URL and redirect URI', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + try { + await openAuthSession('https://example.com/auth', 'com.example://callback'); + } catch (_) { + // Expected in test environment + } + + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalled(); + }); + + it('should accept optional options parameter', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + const options: BrowserSessionOptions = { ephemeralSession: false }; + + try { + await openAuthSession('https://example.com/auth', 'com.example://callback', options); + } catch (_) { + // Expected in test environment + } + + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalled(); + }); + + it('should return a promise', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + const result = openAuthSession('https://example.com/auth', 'com.example://callback'); + + expect(result instanceof Promise).toBe(true); + }); + }); + + describe('Options handling', () => { + it('should use default options when not provided', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + try { + await openAuthSession('https://example.com/auth', 'com.example://callback'); + } catch (_) { + // Expected in test environment + } + + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalled(); + }); + + it('should support ephemeralSession: true option', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + const options: BrowserSessionOptions = { ephemeralSession: true }; + + try { + await openAuthSession('https://example.com/auth', 'com.example://callback', options); + } catch (_) { + // Expected in test environment + } + + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalled(); + }); + + it('should support ephemeralSession: false option', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + const options: BrowserSessionOptions = { ephemeralSession: false }; + + try { + await openAuthSession('https://example.com/auth', 'com.example://callback', options); + } catch (_) { + // Expected in test environment + } + + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalled(); + }); + }); + + describe('URL validation', () => { + it('should validate that URL is a string', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + try { + await openAuthSession('https://example.com/auth', 'com.example://callback'); + } catch (_) { + // Expected + } + + // Native module should be called with valid string + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Object) + ); + }); + }); + + describe('Redirect URI handling', () => { + it('should extract scheme from full redirect URI with path', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example.app://oauth/callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + try { + await openAuthSession('https://example.com/auth', 'com.example.app://oauth/callback'); + } catch (_) { + // Expected in test environment + } + + // Should extract "com.example.app" as the scheme + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalled(); + }); + + it('should handle redirect URI without path', async () => { + const mockResolve = jest + .fn() + .mockResolvedValue({ type: 'success' as const, url: 'com.example://callback?code=abc' }); + (NativeBrowserSessionBridgeSpec.openAuthSession as jest.Mock).mockImplementation( + mockResolve + ); + + try { + await openAuthSession('https://example.com/auth', 'com.example'); + } catch (_) { + // Expected in test environment + } + + expect(NativeBrowserSessionBridgeSpec.openAuthSession).toHaveBeenCalled(); + }); + }); + + describe('Error handling', () => { + it('should throw error if NativeBrowserSessionBridgeSpec is not available', async () => { + // We would need to mock the module as unavailable for this test + // For now, just verify the error message exists + expect(openAuthSession).toBeDefined(); + }); + }); + + describe('Type safety', () => { + it('should have correct BrowserSessionOptions type', () => { + const options: BrowserSessionOptions = { ephemeralSession: true }; + expect(typeof options.ephemeralSession).toBe('boolean'); + }); + + it('should allow ephemeralSession to be true or false', () => { + const optionsTrue: BrowserSessionOptions = { ephemeralSession: true }; + const optionsFalse: BrowserSessionOptions = { ephemeralSession: false }; + + expect(optionsTrue.ephemeralSession).toBe(true); + expect(optionsFalse.ephemeralSession).toBe(false); + }); + }); + }); + + describe('Module exports', () => { + it('should export openAuthSession function', () => { + expect(openAuthSession).toBeDefined(); + expect(typeof openAuthSession).toBe('function'); + }); + + it('should export BrowserSessionOptions type', () => { + // Type check - if this compiles, the type is exported correctly + const options: BrowserSessionOptions = { ephemeralSession: false }; + expect(options).toBeDefined(); + }); + }); +}); diff --git a/packages/react-native-webcrypto-bridge/android/build.gradle b/packages/react-native-webcrypto-bridge/android/build.gradle index 5deab079..7b4c144c 100644 --- a/packages/react-native-webcrypto-bridge/android/build.gradle +++ b/packages/react-native-webcrypto-bridge/android/build.gradle @@ -1,5 +1,6 @@ buildscript { ext.kotlin_version = '2.1.0' + ext.android_gradle_plugin_version = '8.1.0' repositories { google() @@ -7,13 +8,24 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:8.1.0' + classpath "com.android.tools.build:gradle:$android_gradle_plugin_version" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'com.facebook.react' apply false +} + +def isNewArchitectureEnabled() { + return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true" +} + +if (isNewArchitectureEnabled()) { + apply plugin: 'com.facebook.react' +} android { namespace "com.okta.webcryptobridge" @@ -44,6 +56,12 @@ android { testOptions { unitTests.includeAndroidResources = true } + + sourceSets { + main { + java.srcDirs += 'build/generated/source/codegen/java' + } + } } repositories { diff --git a/packages/react-native-webcrypto-bridge/android/settings.gradle b/packages/react-native-webcrypto-bridge/android/settings.gradle new file mode 100644 index 00000000..552e79f0 --- /dev/null +++ b/packages/react-native-webcrypto-bridge/android/settings.gradle @@ -0,0 +1,24 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + + resolutionStrategy { + eachPlugin { + // Apply versions only for standalone builds, not in composite builds + if (gradle.parent == null) { + if (requested.id.id == 'com.android.library') { + useVersion('8.1.0') + } else if (requested.id.id == 'org.jetbrains.kotlin.android') { + useVersion('2.1.0') + } + } + } + } +} + +includeBuild('../../../node_modules/@react-native/gradle-plugin') + +rootProject.name = 'okta-react-native-webcrypto-bridge' diff --git a/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/CryptoAlgorithmRegistry.kt b/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/CryptoAlgorithmRegistry.kt index 06b9ffae..c86af240 100644 --- a/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/CryptoAlgorithmRegistry.kt +++ b/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/CryptoAlgorithmRegistry.kt @@ -1,6 +1,7 @@ package com.okta.webcryptobridge import com.okta.webcryptobridge.algorithms.RSAHandler +import java.util.concurrent.ConcurrentHashMap /** * Registry for managing cryptographic algorithm handlers. @@ -10,7 +11,7 @@ import com.okta.webcryptobridge.algorithms.RSAHandler * for dispatching algorithm-specific operations in the WebCryptoBridgeModule. */ object CryptoAlgorithmRegistry { - private val handlers = mutableMapOf() + private val handlers = ConcurrentHashMap() init { // Register built-in handlers diff --git a/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt b/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt index 21f9f976..8423de59 100644 --- a/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt +++ b/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgeModule.kt @@ -6,7 +6,6 @@ import android.security.keystore.KeyProperties import android.util.Base64 import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext -import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableArray import com.facebook.react.module.annotations.ReactModule @@ -73,15 +72,15 @@ data class CryptoKey( */ @ReactModule(name = WebCryptoBridgeModule.NAME) class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { + NativeWebCryptoBridgeSpec(reactContext) { + + private val cryptoKeys = mutableMapOf() + private val secureRandom = SecureRandom() companion object { const val NAME = "WebCryptoBridge" } - private val cryptoKeys = mutableMapOf() - private val secureRandom = SecureRandom() - override fun getName(): String = NAME private val keyStore: KeyStore by lazy { @@ -132,7 +131,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @return standard Base64-encoded random bytes */ @ReactMethod(isBlockingSynchronousMethod = true) - fun getRandomValues(length: Double): String { + override fun getRandomValues(length: Double): String { val len = length.toInt() val bytes = ByteArray(len) secureRandom.nextBytes(bytes) @@ -145,7 +144,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @return a UUID v4 string (e.g., `"550e8400-e29b-41d4-a716-446655440000"`) */ @ReactMethod(isBlockingSynchronousMethod = true) - fun randomUUID(): String { + override fun randomUUID(): String { return UUID.randomUUID().toString() } @@ -159,7 +158,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @param promise resolves with the standard Base64-encoded digest, or rejects on error */ @ReactMethod - fun digest( + override fun digest( algorithm: String, data: String, promise: Promise @@ -190,7 +189,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @param promise resolves with a JSON string `{"id": "ks:{uuid}"}`, or rejects on error */ @ReactMethod - fun generateKey( + override fun generateKey( algorithmJson: String, extractable: Boolean, keyUsages: ReadableArray, @@ -275,7 +274,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @param promise resolves with a JSON string containing algorithm-specific JWK fields, or rejects on error */ @ReactMethod - fun exportKey( + override fun exportKey( format: String, keyId: String, promise: Promise @@ -328,7 +327,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @param promise resolves with the key identifier string, or rejects on error */ @ReactMethod - fun importKey( + override fun importKey( format: String, keyDataJson: String, algorithmJson: String, @@ -392,7 +391,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @param promise resolves with the standard Base64-encoded signature, or rejects on error */ @ReactMethod - fun sign( + override fun sign( algorithmJson: String, keyId: String, data: String, @@ -462,7 +461,7 @@ class WebCryptoBridgeModule(reactContext: ReactApplicationContext) : * @param promise resolves with `true` if the signature is valid, `false` otherwise, or rejects on error */ @ReactMethod - fun verify( + override fun verify( algorithmJson: String, keyId: String, signatureBase64: String, diff --git a/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt b/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt index 85a2c10a..11d6c5ac 100644 --- a/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt +++ b/packages/react-native-webcrypto-bridge/android/src/main/java/com/okta/webcryptobridge/WebCryptoBridgePackage.kt @@ -1,16 +1,30 @@ package com.okta.webcryptobridge -import com.facebook.react.ReactPackage +import com.facebook.react.BaseReactPackage import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ViewManager +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider -class WebCryptoBridgePackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(WebCryptoBridgeModule(reactContext)) +class WebCryptoBridgePackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == WebCryptoBridgeModule.NAME) { + WebCryptoBridgeModule(reactContext) + } else { + null } - override fun createViewManagers(reactContext: ReactApplicationContext): List> { - return emptyList() - } + override fun getReactModuleInfoProvider() = ReactModuleInfoProvider { + mapOf( + WebCryptoBridgeModule.NAME to ReactModuleInfo( + name = WebCryptoBridgeModule.NAME, + className = "com.okta.webcryptobridge.WebCryptoBridgeModule", + canOverrideExistingModule = false, + needsEagerInit = false, + isCxxModule = false, + isTurboModule = true + ) + ) + } } diff --git a/packages/react-native-webcrypto-bridge/package.json b/packages/react-native-webcrypto-bridge/package.json index 5d628175..6b3cf0f5 100644 --- a/packages/react-native-webcrypto-bridge/package.json +++ b/packages/react-native-webcrypto-bridge/package.json @@ -1,6 +1,7 @@ { "name": "@okta/react-native-webcrypto-bridge", "version": "0.7.0", + "author": "developer@okta.com", "description": "WebCrypto API polyfill for React Native with native iOS and Android bridge", "type": "module", "main": "dist/esm/index.js", @@ -19,6 +20,7 @@ "./android", "*.md", "package.json", + "react-native.config.js", "react-native-webcrypto-bridge.podspec" ], "exports": { diff --git a/packages/react-native-webcrypto-bridge/react-native-webcrypto-bridge.podspec b/packages/react-native-webcrypto-bridge/react-native-webcrypto-bridge.podspec index b8a035bb..1f7d6869 100644 --- a/packages/react-native-webcrypto-bridge/react-native-webcrypto-bridge.podspec +++ b/packages/react-native-webcrypto-bridge/react-native-webcrypto-bridge.podspec @@ -9,6 +9,7 @@ Pod::Spec.new do |s| s.summary = package["description"] s.homepage = "https://github.com/okta/okta-client-javascript" s.license = package["license"] + s.authors = package["author"] s.platforms = { :ios => "13.4" } s.source = { :git => "https://github.com/okta/okta-client-javascript.git", :tag => "#{s.version}" }