Skip to content

feat: Apple Signin iOS/Android/Web#231

Open
IronTony wants to merge 3 commits intomainfrom
feat/apple-signin
Open

feat: Apple Signin iOS/Android/Web#231
IronTony wants to merge 3 commits intomainfrom
feat/apple-signin

Conversation

@IronTony
Copy link
Copy Markdown
Member

Affected Package(s)

  • @forward-software/react-auth (lib)
  • @forward-software/react-auth-google (packages/google-signin)
  • Examples
  • CI/CD / Repository configuration

New package: @forward-software/react-auth-apple (packages/apple-signin)

Related Issue(s)

None

Motivation

Add Apple Sign-In support to the react-auth ecosystem, following the same adapter pattern used by react-auth-google. This enables apps using @forward-software/react-auth to integrate Sign in with Apple across Web, iOS, and Android with a unified API.

Description of Changes

  • Add new @forward-software/react-auth-apple package (v1.0.0) implementing the AuthClient<AppleAuthTokens, AppleAuthCredentials> interface
  • Implement platform-specific authentication:
    • Web: Apple JS SDK integration with popup/redirect flow support
    • iOS: Native AuthenticationServices framework via Expo module (no external dependencies)
    • Android: OAuth flow via Chrome Custom Tabs (requires backend intermediary for Apple's form-post response)
  • Provide ready-to-use AppleSignInButton components for both web and React Native with customizable appearance
  • Include token persistence with pluggable storage (localStorage, MMKV, AsyncStorage, etc.)
  • Add credential state checking on iOS for token refresh and revocation detection
  • Export low-level Apple JS SDK wrapper (./web/appleid) for custom integrations
  • Add unit tests for both web and native AppleAuthClient implementations
  • Register the new package in release-please-config.json and .release-please-manifest.json

Breaking Changes

None

How to Test

  1. CI Checks: Verify that all automated tests (Vitest) and build steps pass successfully on this PR.
  2. Local Verification (Optional):
    • Run pnpm install to install dependencies.
    • Run pnpm --filter @forward-software/react-auth-apple test to run tests for the new package.
    • Run pnpm --filter @forward-software/react-auth-apple build to verify the build succeeds.
    • Run pnpm --filter @forward-software/react-auth-apple lint to check for linting errors.

Checklist

  • My code follows the project's style guidelines
  • I have added or updated tests to cover the changes
  • I have updated relevant documentation
  • All tests are passing locally
  • CI checks are passing
  • I have reviewed my own code and lock file changes
  • I have checked for any potential security implications
  • I have verified the changes work as expected
  • My commit messages follow Conventional Commits format

Notes for Reviewers

  • This package follows the same structure and patterns as packages/google-signin for consistency.
  • Apple only provides user info (name, email) on the first authorization. Subsequent sign-ins return only the identity token and user ID. Consumers should persist user info server-side after the first login.
  • The Android implementation requires a backend endpoint to handle Apple's form_post response mode and redirect back to the app via deep link. See the README for setup details.
  • iOS uses only system frameworks (AuthenticationServices, CryptoKit), so no additional CocoaPods or native dependencies are needed.

@IronTony IronTony marked this pull request as ready for review March 26, 2026 19:13
@IronTony IronTony requested a review from panz3r as a code owner March 26, 2026 19:13
@panz3r panz3r requested review from Copilot and removed request for panz3r March 26, 2026 23:21
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new @forward-software/react-auth-apple adapter package to the react-auth ecosystem, providing Apple Sign-In support across Web and React Native (Expo), and registers it for automated releases.

Changes:

  • Added new packages/apple-signin package with web + native AppleAuthClient implementations, button components, and Apple JS SDK wrapper.
  • Added Expo native modules for iOS (Swift) and Android (Kotlin + Custom Tabs).
  • Updated release-please configuration/manifest and lockfile to include the new package.

Reviewed changes

Copilot reviewed 26 out of 27 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
release-please-config.json Registers packages/apple-signin for release-please tagging.
.release-please-manifest.json Adds initial version entry for packages/apple-signin.
pnpm-lock.yaml Adds new workspace importer and dependency graph updates for the new package.
packages/apple-signin/package.json Defines the new package metadata, exports, scripts, peers, and dev deps.
packages/apple-signin/README.md Documents installation and usage for web/iOS/Android.
packages/apple-signin/tsconfig.json TypeScript build configuration for the new package.
packages/apple-signin/vitest.config.ts Vitest configuration for the new package tests (jsdom + coverage).
packages/apple-signin/test/test-utils.ts Shared test utilities (mock storage + mock Apple JWTs).
packages/apple-signin/test/AppleAuthClient.web.spec.ts Unit tests for web AppleAuthClient behavior.
packages/apple-signin/test/AppleAuthClient.native.spec.ts Unit tests for native AppleAuthClient behavior (mocked native module).
packages/apple-signin/src/types.ts Shared types/config contracts for credentials/tokens/storage and platform configs.
packages/apple-signin/src/index.ts Web entrypoint exports for the package.
packages/apple-signin/src/index.native.ts React Native entrypoint exports for the package.
packages/apple-signin/src/web/index.ts Web sub-entry exports (client + button).
packages/apple-signin/src/web/appleid.ts Thin loader/wrapper around Apple’s JS SDK (AppleID.auth).
packages/apple-signin/src/web/AppleAuthClient.ts Web AuthClient implementation with persistence + JWT exp parsing.
packages/apple-signin/src/web/AppleSignInButton.tsx Web button component that initializes Apple JS SDK and returns credentials.
packages/apple-signin/src/native/index.ts Native sub-entry exports (client + button + module namespace).
packages/apple-signin/src/native/AppleAuthClient.ts Native AuthClient implementation with persistence and credential-state checks.
packages/apple-signin/src/native/AppleSignInButton.tsx React Native button component calling the native module.
packages/apple-signin/src/native/AppleSignInModule.ts JS wrapper around the Expo native module (requireNativeModule).
packages/apple-signin/expo-module.config.json Expo module registration for Apple/Android native modules.
packages/apple-signin/react-auth-apple.podspec Root podspec for iOS integration.
packages/apple-signin/ios/react-auth-apple.podspec iOS-specific podspec colocated under ios/.
packages/apple-signin/ios/AppleSignInModule.swift iOS Expo module implementing configure/signIn/getCredentialState/signOut.
packages/apple-signin/android/build.gradle Android library build config + dependencies (Custom Tabs).
packages/apple-signin/android/src/main/java/expo/modules/applesignin/AppleSignInModule.kt Android Expo module implementing OAuth flow + callback handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +33 to +34
const APPLE_LOGO_SVG = `data:image/svg+xml;base64,${btoa('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 20" fill="currentColor"><path d="M15.5 14.7c-.4.9-.6 1.3-1.1 2.1-.7 1.1-1.7 2.5-2.9 2.5-1.1 0-1.4-.7-2.9-.7-1.5 0-1.9.7-3 .7-1.2 0-2.1-1.2-2.8-2.3C1.2 14.6.5 11.4 1.6 9.2c.8-1.5 2.1-2.4 3.5-2.4 1.3 0 2.2.8 3.2.8 1 0 1.7-.8 3.1-.8 1.2 0 2.4.7 3.2 1.8-2.8 1.5-2.4 5.5.3 6.7l-.4-.6zM11.3 4.5c.5-.7.9-1.6.8-2.5-.8.1-1.7.5-2.3 1.2-.5.6-1 1.5-.8 2.4.9 0 1.7-.4 2.3-1.1z"/></svg>')}`;

Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPLE_LOGO_SVG is computed at module load using btoa(...). In non-browser runtimes (SSR, some test runners), btoa may be undefined, causing an import-time crash even if the component isn’t rendered. Consider inlining a precomputed base64 string or lazily computing it inside the component (guarded by typeof btoa !== 'undefined').

Suggested change
const APPLE_LOGO_SVG = `data:image/svg+xml;base64,${btoa('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 20" fill="currentColor"><path d="M15.5 14.7c-.4.9-.6 1.3-1.1 2.1-.7 1.1-1.7 2.5-2.9 2.5-1.1 0-1.4-.7-2.9-.7-1.5 0-1.9.7-3 .7-1.2 0-2.1-1.2-2.8-2.3C1.2 14.6.5 11.4 1.6 9.2c.8-1.5 2.1-2.4 3.5-2.4 1.3 0 2.2.8 3.2.8 1 0 1.7-.8 3.1-.8 1.2 0 2.4.7 3.2 1.8-2.8 1.5-2.4 5.5.3 6.7l-.4-.6zM11.3 4.5c.5-.7.9-1.6.8-2.5-.8.1-1.7.5-2.3 1.2-.5.6-1 1.5-.8 2.4.9 0 1.7-.4 2.3-1.1z"/></svg>')}`;
const APPLE_LOGO_SVG_SOURCE =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 17 20" fill="currentColor"><path d="M15.5 14.7c-.4.9-.6 1.3-1.1 2.1-.7 1.1-1.7 2.5-2.9 2.5-1.1 0-1.4-.7-2.9-.7-1.5 0-1.9.7-3 .7-1.2 0-2.1-1.2-2.8-2.3C1.2 14.6.5 11.4 1.6 9.2c.8-1.5 2.1-2.4 3.5-2.4 1.3 0 2.2.8 3.2.8 1 0 1.7-.8 3.1-.8 1.2 0 2.4.7 3.2 1.8-2.8 1.5-2.4 5.5.3 6.7l-.4-.6zM11.3 4.5c.5-.7.9-1.6.8-2.5-.8.1-1.7.5-2.3 1.2-.5.6-1 1.5-.8 2.4.9 0 1.7-.4 2.3-1.1z"/></svg>';
const APPLE_LOGO_SVG =
typeof btoa !== 'undefined'
? `data:image/svg+xml;base64,${btoa(APPLE_LOGO_SVG_SOURCE)}`
: `data:image/svg+xml,${encodeURIComponent(APPLE_LOGO_SVG_SOURCE)}`;

Copilot uses AI. Check for mistakes.
Comment on lines +7 to +44
type NativeAppleSignInModule = {
configure(config: {
scopes?: string[];
nonce?: string;
}): void;

signIn(): Promise<AppleAuthCredentials>;
getCredentialState(userID: string): Promise<AppleCredentialState>;
signOut(): Promise<void>;
};

const NativeModule = requireNativeModule<NativeAppleSignInModule>('AppleSignIn');

export function configure(config: AppleNativeAuthConfig): void {
const nativeConfig: { scopes?: string[]; nonce?: string; clientId?: string; redirectUri?: string } = {
scopes: config.scopes,
nonce: config.nonce,
};

if (Platform.OS === 'android') {
nativeConfig.clientId = config.clientId;
nativeConfig.redirectUri = config.androidRedirectUri;
}

NativeModule.configure(nativeConfig);
}

export function signIn(): Promise<AppleAuthCredentials> {
return NativeModule.signIn();
}

export function getCredentialState(userID: string): Promise<AppleCredentialState> {
return NativeModule.getCredentialState(userID);
}

export function signOut(): Promise<void> {
return NativeModule.signOut();
}
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Android’s native module defines a handleCallback function to resolve the pending signIn() promise, but the TS wrapper doesn’t type or export it. As a result, AppleSignInModule.signIn() on Android has no public way to be completed from the app’s deep-link handler, so callers can hang indefinitely. Add handleCallback(params) to NativeAppleSignInModule and export a wrapper function from this file (and document expected params: id_token, optional code, optional user).

Copilot uses AI. Check for mistakes.
* Apple uses response_mode=form_post, so a backend intermediary is needed
* to convert the POST into a deep link redirect back to your app.
*/
androidRedirectUri?: string;
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AppleNativeAuthConfig doesn’t allow providing a state value (CSRF protection), and AppleSignInModule.configure() can’t forward it, but the Android native module supports and reads state. Consider adding state?: string to the native config type and plumbing it through so Android clients can use the same CSRF protection pattern as web.

Suggested change
androidRedirectUri?: string;
androidRedirectUri?: string;
/** Opaque state value for CSRF protection on native (e.g., Android). */
state?: string;

Copilot uses AI. Check for mistakes.
@panz3r
Copy link
Copy Markdown
Member

panz3r commented Mar 27, 2026

@IronTony please update documentation and CI configuration files with the new package identifier as mentioned in the "How to implement or enhance an adapter package" in AGENTS.md file (see here: https://github.com/forwardsoftware/react-auth/blob/main/AGENTS.md#how-to-implement-or-enhance-an-adapter-package).

Also update tsconfig.json to match new values expected after migrating to TS 6 (see PR #234)

@panz3r panz3r assigned panz3r and IronTony and unassigned panz3r Mar 27, 2026
@panz3r panz3r self-requested a review March 27, 2026 09:48
@panz3r panz3r added enhancement New feature or request react-native New features or changes related to React Native/Expo labels Mar 27, 2026
- Introduced  package with a ready-made  implementation and .
- Updated documentation in , , and  to include details about the new adapter.
- Enhanced issue templates and GitHub workflows to accommodate the new package.
- Added support for CSRF protection in the Android OAuth flow and improved error handling in the AppleAuthClient.
@IronTony IronTony force-pushed the feat/apple-signin branch from 212fee5 to 59e41b9 Compare March 27, 2026 22:39
- Removed deprecated version 4.0.3 and updated all references to picomatch to version 4.0.4.
- Ensured compatibility with dependencies that rely on picomatch.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 34 out of 35 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

}, [isLoading]);

return (
<button
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This <button> does not set type="button", so when used inside a <form> it will default to submit and may trigger an unintended form submission. Set an explicit type="button" to avoid surprising behavior.

Suggested change
<button
<button
type="button"

Copilot uses AI. Check for mistakes.
Comment on lines +64 to +68
nonce?.let { uriBuilder.appendQueryParameter("nonce", it) }
state?.let { uriBuilder.appendQueryParameter("state", it) }

pendingPromise = promise

Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pendingPromise is overwritten on every signIn() call without handling an existing in-flight sign-in. If signIn() is invoked twice (or the previous flow never returns), the first promise will never resolve/reject. Consider rejecting/throwing when pendingPromise is already set (or cancelling/rejecting the previous one) to prevent hung callers.

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +189

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

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The manual integration snippet calls AppleSignInModule.getCredentialState(credentials.user) without noting that getCredentialState is iOS-only (it rejects with UNSUPPORTED on Android). Update the example to guard by platform or mention that this call should only be made on iOS.

Suggested change
AppleSignInModule.configure({ scopes: ['name', 'email'] });
const credentials = await AppleSignInModule.signIn();
const state = await AppleSignInModule.getCredentialState(credentials.user);
import { Platform } from 'react-native';
AppleSignInModule.configure({ scopes: ['name', 'email'] });
const credentials = await AppleSignInModule.signIn();
// getCredentialState is iOS-only; it will reject with UNSUPPORTED on Android
if (Platform.OS === 'ios') {
const state = await AppleSignInModule.getCredentialState(credentials.user);
}

Copilot uses AI. Check for mistakes.
* with the Apple Sign-In response parameters.
*/
export function handleCallback(params: HandleCallbackParams): void {
return NativeModule.handleCallback(params);
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleCallback() is exported unconditionally, but the iOS native module does not implement handleCallback. Calling this on iOS will throw at runtime (missing native method). Gate this function behind Platform.OS === 'android' (and throw a clear unsupported error on other platforms) or implement a no-op/unsupported handleCallback in the Apple (iOS) module to keep the JS surface consistent.

Suggested change
return NativeModule.handleCallback(params);
if (Platform.OS !== 'android') {
throw new Error('AppleSignIn.handleCallback is only supported on Android.');
}
if (typeof (NativeModule as any).handleCallback !== 'function') {
throw new Error('AppleSignIn native module does not implement handleCallback on this platform.');
}
return (NativeModule as any).handleCallback(params);

Copilot uses AI. Check for mistakes.
Comment on lines +94 to +118
// Check credential state if user ID is available
if (currentTokens.user) {
try {
const state = await AppleSignInModule.getCredentialState(currentTokens.user);
if (state === 'authorized') {
return currentTokens;
}
throw new Error(
`Apple credential state is '${state}'. User must re-authenticate.`
);
} catch (err) {
if (err instanceof Error && err.message.includes('credential state')) {
throw err;
}
// Android UNSUPPORTED or other native error -- fall through to expiry check
}
}

// Check token expiry as fallback when user ID is unavailable or credential state cannot be determined
if (currentTokens.expiresAt && Date.now() < currentTokens.expiresAt) {
return currentTokens;
}

throw new Error(
'Apple identity token has expired and no user ID is available to check credential state. User must re-authenticate.'
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onRefresh returns currentTokens as soon as credential state is authorized, without checking expiresAt. This can keep using an expired identityToken, which will be rejected by backends. Align with the Google adapter pattern by checking expiresAt first (return only if still valid) and use getCredentialState solely to detect revocation; otherwise require re-authentication.

Suggested change
// Check credential state if user ID is available
if (currentTokens.user) {
try {
const state = await AppleSignInModule.getCredentialState(currentTokens.user);
if (state === 'authorized') {
return currentTokens;
}
throw new Error(
`Apple credential state is '${state}'. User must re-authenticate.`
);
} catch (err) {
if (err instanceof Error && err.message.includes('credential state')) {
throw err;
}
// Android UNSUPPORTED or other native error -- fall through to expiry check
}
}
// Check token expiry as fallback when user ID is unavailable or credential state cannot be determined
if (currentTokens.expiresAt && Date.now() < currentTokens.expiresAt) {
return currentTokens;
}
throw new Error(
'Apple identity token has expired and no user ID is available to check credential state. User must re-authenticate.'
// First, honor the local expiry information. If the token is still valid,
// we can reuse it unless we detect that the credential has been revoked.
if (currentTokens.expiresAt && Date.now() < currentTokens.expiresAt) {
if (currentTokens.user) {
try {
const state = await AppleSignInModule.getCredentialState(currentTokens.user);
if (state !== 'authorized') {
throw new Error(
`Apple credential state is '${state}'. User must re-authenticate.`
);
}
} catch (err) {
if (err instanceof Error && err.message.includes('credential state')) {
throw err;
}
// Android UNSUPPORTED or other native error -- ignore and fall through,
// treating the locally non-expired token as valid.
}
}
return currentTokens;
}
// At this point, the identity token is expired or has no known expiry.
// getCredentialState is used only to detect revocation and provide a clearer error,
// not to keep using an expired identityToken.
if (currentTokens.user) {
try {
const state = await AppleSignInModule.getCredentialState(currentTokens.user);
if (state !== 'authorized') {
throw new Error(
`Apple credential state is '${state}'. User must re-authenticate.`
);
}
} catch (err) {
if (err instanceof Error && err.message.includes('credential state')) {
throw err;
}
// Android UNSUPPORTED or other native error -- ignore and proceed to re-authentication requirement.
}
}
throw new Error(
'Apple identity token has expired or is otherwise invalid. User must re-authenticate.'

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emmm you suggested this: #231 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request react-native New features or changes related to React Native/Expo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants