// 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, and bound to the row it is stored in', () => { // Bound, not merely encrypted: an unbound ciphertext copied onto another // user's row decrypts there, and that account's exports land in someone // else's storage. See test/crypto-context-binding.test.js. assert.match(poll, /cryptoUtil\.encryptString\(appPassword, tokenContext\(req\.user\.id\)\)/); }); 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 sign-in tab is opened without a handle, which COOP would sever', () => { // The original claimed a blank tab during the click and pointed it at the URL // when the request came back — the usual way around a popup blocker, and // broken here: this app sends Cross-Origin-Opener-Policy: same-origin, so the // handle dies as soon as the tab goes cross-origin and assigning its location // did nothing. A blank tab, and a button that looked dead. const handler = ui.slice(ui.indexOf("btn-nc-login-flow")); assert.doesNotMatch(handler, /window\.open\(''/, 'no blank tab claimed up front'); assert.doesNotMatch(handler, /tab\.location/, 'no handle to navigate'); assert.match(handler, /window\.open\(data\.loginUrl, '_blank', 'noopener'\)/); }); test('the sign-in URL is also offered as a real link', () => { // Whatever the browser decides about opening windows, there is a way through. const handler = ui.slice(ui.indexOf("btn-nc-login-flow")); assert.match(handler, /setFlowStatus\('Waiting for you to finish signing in\.', null, data\.loginUrl\)/); assert.match(ui, /a\.rel = 'noopener noreferrer'/); assert.match(ui, /a\.target = '_blank'/); // Built as DOM, not spliced into innerHTML. assert.match(ui, /function setFlowStatus\(message, tone, link\)[\s\S]{0,400}createElement\('a'\)/); }); test('helmet still sends the COOP header this works around', () => { // If this ever stops being true the workaround is harmless, but the comment // explaining it would be wrong, and the old pattern would look safe again. const server = read('server.js'); assert.match(server, /app\.use\(helmet\(\{/); assert.doesNotMatch(server, /crossOriginOpenerPolicy:\s*false/, 'COOP is on by default in helmet; turning it off would need its own reasoning'); }); 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"/); });