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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ The test suite covers:
- **JS API tests** (`src/google/__tests__/GoogleSignIn.test.ts`) — the `configure`-then-call contract on the `GoogleSignIn` wrapper, with the native TurboModule mocked.
- **Error tests** (`src/google/__tests__/errors.test.ts`) — `GoogleSignInError`, the `GoogleSignInErrorCode` enum, and the `isGoogleSignInError` type guard.
- **Component tests** (`src/google/__tests__/GoogleSignInButton.test.tsx`) — accessibility labels, `onPress` / `disabled` behavior, and theme/shape/text variants, using [React Native Testing Library](https://callstack.github.io/react-native-testing-library/).
- **Expo config plugin tests** (`plugin/src/__tests__/withSocialAuth.test.ts`) — `Info.plist` URL scheme reversal and `AppDelegate` URL-handler injection, for both Swift and Objective-C fixtures.

`react-native-svg` is mocked via `__mocks__/react-native-svg.js` so the button renders without native bindings during tests.

Expand Down Expand Up @@ -151,7 +152,8 @@ The `package.json` file contains various scripts for common tasks:
- `yarn typecheck`: type-check files with TypeScript.
- `yarn lint`: lint files with [ESLint](https://eslint.org/).
- `yarn test`: run unit + component tests with [Jest](https://jestjs.io/) and [React Native Testing Library](https://callstack.github.io/react-native-testing-library/).
- `yarn prepare`: build the library to `lib/` with [react-native-builder-bob](https://github.com/callstack/react-native-builder-bob) (also runs automatically before `npm publish`).
- `yarn prepare`: build the library to `lib/` with [react-native-builder-bob](https://github.com/callstack/react-native-builder-bob) and compile the Expo config plugin to `plugin/build/` (also runs automatically before `npm publish`).
- `yarn build:plugin`: compile only the Expo config plugin (faster than `yarn prepare` when iterating on plugin code).
- `yarn example start`: start the Metro server for the example app.
- `yarn example android`: run the example app on Android.
- `yarn example ios`: run the example app on iOS.
Expand Down
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ The Web client ID is what your code references for the ID-token audience; each p

In addition to the Cloud Console step above, the host app needs two iOS-specific changes.

> **Using Expo?** Skip the manual `Info.plist` and `AppDelegate` edits below — [our config plugin](#expo-config-plugin) handles them during `expo prebuild`. Bare React Native CLI users continue with the manual steps in this section.

### 1. Register the OAuth URL scheme

Google routes the sign-in callback back into your app via a custom URL scheme. Add the reversed iOS Client ID to `Info.plist`:
Expand Down Expand Up @@ -124,6 +126,56 @@ GoogleSignIn.configure({

Finally, run `cd ios && pod install` after installing the package.

## Expo config plugin

This package ships an Expo config plugin so you don't have to hand-edit `Info.plist` or `AppDelegate` in Expo projects. **Both React Native CLI and Expo projects are supported** — pick the setup section that matches your project.

> **Heads up:** Expo Go cannot ship third-party native modules. You must use a [development build](https://docs.expo.dev/develop/development-builds/introduction/) (via `expo-dev-client` and EAS Build) or the bare workflow.

### Install

```sh
npx expo install @thoughtbot/react-native-social-auth react-native-svg
```

### Add the plugin

In `app.config.ts` (or `app.json`):

```ts
export default {
expo: {
// ...
plugins: [
[
'@thoughtbot/react-native-social-auth',
{
iosClientId: process.env.EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID,
},
],
],
},
};
```

### Plugin props

| Prop | Type | Required for iOS | Description |
| ------------- | -------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `iosClientId` | `string` | Yes | Your iOS OAuth Client ID (e.g. `123456-abc.apps.googleusercontent.com`). The plugin reverses it and registers the URL scheme. |

Omit `iosClientId` if you only target Android — the plugin becomes a no-op on iOS and logs a warning.

### Regenerate native code

```sh
npx expo prebuild --clean
```

This runs the plugin, which writes the reversed iOS Client ID into `Info.plist`'s `CFBundleURLSchemes` and adds the `application(_:open:options:)` URL forwarder to `AppDelegate`. Subsequent prebuilds are idempotent — the plugin won't re-inject if its marker is already present.

You still call `GoogleSignIn.configure({ webClientId, iosClientId })` from JS at runtime (the plugin handles the native bits; it doesn't replace `configure()`).

## Quick start

```tsx
Expand Down
8 changes: 4 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@

## Phase 6: Expo Support

- [ ] Write an Expo config plugin that handles `Info.plist` URL schemes (iOS) and any Android manifest needs
- [ ] Test in Expo prebuild / development build workflow
- [ ] Document Expo managed workflow limitations (if any) and EAS build setup
- [ ] Add the config plugin to the package's `app.plugin.js` entry
- [x] Write an Expo config plugin that handles `Info.plist` URL schemes and `AppDelegate` URL forwarding (iOS). Android is a no-op — Credential Manager needs no manifest changes.
- [x] Test in Expo prebuild / development build workflow (covered by `plugin/src/__tests__/withSocialAuth.test.ts` + the example app)
- [x] Document Expo managed workflow limitations (Expo Go not supported; dev client / EAS Build required) and the install flow
- [x] Add the config plugin to the package's `app.plugin.js` entry

## Phase 7: Documentation

Expand Down
1 change: 1 addition & 0 deletions app.plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./plugin/build/withSocialAuth').default;
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ export default defineConfig([
},
},
{
ignores: ['node_modules/', 'lib/'],
ignores: ['node_modules/', 'lib/', 'plugin/build/'],
},
]);
19 changes: 9 additions & 10 deletions example/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,16 @@
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "thoughtbot.reactnativesocialauth.example",
"infoPlist": {
"CFBundleURLTypes": [
{
"CFBundleURLSchemes": [
"com.googleusercontent.apps.476683722118-27sdo4g5f7n5egip0r3ig7v3m35q9cql"
]
}
]
}
"bundleIdentifier": "thoughtbot.reactnativesocialauth.example"
},
"plugins": [
[
"@thoughtbot/react-native-social-auth",
{
"iosClientId": "476683722118-27sdo4g5f7n5egip0r3ig7v3m35q9cql.apps.googleusercontent.com"
}
]
],
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
Expand Down
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},
"dependencies": {
"@expo/metro-runtime": "~55.0.9",
"@thoughtbot/react-native-social-auth": "workspace:^",
"expo": "~55.0.15",
"expo-status-bar": "~55.0.5",
"react": "19.2.0",
Expand Down
16 changes: 14 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"android",
"ios",
"cpp",
"plugin/build",
"app.plugin.js",
"*.podspec",
"react-native.config.js",
"!ios/build",
Expand All @@ -33,8 +35,9 @@
],
"scripts": {
"example": "yarn workspace @thoughtbot/react-native-social-auth-example",
"clean": "del-cli lib",
"prepare": "bob build",
"clean": "del-cli lib plugin/build",
"prepare": "bob build && yarn build:plugin",
"build:plugin": "tsc --project plugin/tsconfig.json",
"typecheck": "tsc",
"test": "jest",
"release": "release-it --only-version",
Expand Down Expand Up @@ -68,12 +71,14 @@
"@eslint/compat": "^2.0.3",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@expo/config-plugins": "^9",
"@jest/globals": "^30.0.0",
"@react-native/babel-preset": "0.85.0",
"@react-native/eslint-config": "0.85.0",
"@react-native/jest-preset": "0.85.0",
"@release-it/conventional-changelog": "^10.0.6",
"@testing-library/react-native": "^13.3",
"@types/node": "^26.0.1",
"@types/react": "^19.2.0",
"@types/react-test-renderer": "^19",
"commitlint": "^20.5.0",
Expand All @@ -82,6 +87,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-ft-flow": "^3.0.11",
"eslint-plugin-prettier": "^5.5.5",
"expo": "^56.0.12",
"jest": "^30.3.0",
"lefthook": "^2.1.4",
"prettier": "^3.8.1",
Expand All @@ -95,10 +101,16 @@
"typescript": "^6.0.2"
},
"peerDependencies": {
"@expo/config-plugins": ">=9.0.0",
"react": "*",
"react-native": "*",
"react-native-svg": ">=13.0.0"
},
"peerDependenciesMeta": {
"@expo/config-plugins": {
"optional": true
}
},
"workspaces": [
"example"
],
Expand Down
98 changes: 98 additions & 0 deletions plugin/src/__tests__/withSocialAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from '@jest/globals';
import {
injectObjCURLHandler,
injectSwiftURLHandler,
reverseClientId,
} from '../withSocialAuth';

describe('reverseClientId', () => {
it('reverses a valid iOS client ID', () => {
expect(reverseClientId('123456-abc.apps.googleusercontent.com')).toBe(
'com.googleusercontent.apps.123456-abc'
);
});

it('throws on a Web client ID (missing iOS suffix)', () => {
expect(() => reverseClientId('123456-xyz.example.com')).toThrow(
/must end with "\.apps\.googleusercontent\.com"/
);
});

it('throws on an empty string', () => {
expect(() => reverseClientId('')).toThrow();
});
});

const SWIFT_FIXTURE = `import Expo
import ExpoModulesCore

@UIApplicationMain
public class AppDelegate: ExpoAppDelegate {
public override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: ...
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
`;

describe('injectSwiftURLHandler', () => {
it('adds the import and URL handler override', () => {
const out = injectSwiftURLHandler(SWIFT_FIXTURE);
expect(out).toContain('import react_native_social_auth');
expect(out).toContain('GoogleSignIn.handleURL(url)');
expect(out).toContain(
'public override func application(\n _ app: UIApplication'
);
});

it('places the override inside the AppDelegate class (before the closing brace)', () => {
const out = injectSwiftURLHandler(SWIFT_FIXTURE);
const handlerIndex = out.indexOf('GoogleSignIn.handleURL');
const lastBraceIndex = out.lastIndexOf('}');
expect(handlerIndex).toBeLessThan(lastBraceIndex);
});

it('throws if the closing brace cannot be located', () => {
expect(() => injectSwiftURLHandler('// no class here')).toThrow(
/closing brace/
);
});
});

const OBJC_FIXTURE = `#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

@end
`;

describe('injectObjCURLHandler', () => {
it('adds the import and openURL method', () => {
const out = injectObjCURLHandler(OBJC_FIXTURE);
expect(out).toContain('#import <react_native_social_auth/GoogleSignIn.h>');
expect(out).toContain('[GoogleSignIn handleURL:url]');
expect(out).toContain(
'- (BOOL)application:(UIApplication *)application\n openURL:(NSURL *)url'
);
});

it('places the method before @end', () => {
const out = injectObjCURLHandler(OBJC_FIXTURE);
const handlerIndex = out.indexOf('[GoogleSignIn handleURL:url]');
const endIndex = out.lastIndexOf('@end');
expect(handlerIndex).toBeLessThan(endIndex);
});

it('throws if @end is missing', () => {
expect(() => injectObjCURLHandler('// no class here')).toThrow(/@end/);
});
});
Loading
Loading