Conversation
There was a problem hiding this comment.
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-signinpackage with web + nativeAppleAuthClientimplementations, 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.
| 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>')}`; | ||
|
|
There was a problem hiding this comment.
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').
| 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)}`; |
| 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(); | ||
| } |
There was a problem hiding this comment.
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).
| * 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; |
There was a problem hiding this comment.
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.
| androidRedirectUri?: string; | |
| androidRedirectUri?: string; | |
| /** Opaque state value for CSRF protection on native (e.g., Android). */ | |
| state?: string; |
|
@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 Also update |
- 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.
212fee5 to
59e41b9
Compare
- 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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
| <button | |
| <button | |
| type="button" |
| nonce?.let { uriBuilder.appendQueryParameter("nonce", it) } | ||
| state?.let { uriBuilder.appendQueryParameter("state", it) } | ||
|
|
||
| pendingPromise = promise | ||
|
|
There was a problem hiding this comment.
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.
|
|
||
| AppleSignInModule.configure({ scopes: ['name', 'email'] }); | ||
| const credentials = await AppleSignInModule.signIn(); | ||
| const state = await AppleSignInModule.getCredentialState(credentials.user); |
There was a problem hiding this comment.
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.
| 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); | |
| } |
| * with the Apple Sign-In response parameters. | ||
| */ | ||
| export function handleCallback(params: HandleCallbackParams): void { | ||
| return NativeModule.handleCallback(params); |
There was a problem hiding this comment.
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.
| 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); |
| // 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.' |
There was a problem hiding this comment.
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.
| // 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.' |
Affected Package(s)
@forward-software/react-auth(lib)@forward-software/react-auth-google(packages/google-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-authto integrate Sign in with Apple across Web, iOS, and Android with a unified API.Description of Changes
@forward-software/react-auth-applepackage (v1.0.0) implementing theAuthClient<AppleAuthTokens, AppleAuthCredentials>interfaceAuthenticationServicesframework via Expo module (no external dependencies)AppleSignInButtoncomponents for both web and React Native with customizable appearance./web/appleid) for custom integrationsAppleAuthClientimplementationsrelease-please-config.jsonand.release-please-manifest.jsonBreaking Changes
None
How to Test
Vitest) and build steps pass successfully on this PR.pnpm installto install dependencies.pnpm --filter @forward-software/react-auth-apple testto run tests for the new package.pnpm --filter @forward-software/react-auth-apple buildto verify the build succeeds.pnpm --filter @forward-software/react-auth-apple lintto check for linting errors.Checklist
Notes for Reviewers
packages/google-signinfor consistency.form_postresponse mode and redirect back to the app via deep link. See the README for setup details.AuthenticationServices,CryptoKit), so no additional CocoaPods or native dependencies are needed.