diff --git a/desktop/main.js b/desktop/main.js index f9698ff70..02c913d3a 100644 --- a/desktop/main.js +++ b/desktop/main.js @@ -1,17 +1,110 @@ -const {app, BrowserWindow, shell} = require('electron') +const {app, BrowserWindow, shell, ipcMain} = require('electron') const path = require('path') const express = require('express') const eApp = express() const portInUse = require('./portInUse.js') +const oauth = require('./oauth.js') const frontendPath = 'frontend/' +const PROTOCOL = 'vikunja-desktop' + +let mainWindow = null +let pendingDeepLinkUrl = null + +// Ensure single instance so deep links reach the running app on Windows/Linux +const gotTheLock = app.requestSingleInstanceLock() +if (!gotTheLock) { + app.quit() + // Must exit the process immediately — app.quit() is async and the rest of this + // file would still execute, potentially opening a blank window. + process.exit(0) +} + +// Register the custom protocol for deep links +if (process.defaultApp) { + // During development, register with the path to the script + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [path.resolve(process.argv[1])]) + } +} else { + app.setAsDefaultProtocolClient(PROTOCOL) +} + +// Handle deep link on macOS (app already running or launched via URL) +app.on('open-url', (event, url) => { + event.preventDefault() + if (mainWindow) { + handleDeepLink(url) + } else { + // Window not ready yet — buffer the URL for processing after createWindow() + pendingDeepLinkUrl = url + } +}) + +// Handle deep link on Windows/Linux when a second instance is launched +app.on('second-instance', (_event, argv) => { + // Focus the main window + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore() + mainWindow.focus() + } + + // Find the deep link URL in argv + const deepLinkUrl = argv.find(arg => arg.startsWith(`${PROTOCOL}://`)) + if (deepLinkUrl) { + handleDeepLink(deepLinkUrl) + } +}) + +function handleDeepLink(url) { + try { + const parsed = new URL(url) + if (parsed.hostname === 'callback') { + const code = parsed.searchParams.get('code') + if (code && mainWindow) { + // Store the apiUrl that was used to start login so we can + // exchange the code at the correct endpoint + const apiUrl = pendingApiUrl + if (!apiUrl) { + mainWindow.webContents.send('oauth:error', 'No pending login session') + return + } + + oauth.exchangeCodeForTokens(apiUrl, code) + .then(tokens => { + mainWindow.webContents.send('oauth:tokens', tokens) + }) + .catch(err => { + mainWindow.webContents.send('oauth:error', err.message) + }) + } + } + } catch { + // Invalid URL, ignore + } +} + +let pendingApiUrl = null + +// IPC: Start OAuth login flow +ipcMain.handle('oauth:start-login', async (_event, apiUrl) => { + pendingApiUrl = apiUrl + const authUrl = oauth.startLogin(apiUrl) + await shell.openExternal(authUrl) +}) + +// IPC: Refresh access token +ipcMain.handle('oauth:refresh-token', async (_event, apiUrl, refreshToken) => { + return oauth.refreshAccessToken(apiUrl, refreshToken) +}) function createWindow() { // Create the browser window. - const mainWindow = new BrowserWindow({ + mainWindow = new BrowserWindow({ width: 1680, height: 960, webPreferences: { + preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, sandbox: true, @@ -54,6 +147,10 @@ function createWindow() { // Hide the toolbar mainWindow.setMenuBarVisibility(false) + mainWindow.on('closed', () => { + mainWindow = null + }) + // We try to use the same port every time and only use a different one if that does not succeed. let port = 45735 portInUse(port, used => { @@ -71,6 +168,18 @@ function createWindow() { const server = eApp.listen(port, '127.0.0.1', () => { console.log(`Server started on port ${server.address().port}`) mainWindow.loadURL(`http://127.0.0.1:${server.address().port}`) + + // Process any deep link that arrived before the page was ready, + // either buffered from open-url or passed via process.argv on first launch + mainWindow.webContents.once('did-finish-load', () => { + if (!pendingDeepLinkUrl) { + pendingDeepLinkUrl = process.argv.find(arg => arg.startsWith(`${PROTOCOL}://`)) || null + } + if (pendingDeepLinkUrl) { + handleDeepLink(pendingDeepLinkUrl) + pendingDeepLinkUrl = null + } + }) }) }) } @@ -94,4 +203,3 @@ app.whenReady().then(() => { app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() }) - diff --git a/desktop/oauth.js b/desktop/oauth.js new file mode 100644 index 000000000..06c1d41bc --- /dev/null +++ b/desktop/oauth.js @@ -0,0 +1,115 @@ +const crypto = require('crypto') +const {net} = require('electron') + +const CLIENT_ID = 'vikunja-desktop' +const REDIRECT_URI = 'vikunja-desktop://callback' + +let pendingCodeVerifier = null + +function generateCodeVerifier() { + return crypto.randomBytes(32).toString('base64url') +} + +function generateCodeChallenge(verifier) { + return crypto.createHash('sha256').update(verifier).digest('base64url') +} + +function buildAuthorizationUrl(frontendUrl, codeChallenge) { + // Strip trailing slash and /api/v1 suffix to get the frontend origin + let base = frontendUrl.replace(/\/+$/, '').replace(/\/api\/v1$/, '') + + const url = new URL(base) + url.pathname = url.pathname.replace(/\/+$/, '') + '/oauth/authorize' + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', CLIENT_ID) + url.searchParams.set('redirect_uri', REDIRECT_URI) + url.searchParams.set('code_challenge', codeChallenge) + url.searchParams.set('code_challenge_method', 'S256') + + return url.toString() +} + +function startLogin(apiUrl) { + const verifier = generateCodeVerifier() + const challenge = generateCodeChallenge(verifier) + pendingCodeVerifier = verifier + + return buildAuthorizationUrl(apiUrl, challenge) +} + +function postJSON(url, body) { + return new Promise((resolve, reject) => { + const request = net.request({ + method: 'POST', + url, + }) + request.setHeader('Content-Type', 'application/json') + + let responseData = '' + + request.on('response', (response) => { + response.on('data', (chunk) => { + responseData += chunk.toString() + }) + response.on('end', () => { + try { + const parsed = JSON.parse(responseData) + if (response.statusCode >= 200 && response.statusCode < 300) { + resolve(parsed) + } else { + reject(new Error(parsed.message || `HTTP ${response.statusCode}`)) + } + } catch { + reject(new Error(`Invalid JSON response: ${responseData.substring(0, 200)}`)) + } + }) + }) + + request.on('error', (err) => { + reject(err) + }) + + request.write(JSON.stringify(body)) + request.end() + }) +} + +function getTokenEndpoint(apiUrl) { + let base = apiUrl.replace(/\/+$/, '') + if (!base.endsWith('/api/v1')) { + base += '/api/v1' + } + return `${base}/oauth/token` +} + +async function exchangeCodeForTokens(apiUrl, code) { + const verifier = pendingCodeVerifier + pendingCodeVerifier = null + + if (!verifier) { + throw new Error('No pending PKCE verifier found') + } + + const tokenUrl = getTokenEndpoint(apiUrl) + return postJSON(tokenUrl, { + grant_type: 'authorization_code', + code, + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + code_verifier: verifier, + }) +} + +async function refreshAccessToken(apiUrl, refreshToken) { + const tokenUrl = getTokenEndpoint(apiUrl) + return postJSON(tokenUrl, { + grant_type: 'refresh_token', + refresh_token: refreshToken, + }) +} + +module.exports = { + startLogin, + exchangeCodeForTokens, + refreshAccessToken, +} diff --git a/desktop/package.json b/desktop/package.json index 383665e14..c35d6572c 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -21,6 +21,10 @@ "productName": "Vikunja Desktop", "artifactName": "${productName}-${version}.${ext}", "icon": "build/icon.icns", + "protocols": { + "name": "Vikunja Desktop", + "schemes": ["vikunja-desktop"] + }, "linux": { "target": [ "deb", diff --git a/desktop/preload.js b/desktop/preload.js new file mode 100644 index 000000000..19e907b8b --- /dev/null +++ b/desktop/preload.js @@ -0,0 +1,15 @@ +const {contextBridge, ipcRenderer} = require('electron') + +contextBridge.exposeInMainWorld('vikunjaDesktop', { + startOAuthLogin: (apiUrl) => ipcRenderer.invoke('oauth:start-login', apiUrl), + onOAuthTokens: (callback) => { + ipcRenderer.removeAllListeners('oauth:tokens') + ipcRenderer.on('oauth:tokens', (_event, tokens) => callback(tokens)) + }, + onOAuthError: (callback) => { + ipcRenderer.removeAllListeners('oauth:error') + ipcRenderer.on('oauth:error', (_event, error) => callback(error)) + }, + refreshToken: (apiUrl, refreshToken) => ipcRenderer.invoke('oauth:refresh-token', apiUrl, refreshToken), + isDesktop: true, +}) diff --git a/frontend/src/helpers/auth.ts b/frontend/src/helpers/auth.ts index 7a9620140..1d46258dc 100644 --- a/frontend/src/helpers/auth.ts +++ b/frontend/src/helpers/auth.ts @@ -1,4 +1,5 @@ import {HTTPFactory} from '@/helpers/fetcher' +import {isDesktopApp, refreshDesktopToken} from '@/helpers/desktopAuth' let savedToken: string | null = null @@ -31,6 +32,7 @@ export const getToken = (): string | null => { export const removeToken = () => { savedToken = null localStorage.removeItem('token') + localStorage.removeItem('desktopOAuthRefreshToken') } /** @@ -43,6 +45,22 @@ export const removeToken = () => { * the token in localStorage was already updated and adopt it directly. */ export async function refreshToken(persist: boolean): Promise { + // In desktop mode, refresh via IPC to the Electron main process + if (isDesktopApp()) { + const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken') + if (!storedRefreshToken) { + throw new Error('No desktop OAuth refresh token available') + } + try { + const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken) + saveToken(tokens.access_token, persist) + localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token) + } catch (e) { + throw new Error('Error renewing token: ', {cause: e}) + } + return + } + // Capture the token before waiting for the lock so we can detect // if another tab refreshed while we were queued. const tokenBeforeLock = localStorage.getItem('token') diff --git a/frontend/src/helpers/desktopAuth.ts b/frontend/src/helpers/desktopAuth.ts new file mode 100644 index 000000000..e9aacf32c --- /dev/null +++ b/frontend/src/helpers/desktopAuth.ts @@ -0,0 +1,21 @@ +import type {OAuthTokens} from '@/types/desktop' + +export function isDesktopApp(): boolean { + return !!window.vikunjaDesktop?.isDesktop +} + +export function startDesktopOAuthLogin(apiUrl: string): Promise { + return window.vikunjaDesktop!.startOAuthLogin(apiUrl) +} + +export function listenForDesktopOAuthTokens(callback: (tokens: OAuthTokens) => void): void { + window.vikunjaDesktop!.onOAuthTokens(callback) +} + +export function listenForDesktopOAuthError(callback: (error: string) => void): void { + window.vikunjaDesktop!.onOAuthError(callback) +} + +export function refreshDesktopToken(apiUrl: string, refreshToken: string): Promise { + return window.vikunjaDesktop!.refreshToken(apiUrl, refreshToken) +} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index f978a2e14..6ced8283d 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -258,6 +258,18 @@ export const useAuthStore = defineStore('auth', () => { } } + async function handleDesktopOAuthTokens(tokens: {access_token: string, refresh_token: string, expires_in: number}) { + setIsLoading(true) + try { + removeToken() + saveToken(tokens.access_token, true) + localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token) + await checkAuth() + } finally { + setIsLoading(false) + } + } + async function linkShareAuth({hash, password}) { const HTTP = HTTPFactory() const response = await HTTP.post('/shares/' + hash + '/auth', { @@ -547,6 +559,7 @@ export const useAuthStore = defineStore('auth', () => { login, register, openIdAuth, + handleDesktopOAuthTokens, linkShareAuth, checkAuth, refreshUserInfo, diff --git a/frontend/src/types/desktop.d.ts b/frontend/src/types/desktop.d.ts new file mode 100644 index 000000000..4e8c28506 --- /dev/null +++ b/frontend/src/types/desktop.d.ts @@ -0,0 +1,19 @@ +export interface OAuthTokens { + access_token: string + refresh_token: string + expires_in: number +} + +export interface VikunjaDesktop { + isDesktop: boolean + startOAuthLogin: (apiUrl: string) => Promise + onOAuthTokens: (callback: (tokens: OAuthTokens) => void) => void + onOAuthError: (callback: (error: string) => void) => void + refreshToken: (apiUrl: string, refreshToken: string) => Promise +} + +declare global { + interface Window { + vikunjaDesktop?: VikunjaDesktop + } +}