Refs #96: user can now fill in a clientid from google and get authenticated

This commit is contained in:
Roland 2026-06-28 16:45:27 +02:00
parent d919387a9d
commit d30ac145e8
9 changed files with 500 additions and 17 deletions

View file

@ -13,6 +13,12 @@ export const DEFAULT_SETTINGS: ContactsPluginSettings = {
authKey: '',
authType: 'apikey'
},
GoogleContact: {
clientId: '',
clientSecret: '',
accessToken: '',
refreshToken: ''
},
createFieldsKeys: [
"N.PREFIX",
"N.GN",

View file

@ -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;
}

View file

@ -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<AppHttpResponse> => {
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<VCardMeta | AppHttpResponse | undefined> => {
const settings = getSettings();
const body = `<?xml version="1.0" encoding="UTF-8"?>
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag />
<D:getlastmodified />
<D:resourcetype />
<C:address-data>
<C:prop name="UID"/>
<C:prop name="FN"/>
</C:address-data>
</D:prop>
<C:filter>
<C:prop-filter name="UID">
<C:text-match match-type="contains">${uid}</C:text-match>
</C:prop-filter>
</C:filter>
</C:addressbook-query>`;
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<VCardMeta[] | AppHttpResponse> => {
const settings = getSettings();
const body = `<?xml version="1.0" encoding="UTF-8"?>
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag />
<D:getlastmodified />
<D:resourcetype />
<C:address-data>
<C:prop name="UID"/>
<C:prop name="FN"/>
</C:address-data>
</D:prop>
</C:addressbook-query>`;
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<VCardRaw | AppHttpResponse> => {
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<AppHttpResponse> => {
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<AppHttpResponse> => {
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
};
}

View file

@ -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() {

View file

@ -0,0 +1,70 @@
export interface OAuthCallbackData {
code?: string;
state?: string;
error?: string;
}
type Request = {
url?: string;
method?: string;
headers: Record<string, string | string[] | undefined>;
};
type Response = {
writeHead(status: number, headers?: Record<string, string>): 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(`
<html>
<body>
<h2>Authentication callback received</h2>
<p>You may close this window.</p>
</body>
</html>
`);
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;
}

View file

@ -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<TokenResponse> {
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<LoginResult> {
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();
});
}

View file

@ -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 (
<>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Client Id</div>
<div className="setting-item-description">
Oauth client id from google cloud
</div>
</div>
<div className="setting-item-control">
<input
className="textfield"
type="text"
placeholder="ClientId"
value={googleContactSettings.clientId}
onChange={e => setGoogleContactSettings({ ...googleContactSettings, clientId: e.target.value })}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Client Secret</div>
<div className="setting-item-description">
auth client secret from google cloud.
</div>
</div>
<div className="setting-item-control">
<input
className="textfield"
type="password"
placeholder="clientSecret"
value={googleContactSettings.clientSecret}
onChange={e => setGoogleContactSettings({ ...googleContactSettings, clientSecret: e.target.value })}
/>
</div>
</div>
</>
);
}

View file

@ -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" && (
<GoogleContactsSettings
googleContactSettings={googleContactSettings}
setGoogleContactSettings={setGoogleContactSettings}
/>
)}
</>
)}
</>

View file

@ -37,6 +37,12 @@ const mockSettings: ContactsPluginSettings = {
authKey: '',
authType: 'apikey'
},
GoogleContact: {
clientId: '',
clientSecret: '',
accessToken: '',
refreshToken: ''
},
createFieldsKeys:['N.FN']
};