Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/appauth-expo-native-configuration.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 7 additions & 8 deletions docs/docs/usage/expo-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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'
}
}
```
Expand Down Expand Up @@ -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`

Expand All @@ -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).
For advanced use cases or non-Expo projects, see the [Manual Setup Guide](../#manual-setup).
Original file line number Diff line number Diff line change
@@ -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<AppAuthProps | undefined> = (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<AppAuthProps | undefined> = (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;
},
]);
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;
});
};
9 changes: 9 additions & 0 deletions packages/react-native-app-auth/plugin/src/expo-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 0 additions & 2 deletions packages/react-native-app-auth/plugin/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
withAppAuthAppDelegateHeader,
withUrlSchemes,
withBridgingHeader,
withXcodeBuildSettings,
} from './ios';
import { withAppAuthAppBuildGradle } from './android';

Expand All @@ -32,7 +31,6 @@ const withAppAuth: AppAuthConfigPlugin = (config, props) => {
return withPlugins(config, [
// iOS
withBridgingHeader,
withXcodeBuildSettings,
withAppAuthAppDelegate,
withAppAuthAppDelegateHeader,
[withUrlSchemes, transformedProps],
Expand Down
30 changes: 17 additions & 13 deletions packages/react-native-app-auth/plugin/src/ios/app-delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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`
);
}
Expand Down
137 changes: 68 additions & 69 deletions packages/react-native-app-auth/plugin/src/ios/bridging-header.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();

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<string, string> = {
...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;
});
});
};
2 changes: 1 addition & 1 deletion packages/react-native-app-auth/plugin/src/ios/index.ts
Original file line number Diff line number Diff line change
@@ -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';
export { withBridgingHeader } from './bridging-header';
Loading