From d30ac145e867fca9569cdee1cf701cd55ea7d97e Mon Sep 17 00:00:00 2001 From: Roland Date: Sun, 28 Jun 2026 16:45:27 +0200 Subject: [PATCH] Refs #96: user can now fill in a clientid from google and get authenticated --- src/settings/setting.ts | 6 + src/settings/settings.d.ts | 8 + src/sync/adapters/googleContactsAdapter.ts | 240 ++++++++++++++++++ src/sync/adapters/index.ts | 4 +- src/sync/auth/callbackServer.ts | 70 +++++ src/sync/auth/oauth2.ts | 103 ++++++-- .../components/googleContactSettings.tsx | 55 ++++ .../components/synchronizationSettings.tsx | 25 +- tests/context.spec.ts | 6 + 9 files changed, 500 insertions(+), 17 deletions(-) create mode 100644 src/sync/adapters/googleContactsAdapter.ts create mode 100644 src/sync/auth/callbackServer.ts create mode 100644 src/ui/settings/components/googleContactSettings.tsx diff --git a/src/settings/setting.ts b/src/settings/setting.ts index e3a56f1..d2c7878 100644 --- a/src/settings/setting.ts +++ b/src/settings/setting.ts @@ -13,6 +13,12 @@ export const DEFAULT_SETTINGS: ContactsPluginSettings = { authKey: '', authType: 'apikey' }, + GoogleContact: { + clientId: '', + clientSecret: '', + accessToken: '', + refreshToken: '' + }, createFieldsKeys: [ "N.PREFIX", "N.GN", diff --git a/src/settings/settings.d.ts b/src/settings/settings.d.ts index 1630b1f..0f7f86a 100644 --- a/src/settings/settings.d.ts +++ b/src/settings/settings.d.ts @@ -10,6 +10,7 @@ export interface ContactsPluginSettings { syncEnabled: boolean; groupInsights: boolean; CardDAV: CardDavSyncSettings; + GoogleContact: GoogleContactSyncSettings; createFieldsKeys: string[] } @@ -21,6 +22,13 @@ interface CardDavSyncSettings { authType: AuthType; } +interface GoogleContactSyncSettings { + clientId: string; + clientSecret: string; + accessToken: string; + refreshToken: string; +} + interface ProcessorsSettings { [key: string]: string|boolean; } diff --git a/src/sync/adapters/googleContactsAdapter.ts b/src/sync/adapters/googleContactsAdapter.ts new file mode 100644 index 0000000..3845e9b --- /dev/null +++ b/src/sync/adapters/googleContactsAdapter.ts @@ -0,0 +1,240 @@ +import { getSettings } from "src/context/sharedSettingsContext"; +import { ContactsPluginSettings } from "src/settings/settings"; +import { VCardMeta, VCardRaw } from "src/sync/adapters/adapter"; +import { AdapterInterface } from "src/sync/adapters/adapterInterface"; +import { CarddavSettingsInterface } from "src/ui/settings/components/carddavSettings"; +import { AppHttpResponse, PlatformHttpClient } from "src/util/platformHttpClient"; +import { fnOutOfString, uidOutOfString } from "src/util/vcard"; + + +export function googleContactsAdapter(): AdapterInterface { + + const getAuthHeader = (settings:ContactsPluginSettings) => { + if (settings.CardDAV.authType === 'apikey') { + return `Bearer ${settings.CardDAV.authKey}`; + } else { + return `Basic ${settings.CardDAV.authKey}`; + } + } + + const checkConnectivity = async (settings: CarddavSettingsInterface): Promise => { + const headers = { Authorization : ''}; + if (settings.authKey) { + headers.Authorization = `Bearer ${settings.authKey}`; + } else { + headers.Authorization = 'Basic ' + btoa(settings.username + ":" + settings.password); + } + return await PlatformHttpClient.request({ + url: settings.addressBookUrl, + method: 'OPTIONS', + headers, + }); + } + + const isVcfNode = (response: Element): boolean => { + const href = response.querySelector('href')?.textContent; + const successPropstat = Array.from(response.querySelectorAll('propstat')).find( + propstat => propstat.querySelector('status')?.textContent?.includes('200 OK') + ); + + if (!successPropstat || !href || !href.endsWith('.vcf')) { + return false; + } + + return true; + }; + + + const getMetaByUid = async (uid: string): Promise => { + const settings = getSettings(); + const body = ` + + + + + + + + + + + + + ${uid} + + +`; + + const res = await PlatformHttpClient.request({ + url: settings.CardDAV.addressBookUrl, + method: 'REPORT', + headers: { + 'Authorization': getAuthHeader(settings), + 'Content-Type': 'application/xml', + 'Depth': '1' + }, + body + }); + + if(res.errorMessage) { + return res; + } + + const parser = new DOMParser(); + const xml = parser.parseFromString(res.data, 'application/xml'); + const responses = xml.querySelectorAll('response'); + + if(!responses || responses.length !== 1) { + return; + } + + const response = responses[0]; + + if (!isVcfNode(response)) { + return; + } + + const href = response.querySelector('href')?.textContent || ''; + const etag = response.querySelector('getetag')?.textContent || ''; + const lastModified = response.querySelector('getlastmodified')?.textContent || ''; + const adressData = response.querySelector('address-data')?.textContent || ''; + + if (!href) { + return; + } + + return { + href, + etag: etag?.replace(/"/g, '') || '', // Remove quotes from etag + lastModified: lastModified ? new Date(lastModified) : new Date(), + uid: uidOutOfString(adressData), + fn: fnOutOfString(adressData) + } + } + + + const getMetaList = async (): Promise => { + const settings = getSettings(); + const body = ` + + + + + + + + + + +`; + + const res = await PlatformHttpClient.request({ + url: settings.CardDAV.addressBookUrl , + method: 'REPORT', + headers: { + 'Authorization': getAuthHeader(settings), + 'Content-Type': 'application/xml', + 'Depth': '1' + }, + body + }); + + if(res.errorMessage) { + return res; + } + + const parser = new DOMParser(); + const xml = parser.parseFromString(res.data, 'application/xml'); + + const responses = xml.querySelectorAll('response'); + const vcardMetas: VCardMeta[] = []; + + responses.forEach(response => { + if(!response) { + return; + } + + if (!isVcfNode(response)) { + return; + } + + const href = response.querySelector('href')?.textContent; + const etag = response.querySelector('getetag')?.textContent; + const lastModified = response.querySelector('getlastmodified')?.textContent; + const adressData = response.querySelector('address-data')?.textContent || ''; + + if (!href) { + return; + } + + vcardMetas.push({ + href, + etag: etag?.replace(/"/g, '') || '', // Remove quotes from etag + lastModified: lastModified ? new Date(lastModified) : new Date(), + uid: uidOutOfString(adressData), + fn: fnOutOfString(adressData) + }); + }); + + return vcardMetas; + } + + + const pull = async (href: string): Promise => { + const settings = getSettings(); + const vcfUrl = new URL(href, settings.CardDAV.addressBookUrl).toString(); + const res = await PlatformHttpClient.request({ + url: vcfUrl, + method: 'GET', + headers: { + Authorization: getAuthHeader(settings), + ['Accept']: 'text/vcard; version=4.0; charset=utf-8;' + } + }); + + if(res.errorMessage) { + return res; + } + + return { + uid: uidOutOfString(res.data), + raw: res.data, + } + } + + const push = async (vcard: VCardRaw): Promise => { + const settings = getSettings(); + const vcfUrl = settings.CardDAV.addressBookUrl + `/${vcard.uid}.vcf`; + return await PlatformHttpClient.request({ + url: vcfUrl, + method: 'PUT', + body: vcard.raw, + headers: { + ['Authorization']: getAuthHeader(settings), + ['Content-Type']: 'text/vcard; version=4.0; charset=utf-8;' + } + }); + } + + const deleteContact = async (href: string): Promise => { + const settings = getSettings(); + const vcfUrl = new URL(href, settings.CardDAV.addressBookUrl).toString(); + return await PlatformHttpClient.request({ + url: vcfUrl, + method: 'DELETE', + headers: { + Authorization: getAuthHeader(settings), + } + }); + } + + return { + checkConnectivity, + getMetaByUid, + getMetaList, + pull, + push, + delete: deleteContact + }; + +} diff --git a/src/sync/adapters/index.ts b/src/sync/adapters/index.ts index 2c9e387..a14b1b1 100644 --- a/src/sync/adapters/index.ts +++ b/src/sync/adapters/index.ts @@ -1,9 +1,11 @@ import { settings } from "src/context/sharedSettingsContext"; import { carddavGenericAdapter } from "src/sync/adapters/carddavGeneric"; +import { googleContactsAdapter } from "src/sync/adapters/googleContactsAdapter"; export const adapters = { None: undefined, - CardDAV: carddavGenericAdapter() + CardDAV: carddavGenericAdapter(), + GoogleContacts: googleContactsAdapter() } export function getCurrentAdapter() { diff --git a/src/sync/auth/callbackServer.ts b/src/sync/auth/callbackServer.ts new file mode 100644 index 0000000..390fb10 --- /dev/null +++ b/src/sync/auth/callbackServer.ts @@ -0,0 +1,70 @@ + + +export interface OAuthCallbackData { + code?: string; + state?: string; + error?: string; +} + +type Request = { + url?: string; + method?: string; + headers: Record; +}; + +type Response = { + writeHead(status: number, headers?: Record): void; + end(data?: string): void; +}; + +const CALLBACK_PORT = 37288; + +const protocol = 'node:http'; +// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-require-imports +const http = require(protocol); +// @ts-ignore +let server: http.Server | undefined; + +export function startServer( + callback: (data: OAuthCallbackData) => void +): void { + + server = http.createServer((req: Request , res: Response) => { + + const url = new URL( + req.url ?? "", + `http://127.0.0.1:${CALLBACK_PORT}` + ); + + res.writeHead(200, { + "Content-Type": "text/html", + }); + + res.end(` + + +

