Skip to content

Commit be8aaa9

Browse files
feat: implement Apple Sign-In and enhance account management features
- Added support for Apple Sign-In, including token verification and user account handling. - Updated authentication routes to include Apple as a sign-in option. - Introduced account deletion functionality, allowing users to permanently delete their accounts and associated data. - Enhanced the user interface with a Data Consent Modal to inform users about data handling practices. - Updated .gitignore files to exclude sensitive keys and configuration files for both iOS and Android platforms.
1 parent c272e12 commit be8aaa9

13 files changed

Lines changed: 694 additions & 22 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ ios/DerivedData/
4444
*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
4545
*.hmap
4646
*.ipa
47+
48+
# Apple Sign in with Apple private keys
49+
*.p8

android/.gitignore

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ captures/
5353
.idea/navEditor.xml
5454

5555
# Keystore files
56-
# Uncomment the following lines if you do not want to check your keystore files in.
57-
#*.jks
58-
#*.keystore
56+
*.jks
57+
*.keystore
58+
keystore.properties
5959

6060
# External native build folder generated in Android Studio 2.2 and later
6161
.externalNativeBuild

android/app/build.gradle

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
apply plugin: 'com.android.application'
22

3+
def keystorePropertiesFile = rootProject.file("keystore.properties")
4+
def keystoreProperties = new Properties()
5+
if (keystorePropertiesFile.exists()) {
6+
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
7+
}
8+
39
android {
410
namespace = "app.botschat.console"
511
compileSdk = rootProject.ext.compileSdkVersion
@@ -16,8 +22,17 @@ android {
1622
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
1723
}
1824
}
25+
signingConfigs {
26+
release {
27+
storeFile file(keystoreProperties['storeFile'] ?: 'upload-keystore.jks')
28+
storePassword keystoreProperties['storePassword'] ?: ''
29+
keyAlias keystoreProperties['keyAlias'] ?: 'upload'
30+
keyPassword keystoreProperties['keyPassword'] ?: ''
31+
}
32+
}
1933
buildTypes {
2034
release {
35+
signingConfig signingConfigs.release
2136
minifyEnabled false
2237
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
2338
}

ios/App/App.xcodeproj/project.pbxproj

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313
504EC3081FED79650016851F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 504EC3071FED79650016851F /* AppDelegate.swift */; };
1414
504EC30D1FED79650016851F /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30B1FED79650016851F /* Main.storyboard */; };
1515
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
16-
A1B2C3D4E5F6A7B8C9D0E1F3 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* GoogleService-Info.plist */; };
1716
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
1817
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
18+
A1B2C3D4E5F6A7B8C9D0E1F3 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* GoogleService-Info.plist */; };
1919
/* End PBXBuildFile section */
2020

2121
/* Begin PBXFileReference section */
@@ -27,9 +27,9 @@
2727
504EC30E1FED79650016851F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
2828
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
2929
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
30-
A1B2C3D4E5F6A7B8C9D0E1F2 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "App/GoogleService-Info.plist"; sourceTree = "<group>"; };
3130
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
3231
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
32+
A1B2C3D4E5F6A7B8C9D0E1F2 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "App/GoogleService-Info.plist"; sourceTree = "<group>"; };
3333
/* End PBXFileReference section */
3434

3535
/* Begin PBXFrameworksBuildPhase section */
@@ -298,6 +298,7 @@
298298
baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */;
299299
buildSettings = {
300300
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
301+
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
301302
CODE_SIGN_STYLE = Automatic;
302303
CURRENT_PROJECT_VERSION = 1;
303304
DEVELOPMENT_TEAM = C5N5PPC329;
@@ -321,6 +322,7 @@
321322
isa = XCBuildConfiguration;
322323
buildSettings = {
323324
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
325+
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
324326
CODE_SIGN_STYLE = Automatic;
325327
CURRENT_PROJECT_VERSION = 1;
326328
DEVELOPMENT_TEAM = C5N5PPC329;

ios/App/App/App.entitlements

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>com.apple.developer.applesignin</key>
6+
<array>
7+
<string>Default</string>
8+
</array>
9+
</dict>
10+
</plist>

packages/api/src/routes/auth.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -155,19 +155,28 @@ async function handleFirebaseAuth(c: {
155155
return c.json({ error: msg }, 401);
156156
}
157157

158-
const email = firebaseUser.email?.toLowerCase();
159-
if (!email) {
160-
return c.json({ error: "Account has no email address" }, 400);
161-
}
162-
163158
const firebaseUid = firebaseUser.sub;
164159
// Determine provider from Firebase token (google.com, github.com, etc.)
165160
const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
166161
const authProvider = signInProvider.includes("google")
167162
? "google"
168163
: signInProvider.includes("github")
169164
? "github"
170-
: signInProvider;
165+
: signInProvider.includes("apple")
166+
? "apple"
167+
: signInProvider;
168+
169+
// Apple Sign-In may hide the user's real email; generate a short placeholder
170+
let email = firebaseUser.email?.toLowerCase() || null;
171+
if (!email && authProvider === "apple") {
172+
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(firebaseUid))))
173+
.slice(0, 6).map(b => b.toString(16).padStart(2, "0")).join("");
174+
email = `apple_${hash}@privaterelay.appleid.com`;
175+
}
176+
if (!email) {
177+
return c.json({ error: "Account has no email address" }, 400);
178+
}
179+
171180
const displayName = firebaseUser.name ?? email.split("@")[0];
172181

