fix: Google OAuth redirect, Tasks Account creation, Apple Reminders CalDAV removal

- Fix Google OAuth callback to redirect using NEXTAUTH_URL (prevents session loss behind reverse proxy)
- Create/update Account record during Google OAuth callback so Google Tasks API has access tokens
- Remove legacy CalDAV fallback from Apple Reminders - only use CloudKit (iOS 13+ Reminders app)
- Add debug command to icloud-reminders.py for diagnosing CloudKit connectivity
- Improve profile save error messages to show details
- Add better error logging throughout CloudKit reminders flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin 2026-02-20 17:26:43 +01:00
parent 174c7f3e0e
commit 7f76774258
5 changed files with 140 additions and 94 deletions

View File

@ -3,14 +3,15 @@
Python bridge for Apple iCloud Reminders using pyicloud. Python bridge for Apple iCloud Reminders using pyicloud.
Called from Node.js via subprocess. Called from Node.js via subprocess.
Uses CloudKit Web Services API (ckdatabasews) for modern iOS 13+ reminders, Uses CloudKit Web Services API (ckdatabasews) for modern iOS 13+ reminders.
with fallback to legacy /rd/startup API for older accounts. NO legacy CalDAV fallback only real Reminders app data.
Usage: Usage:
python3 icloud-reminders.py init <email> <password> [--session-dir <dir>] python3 icloud-reminders.py init <email> <password> [--session-dir <dir>]
python3 icloud-reminders.py verify <email> <code> [--session-dir <dir>] python3 icloud-reminders.py verify <email> <password> <code> [--session-dir <dir>]
python3 icloud-reminders.py lists <email> <password> [--session-dir <dir>] python3 icloud-reminders.py lists <email> <password> [--session-dir <dir>]
python3 icloud-reminders.py reminders <email> <password> [--collection-guid <guid>] [--session-dir <dir>] python3 icloud-reminders.py reminders <email> <password> [--collection-guid <guid>] [--session-dir <dir>]
python3 icloud-reminders.py debug <email> <password> [--session-dir <dir>]
Output: JSON to stdout Output: JSON to stdout
""" """
@ -61,7 +62,12 @@ def ck_request(api, path, body=None):
url = f"{base}{path}?{urlencode(params)}" url = f"{base}{path}?{urlencode(params)}"
data = json.dumps(body) if body else "{}" data = json.dumps(body) if body else "{}"
resp = api.session.post(url, data=data, headers={"Content-type": "text/plain"}) resp = api.session.post(url, data=data, headers={"Content-type": "text/plain"})
return resp.json() result = resp.json()
# Check for CloudKit errors
if "error" in result:
err = result["error"]
raise Exception(f"CloudKit error: {err.get('reason', err.get('serverErrorCode', str(err)))}")
return result
def ck_list_zones(api): def ck_list_zones(api):
@ -115,17 +121,17 @@ def discover_schema(api, zone_id):
} }
record_types[rt]["count"] += 1 record_types[rt]["count"] += 1
return record_types, records return record_types, records
except Exception: except Exception as e:
sys.stderr.write(f"[REMINDERS] discover_schema error: {e}\n")
return {}, [] return {}, []
def find_list_record_type(record_types): def find_list_record_type(record_types):
"""Find the record type used for reminder lists.""" """Find the record type used for reminder lists."""
# Check known names
for rt in LIST_RECORD_TYPES: for rt in LIST_RECORD_TYPES:
if rt in record_types: if rt in record_types:
return rt return rt
# Heuristic: look for record types with a 'title' field # Heuristic: look for record types with a 'title' field but not 'parentList'
for rt, info in record_types.items(): for rt, info in record_types.items():
fields = info.get("fields", []) fields = info.get("fields", [])
if "title" in fields and "parentList" not in fields: if "title" in fields and "parentList" not in fields:
@ -183,9 +189,13 @@ def fetch_lists_via_cloudkit(api):
zone_id = zone.get("zoneID", {}) zone_id = zone.get("zoneID", {})
zone_name = zone_id.get("zoneName", "unknown") zone_name = zone_id.get("zoneName", "unknown")
sys.stderr.write(f"[REMINDERS] Scanning zone: {zone_name}\n")
# Step 2: Discover schema via zone changes # Step 2: Discover schema via zone changes
record_types, all_records = discover_schema(api, zone_id) record_types, all_records = discover_schema(api, zone_id)
sys.stderr.write(f"[REMINDERS] Zone '{zone_name}' has record types: {list(record_types.keys())}\n")
if not record_types: if not record_types:
# Try querying common record types directly # Try querying common record types directly
for rt in LIST_RECORD_TYPES: for rt in LIST_RECORD_TYPES:
@ -197,11 +207,14 @@ def fetch_lists_via_cloudkit(api):
"count": len(records), "count": len(records),
"fields": list(records[0].get("fields", {}).keys()), "fields": list(records[0].get("fields", {}).keys()),
} }
sys.stderr.write(f"[REMINDERS] Found {len(records)} records of type '{rt}'\n")
break break
except Exception: except Exception as e:
sys.stderr.write(f"[REMINDERS] Query for '{rt}' failed: {e}\n")
continue continue
list_type = find_list_record_type(record_types) list_type = find_list_record_type(record_types)
sys.stderr.write(f"[REMINDERS] List record type: {list_type}\n")
if list_type: if list_type:
# Query for lists using the discovered record type # Query for lists using the discovered record type
@ -223,13 +236,11 @@ def fetch_lists_via_cloudkit(api):
}) })
else: else:
# Fallback: extract lists from zone changes data # Fallback: extract lists from zone changes data
# Look for any record that looks like a list
for r in all_records: for r in all_records:
fields = r.get("fields", {}) fields = r.get("fields", {})
title = extract_field_value(fields.get("title")) title = extract_field_value(fields.get("title"))
if title and "parentList" not in fields and "parent" not in fields: if title and "parentList" not in fields and "parent" not in fields:
rt = r.get("recordType", "") rt = r.get("recordType", "")
# Skip obvious task/reminder records
if rt.lower() not in ("task", "reminder", "item"): if rt.lower() not in ("task", "reminder", "item"):
all_lists.append({ all_lists.append({
"guid": r.get("recordName", ""), "guid": r.get("recordName", ""),
@ -343,55 +354,6 @@ def fetch_reminders_via_cloudkit(api, collection_guid=None):
return [], str(e) return [], str(e)
# ---------------------------------------------------------------------------
# Legacy API fallback
# ---------------------------------------------------------------------------
def fetch_lists_via_legacy(api):
"""Fetch lists using the legacy /rd/startup API."""
reminders_service = api.reminders
reminders_service.refresh()
lists = []
for list_title in reminders_service.lists.keys():
lists.append({
"guid": list_title,
"title": list_title,
})
return lists
def fetch_reminders_via_legacy(api, collection_guid=None):
"""Fetch reminders using the legacy /rd/startup API."""
reminders_service = api.reminders
reminders_service.refresh()
result = []
for list_title, tasks in reminders_service.lists.items():
if collection_guid and list_title != collection_guid:
continue
for i, task in enumerate(tasks):
reminder = {
"guid": f"{list_title}_{i}",
"pGuid": list_title,
"title": task.get("title", "Untitled") if isinstance(task, dict) else str(task),
"description": (task.get("desc", "") or "") if isinstance(task, dict) else "",
"priority": task.get("priority", 0) if isinstance(task, dict) else 0,
"isCompleted": False,
"dueDate": None,
"completedDate": None,
}
if isinstance(task, dict):
due = task.get("due")
if due and hasattr(due, 'isoformat'):
reminder["dueDate"] = due.isoformat()
result.append(reminder)
return result
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Commands # Commands
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -458,7 +420,7 @@ def cmd_verify(args):
def cmd_lists(args): def cmd_lists(args):
"""Fetch all reminder lists. Tries CloudKit first, falls back to legacy API.""" """Fetch all reminder lists via CloudKit only (iOS 13+ Reminders app)."""
try: try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir)) api = get_api(args.email, args.password, get_session_dir(args.session_dir))
@ -470,7 +432,7 @@ def cmd_lists(args):
})) }))
return return
# Try CloudKit first (modern iOS 13+ reminders) # CloudKit only — no legacy CalDAV fallback
lists, ck_error = fetch_lists_via_cloudkit(api) lists, ck_error = fetch_lists_via_cloudkit(api)
if lists: if lists:
@ -479,16 +441,19 @@ def cmd_lists(args):
"lists": lists, "lists": lists,
"source": "cloudkit" "source": "cloudkit"
})) }))
return elif ck_error:
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
# Fallback to legacy API
sys.stderr.write(f"[REMINDERS] CloudKit failed ({ck_error}), trying legacy API...\n")
legacy_lists = fetch_lists_via_legacy(api)
print(json.dumps({ print(json.dumps({
"error": None, "error": f"Could not fetch Reminders via iCloud (CloudKit): {ck_error}. "
"lists": legacy_lists, "This may require Apple Developer CloudKit access or a different authentication method.",
"source": "legacy" "lists": [],
"source": "cloudkit"
}))
else:
print(json.dumps({
"error": "No reminder lists found via CloudKit. Your account may not have iCloud Reminders enabled.",
"lists": [],
"source": "cloudkit"
})) }))
except Exception as e: except Exception as e:
@ -499,7 +464,7 @@ def cmd_lists(args):
def cmd_reminders(args): def cmd_reminders(args):
"""Fetch reminders. Tries CloudKit first, falls back to legacy API.""" """Fetch reminders via CloudKit only (iOS 13+ Reminders app)."""
try: try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir)) api = get_api(args.email, args.password, get_session_dir(args.session_dir))
@ -510,7 +475,7 @@ def cmd_reminders(args):
})) }))
return return
# Try CloudKit first # CloudKit only — no legacy CalDAV fallback
reminders, ck_error = fetch_reminders_via_cloudkit(api, args.collection_guid) reminders, ck_error = fetch_reminders_via_cloudkit(api, args.collection_guid)
if reminders or ck_error is None: if reminders or ck_error is None:
@ -519,16 +484,12 @@ def cmd_reminders(args):
"reminders": reminders, "reminders": reminders,
"source": "cloudkit" "source": "cloudkit"
})) }))
return else:
sys.stderr.write(f"[REMINDERS] CloudKit error: {ck_error}\n")
# Fallback to legacy API
sys.stderr.write(f"[REMINDERS] CloudKit failed ({ck_error}), trying legacy API...\n")
legacy_reminders = fetch_reminders_via_legacy(api, args.collection_guid)
print(json.dumps({ print(json.dumps({
"error": None, "error": f"Could not fetch reminders via CloudKit: {ck_error}",
"reminders": legacy_reminders, "reminders": [],
"source": "legacy" "source": "cloudkit"
})) }))
except Exception as e: except Exception as e:
@ -538,6 +499,49 @@ def cmd_reminders(args):
})) }))
def cmd_debug(args):
"""Debug: show available webservices and CloudKit info."""
try:
api = get_api(args.email, args.password, get_session_dir(args.session_dir))
if api.requires_2fa or api.requires_2sa:
print(json.dumps({
"error": "2FA required",
"services": []
}))
return
services = list(api.data.get("webservices", {}).keys())
ck_url = api.data.get("webservices", {}).get("ckdatabasews", {}).get("url", "N/A")
# Try listing zones
zones_info = []
try:
zones_data = ck_list_zones(api)
for zone in zones_data.get("zones", []):
zone_id = zone.get("zoneID", {})
zone_name = zone_id.get("zoneName", "unknown")
# Try discovering record types
record_types, records = discover_schema(api, zone_id)
zones_info.append({
"name": zone_name,
"recordTypes": {k: {"count": v["count"], "fields": v["fields"]} for k, v in record_types.items()},
"totalRecords": len(records),
})
except Exception as e:
zones_info = [{"error": str(e)}]
print(json.dumps({
"services": services,
"ckdatabasews_url": ck_url,
"zones": zones_info,
}, indent=2))
except Exception as e:
print(json.dumps({"error": str(e)}))
def main(): def main():
parser = argparse.ArgumentParser(description='iCloud Reminders bridge') parser = argparse.ArgumentParser(description='iCloud Reminders bridge')
parser.add_argument('--session-dir', type=str, default=None) parser.add_argument('--session-dir', type=str, default=None)
@ -565,6 +569,11 @@ def main():
p_rem.add_argument('password') p_rem.add_argument('password')
p_rem.add_argument('--collection-guid', type=str, default=None) p_rem.add_argument('--collection-guid', type=str, default=None)
# debug
p_debug = subparsers.add_parser('debug')
p_debug.add_argument('email')
p_debug.add_argument('password')
args = parser.parse_args() args = parser.parse_args()
if args.command == 'init': if args.command == 'init':
@ -575,6 +584,8 @@ def main():
cmd_lists(args) cmd_lists(args)
elif args.command == 'reminders': elif args.command == 'reminders':
cmd_reminders(args) cmd_reminders(args)
elif args.command == 'debug':
cmd_debug(args)
else: else:
parser.print_help() parser.print_help()
sys.exit(1) sys.exit(1)

