Skip to content

Commit 9e277e2

Browse files
committed
Added apple login
1 parent 48d1d3f commit 9e277e2

13 files changed

Lines changed: 375 additions & 61 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"WebFetch(domain:docs.fx.land)",
1818
"Bash(npm search:*)",
1919
"Bash(npm pack:*)",
20-
"Bash(npm view:*)"
20+
"Bash(npm view:*)",
21+
"Bash(tree:*)"
2122
]
2223
}
2324
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ Note: Firebase backend requires `GOOGLE_APPLICATION_CREDENTIALS` environment var
266266
# cd ~/pinning-service/pinning-webui/
267267
~/pinning-service/pinning-webui# git pull
268268
npm install
269-
~/pinning-service/pinning-webui# VITE_GOOGLE_CLIENT_ID={YOUR GOGLE VITE} npm run build
269+
~/pinning-service/pinning-webui# VITE_GOOGLE_CLIENT_ID={client_id} VITE_WALLETCONNECT_PROJECT_ID={project_id} VITE_APPLE_CLIENT_ID=land.fx.cloud npm run build
270270
~/pinning-service/pinning-webui# cp -r dist/* /home/root/pinning-service/pinning-webui/dist/
271271
/pinning-service/pinning-webui# cp package.json package-lock.json /home/root/pinning-service/pinning-webui/
272272
~/pinning-service/pinning-webui# cd /home/root/pinning-service/pinning-webui

pinning-webui/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1111
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
1212
<script src="https://accounts.google.com/gsi/client" async defer></script>
13+
<script src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js" async defer></script>
1314
</head>
1415
<body>
1516
<div id="root"></div>

pinning-webui/package-lock.json

Lines changed: 36 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pinning-webui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@functionland/fula-client": "^0.2.24",
2222
"@rainbow-me/rainbowkit": "^2.2.0",
2323
"@tanstack/react-query": "^5.62.0",
24+
"apple-signin-auth": "^2.0.0",
2425
"cookie-parser": "^1.4.7",
2526
"cors": "^2.8.5",
2627
"dotenv": "^16.5.0",

