Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
315 changes: 315 additions & 0 deletions frontend/src/components/AppleAuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
import React, { useEffect, useRef } from "react";
import { useOpenSecret } from "@opensecret/react";
import { v4 as uuidv4 } from "uuid";
import { sha256 } from "@noble/hashes/sha256";
import { bytesToHex } from "@noble/hashes/utils";
import { Button } from "./ui/button";
import { Apple } from "./icons/Apple";

// Define the props interface
interface AppleAuthProviderProps {
onSuccess?: () => void;
onError?: (error: Error) => void;
inviteCode?: string;
redirectAfterLogin?: (plan?: string) => void;
selectedPlan?: string;
className?: string;
children?: React.ReactNode;
}

// Define AppleID interface which will be added to window
declare global {
interface Window {
AppleID: {
auth: {
init: (config: {
clientId: string;
scope: string;
redirectURI: string;
state: string;
nonce: string;
usePopup: boolean;
}) => void;
signIn: () => Promise<{
authorization: {
code: string;
state: string;
id_token?: string;
};
}>;
};
};
}
}

// Define Apple Sign In event types
interface AppleSignInAuthorizationData {
code: string;
state: string;
id_token?: string;
}

interface AppleSignInSuccessEventDetail {
data?: {
authorization?: AppleSignInAuthorizationData;
};
authorization?: AppleSignInAuthorizationData;
code?: string;
state?: string;
id_token?: string;
}

interface AppleSignInSuccessEvent extends Event {
detail: AppleSignInSuccessEventDetail | string;
authorization?: AppleSignInAuthorizationData;
}

interface AppleSignInFailureEvent extends Event {
detail: {
error: string;
};
}