View File

@ -14,15 +14,17 @@ export async function GET(request: NextRequest) {
const code = searchParams.get('code'); const code = searchParams.get('code');
const state = searchParams.get('state'); // User email passed from start route const state = searchParams.get('state'); // User email passed from start route
const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
if (!code) { if (!code) {
return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', request.url)); return NextResponse.redirect(new URL('/tasks?error=oauth_code_missing', appBaseUrl));
} }
// Get user from session or state parameter // Get user from session or state parameter
const userEmail = session?.user?.email || state; const userEmail = session?.user?.email || state;
if (!userEmail) { if (!userEmail) {
return NextResponse.redirect(new URL('/auth/login?error=session_expired', request.url)); return NextResponse.redirect(new URL('/auth/login?error=session_expired', appBaseUrl));
} }
// Find user in database // Find user in database
@ -31,7 +33,7 @@ export async function GET(request: NextRequest) {
}); });
if (!user) { if (!user) {
return NextResponse.redirect(new URL('/auth/login?error=user_not_found', request.url)); return NextResponse.redirect(new URL('/auth/login?error=user_not_found', appBaseUrl));
} }
// Initialize Google OAuth client // Initialize Google OAuth client
@ -119,10 +121,43 @@ export async function GET(request: NextRequest) {
}); });
} }
// Also store the Google account record for Tasks API access
const existingAccount = await prisma.account.findFirst({
where: { userId: user.id, provider: 'google' }
});
if (existingAccount) {
await prisma.account.update({
where: { id: existingAccount.id },
data: {
access_token: tokens.access_token || '',
refresh_token: tokens.refresh_token || existingAccount.refresh_token,
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,
scope: tokens.scope || existingAccount.scope,
token_type: tokens.token_type || 'Bearer',
}
});
} else {
await prisma.account.create({
data: {
userId: user.id,
type: 'oauth',
provider: 'google',
providerAccountId: userEmail,
access_token: tokens.access_token || '',
refresh_token: tokens.refresh_token || null,
expires_at: tokens.expiry_date ? Math.floor(tokens.expiry_date / 1000) : null,
scope: tokens.scope || '',
token_type: tokens.token_type || 'Bearer',
}
});
}
// Redirect to tasks page with success message // Redirect to tasks page with success message
return NextResponse.redirect(new URL('/tasks?calendar=connected', request.url)); return NextResponse.redirect(new URL('/tasks?calendar=connected', appBaseUrl));
} catch (error) { } catch (error) {
console.error('Google OAuth error:', error); console.error('Google OAuth error:', error);
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', request.url)); const appBaseUrl = process.env.NEXTAUTH_URL || request.url;
return NextResponse.redirect(new URL('/tasks?error=oauth_failed', appBaseUrl));
} }
} }

View File

@ -9,7 +9,8 @@ export async function GET(request: NextRequest) {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
if (!session?.user?.email) { if (!session?.user?.email) {
return NextResponse.redirect(new URL('/auth/login', request.url)); const baseUrl = process.env.NEXTAUTH_URL || request.url;
return NextResponse.redirect(new URL('/auth/login', baseUrl));
} }
const clientId = process.env.GOOGLE_CLIENT_ID; const clientId = process.env.GOOGLE_CLIENT_ID;

View File

@ -4651,8 +4651,7 @@ function SettingsSidebar({
setTimeout(() => setAccountMsg(''), 3000); setTimeout(() => setAccountMsg(''), 3000);
} else { } else {
console.error('Failed to update profile:', data); console.error('Failed to update profile:', data);
setAccountMsg(data.error || 'Failed to update profile'); setAccountMsg(data.details ? `${data.error}: ${data.details}` : (data.error || 'Failed to update profile'));
if (data.details) console.error('Update profile details:', data.details);
} }
} catch (e) { } catch (e) {
console.error('Error updating profile:', e); console.error('Error updating profile:', e);

File diff suppressed because one or more lines are too long