pinning-webui/server/app.ts

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,11 @@ import {
4747

4848
// Session user type
4949
export interface SessionUser {
50-
id: string; // Google user ID (sub claim)
50+
id: string; // User ID (Google sub claim or Apple sub)
5151
email: string;
5252
name: string;
5353
picture: string;
54+
provider: 'google' | 'apple'; // Authentication provider
5455
}
5556

5657
// Extend express session
@@ -80,6 +81,11 @@ export interface AppConfig {
8081
systemKey?: string; // For x402 gateway integration
8182
s3AdminJwt?: string; // For internal S3 fetch (share links)
8283
s3InternalUrl?: string; // Internal S3 endpoint (default: http://127.0.0.1:9000)
84+
// Apple Sign-In configuration
85+
appleClientId?: string;
86+
appleTeamId?: string;
87+
appleKeyId?: string;
88+
applePrivateKey?: string;
8389
}
8490

8591
// Database operations type (async for PostgreSQL)
@@ -298,7 +304,7 @@ export function createApp(config: AppConfig, options?: { skipRateLimit?: boolean
298304
contentSecurityPolicy: {
299305
directives: {
300306
defaultSrc: ["'self'"],
301-
scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", "https://accounts.google.com", "https://apis.google.com", "https://www.gstatic.com"],
307+
scriptSrc: ["'self'", "'unsafe-inline'", "'wasm-unsafe-eval'", "https://accounts.google.com", "https://apis.google.com", "https://www.gstatic.com", "https://appleid.cdn-apple.com"],
302308
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://accounts.google.com"],
303309
fontSrc: ["'self'", "https://fonts.gstatic.com"],
304310
imgSrc: ["'self'", "data:", "https:", "blob:"],
@@ -345,8 +351,10 @@ export function createApp(config: AppConfig, options?: { skipRateLimit?: boolean
345351
// Solana (for Phantom)
346352
"https://*.solana.com",
347353
"wss://*.solana.com",
354+
// Apple Sign-In
355+
"https://appleid.apple.com",
348356
],
349-
frameSrc: ["'self'", "blob:", "https://accounts.google.com", "https://*.phantom.app", "https://verify.walletconnect.org", "https://verify.walletconnect.com", "https://*.walletconnect.org", "https://*.walletconnect.com"],
357+
frameSrc: ["'self'", "blob:", "https://accounts.google.com", "https://appleid.apple.com", "https://*.phantom.app", "https://verify.walletconnect.org", "https://verify.walletconnect.com", "https://*.walletconnect.org", "https://*.walletconnect.com"],
350358
objectSrc: ["'self'", "blob:"],
351359
mediaSrc: ["'self'", "blob:"],
352360
frameAncestors: ["'self'"],
@@ -453,6 +461,7 @@ export function createApp(config: AppConfig, options?: { skipRateLimit?: boolean
453461
email: email,
454462
name: name || '',
455463
picture: picture || '',
464+
provider: 'google',
456465
};
457466

458467
res.json({
@@ -466,6 +475,68 @@ export function createApp(config: AppConfig, options?: { skipRateLimit?: boolean
466475
}
467476
});
468477

478+
// Apple Sign-In endpoint
479+
app.post('/auth/apple', async (req: Request, res: Response) => {
480+
try {
481+
const { identityToken, user: appleUser, referralCode } = req.body;
482+
483+
if (!identityToken) {
484+
return res.status(400).json({ error: 'Missing identity token' });
485+
}
486+
487+
if (!config.appleClientId) {
488+
return res.status(500).json({ error: 'Apple Sign-In not configured' });
489+
}
490+
491+
// Dynamically import apple-signin-auth (ESM module)
492+
const AppleSignIn = await import('apple-signin-auth');
493+
494+
// Verify the identity token with Apple
495+
const applePayload = await AppleSignIn.default.verifyIdToken(identityToken, {
496+
audience: config.appleClientId,
497+
ignoreExpiration: false,
498+
});
499+
500+
const { sub, email: tokenEmail } = applePayload;
501+
502+
if (!sub) {
503+
return res.status(400).json({ error: 'Invalid token: missing user ID' });
504+
}
505+
506+
// Apple only sends email on first sign-in, so we need to handle both cases
507+
// Priority: token email > user object email
508+
const userEmail = tokenEmail || appleUser?.email;
509+
510+
if (!userEmail) {
511+
return res.status(400).json({ error: 'Email is required. Please ensure you share your email with the app.' });
512+
}
513+
514+
// Get name from user object (only provided on first sign-in)
515+
const userName = appleUser?.name
516+
? `${appleUser.name.firstName || ''} ${appleUser.name.lastName || ''}`.trim()
517+
: '';
518+
519+
const user = await dbOps.getOrCreateUser(userEmail, userName, '', referralCode || undefined);
520+
521+
req.session.user = {
522+
id: sub,
523+
email: userEmail,
524+
name: userName || user.name || '',
525+
picture: '', // Apple doesn't provide profile pictures
526+
provider: 'apple',
527+
};
528+
529+
res.json({
530+
success: true,
531+
user: req.session.user,
532+
isNew: user.isNew,
533+
});
534+
} catch (error) {
535+
console.error('[webui] Apple auth error:', error);
536+
res.status(401).json({ error: 'Authentication failed' });
537+
}
538+
});
539+
469540
app.post('/auth/logout', (req: Request, res: Response) => {
470541
req.session.destroy((err) => {
471542
if (err) {

pinning-webui/server/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dotenv/config';
22
import { fileURLToPath } from 'url';
33
import path from 'path';
4+
import fs from 'fs';
45
import express from 'express';
56
import { v4 as uuidv4 } from 'uuid';
67
import { createApp, initializeDatabase, seedChainSyncState, type AppConfig } from './app.js';
@@ -22,6 +23,13 @@ const config: AppConfig = {
2223
systemKey: process.env.PINNING_SYSTEM_KEY, // For x402 gateway integration
2324
s3AdminJwt: process.env.S3_ADMIN_JWT, // For internal S3 fetch (share links)
2425
s3InternalUrl: process.env.S3_INTERNAL_URL || 'http://127.0.0.1:9000',
26+
// Apple Sign-In configuration
27+
appleClientId: process.env.APPLE_CLIENT_ID,
28+
appleTeamId: process.env.APPLE_TEAM_ID,
29+
appleKeyId: process.env.APPLE_KEY_ID,
30+
applePrivateKey: process.env.APPLE_PRIVATE_KEY_PATH
31+
? fs.readFileSync(process.env.APPLE_PRIVATE_KEY_PATH, 'utf8')
32+
: undefined,
2533
};
2634

2735
// Debug .env loading
@@ -31,6 +39,8 @@ console.log(`[webui] SESSION_SECRET: ${config.sessionSecret.substring(0, 10)}.
3139
console.log(`[webui] POSTGRES_HOST: ${process.env.POSTGRES_HOST || '(not set)'}`);
3240
console.log(`[webui] NODE_ENV: ${config.nodeEnv}`);
3341
console.log(`[webui] PINNING_SYSTEM_KEY: ${config.systemKey ? '****' : '(not set)'}`);
42+
console.log(`[webui] APPLE_CLIENT_ID: ${config.appleClientId || '(not set)'}`);
43+
console.log(`[webui] APPLE_PRIVATE_KEY: ${config.applePrivateKey ? 'loaded from file' : '(not set)'}`);
3444

3545
async function main() {
3646
try {

pinning-webui/src/context/AuthContext.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,27 @@ import { createContext, useContext, useState, useEffect, useCallback, ReactNode
22
import { clearAllKeys } from '../services/secureStorage';
33

44
interface User {
5-
id: string; // Google user ID for encryption key derivation
5+
id: string; // User ID for encryption key derivation (Google sub or Apple sub)
66
email: string;
77
name: string;
88
picture: string;
9+
provider: 'google' | 'apple'; // Authentication provider
10+
}
11+
12+
// Apple user info sent on first sign-in
13+
interface AppleUserInfo {
14+
email?: string;
15+
name?: {
16+
firstName?: string;
17+
lastName?: string;
18+
};
919
}
1020

1121
interface AuthContextType {
1222
user: User | null;
1323
loading: boolean;
1424
login: (credential: string, referralCode?: string) => Promise<{ isNew: boolean }>;
25+
loginWithApple: (identityToken: string, appleUser?: AppleUserInfo, referralCode?: string) => Promise<{ isNew: boolean }>;
1526
logout: () => Promise<void>;
1627
}
1728

@@ -26,7 +37,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
2637
const res = await fetch('/auth/me', { credentials: 'include' });
2738
if (res.ok) {
2839
const data = await res.json();
29-
setUser(data.user);
40+
// Backward compatibility: default to 'google' if provider not set
41+
setUser({
42+
...data.user,
43+
provider: data.user.provider || 'google',
44+
});
3045
}
3146
} catch (error) {
3247
console.error('Auth check failed:', error);
@@ -52,7 +67,32 @@ export function AuthProvider({ children }: { children: ReactNode }) {
5267
}
5368

5469
const data = await res.json();
55-
setUser(data.user);
70+
// Ensure provider is set (should be 'google' from server)
71+
setUser({
72+
...data.user,
73+
provider: data.user.provider || 'google',
74+
});
75+
return { isNew: data.isNew };
76+
};
77+
78+
const loginWithApple = async (identityToken: string, appleUser?: AppleUserInfo, referralCode?: string) => {
79+
const res = await fetch('/auth/apple', {
80+
method: 'POST',
81+
headers: { 'Content-Type': 'application/json' },
82+
credentials: 'include',
83+
body: JSON.stringify({ identityToken, user: appleUser, referralCode }),
84+
});
85+
86+
if (!res.ok) {
87+
const errorData = await res.json().catch(() => ({}));
88+
throw new Error(errorData.error || 'Apple login failed');
89+
}
90+
91+
const data = await res.json();
92+
setUser({
93+
...data.user,
94+
provider: data.user.provider || 'apple',
95+
});
5696
return { isNew: data.isNew };
5797
};
5898

@@ -71,7 +111,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
71111
};
72112

73113
return (
74-
<AuthContext.Provider value={{ user, loading, login, logout }}>
114+
<AuthContext.Provider value={{ user, loading, login, loginWithApple, logout }}>
75115
{children}
76116
</AuthContext.Provider>
77117
);

pinning-webui/src/pages/GetKey.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export default function GetKey() {
1313
const [finalRedirectUrl, setFinalRedirectUrl] = useState<string | null>(null);
1414

1515
const redirectUrl = searchParams.get('redirect');
16+
const platformParam = searchParams.get('platform');
1617

1718
useEffect(() => {
1819
// Wait for auth to finish loading
@@ -41,8 +42,15 @@ export default function GetKey() {
4142

4243
// If user is not logged in, redirect to login with returnTo
4344
if (!user) {
44-
const currentUrl = `/get-key?redirect=${encodeURIComponent(redirectUrl)}`;
45-
navigate(`/login?returnTo=${encodeURIComponent(currentUrl)}`, { replace: true });
45+
let currentUrl = `/get-key?redirect=${encodeURIComponent(redirectUrl)}`;
46+
if (platformParam) {
47+
currentUrl += `&platform=${encodeURIComponent(platformParam)}`;
48+
}
49+
let loginUrl = `/login?returnTo=${encodeURIComponent(currentUrl)}`;
50+
if (platformParam) {
51+
loginUrl += `&platform=${encodeURIComponent(platformParam)}`;
52+
}
53+
navigate(loginUrl, { replace: true });
4654
return;
4755
}
4856

@@ -79,7 +87,7 @@ export default function GetKey() {
7987
};
8088

8189
fetchKeyAndRedirect();
82-
}, [user, loading, redirectUrl, navigate]);
90+
}, [user, loading, redirectUrl, platformParam, navigate]);
8391

8492
// Show loading state
8593
if (loading || processing) {

0 commit comments

Comments
 (0)