// Nextcloud's own Login Flow v2: the person signs in on Nextcloud however they // normally do — SSO, 2FA, a password manager — and Nextcloud hands back an app // password it generated. We never see their real password and they never have // to find the app-password screen. const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); const route = read('src/routes/nextcloud.js'); const ui = read('public/js/nextcloud.js'); const start = route.slice(route.indexOf("router.post('/nextcloud/login-flow/start'"), route.indexOf("router.post('/nextcloud/login-flow/poll'")); const poll = route.slice(route.indexOf("router.post('/nextcloud/login-flow/poll'"), route.indexOf("router.post('/nextcloud/connect'")); test('the URLs the server hands back are checked, not followed on trust', () => { // The remote server chooses both the login URL and the poll endpoint. An // endpoint pointing elsewhere would make this a request-forgery gadget aimed // at whatever it named, with our credentials attached. assert.match(start, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/); assert.match(start, /assertSafeHttpsUrl\(poll\.endpoint, 'Nextcloud poll endpoint'\)/); assert.match(start, /assertSafeHttpsUrl\(loginUrl, 'Nextcloud login URL'\)/); // And both must be on the host the person actually typed. assert.match(start, /new URL\(poll\.endpoint\)\.host !== origin \|\| new URL\(loginUrl\)\.host !== origin/); assert.match(start, /pointed the login somewhere else/); }); test('the poll token never reaches the browser', () => { // It is a credential. The browser gets an opaque handle instead. assert.match(start, /res\.json\(\{ success: true, handle: handle, loginUrl: loginUrl \}\)/); assert.doesNotMatch(start, /res\.json\([^)]*token/); const flowHandler = ui.slice(ui.indexOf('btn-nc-login-flow')); assert.match(flowHandler, /handle: data\.handle/); // The manual fallback above it does send an app password — that is its whole // point. This path must not. assert.doesNotMatch(flowHandler, /poll\.token|appPassword/); }); test('a handle belongs to the account that started the flow', () => { assert.match(poll, /!flow \|\| flow\.owner !== req\.user\.id/); assert.match(poll, /a handle is not a bearer token/); }); test('"not finished yet" is a normal answer, not a failure', () => { // Nextcloud answers 404 while the person is still typing their password. assert.match(poll, /if \(status === 404\) return res\.json\(\{ success: true, pending: true \}\)/); }); test('the server Nextcloud reports is re-checked before it is stored', () => { assert.match(poll, /answer\.data\.server \|\| flow\.url/); assert.match(poll, /assertSafeHttpsUrl\(serverUrl, 'Nextcloud URL'\)/); }); test('the app password is encrypted at rest, like every other credential here', () => { assert.match(poll, /cryptoUtil\.encryptString\(appPassword\)/); }); test('a flow expires, and starting again replaces the old one', () => { assert.match(route, /LOGIN_FLOW_TTL_MS = 20 \* 60 \* 1000/); assert.match(route, /function sweepLoginFlows\(\)/); assert.match(start, /flow\.owner === req\.user\.id && key !== handle\) loginFlows\.delete\(key\)/); }); test('the tab is opened while the click is still trusted', () => { // Opening after an await is what a popup blocker stops. const handler = ui.slice(ui.indexOf("btn-nc-login-flow")); const open = handler.indexOf("window.open('', '_blank')"); const fetchAt = handler.indexOf("fetch('/api/nextcloud/login-flow/start'"); assert.ok(open > -1 && open < fetchAt, 'the tab must be claimed before the request'); assert.match(handler, /a popup blocker eats it|still trusted/); }); test('polling stops: on success, on failure, and on a deadline', () => { const handler = ui.slice(ui.indexOf("btn-nc-login-flow")); assert.match(handler, /Date\.now\(\) > until\) return stopPolling/); assert.match(handler, /if \(result\.connected\)/); // stopPolling owns the interval, and is what every exit calls. assert.match(ui, /function stopPolling\(message, tone\)[\s\S]{0,160}clearInterval\(pollTimer\)/); // A dropped poll is not a failed sign-in. assert.match(handler, /a dropped poll is not a failed sign-in/); }); test('the app-password path is still there, as the fallback', () => { assert.match(route, /router\.post\('\/nextcloud\/connect'/); const settings = read('public/components/settings.html'); assert.match(settings, /Use an app password instead/); assert.match(settings, /id="btn-nc-login-flow"/); });