diff --git a/.changeset/appauth-expo-native-configuration.md b/.changeset/appauth-expo-native-configuration.md new file mode 100644 index 000000000..8c43e17cf --- /dev/null +++ b/.changeset/appauth-expo-native-configuration.md @@ -0,0 +1,5 @@ +--- +"react-native-app-auth": patch +--- + +Write callback schemes through Info.plist modResults, deduplicate them, and skip missing schemes. Use the standard Android Gradle mod, preserve unrelated placeholders, and update one generated assignment at the end of defaultConfig. Resolve each app target build configuration’s actual bridging header instead of recursively editing an arbitrary .h file; configure a new header for both Debug and Release when needed. Support final Swift AppDelegate declarations and missing factory properties, fail clearly on unsupported entry points, and detect installed Expo versions for workspace dependencies. diff --git a/docs/docs/usage/expo-setup.md b/docs/docs/usage/expo-setup.md index 7f9216524..5243734ba 100644 --- a/docs/docs/usage/expo-setup.md +++ b/docs/docs/usage/expo-setup.md @@ -44,7 +44,8 @@ Add the plugin to your `app.json` or `app.config.js`: **Configuration Options:** - `redirectUrls` (required): Array of OAuth redirect URLs for your app - - The URL scheme (before `://`) will be automatically configured for both iOS and Android + - The first URL's scheme (before `:`) is configured for both iOS and Android; URLs may use either `scheme:/path` or `scheme://path` + - Additional redirect URLs must use that same scheme, or their schemes need separate native configuration - Example: `"com.myapp://oauth"` → scheme is `com.myapp` ### 3. Generate Native Projects @@ -74,7 +75,7 @@ const config: AuthConfiguration = { // Perform authentication try { const result = await authorize(config); - console.log('Access token:', result.accessToken); + // Use result.accessToken for authenticated requests. Do not log tokens. } catch (error) { console.error('Auth error:', error); } @@ -109,9 +110,7 @@ Check that the manifest placeholder was added to `android/app/build.gradle`: ```gradle android { defaultConfig { - manifestPlaceholders = [ - appAuthRedirectScheme: 'com.yourapp.scheme', - ] + manifestPlaceholders.appAuthRedirectScheme = 'com.yourapp.scheme' } } ``` @@ -169,8 +168,8 @@ If you have React Navigation deep linking, ensure your OAuth scheme is different If you're migrating from manual iOS/Android setup: -1. Remove manual URL scheme configurations from `Info.plist` and `build.gradle` -2. Remove manual AppDelegate modifications (the plugin handles this automatically for Expo SDK 53+) +1. Keep unrelated URL schemes and manifest placeholders; the plugin preserves them +2. Keep existing bridging-header imports; the plugin adds its import to the header selected by each application build configuration 3. Add the plugin configuration to `app.json` 4. Run `npx expo prebuild --clean` @@ -180,4 +179,4 @@ If you're migrating from manual iOS/Android setup: - **CNG workflow only**: Expo Go is not supported (OAuth requires native configuration) - **First-party providers**: Some OAuth providers may require additional native configuration -For advanced use cases or non-Expo projects, see the [Manual Setup Guide](../#manual-setup). \ No newline at end of file +For advanced use cases or non-Expo projects, see the [Manual Setup Guide](../#manual-setup). diff --git a/packages/react-native-app-auth/plugin/src/android/app-build-gradle.ts b/packages/react-native-app-auth/plugin/src/android/app-build-gradle.ts index 30cd31843..e9ce1740f 100644 --- a/packages/react-native-app-auth/plugin/src/android/app-build-gradle.ts +++ b/packages/react-native-app-auth/plugin/src/android/app-build-gradle.ts @@ -1,39 +1,39 @@ -import * as fs from 'fs'; -import { AndroidConfig, withDangerousMod, ConfigPlugin } from '@expo/config-plugins'; +import { withAppBuildGradle, ConfigPlugin } from '@expo/config-plugins'; +import { + createGeneratedHeaderComment, + removeGeneratedContents, +} from '@expo/config-plugins/build/utils/generateCode'; import { AppAuthProps } from '../types'; const codeModAndroid = require('@expo/config-plugins/build/android/codeMod'); +const TAG = 'react-native-app-auth'; -export const withAppAuthAppBuildGradle: ConfigPlugin = (rootConfig, props) => - withDangerousMod(rootConfig, [ - 'android', - config => { - // find the app/build.gradle file and checks its format - const appBuildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath( - config.modRequest.projectRoot - ); +export const withAppAuthAppBuildGradle: ConfigPlugin = (rootConfig, props) => { + const scheme = props?.android?.appAuthRedirectScheme; + if (!scheme) { + return rootConfig; + } + if (typeof scheme !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(scheme)) { + throw new Error('appAuthRedirectScheme must be a valid URL scheme'); + } - // BEWARE: we update the app/build.gradle file *outside* of the standard Expo config procedure ! - let contents = fs.readFileSync(appBuildGradlePath, 'utf8'); - - if (contents.includes('manifestPlaceholders')) { - throw new Error( - 'app/build.gradle already contains manifestPlaceholders, cannot update automatically !' - ); - } - - contents = codeModAndroid.appendContentsInsideDeclarationBlock( - contents, - 'defaultConfig', - ` manifestPlaceholders = [ - appAuthRedirectScheme: '${props?.android?.appAuthRedirectScheme}', - ] - ` - ); - - // and finally we write the file back to the disk - fs.writeFileSync(appBuildGradlePath, contents, 'utf8'); - - return config; - }, - ]); \ No newline at end of file + return withAppBuildGradle(rootConfig, config => { + if (config.modResults.language !== 'groovy') { + throw new Error('react-native-app-auth requires a Groovy app/build.gradle'); + } + const contents = removeGeneratedContents(config.modResults.contents, TAG) ?? config.modResults.contents; + const assignment = ` manifestPlaceholders.appAuthRedirectScheme = '${scheme}'`; + const insertion = [ + createGeneratedHeaderComment(assignment, TAG, '//'), + assignment, + `// @generated end ${TAG}`, + '', + ].join('\n'); + config.modResults.contents = codeModAndroid.appendContentsInsideDeclarationBlock( + contents, + 'defaultConfig', + insertion + ); + return config; + }); +}; diff --git a/packages/react-native-app-auth/plugin/src/expo-version.ts b/packages/react-native-app-auth/plugin/src/expo-version.ts index 6521d165d..eefff9480 100644 --- a/packages/react-native-app-auth/plugin/src/expo-version.ts +++ b/packages/react-native-app-auth/plugin/src/expo-version.ts @@ -29,6 +29,15 @@ const readExpoPackageVersion = (projectRoot?: string): string | undefined => { return undefined; } + try { + const expoPackagePath = require.resolve('expo/package.json', { paths: [projectRoot] }); + return JSON.parse(fs.readFileSync(expoPackagePath, 'utf8')).version; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') { + throw error; + } + } + const packageJsonPath = path.join(projectRoot, 'package.json'); if (!fs.existsSync(packageJsonPath)) { return undefined; diff --git a/packages/react-native-app-auth/plugin/src/index.ts b/packages/react-native-app-auth/plugin/src/index.ts index cfb06e948..abce5e4e0 100644 --- a/packages/react-native-app-auth/plugin/src/index.ts +++ b/packages/react-native-app-auth/plugin/src/index.ts @@ -5,7 +5,6 @@ import { withAppAuthAppDelegateHeader, withUrlSchemes, withBridgingHeader, - withXcodeBuildSettings, } from './ios'; import { withAppAuthAppBuildGradle } from './android'; @@ -32,7 +31,6 @@ const withAppAuth: AppAuthConfigPlugin = (config, props) => { return withPlugins(config, [ // iOS withBridgingHeader, - withXcodeBuildSettings, withAppAuthAppDelegate, withAppAuthAppDelegateHeader, [withUrlSchemes, transformedProps], diff --git a/packages/react-native-app-auth/plugin/src/ios/app-delegate.ts b/packages/react-native-app-auth/plugin/src/ios/app-delegate.ts index c05133222..69e0bc1bf 100644 --- a/packages/react-native-app-auth/plugin/src/ios/app-delegate.ts +++ b/packages/react-native-app-auth/plugin/src/ios/app-delegate.ts @@ -15,10 +15,15 @@ const APP_AUTH_RESUME_BLOCK = `if let authorizationFlowManagerDelegate = self.au }`; export const applyExpo53AppDelegatePatch = (contents: string): string => { + const appDelegatePattern = + /^(\s*(?:(?:public|open|final)\s+)*class\s+AppDelegate\s*:\s*ExpoAppDelegate)([^{]*)(\{)/m; + if (!appDelegatePattern.test(contents)) { + throw new Error('Unable to find the Expo AppDelegate declaration; configure AppAuth manually'); + } contents = contents.replace( - /^(\s*(?:public\s+)?class\s+AppDelegate\s*:\s*ExpoAppDelegate)([^{]*)(\{)/m, + appDelegatePattern, (match, declaration, conformances, openingBrace) => { - if (conformances.includes(APP_AUTH_PROTOCOL)) { + if (conformances.split(',').some((protocol: string) => protocol.trim() === APP_AUTH_PROTOCOL)) { return match; } @@ -33,21 +38,20 @@ export const applyExpo53AppDelegatePatch = (contents: string): string => { ); if (!APP_AUTH_DELEGATE_PROPERTY_PATTERN.test(contents)) { - const reactNativeFactoryPattern = - /^(\s*)(?:public\s+)?var\s+reactNativeFactory\s*:\s*RCTReactNativeFactory\?\s*$/m; - const factoryMatch = contents.match(reactNativeFactoryPattern); - if (factoryMatch) { - const indent = factoryMatch[1]; - contents = contents.replace( - reactNativeFactoryPattern, - match => `${match}\n\n${indent}${APP_AUTH_DELEGATE_PROPERTY}` - ); - } + contents = contents.replace( + appDelegatePattern, + match => `${match}\n ${APP_AUTH_DELEGATE_PROPERTY}\n` + ); } if (!contents.includes('resumeExternalUserAgentFlow(with: url)')) { + const openUrlPattern = + /((?:public\s+)?override\s+func\s+application\s*\([^)]*\bopen\s+url\s*:\s*URL[^)]*\)\s*->\s*Bool\s*\{)/m; + if (!openUrlPattern.test(contents)) { + throw new Error('Unable to find the AppDelegate open URL handler; configure AppAuth manually'); + } contents = contents.replace( - /((?:public\s+)?override\s+func\s+application\s*\([\s\S]*?open\s+url\s*:\s*URL[\s\S]*?\)\s*->\s*Bool\s*\{)/m, + openUrlPattern, match => `${match}\n ${APP_AUTH_RESUME_BLOCK}\n` ); } diff --git a/packages/react-native-app-auth/plugin/src/ios/bridging-header.ts b/packages/react-native-app-auth/plugin/src/ios/bridging-header.ts index f0e7e28d9..a5a65a144 100644 --- a/packages/react-native-app-auth/plugin/src/ios/bridging-header.ts +++ b/packages/react-native-app-auth/plugin/src/ios/bridging-header.ts @@ -1,87 +1,86 @@ import * as fs from 'fs'; import * as path from 'path'; -import { withDangerousMod, withXcodeProject, ConfigPlugin } from '@expo/config-plugins'; +import { IOSConfig, withXcodeProject, ConfigPlugin } from '@expo/config-plugins'; import { isExpo53OrLater } from '../expo-version'; const BRIDGING_HEADER_NAME = 'AppDelegate+RNAppAuth.h'; -const BRIDGING_HEADER_CONTENT = '#import "RNAppAuthAuthorizationFlowManager.h"\n'; - -interface ConfigWithBridgingHeader { - _createdBridgingHeader?: string; - [key: string]: any; -} - -const findBridgingHeader = (dir: string): string | null => { - const files = fs.readdirSync(dir); - - // First check current directory - const headerInCurrentDir = files.find(f => f.endsWith('-Bridging-Header.h') || f.endsWith('.h')); - if (headerInCurrentDir) { - return path.join(dir, headerInCurrentDir); - } - - // Then check subdirectories - for (const file of files) { - const fullPath = path.join(dir, file); - if (fs.statSync(fullPath).isDirectory()) { - const found = findBridgingHeader(fullPath); - if (found) { - return found; - } - } - } - - return null; -}; +const BRIDGING_HEADER_IMPORT = '#import "RNAppAuthAuthorizationFlowManager.h"'; export const withBridgingHeader: ConfigPlugin = rootConfig => { if (!isExpo53OrLater(rootConfig)) { return rootConfig; } - return withDangerousMod(rootConfig, [ - 'ios', - config => { - const iosPath = path.join(config.modRequest.projectRoot, 'ios'); - - // Search for existing bridging header in the project and subfolders - const existingHeaderPath = findBridgingHeader(iosPath); - const importLine = BRIDGING_HEADER_CONTENT; - let headerPath: string; - - if (existingHeaderPath) { - headerPath = existingHeaderPath; - const content = fs.readFileSync(headerPath, 'utf8'); - - if (!content.includes(importLine)) { - fs.writeFileSync(headerPath, `${importLine}\n${content}`); + return withXcodeProject(rootConfig, config => { + const project = config.modResults; + const projectRoot = config.modRequest.projectRoot; + const iosRoot = path.join(projectRoot, 'ios'); + const projectName = IOSConfig.XcodeUtils.getProjectName(projectRoot); + const { target } = IOSConfig.XcodeUtils.getApplicationNativeTarget({ project, projectName }); + const configurations = IOSConfig.XcodeUtils.getBuildConfigurationsForListId( + project, + target.buildConfigurationList + ); + const projectConfigurations = IOSConfig.XcodeUtils.getBuildConfigurationsForListId( + project, + project.getFirstProject().firstProject.buildConfigurationList + ); + const defaultHeader = path.join( + path.dirname(IOSConfig.Paths.getAppDelegateFilePath(projectRoot)), + BRIDGING_HEADER_NAME + ); + const headers = new Map(); + + for (const [, configuration] of configurations) { + const inherited = projectConfigurations.find(([, item]) => item.name === configuration.name)?.[1].buildSettings ?? {}; + const settings = { ...inherited, ...configuration.buildSettings }; + const configuredHeader = settings.SWIFT_OBJC_BRIDGING_HEADER; + let headerPath = defaultHeader; + + if (configuredHeader) { + const variables: Record = { + ...settings, + SRCROOT: iosRoot, + PROJECT_DIR: iosRoot, + PROJECT_NAME: projectName, + TARGET_NAME: IOSConfig.XcodeUtils.unquote(target.name), + CONFIGURATION: IOSConfig.XcodeUtils.unquote(configuration.name), + inherited: inherited.SWIFT_OBJC_BRIDGING_HEADER ?? '', + }; + const resolved = IOSConfig.XcodeUtils.resolveXcodeBuildSetting( + IOSConfig.XcodeUtils.unquote(configuredHeader).replace(/\$\{([^}]+)\}/g, '$($1)'), + name => { + const value = variables[name]; + if (value === undefined) { + throw new Error(`Unable to resolve bridging header build setting: ${name}`); + } + return IOSConfig.XcodeUtils.unquote(String(value)); + } + ); + if (!resolved || resolved.includes('$')) { + throw new Error('Unable to resolve SWIFT_OBJC_BRIDGING_HEADER; configure the AppAuth import manually'); + } + headerPath = path.resolve(iosRoot, resolved); + if (!fs.existsSync(headerPath)) { + throw new Error(`Configured bridging header does not exist: ${headerPath}`); } } else { - // Default to new file if none found - headerPath = path.join(iosPath, BRIDGING_HEADER_NAME); - fs.writeFileSync(headerPath, `${importLine}\n`); - (config as ConfigWithBridgingHeader)._createdBridgingHeader = BRIDGING_HEADER_NAME; + configuration.buildSettings.SWIFT_OBJC_BRIDGING_HEADER = JSON.stringify( + path.relative(iosRoot, headerPath) + ); } - return config; - }, - ]); -}; - -export const withXcodeBuildSettings: ConfigPlugin = rootConfig => - withXcodeProject(rootConfig, config => { - const project = config.modResults; - const target = project.getFirstTarget().uuid; - - const currentSetting = project.getBuildProperty('SWIFT_OBJC_BRIDGING_HEADER', target); - - if (!currentSetting && (config as ConfigWithBridgingHeader)._createdBridgingHeader) { - project.addBuildProperty( - 'SWIFT_OBJC_BRIDGING_HEADER', - `$(SRCROOT)/${(config as ConfigWithBridgingHeader)._createdBridgingHeader}`, - target - ); + if (!headers.has(headerPath)) { + const contents = fs.existsSync(headerPath) ? fs.readFileSync(headerPath, 'utf8') : ''; + headers.set(headerPath, contents); + } } + for (const [headerPath, contents] of headers) { + if (!contents.includes(BRIDGING_HEADER_IMPORT)) { + fs.writeFileSync(headerPath, `${BRIDGING_HEADER_IMPORT}\n${contents}`, 'utf8'); + } + } return config; - }); \ No newline at end of file + }); +}; diff --git a/packages/react-native-app-auth/plugin/src/ios/index.ts b/packages/react-native-app-auth/plugin/src/ios/index.ts index 793423aad..135615417 100644 --- a/packages/react-native-app-auth/plugin/src/ios/index.ts +++ b/packages/react-native-app-auth/plugin/src/ios/index.ts @@ -1,4 +1,4 @@ export { withAppAuthAppDelegateHeader } from './app-delegate-header'; export { withAppAuthAppDelegate } from './app-delegate'; export { withUrlSchemes } from './info-plist'; -export { withBridgingHeader, withXcodeBuildSettings } from './bridging-header'; \ No newline at end of file +export { withBridgingHeader } from './bridging-header'; diff --git a/packages/react-native-app-auth/plugin/src/ios/info-plist.ts b/packages/react-native-app-auth/plugin/src/ios/info-plist.ts index 2ae64545f..75227f601 100644 --- a/packages/react-native-app-auth/plugin/src/ios/info-plist.ts +++ b/packages/react-native-app-auth/plugin/src/ios/info-plist.ts @@ -2,22 +2,26 @@ import { withInfoPlist, ConfigPlugin } from '@expo/config-plugins'; import { AppAuthProps } from '../types'; export const withUrlSchemes: ConfigPlugin = (config, props) => { + const scheme = props?.ios?.urlScheme; + if (!scheme) { + return config; + } + if (typeof scheme !== 'string' || !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(scheme)) { + throw new Error('ios.urlScheme must be a valid URL scheme'); + } + return withInfoPlist(config, cfg => { - if (!cfg.ios) { - cfg.ios = {}; - } - if (!cfg.ios.infoPlist) { - cfg.ios.infoPlist = {}; - } - if (!cfg.ios.infoPlist.CFBundleURLTypes) { - cfg.ios.infoPlist.CFBundleURLTypes = []; + const urlTypes = cfg.modResults.CFBundleURLTypes ?? []; + if (!urlTypes.some(type => type.CFBundleURLSchemes?.includes(scheme))) { + cfg.modResults.CFBundleURLTypes = [ + ...urlTypes, + { + CFBundleURLName: '$(PRODUCT_BUNDLE_IDENTIFIER)', + CFBundleURLSchemes: [scheme], + }, + ]; } - - cfg.ios.infoPlist.CFBundleURLTypes.push({ - CFBundleURLName: '$(PRODUCT_BUNDLE_IDENTIFIER)', - CFBundleURLSchemes: [props?.ios?.urlScheme], - }); return cfg; }); -}; \ No newline at end of file +};