diff --git a/apps/desktop/build/entitlements.mas.child.plist b/apps/desktop/build/entitlements.mas.child.plist index d8dc69e808..e4b6d54abf 100644 --- a/apps/desktop/build/entitlements.mas.child.plist +++ b/apps/desktop/build/entitlements.mas.child.plist @@ -6,5 +6,9 @@ com.apple.security.inherit + com.apple.developer.applesignin + + Default + diff --git a/apps/desktop/build/entitlements.mas.plist b/apps/desktop/build/entitlements.mas.plist index a101240dda..9e1e86bc98 100644 --- a/apps/desktop/build/entitlements.mas.plist +++ b/apps/desktop/build/entitlements.mas.plist @@ -10,5 +10,9 @@ com.apple.security.network.client + com.apple.developer.applesignin + + Default + diff --git a/apps/desktop/forge.config.cts b/apps/desktop/forge.config.cts index c060718dfa..7343217595 100644 --- a/apps/desktop/forge.config.cts +++ b/apps/desktop/forge.config.cts @@ -95,6 +95,9 @@ const noopAfterCopy = (_buildPath, _electronVersion, _platform, _arch, callback) const ignorePattern = new RegExp(`^/node_modules/(?!${[...keepModules].join("|")})`) +// For MAS builds, include the Apple Auth Helper (native Sign in with Apple is only available on MAS) +const isMAS = platform === "mas" + const config: ForgeConfig = { packagerConfig: { name: isStaging ? "Folo Staging" : "Folo", @@ -102,7 +105,11 @@ const config: ForgeConfig = { buildVersion: process.env.BUILD_VERSION || undefined, appBundleId: "is.follow", icon: isStaging ? "resources/icon-staging" : "resources/icon", - extraResource: ["./resources/app-update.yml"], + extraResource: [ + "./resources/app-update.yml", + // Include Apple Auth Helper only for MAS builds (native Sign in with Apple) + ...(isMAS ? ["./resources/apple-auth-helper"] : []), + ], protocols: [ { name: "Folo", diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index ec4b277904..7964f6a5bd 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -1,7 +1,32 @@ +import { execFile } from "node:child_process" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" + +import { app } from "electron" import type { IpcContext } from "electron-ipc-decorator" import { IpcMethod, IpcService } from "electron-ipc-decorator" +import path from "pathe" +import { isMAS } from "../../env" import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" +import { logger } from "../../logger" + +const execFileAsync = promisify(execFile) + +export interface NativeAppleAuthResult { + success: boolean + data?: { + identityToken: string + authorizationCode: string + user: string + email?: string + fullName?: { + givenName?: string + familyName?: string + } + } + error?: string +} export class AuthService extends IpcService { static override readonly groupName = "auth" @@ -15,4 +40,97 @@ export class AuthService extends IpcService { async signOut(_context: IpcContext): Promise { await deleteNotificationsToken() } + + /** + * Performs native Sign in with Apple using the macOS AuthenticationServices framework. + * This is only available on Mac App Store (MAS) builds. + * Returns the Apple ID credential including the identity token for server-side verification. + */ + @IpcMethod() + async signInWithApple(_context: IpcContext): Promise { + if (!isMAS) { + return { + success: false, + error: "Native Sign in with Apple is only available on Mac App Store builds", + } + } + + try { + const __dirname = fileURLToPath(new URL(".", import.meta.url)) + // In production, the helper is in the Resources directory + // In development, we need to find it relative to the source + // Path from: apps/desktop/layer/main/src/ipc/services/ + // To: apps/desktop/resources/apple-auth-helper/ + // The helper is packaged as an .app bundle to have a proper Bundle ID for Sign in with Apple + const helperPath = app.isPackaged + ? path.join( + process.resourcesPath, + "apple-auth-helper", + "AppleAuthHelper.app", + "Contents", + "MacOS", + "AppleAuthHelper", + ) + : path.resolve( + __dirname, + "../../../../../resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper", + ) + + logger.info("Executing AppleAuthHelper", { helperPath }) + + const { stdout, stderr } = await execFileAsync(helperPath, [], { + timeout: 120000, // 2 minutes timeout for user interaction + }) + + if (stderr) { + logger.warn("AppleAuthHelper stderr", { stderr }) + } + + const result = JSON.parse(stdout) as NativeAppleAuthResult + logger.info("AppleAuthHelper result", { success: result.success, hasData: !!result.data }) + + return result + } catch (error) { + logger.error("Failed to execute AppleAuthHelper", { error }) + + // When the helper exits with non-zero status, execFileAsync rejects + // but the error object still contains stdout with the JSON result + if (error && typeof error === "object" && "stdout" in error) { + const { stdout } = error as { stdout?: string } + if (stdout) { + try { + const result = JSON.parse(stdout) as NativeAppleAuthResult + logger.info("AppleAuthHelper result from error.stdout", { + success: result.success, + error: result.error, + }) + return result + } catch { + // Failed to parse stdout, fall through to generic error handling + } + } + } + + if (error instanceof Error) { + return { + success: false, + error: error.message, + } + } + + return { + success: false, + error: "Unknown error occurred during Sign in with Apple", + } + } + } + + /** + * Check if native Sign in with Apple is available. + * This is only true on Mac App Store (MAS) builds. + */ + @IpcMethod() + isNativeAppleAuthAvailable(_context: IpcContext): boolean { + return isMAS + } } diff --git a/apps/desktop/layer/renderer/src/lib/auth.ts b/apps/desktop/layer/renderer/src/lib/auth.ts index 09e308de83..1c530a3715 100644 --- a/apps/desktop/layer/renderer/src/lib/auth.ts +++ b/apps/desktop/layer/renderer/src/lib/auth.ts @@ -1,8 +1,11 @@ +import type { LoginRuntime } from "@follow/shared/auth" import { Auth } from "@follow/shared/auth" import { env } from "@follow/shared/env.desktop" import { createDesktopAPIHeaders } from "@follow/utils/headers" import PKG from "@pkg" +import { ipcServices } from "./client" + const headers = createDesktopAPIHeaders({ version: PKG.version }) const auth = new Auth({ @@ -38,4 +41,66 @@ export const { updateUser, } = auth.authClient -export const { loginHandler } = auth +/** + * Enhanced login handler that supports native Sign in with Apple on Mac App Store builds. + * For Apple provider on MAS builds, it uses the native AuthenticationServices + * framework to get an identity token, then authenticates with the server using the idToken flow. + * Non-MAS builds (DMG) use web-based Apple Sign In. + */ +export const loginHandler = async ( + provider: string, + runtime?: LoginRuntime, + args?: { + email?: string + password?: string + headers?: Record + }, +) => { + // Check if we should use native Apple Sign In (only available on MAS builds) + if (provider === "apple" && ipcServices) { + console.info("[Apple Auth] Checking native availability...") + try { + const isNativeAvailable = await ipcServices.auth.isNativeAppleAuthAvailable() + console.info("[Apple Auth] isNativeAvailable:", isNativeAvailable) + + if (isNativeAvailable) { + console.info("[Apple Auth] Calling signInWithApple...") + const result = await ipcServices.auth.signInWithApple() + console.info("[Apple Auth] signInWithApple result:", { + success: result.success, + hasData: !!result.data, + error: result.error, + }) + + if (!result.success || !result.data) { + // If user canceled, just return silently + if (result.error?.includes("canceled")) { + console.info("[Apple Auth] User canceled, returning silently") + return + } + console.error("[Apple Auth] Native sign in failed:", result.error) + throw new Error(result.error || "Failed to sign in with Apple") + } + + console.info("[Apple Auth] Got identity token, authenticating with server...") + // Use the identity token to authenticate with the server + // The idToken flow in better-auth doesn't redirect, it authenticates directly + return authClient.signIn.social({ + provider: "apple", + idToken: { + token: result.data.identityToken, + }, + }) + } else { + console.info("[Apple Auth] Native not available, falling back to web") + } + } catch (error) { + console.error("[Apple Auth] Native Apple Sign In failed:", error) + // Fall through to web-based Apple Sign In + } + } + + // Use the default login handler for all other cases + console.info("[Apple Auth] Using web-based login handler") + return auth.loginHandler(provider, runtime, args) +} diff --git a/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/Info.plist b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/Info.plist new file mode 100644 index 0000000000..4720e092a5 --- /dev/null +++ b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + AppleAuthHelper + CFBundleIdentifier + is.follow + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AppleAuthHelper + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + + diff --git a/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper new file mode 100755 index 0000000000..e804203805 Binary files /dev/null and b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.app/Contents/MacOS/AppleAuthHelper differ diff --git a/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.swift b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.swift new file mode 100644 index 0000000000..60ebe261a2 --- /dev/null +++ b/apps/desktop/resources/apple-auth-helper/AppleAuthHelper.swift @@ -0,0 +1,253 @@ +import AuthenticationServices +import Foundation + +// MARK: - Apple Sign In Helper for Electron + +/// A helper tool that performs native Sign in with Apple authentication. +/// Outputs JSON with the authentication result or error. + +class AppleSignInHelper: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { + private var result: Result? + private var presentationWindow: NSWindow? + private var authController: ASAuthorizationController? + + struct AppleAuthResult: Codable { + let identityToken: String + let authorizationCode: String + let user: String + let email: String? + let fullName: FullName? + + struct FullName: Codable { + let givenName: String? + let familyName: String? + } + } + + enum AppleSignInError: Error, LocalizedError { + case canceled + case failed(String) + case invalidCredential + case unknown + + var errorDescription: String? { + switch self { + case .canceled: + return "User canceled the Sign in with Apple request" + case .failed(let message): + return message + case .invalidCredential: + return "Invalid credential received from Apple" + case .unknown: + return "Unknown error occurred" + } + } + } + + func startSignIn() { + fputs("[AppleAuthHelper] Creating authorization request...\n", stderr) + + let appleIDProvider = ASAuthorizationAppleIDProvider() + let request = appleIDProvider.createRequest() + request.requestedScopes = [.fullName, .email] + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + + // Keep a strong reference to prevent premature deallocation + self.authController = controller + + fputs("[AppleAuthHelper] Performing authorization request...\n", stderr) + controller.performRequests() + } + + private func complete(with result: Result) { + fputs("[AppleAuthHelper] Completing with result...\n", stderr) + self.result = result + + // Close the presentation window and stop the app on the main thread + DispatchQueue.main.async { + self.presentationWindow?.close() + self.presentationWindow = nil + NSApp.stop(nil) + } + } + + func getResult() -> Result { + return result ?? .failure(AppleSignInError.unknown) + } + + // MARK: - ASAuthorizationControllerDelegate + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + fputs("[AppleAuthHelper] Authorization completed successfully\n", stderr) + + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { + complete(with: .failure(AppleSignInError.invalidCredential)) + return + } + + guard let identityTokenData = appleIDCredential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) else { + complete(with: .failure(AppleSignInError.failed("Failed to get identity token"))) + return + } + + guard let authorizationCodeData = appleIDCredential.authorizationCode, + let authorizationCode = String(data: authorizationCodeData, encoding: .utf8) else { + complete(with: .failure(AppleSignInError.failed("Failed to get authorization code"))) + return + } + + var fullName: AppleAuthResult.FullName? + if let name = appleIDCredential.fullName { + fullName = AppleAuthResult.FullName( + givenName: name.givenName, + familyName: name.familyName + ) + } + + let authResult = AppleAuthResult( + identityToken: identityToken, + authorizationCode: authorizationCode, + user: appleIDCredential.user, + email: appleIDCredential.email, + fullName: fullName + ) + + complete(with: .success(authResult)) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + fputs("[AppleAuthHelper] Authorization failed with error: \(error.localizedDescription)\n", stderr) + + if let authError = error as? ASAuthorizationError { + switch authError.code { + case .canceled: + complete(with: .failure(AppleSignInError.canceled)) + case .failed: + complete(with: .failure(AppleSignInError.failed("Authorization failed: \(error.localizedDescription)"))) + case .invalidResponse: + complete(with: .failure(AppleSignInError.failed("Invalid response from Apple: \(error.localizedDescription)"))) + case .notHandled: + complete(with: .failure(AppleSignInError.failed("Request not handled: \(error.localizedDescription)"))) + case .notInteractive: + complete(with: .failure(AppleSignInError.failed("Not interactive: \(error.localizedDescription)"))) + case .unknown: + complete(with: .failure(AppleSignInError.failed("Unknown error (code: \(authError.code.rawValue)): \(error.localizedDescription)"))) + case .matchedExcludedCredential: + complete(with: .failure(AppleSignInError.failed("Matched excluded credential: \(error.localizedDescription)"))) + case .credentialImport: + complete(with: .failure(AppleSignInError.failed("Credential import error: \(error.localizedDescription)"))) + case .credentialExport: + complete(with: .failure(AppleSignInError.failed("Credential export error: \(error.localizedDescription)"))) + case .preferSignInWithApple: + complete(with: .failure(AppleSignInError.failed("Prefer Sign in with Apple: \(error.localizedDescription)"))) + case .deviceNotConfiguredForPasskeyCreation: + complete(with: .failure(AppleSignInError.failed("Device not configured for passkey creation: \(error.localizedDescription)"))) + @unknown default: + complete(with: .failure(AppleSignInError.failed("Unknown authorization error (code: \(authError.code.rawValue)): \(error.localizedDescription)"))) + } + } else { + complete(with: .failure(AppleSignInError.failed("Non-authorization error: \(error.localizedDescription)"))) + } + } + + // MARK: - ASAuthorizationControllerPresentationContextProviding + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + fputs("[AppleAuthHelper] Creating presentation anchor...\n", stderr) + + // Return an existing window if available + if let window = NSApplication.shared.keyWindow { + fputs("[AppleAuthHelper] Using existing key window\n", stderr) + return window + } + + // Create a minimal transparent window as the anchor for the Sign in with Apple sheet + // The system's Sign in with Apple UI appears as a popover anchored to this window + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1, height: 1), + styleMask: [], // No title bar, no decorations + backing: .buffered, + defer: false + ) + window.isOpaque = false + window.backgroundColor = .clear + window.hasShadow = false + window.level = .modalPanel + window.center() + window.orderFrontRegardless() + + self.presentationWindow = window + fputs("[AppleAuthHelper] Created minimal transparent window\n", stderr) + return window + } +} + +// MARK: - Main Entry Point + +struct OutputResult: Codable { + let success: Bool + let data: AppleSignInHelper.AppleAuthResult? + let error: String? +} + +fputs("[AppleAuthHelper] Starting...\n", stderr) + +// Initialize the application +let app = NSApplication.shared + +// Use .accessory so the app doesn't appear in the Dock +// but can still present system UI elements like Sign in with Apple +app.setActivationPolicy(.accessory) + +// Activate the app to allow system dialogs to appear +app.activate(ignoringOtherApps: true) + +fputs("[AppleAuthHelper] App initialized with accessory policy, starting sign in...\n", stderr) + +let helper = AppleSignInHelper() +helper.startSignIn() + +// Run the standard Cocoa event loop +// This properly handles all events including Touch ID authentication +// The loop exits when NSApp.stop() is called from the completion handler +fputs("[AppleAuthHelper] Running app event loop...\n", stderr) +app.run() + +fputs("[AppleAuthHelper] App event loop ended\n", stderr) + +// Get the result after the run loop exits +let result = helper.getResult() + +let output: OutputResult +switch result { +case .success(let authResult): + output = OutputResult(success: true, data: authResult, error: nil) +case .failure(let error): + output = OutputResult(success: false, data: nil, error: error.localizedDescription) +} + +// Output JSON to stdout +let encoder = JSONEncoder() +encoder.outputFormatting = .prettyPrinted +if let jsonData = try? encoder.encode(output), + let jsonString = String(data: jsonData, encoding: .utf8) { + print(jsonString) +} else { + print("{\"success\": false, \"error\": \"Failed to encode result\"}") +} + +fputs("[AppleAuthHelper] Done\n", stderr) + +// Exit with appropriate code +exit(result.isSuccess ? 0 : 1) + +extension Result { + var isSuccess: Bool { + if case .success = self { return true } + return false + } +} diff --git a/apps/desktop/resources/apple-auth-helper/build.sh b/apps/desktop/resources/apple-auth-helper/build.sh new file mode 100755 index 0000000000..14e7d5b4b0 --- /dev/null +++ b/apps/desktop/resources/apple-auth-helper/build.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +# Build script for AppleAuthHelper +# This script compiles the Swift helper for Sign in with Apple and packages it as an .app bundle + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUTPUT_DIR="${SCRIPT_DIR}" +OUTPUT_NAME="AppleAuthHelper" +APP_BUNDLE="${OUTPUT_DIR}/${OUTPUT_NAME}.app" + +echo "Building AppleAuthHelper..." + +# Clean up old bundle if exists +rm -rf "${APP_BUNDLE}" + +# Create .app bundle structure +mkdir -p "${APP_BUNDLE}/Contents/MacOS" + +# Compile for both architectures (universal binary) +swiftc \ + -O \ + -target arm64-apple-macos11.0 \ + -o "${OUTPUT_DIR}/${OUTPUT_NAME}-arm64" \ + "${SCRIPT_DIR}/AppleAuthHelper.swift" + +swiftc \ + -O \ + -target x86_64-apple-macos11.0 \ + -o "${OUTPUT_DIR}/${OUTPUT_NAME}-x86_64" \ + "${SCRIPT_DIR}/AppleAuthHelper.swift" + +# Create universal binary directly in the bundle +lipo -create \ + "${OUTPUT_DIR}/${OUTPUT_NAME}-arm64" \ + "${OUTPUT_DIR}/${OUTPUT_NAME}-x86_64" \ + -output "${APP_BUNDLE}/Contents/MacOS/${OUTPUT_NAME}" + +# Clean up architecture-specific binaries +rm "${OUTPUT_DIR}/${OUTPUT_NAME}-arm64" "${OUTPUT_DIR}/${OUTPUT_NAME}-x86_64" + +# Remove old standalone binary if exists +rm -f "${OUTPUT_DIR}/${OUTPUT_NAME}" + +# Generate Info.plist directly in the bundle +# (Do NOT keep Info.plist at the directory level, or Apple will treat the whole directory as a bundle) +cat > "${APP_BUNDLE}/Contents/Info.plist" << 'PLIST' + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + AppleAuthHelper + CFBundleIdentifier + is.follow + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AppleAuthHelper + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + + +PLIST + +# Make executable +chmod +x "${APP_BUNDLE}/Contents/MacOS/${OUTPUT_NAME}" + +echo "Build complete: ${APP_BUNDLE}"