Authentication callback received

+

You may close this window.

+ + + `); + + callback({ + code: url.searchParams.get("code") ?? undefined, + state: url.searchParams.get("state") ?? undefined, + error: url.searchParams.get("error") ?? undefined, + }); + }); + + server.listen(CALLBACK_PORT, "127.0.0.1"); +} + +export function stopServer(): void { + + if (!server) { + return; + } + + server.close(); + server = undefined; +} diff --git a/src/sync/auth/oauth2.ts b/src/sync/auth/oauth2.ts index 0c5e77d..7423ffe 100644 --- a/src/sync/auth/oauth2.ts +++ b/src/sync/auth/oauth2.ts @@ -1,7 +1,10 @@ -import { shell } from "electron"; +import {getSettings} from "src/context/sharedSettingsContext"; +import {OAuthCallbackData, startServer, stopServer} from "src/sync/auth/callbackServer"; +import {PlatformHttpClient} from "src/util/platformHttpClient"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { shell } = require("electron"); export interface OAuthConfig { - clientId: string; authorizationEndpoint: string; tokenEndpoint: string; scopes: string[]; @@ -13,8 +16,15 @@ export interface OAuthConfig { redirectUrl: string; } +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; +} + export const GOOGLE_OAUTH_CONFIG: OAuthConfig = { - clientId:'developement', authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth', tokenEndpoint: @@ -35,9 +45,9 @@ export function buildAuthorizationUrl( state: string, codeChallenge: string ): string { - + const settings = getSettings(); const params = new URLSearchParams({ - client_id: GOOGLE_OAUTH_CONFIG.clientId, + client_id: settings.GoogleContact.clientId, redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUrl, response_type: GOOGLE_OAUTH_CONFIG.responseType, scope: GOOGLE_OAUTH_CONFIG.scopes.join(' '), @@ -86,16 +96,79 @@ export function generatePkceCodeVerifier(length = 64): string { .join(''); } -export async function login() { - const codeVerifier = generatePkceCodeVerifier(); - const codeChallenge = await generatePkceCodeChallenge(codeVerifier); - const state = crypto.randomUUID(); - const authUrl = buildAuthorizationUrl(state, codeChallenge); - await shell.openExternal(authUrl); +async function exchangeCodeForToken( + code: string, + verifier: string +): Promise { + const settings = getSettings(); + const response = await PlatformHttpClient.request({ + url: GOOGLE_OAUTH_CONFIG.tokenEndpoint, + method: "POST", + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: settings.GoogleContact.clientId, + code, + client_secret: settings.GoogleContact.clientSecret, + redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUrl, + code_verifier: verifier + }).toString(), + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }); - return { - codeVerifier, - state, - }; + if (response.status < 200 || response.status >= 300) { + throw new Error( + `Token exchange failed (${response.status}): ${response.data}` + ); + } + + return JSON.parse(response.data) as TokenResponse; +} + +export interface LoginResult { + accessToken: string; + refreshToken: string; + expiresAt: number; +} + +export function login(): Promise { + return new Promise((resolve, reject) => { + const run = async () => { + try { + const codeVerifier = generatePkceCodeVerifier(); + const codeChallenge = await generatePkceCodeChallenge(codeVerifier); + const state = crypto.randomUUID(); + const authUrl = buildAuthorizationUrl(state, codeChallenge); + + startServer(async (data: OAuthCallbackData) => { + try { + stopServer(); + + if (!data.code) { + throw new Error("OAuth callback did not include a code."); + } + + const tokens = await exchangeCodeForToken(data.code, codeVerifier); + + resolve({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? "", + expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000, + }); + } catch (err) { + reject(err); + } + }); + + await shell.openExternal(authUrl); + } catch (err) { + stopServer(); + reject(err); + } + }; + + void run(); + }); } diff --git a/src/ui/settings/components/googleContactSettings.tsx b/src/ui/settings/components/googleContactSettings.tsx new file mode 100644 index 0000000..79c0570 --- /dev/null +++ b/src/ui/settings/components/googleContactSettings.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; + +export interface GoogleContactSettingsInterface { + clientId: string; + clientSecret: string; + accessToken: string; + refreshToken: string; +} + +interface GoogleContactSettingsProps { + googleContactSettings: GoogleContactSettingsInterface; + setGoogleContactSettings: (settings: GoogleContactSettingsInterface) => void; +} + +export default function GoogleContactSettings({ googleContactSettings, setGoogleContactSettings }: GoogleContactSettingsProps) { + + return ( + <> +
+
+
Client Id
+
+ Oauth client id from google cloud +
+
+
+ setGoogleContactSettings({ ...googleContactSettings, clientId: e.target.value })} + /> +
+
+
+
+
Client Secret
+
+ auth client secret from google cloud. +
+
+
+ setGoogleContactSettings({ ...googleContactSettings, clientSecret: e.target.value })} + /> +
+
+ + ); +} diff --git a/src/ui/settings/components/synchronizationSettings.tsx b/src/ui/settings/components/synchronizationSettings.tsx index bdb4e0d..6b670ba 100644 --- a/src/ui/settings/components/synchronizationSettings.tsx +++ b/src/ui/settings/components/synchronizationSettings.tsx @@ -6,6 +6,7 @@ import {SyncSelected} from "src/settings/settings"; import {carddavGenericAdapter} from "src/sync/adapters/carddavGeneric"; import CarddavSettings from "src/ui/settings/components/carddavSettings"; import {oauth2} from "src/sync/auth"; +import GoogleContactsSettings from "src/ui/settings/components/googleContactSettings"; const initCardavSettings = { addressBookUrl: "", @@ -14,6 +15,13 @@ const initCardavSettings = { authKey: "" }; +const initGoogleContactSettings = { + clientId: "", + clientSecret: "", + accessToken: "", + refreshToken: "", +}; + export function SynchronizationSettings() { const isDisabled = Platform.isMobileApp; const [warning, setWarning] = useState(''); @@ -24,6 +32,11 @@ export function SynchronizationSettings() { ...initCardavSettings, addressBookUrl: initSettings.CardDAV.addressBookUrl }); + const [googleContactSettings, setGoogleContactSettings] = useState({ + ...initGoogleContactSettings, + clientId: initSettings.GoogleContact.clientId, + clientSecret: initSettings.GoogleContact.clientSecret + }); const enableSync = async () => { if (!['CardDAV', 'GoogleContacts'].includes(syncSelected)) { @@ -78,7 +91,11 @@ export function SynchronizationSettings() { const enableGoogleContacts = async () => { try { - await oauth2.login(); + await updateSetting('GoogleContact.clientId', googleContactSettings.clientId) + await updateSetting('GoogleContact.clientSecret', googleContactSettings.clientSecret) + const successExchanged = await oauth2.login(); // TODO: handle failure scenarios. + await updateSetting('GoogleContact.accessToken', successExchanged.accessToken) + await updateSetting('GoogleContact.refreshToken', successExchanged.refreshToken) console.log('aaaaaaaa'); } catch (err: any) { setWarning(`failed to enable connection!`); @@ -186,6 +203,12 @@ export function SynchronizationSettings() { setCarddavSettings={setCarddavSettings} /> )} + {syncSelected === "GoogleContacts" && ( + + )} )} diff --git a/tests/context.spec.ts b/tests/context.spec.ts index 27eee64..bb55b57 100644 --- a/tests/context.spec.ts +++ b/tests/context.spec.ts @@ -37,6 +37,12 @@ const mockSettings: ContactsPluginSettings = { authKey: '', authType: 'apikey' }, + GoogleContact: { + clientId: '', + clientSecret: '', + accessToken: '', + refreshToken: '' + }, createFieldsKeys:['N.FN'] };