feat: add OAuth PKCE authentication flow to desktop app

Add a complete OAuth 2.0 PKCE flow for the Electron desktop app:

- Implement PKCE code generation and token exchange in Electron
- Register custom protocol handler (vikunja-desktop://) for deep links
- Handle deep link race conditions (buffered URLs, process.argv fallback)
- Prevent duplicate IPC listener accumulation on re-mount
- Preserve sub-paths in OAuth authorize URL for non-root deployments
- Add token refresh support using Electron's net module
This commit is contained in:
kolaente 2026-03-30 19:28:12 +02:00 committed by kolaente
parent 6566f98103
commit dd7532a57a
8 changed files with 316 additions and 3 deletions

View File

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

115
desktop/oauth.js Normal file
View File

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

View File

@ -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",

15
desktop/preload.js Normal file
View File

@ -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,
})

View File

@ -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<void> {
// 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')

View File

@ -0,0 +1,21 @@
import type {OAuthTokens} from '@/types/desktop'
export function isDesktopApp(): boolean {
return !!window.vikunjaDesktop?.isDesktop
}
export function startDesktopOAuthLogin(apiUrl: string): Promise<void> {
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<OAuthTokens> {
return window.vikunjaDesktop!.refreshToken(apiUrl, refreshToken)
}

View File

@ -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,

19
frontend/src/types/desktop.d.ts vendored Normal file
View File

@ -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<void>
onOAuthTokens: (callback: (tokens: OAuthTokens) => void) => void
onOAuthError: (callback: (error: string) => void) => void
refreshToken: (apiUrl: string, refreshToken: string) => Promise<OAuthTokens>
}
declare global {
interface Window {
vikunjaDesktop?: VikunjaDesktop
}
}