mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-05-07 12:37:14 -05:00
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
116 lines
2.8 KiB
JavaScript
116 lines
2.8 KiB
JavaScript
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,
|
|
}
|