export function AppleAuthProvider({
onSuccess,
onError,
inviteCode = "",
redirectAfterLogin,
selectedPlan,
className,
children
}: AppleAuthProviderProps) {
const os = useOpenSecret();
const appleScriptLoaded = useRef(false);
const appleAuthInitialized = useRef(false);
const rawNonceRef = useRef<string>("");

// Load Apple Sign In JS SDK on mount (but don't initialize auth yet)
useEffect(() => {
// Don't load the script multiple times
if (appleScriptLoaded.current) return;

// Skip if we're in a Tauri environment (will use native flow on iOS)
if (window.location.protocol === "tauri:") return;

// Load Apple Sign In JS SDK
const script = document.createElement("script");
script.src =
"https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js";
script.async = true;
script.onload = () => {
console.log("[Apple Auth] SDK loaded successfully");
};
document.head.appendChild(script);

appleScriptLoaded.current = true;

return () => {
// Clean up script on unmount if needed
if (script.parentNode) {
script.parentNode.removeChild(script);
}
};
}, []);

Comment thread
AnthonyRonning marked this conversation as resolved.
const initializeAppleAuth = async () => {
if (!window.AppleID) {
console.error("[Apple Auth] AppleID JS SDK not loaded");
return;
}

try {
try {
// First we need to get the proper state and auth URL from the backend
const initiateResult = await os.initiateAppleAuth(inviteCode || "");
console.log("[Apple Auth] Initiating result");

// Generate the nonce for Apple
rawNonceRef.current = uuidv4();
const hashedNonce = bytesToHex(sha256(new TextEncoder().encode(rawNonceRef.current)));

// Store the raw nonce in sessionStorage to access it during callback
sessionStorage.setItem("apple_auth_nonce", rawNonceRef.current);

// Store the state from the backend for CSRF validation
const state = initiateResult.state || "";
sessionStorage.setItem("apple_auth_state", state);

// Store selected plan if present
if (selectedPlan) {
sessionStorage.setItem("selected_plan", selectedPlan);
}

// Initialize Apple auth with required parameters
window.AppleID.auth.init({
clientId: "cloud.opensecret.maple.services", // Apple Services ID
Comment thread
AnthonyRonning marked this conversation as resolved.
scope: "name email",
redirectURI: "https://trymaple.ai/auth/apple/callback",
state: state, // Use the state from the backend
nonce: hashedNonce,
usePopup: true // Using popup to capture authentication on client side
});
Comment thread
AnthonyRonning marked this conversation as resolved.
} catch (error) {
console.error("[Apple Auth] Failed to initialize:", error);
if (onError && error instanceof Error) {
onError(error);
}
}

console.log(
"[Apple Auth] Using redirectURI:",
window.location.origin + "/auth/apple/callback"
);

// Add event listeners for Apple Sign In response
document.addEventListener("AppleIDSignInOnSuccess", async (event) => {
// Handle successful response
try {
// Cast event to AppleSignInSuccessEvent
const appleEvent = event as AppleSignInSuccessEvent;

// Log the entire event for debugging
console.log("[Apple Auth] Success");

// Access the data - the structure might vary
let code, state;

// Different versions of Apple Sign In JS SDK might structure the data differently
if (
typeof appleEvent.detail === "object" &&
appleEvent.detail.data?.authorization?.code
) {
// Standard structure
code = appleEvent.detail.data.authorization.code;
state = appleEvent.detail.data.authorization.state;
} else if (
typeof appleEvent.detail === "object" &&
appleEvent.detail.authorization?.code
) {
// Alternative structure
code = appleEvent.detail.authorization.code;
state = appleEvent.detail.authorization.state;
} else if (typeof appleEvent.detail === "object" && appleEvent.detail.code) {
// Simplified structure
code = appleEvent.detail.code;
state = appleEvent.detail.state;
} else if (appleEvent.authorization?.code) {
// Another possible structure
code = appleEvent.authorization.code;
state = appleEvent.authorization.state;
} else if (typeof appleEvent.detail === "string") {
// Sometimes the data might be a stringified JSON
try {
const parsedData = JSON.parse(appleEvent.detail);
code = parsedData.code || parsedData.authorization?.code;
state = parsedData.state || parsedData.authorization?.state;
} catch (e) {
console.error("[Apple Auth] Failed to parse string data:", e);
}
}

console.log("[Apple Auth] Parsed data");

if (code && state) {
Comment thread
AnthonyRonning marked this conversation as resolved.
// Call the OpenSecret SDK to handle the authentication
await os.handleAppleCallback(code, state, inviteCode || "");

// Handle successful login redirection
if (onSuccess) {
onSuccess();
}

if (redirectAfterLogin) {
redirectAfterLogin(selectedPlan);
}
} else {
throw new Error("Missing required authentication data");
}
} catch (error) {
console.error("[Apple Auth] Error processing authentication:", error);
if (onError && error instanceof Error) {
onError(error);
}
}
});
Comment thread
AnthonyRonning marked this conversation as resolved.

// Listen for authorization failures
document.addEventListener("AppleIDSignInOnFailure", (event) => {
const failureEvent = event as AppleSignInFailureEvent;
console.error("[Apple Auth] Sign In failed:", failureEvent.detail.error);
if (onError) {
onError(new Error(failureEvent.detail.error || "Apple authentication failed"));
}
});
} catch (error) {
console.error("[Apple Auth] Failed to initialize Apple Sign In:", error);
if (onError && error instanceof Error) {
onError(error);
}
}
};

const handleAppleSignIn = async () => {
try {
if (!window.AppleID) {
throw new Error("Apple Sign In SDK not loaded");
}

// Initialize Apple Auth if not already initialized
if (!appleAuthInitialized.current) {
await initializeAppleAuth();
appleAuthInitialized.current = true;
}

// This will open a popup for Apple authentication
// Add additional handling to be more robust
const authResult = await window.AppleID.auth.signIn();

// Log the direct result from signIn (might contain data in some cases)
console.log("[Apple Auth] Direct signIn");

// Some implementations might return the data directly
if (authResult && authResult.authorization && authResult.authorization.code) {
const code = authResult.authorization.code;
const state = authResult.authorization.state;

console.log("[Apple Auth] Found authorization in direct result");

if (code && state) {
// Call the OpenSecret SDK to handle the authentication
await os.handleAppleCallback(code, state, inviteCode || "");

// Handle successful login redirection
if (onSuccess) {
onSuccess();
}

if (redirectAfterLogin) {
redirectAfterLogin(selectedPlan);
}
}
}
// If not returned directly, it will be handled by the event listener
} catch (error) {
console.error("[Apple Auth] Sign In failed:", error);
if (onError && error instanceof Error) {
onError(error);
}
}
};

// Skip rendering on Tauri (iOS will use native button)
if (window.location.protocol === "tauri:") {
return null;
}

// Render Apple Sign In button for web
return children ? (
<div onClick={handleAppleSignIn}>{children}</div>
) : (
<Button onClick={handleAppleSignIn} className={className || "w-full"}>
<Apple className="mr-2 h-4 w-4" />
Log in with Apple
</Button>
);
}
34 changes: 28 additions & 6 deletions frontend/src/routes/auth.$provider.callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ function formatProviderName(provider: string): string {
return "GitHub";
case "google":
return "Google";
case "apple":
return "Apple";
default:
return provider.charAt(0).toUpperCase() + provider.slice(1);
}
Expand All @@ -26,7 +28,7 @@ function OAuthCallback() {
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
const { handleGitHubCallback, handleGoogleCallback } = useOpenSecret();
const { handleGitHubCallback, handleGoogleCallback, handleAppleCallback } = useOpenSecret();
const processedRef = useRef(false);

// Helper functions for the callback process
Expand Down Expand Up @@ -94,13 +96,33 @@ function OAuthCallback() {
const code = urlParams.get("code");
const state = urlParams.get("state");

if (code && state) {
// For Apple, we might get form data instead of URL parameters
// Apple uses form_post with POST request in some scenarios
let appleData = null;
if (provider === "apple" && !code) {
// Check if we have Apple data in sessionStorage from form_post
const appleFormData = sessionStorage.getItem("apple_form_data");
if (appleFormData) {
try {
appleData = JSON.parse(appleFormData);
sessionStorage.removeItem("apple_form_data");
} catch (e) {
console.error("Failed to parse Apple form data:", e);
}
}
}

if ((code && state) || (provider === "apple" && appleData)) {
try {
// Get the auth token from localStorage based on the provider
// Handle the callback based on the provider
if (provider === "github") {
await handleGitHubCallback(code, state, "");
await handleGitHubCallback(code || "", state || "", "");
} else if (provider === "google") {
await handleGoogleCallback(code, state, "");
await handleGoogleCallback(code || "", state || "", "");
} else if (provider === "apple") {
// This handles the redirect flow (backup for non-popup scenarios)
// Most Apple auth will now be handled client-side in the AppleAuthProvider component
await handleAppleCallback(code || "", state || "", "");
} else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw new Error(`Unsupported provider: ${provider}`);
}
Expand All @@ -120,7 +142,7 @@ function OAuthCallback() {
};

processCallback();
}, [handleGitHubCallback, handleGoogleCallback, navigate, provider]);
}, [handleGitHubCallback, handleGoogleCallback, handleAppleCallback, navigate, provider]);

// If this is a Tauri app auth flow (desktop or mobile), show a different UI
if (localStorage.getItem("redirect-to-native") === "true") {
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/routes/desktop-auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const Route = createFileRoute("/desktop-auth")({
validateSearch: (search: Record<string, unknown>): DesktopAuthSearchParams => {
const provider = typeof search.provider === "string" ? search.provider : "github";
// Validate provider is supported
if (provider !== "github" && provider !== "google") {
if (provider !== "github" && provider !== "google" && provider !== "apple") {
Comment thread
AnthonyRonning marked this conversation as resolved.
throw new Error(`Unsupported provider: ${provider}`);
}
return {
Expand Down Expand Up @@ -52,6 +52,9 @@ function DesktopAuth() {
} else if (provider === "google") {
const result = await os.initiateGoogleAuth("");
auth_url = result.auth_url;
} else if (provider === "apple") {
const result = await os.initiateAppleAuth("");
auth_url = result.auth_url;
} else {
throw new Error("Unsupported provider");
}
Expand Down
Loading