173182
// 2. Look up existing user by firebase_uid first, then by email
@@ -241,6 +250,7 @@ async function handleFirebaseAuth(c: {
241250
auth.post("/firebase", (c) => handleFirebaseAuth(c));
242251
auth.post("/google", (c) => handleFirebaseAuth(c));
243252
auth.post("/github", (c) => handleFirebaseAuth(c));
253+
auth.post("/apple", (c) => handleFirebaseAuth(c));
244254

245255
/**
246256
* POST /api/auth/dev-login — development-only passwordless login by email.
@@ -307,6 +317,7 @@ auth.get("/config", (c) => {
307317
emailEnabled: isDev,
308318
googleEnabled: !!c.env.FIREBASE_PROJECT_ID,
309319
githubEnabled: !!c.env.FIREBASE_PROJECT_ID,
320+
appleEnabled: !!c.env.FIREBASE_PROJECT_ID,
310321
});
311322
});
312323

@@ -342,4 +353,26 @@ auth.get("/me", async (c) => {
342353
});
343354
});
344355

356+
/** DELETE /api/auth/account — permanently delete the authenticated user's account and all data */
357+
auth.delete("/account", async (c) => {
358+
const userId = c.get("userId" as never) as string;
359+
if (!userId) return c.json({ error: "Unauthorized" }, 401);
360+
361+
// Delete all user media from R2
362+
const prefix = `${userId}/`;
363+
let cursor: string | undefined;
364+
do {
365+
const listed = await c.env.MEDIA.list({ prefix, cursor });
366+
if (listed.objects.length > 0) {
367+
await Promise.all(listed.objects.map(obj => c.env.MEDIA.delete(obj.key)));
368+
}
369+
cursor = listed.truncated ? listed.cursor : undefined;
370+
} while (cursor);
371+
372+
// Delete user record — all related tables use ON DELETE CASCADE
373+
await c.env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId).run();
374+
375+
return c.json({ ok: true });
376+
});
377+
345378
export { auth };

packages/api/src/utils/firebase.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ export async function verifyAnyGoogleToken(
235235
const { payload: peek } = parseJwtUnverified(idToken);
236236

237237
if (peek.iss === `${FIREBASE_TOKEN_ISSUER_PREFIX}${firebaseProjectId}`) {
238-
// Standard Firebase ID token
238+
// Standard Firebase ID token (from web Firebase popup)
239239
return verifyFirebaseIdToken(idToken, firebaseProjectId);
240240
}
241241

@@ -244,9 +244,97 @@ export async function verifyAnyGoogleToken(
244244
return verifyGoogleIdToken(idToken, allowedGoogleClientIds);
245245
}
246246

247+
if (peek.iss === "https://appleid.apple.com") {
248+
// Native Apple ID token (from iOS Sign in with Apple)
249+
return verifyAppleIdToken(idToken, []);
250+
}
251+
247252
throw new Error(`Unrecognized token issuer: ${peek.iss}`);
248253
}
249254

255+
// ---------------------------------------------------------------------------
256+
// Apple ID Token verification (for native iOS Sign in with Apple)
257+
// ---------------------------------------------------------------------------
258+
259+
const APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys";
260+
261+
let cachedAppleKeys: JsonWebKey[] | null = null;
262+
let cachedAppleAt = 0;
263+
264+
async function getApplePublicKeys(): Promise<JsonWebKey[]> {
265+
const now = Date.now();
266+
if (cachedAppleKeys && now - cachedAppleAt < CACHE_TTL_MS) {
267+
return cachedAppleKeys;
268+
}
269+
const resp = await fetch(APPLE_JWKS_URL);
270+
if (!resp.ok) {
271+
throw new Error(`Failed to fetch Apple JWKS: ${resp.status}`);
272+
}
273+
const jwks = (await resp.json()) as { keys: JsonWebKey[] };
274+
cachedAppleKeys = jwks.keys;
275+
cachedAppleAt = now;
276+
return jwks.keys;
277+
}
278+
279+
export async function verifyAppleIdToken(
280+
idToken: string,
281+
allowedAudiences: string[],
282+
): Promise<FirebaseTokenPayload> {
283+
const { header, payload, signatureBytes, signedContent } =
284+
parseJwtUnverified(idToken);
285+
286+
if (header.alg !== "RS256") {
287+
throw new Error(`Unsupported algorithm: ${header.alg}`);
288+
}
289+
290+
let keys = await getApplePublicKeys();
291+
let matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
292+
if (!matchingKey) {
293+
cachedAppleKeys = null;
294+
keys = await getApplePublicKeys();
295+
matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
296+
if (!matchingKey) {
297+
throw new Error(`No matching Apple key for kid: ${header.kid}`);
298+
}
299+
}
300+
301+
const key = await crypto.subtle.importKey(
302+
"jwk", matchingKey,
303+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
304+
false, ["verify"],
305+
);
306+
307+
const valid = await crypto.subtle.verify(
308+
"RSASSA-PKCS1-v1_5", key, signatureBytes,
309+
new TextEncoder().encode(signedContent),
310+
);
311+
if (!valid) throw new Error("Invalid Apple token signature");
312+
313+
const now = Math.floor(Date.now() / 1000);
314+
if (payload.exp < now) throw new Error("Apple token has expired");
315+
if (payload.iat > now + 300) throw new Error("Apple token issued in the future");
316+
317+
if (payload.iss !== "https://appleid.apple.com") {
318+
throw new Error(`Invalid Apple token issuer: ${payload.iss}`);
319+
}
320+
321+
if (allowedAudiences.length > 0 && !allowedAudiences.includes(payload.aud)) {
322+
throw new Error(`Invalid Apple token audience: ${payload.aud}`);
323+
}
324+
325+
if (!payload.sub) throw new Error("Missing subject in Apple token");
326+
327+
// Synthesize firebase-like fields so the rest of the auth flow works
328+
if (!payload.firebase) {
329+
payload.firebase = {
330+
sign_in_provider: "apple.com",
331+
identities: { "apple.com": [payload.sub] },
332+
};
333+
}
334+
335+
return payload;
336+
}
337+
250338
// ---------------------------------------------------------------------------
251339
// Shared verification helpers
252340
// ---------------------------------------------------------------------------

packages/web/src/App.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import { ChatWindow } from "./components/ChatWindow";
1919
import { ThreadPanel } from "./components/ThreadPanel";
2020
import { JobList } from "./components/JobList";
2121
import { LoginPage } from "./components/LoginPage";
22+
import { DataConsentModal } from "./components/DataConsentModal";
2223
import { OnboardingPage } from "./components/OnboardingPage";
2324
import { ConnectionSettings } from "./components/ConnectionSettings";
2425
import { E2ESettings } from "./components/E2ESettings";
26+
import { AccountSettings } from "./components/AccountSettings";
2527
import { DebugLogPanel } from "./components/DebugLogPanel";
2628
import { CronSidebar } from "./components/CronSidebar";
2729
import { CronDetail } from "./components/CronDetail";
@@ -65,6 +67,15 @@ export default function App() {
6567
const mainLayout = useDefaultLayout({ id: "botschat-main" });
6668
const contentLayout = useDefaultLayout({ id: "botschat-content" });
6769

70+
const [dataConsentGiven, setDataConsentGiven] = useState(() => {
71+
return localStorage.getItem("botschat_data_consent") === "1";
72+
});
73+
74+
const handleDataConsent = useCallback(() => {
75+
setDataConsentGiven(true);
76+
localStorage.setItem("botschat_data_consent", "1");
77+
}, []);
78+
6879
// Onboarding: show setup page for new users who haven't connected OpenClaw yet.
6980
// Once dismissed (skip or connected), we remember it for this session.
7081
const [onboardingDismissed, setOnboardingDismissed] = useState(() => {
@@ -850,6 +861,16 @@ export default function App() {
850861
);
851862
}
852863

864+
if (!dataConsentGiven) {
865+
return (
866+
<AppStateContext.Provider value={state}>
867+
<AppDispatchContext.Provider value={dispatch}>
868+
<DataConsentModal onAccept={handleDataConsent} />
869+
</AppDispatchContext.Provider>
870+
</AppStateContext.Provider>
871+
);
872+
}
873+
853874
// Show onboarding for new users: channels have been fetched (first API call completed)
854875
// and none exist. This prevents flashing onboarding for returning users whose
855876
// channel list simply hasn't loaded yet.
@@ -1097,6 +1118,9 @@ export default function App() {
10971118
{state.sessionModel ?? state.defaultModel ?? "Not connected"}
10981119
</span>
10991120
</div>
1121+
1122+
{/* Account Settings */}
1123+
<AccountSettings />
11001124
</div>
11011125
)}
11021126

packages/web/src/api.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export type AuthConfig = {
128128
emailEnabled: boolean;
129129
googleEnabled: boolean;
130130
githubEnabled: boolean;
131+
appleEnabled: boolean;
131132
};
132133

133134
export const authApi = {
@@ -141,6 +142,7 @@ export const authApi = {
141142
firebase: (idToken: string) =>
142143
request<AuthResponse>("POST", "/auth/firebase", { idToken }),
143144
me: () => request<{ id: string; email: string; displayName: string | null; settings: UserSettings }>("GET", "/me"),
145+
deleteAccount: () => request<{ ok: boolean }>("DELETE", "/auth/account"),
144146
};
145147

146148
// ---- User settings ----

0 commit comments

Comments
 (0)