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:
parent
6566f98103
commit
dd7532a57a
114
desktop/main.js
114
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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue