Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"lib":"2.0.4","packages/google-signin":"1.1.1"}
{"lib":"2.0.4","packages/google-signin":"1.1.1","packages/apple-signin":"1.0.0"}
224 changes: 224 additions & 0 deletions packages/apple-signin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# @forward-software/react-auth-apple

Apple Sign-In adapter for [`@forward-software/react-auth`](https://github.com/forwardsoftware/react-auth) with support for **Web** and **React Native** (Expo).

## Installation

```bash
npm install @forward-software/react-auth-apple @forward-software/react-auth
# or
pnpm add @forward-software/react-auth-apple @forward-software/react-auth
```

### React Native (Expo)

This package includes an Expo native module. You need a **development build** (not Expo Go):

```bash
npx expo prebuild
npx expo run:ios
```

No additional CocoaPods are required -- Apple Sign-In uses the system `AuthenticationServices` framework.

## Quick Start

### Web

```tsx
import { createAuth } from '@forward-software/react-auth';
import { AppleAuthClient, AppleSignInButton } from '@forward-software/react-auth-apple';

const appleClient = new AppleAuthClient({
clientId: 'com.example.service', // Your Apple Services ID
redirectURI: 'https://example.com/auth/apple/callback',
});

const { AuthProvider, useAuthClient } = createAuth(appleClient);

function App() {
return (
<AuthProvider>
<LoginScreen />
</AuthProvider>
);
}

function LoginScreen() {
const auth = useAuthClient();

return (
<AppleSignInButton
config={{
clientId: 'com.example.service',
redirectURI: 'https://example.com/auth/apple/callback',
}}
onCredential={(credentials) => auth.login(credentials)}
onError={(error) => console.error(error)}
/>
);
}
```

### React Native

```tsx
import { createAuth } from '@forward-software/react-auth';
import { AppleAuthClient, AppleSignInButton } from '@forward-software/react-auth-apple';
import { MMKV } from 'react-native-mmkv';

const mmkv = new MMKV();
const storage = {
getItem: (key: string) => mmkv.getString(key) ?? null,
setItem: (key: string, value: string) => mmkv.set(key, value),
removeItem: (key: string) => mmkv.delete(key),
};

const appleClient = new AppleAuthClient({
clientId: 'com.example.app',
storage,
});

const { AuthProvider, useAuthClient } = createAuth(appleClient);

function LoginScreen() {
const auth = useAuthClient();

return (
<AppleSignInButton
config={{ clientId: 'com.example.app', storage }}
onCredential={(credentials) => auth.login(credentials)}
onError={(error) => console.error(error)}
/>
);
}
```

## Web Setup

1. Register a **Services ID** in the [Apple Developer Console](https://developer.apple.com/account/resources/identifiers/list/serviceId)
2. Configure the **Sign in with Apple** capability with your domain and redirect URL
3. Pass the Services ID as `clientId` and your registered redirect URL as `redirectURI`

## React Native Setup

### iOS

No additional setup is needed beyond enabling the **Sign in with Apple** capability in your Xcode project:

1. Open your project in Xcode
2. Go to **Signing & Capabilities**
3. Click **+ Capability** and add **Sign in with Apple**

### Android

Apple Sign-In on Android uses a web-based OAuth flow via Chrome Custom Tabs. This requires a **backend intermediary** because Apple uses `response_mode=form_post`:

1. Your backend receives the POST from Apple at your `androidRedirectUri`
2. It extracts the `id_token` and `code` from the POST body
3. It redirects back to your app via a deep link with these parameters

```tsx
<AppleSignInButton
config={{
clientId: 'com.example.service',
storage,
androidRedirectUri: 'https://api.example.com/auth/apple/android-callback',
}}
onCredential={(credentials) => auth.login(credentials)}
/>
```

## Button Props

### Web (`AppleSignInButton`)

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `config` | `AppleWebAuthConfig` | required | Apple Sign-In configuration |
| `onCredential` | `(credentials) => void` | required | Called with credentials on success |
| `onError` | `(error) => void` | - | Called on error |
| `color` | `'black' \| 'white' \| 'white-outline'` | `'black'` | Button color scheme |
| `type` | `'sign-in' \| 'continue' \| 'sign-up'` | `'sign-in'` | Button label type |
| `label` | `string` | Based on `type` | Custom label for localization |
| `width` | `number` | auto | Button width in pixels |
| `height` | `number` | `44` | Button height in pixels |

### React Native (`AppleSignInButton`)

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `config` | `AppleNativeAuthConfig` | required | Apple Sign-In configuration |
| `onCredential` | `(credentials) => void` | required | Called with credentials on success |
| `onError` | `(error) => void` | - | Called on error |
| `style` | `StyleProp<ViewStyle>` | - | Additional button styles |
| `disabled` | `boolean` | `false` | Disable the button |
| `color` | `'black' \| 'white'` | `'black'` | Button color scheme |
| `label` | `string` | `'Sign in with Apple'` | Custom label for localization |

## Manual Integration

You can use the SDK wrapper directly for custom flows:

```ts
// Web
import { loadAppleIdScript, initializeAppleAuth, signInWithApple } from '@forward-software/react-auth-apple/web/appleid';

await loadAppleIdScript();
initializeAppleAuth({
clientId: 'com.example.service',
scope: 'name email',
redirectURI: 'https://example.com/callback',
usePopup: true,
});
const response = await signInWithApple();
```

```ts
// React Native
import { AppleSignInModule } from '@forward-software/react-auth-apple';

AppleSignInModule.configure({ scopes: ['name', 'email'] });
const credentials = await AppleSignInModule.signIn();
const state = await AppleSignInModule.getCredentialState(credentials.user);
```

## Token Behavior

- **Identity Token**: Apple issues a JWT `identityToken` (similar to Google's `idToken`). The `exp` claim is extracted automatically for expiration tracking.
- **First Authorization Only**: Apple provides user info (email, name) only on the **first** authorization. Subsequent sign-ins return only the `identityToken` and `user` ID. Store user info on your backend after the first login.
- **Credential State**: On iOS, you can check if the user's Apple ID is still authorized via `getCredentialState()`. This is used during token refresh instead of silent re-authentication.
- **No Client-Side Refresh**: Apple does not support client-side token refresh. When the identity token expires, the user must re-authenticate.

## API Reference

### Types

- `AppleAuthTokens` - Token object stored after sign-in
- `AppleAuthCredentials` - Credentials passed to `login()`
- `AppleFullName` - Structured name (givenName, familyName, etc.)
- `AppleWebAuthConfig` - Web configuration
- `AppleNativeAuthConfig` - Native configuration
- `AppleScope` - `'name' | 'email'`
- `TokenStorage` - Storage interface for persistence

### Classes

- `AppleAuthClient` - Implements `AuthClient<AppleAuthTokens, AppleAuthCredentials>`

### Functions (Web SDK)

- `loadAppleIdScript()` - Load the Apple JS SDK
- `initializeAppleAuth(config)` - Initialize the SDK
- `signInWithApple()` - Trigger sign-in flow

### Functions (Native Module)

- `AppleSignInModule.configure(config)` - Configure the native module
- `AppleSignInModule.signIn()` - Trigger native sign-in
- `AppleSignInModule.getCredentialState(userID)` - Check credential state (iOS only)
- `AppleSignInModule.signOut()` - Sign out (no-op, clears JS-side storage)

## License

MIT
35 changes: 35 additions & 0 deletions packages/apple-signin/android/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

group = 'expo.modules.applesignin'
version = '1.0.0'

android {
namespace "expo.modules.applesignin"
compileSdkVersion safeExtGet("compileSdkVersion", 34)

defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 23)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}

kotlinOptions {
jvmTarget = "17"
}
}

dependencies {
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${safeExtGet('kotlinVersion', '1.9.24')}"

implementation 'androidx.browser:browser:1.8.0'
}

def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package expo.modules.applesignin

import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

/**
* Apple Sign-In on Android uses web-based OAuth via Custom Tabs.
*
* Apple's authorization endpoint uses response_mode=form_post, which means
* the response is POSTed to your redirectUri. A backend intermediary is required
* to convert this POST into a deep link redirect back to your app with the
* id_token and authorization code as query parameters.
*/
class AppleSignInModule : Module() {
private var clientId: String? = null
private var redirectUri: String? = null
private var scopes: List<String> = listOf("name", "email")
private var nonce: String? = null
private var state: String? = null
private var pendingPromise: Promise? = null

override fun definition() = ModuleDefinition {
Name("AppleSignIn")

Function("configure") { config: Map<String, Any?> ->
clientId = config["clientId"] as? String
redirectUri = config["redirectUri"] as? String
scopes = (config["scopes"] as? List<*>)?.filterIsInstance<String>() ?: listOf("name", "email")
nonce = config["nonce"] as? String
state = config["state"] as? String
}

AsyncFunction("signIn") { promise: Promise ->
val cid = clientId
if (cid == null) {
promise.reject(CodedException("NOT_CONFIGURED", "AppleSignIn has not been configured with a clientId. Call configure() first.", null))
return@AsyncFunction
}

val redirect = redirectUri
if (redirect == null) {
promise.reject(CodedException("MISSING_REDIRECT_URI", "androidRedirectUri is required for Apple Sign-In on Android.", null))
return@AsyncFunction
}

val activity = appContext.currentActivity
if (activity == null) {
promise.reject(CodedException("NO_ACTIVITY", "No current activity available", null))
return@AsyncFunction
}

val scopeString = scopes.joinToString(" ")
val uriBuilder = Uri.parse("https://appleid.apple.com/auth/authorize").buildUpon()
.appendQueryParameter("client_id", cid)
.appendQueryParameter("redirect_uri", redirect)
.appendQueryParameter("response_type", "code id_token")
.appendQueryParameter("response_mode", "form_post")
.appendQueryParameter("scope", scopeString)

nonce?.let { uriBuilder.appendQueryParameter("nonce", it) }
state?.let { uriBuilder.appendQueryParameter("state", it) }

pendingPromise = promise

try {
val customTabsIntent = CustomTabsIntent.Builder().build()
customTabsIntent.launchUrl(activity, uriBuilder.build())
} catch (e: Exception) {
pendingPromise = null
promise.reject(CodedException("SIGN_IN_FAILED", "Failed to launch Apple Sign-In: ${e.message}", e))
}
}

/**
* Called from your app's deep link handler after the backend redirects
* with the Apple Sign-In response parameters.
*/
Function("handleCallback") { params: Map<String, Any?> ->
val promise = pendingPromise
if (promise == null) {
throw CodedException("NO_PENDING_SIGN_IN", "No pending sign-in to handle", null)
}

pendingPromise = null

val identityToken = params["id_token"] as? String
if (identityToken == null) {
promise.reject(CodedException("MISSING_TOKEN", "No identity token in callback", null))
return@Function
}

val response = mutableMapOf<String, Any?>(
"identityToken" to identityToken,
)

val code = params["code"] as? String
if (code != null) {
response["authorizationCode"] = code
}

val user = params["user"] as? String
if (user != null) {
response["user"] = user
}

promise.resolve(response)
}

AsyncFunction("getCredentialState") { _: String, promise: Promise ->
// Apple credential state is not available on Android
promise.reject(CodedException("UNSUPPORTED", "getCredentialState is not supported on Android", null))
}

AsyncFunction("signOut") { promise: Promise ->
// Apple has no sign-out API; clearing is handled on the JS side
promise.resolve(null)
}
}
}
9 changes: 9 additions & 0 deletions packages/apple-signin/expo-module.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"platforms": ["apple", "android"],
"apple": {
"modules": ["AppleSignInModule"]
},
"android": {
"modules": ["expo.modules.applesignin.AppleSignInModule"]
}
}
Loading
Loading