pediatric-ai-scribe-v3/test/nextcloud-login-flow.test.js
Daniel c68e3a6219 fix: "Sign in with Nextcloud" opens the sign-in page
The button did nothing. It claimed a blank tab during the click and
pointed it at the login URL once the request returned — the standard way
around a popup blocker, and it cannot work in this app: helmet sends
Cross-Origin-Opener-Policy: same-origin, which severs the handle to that
tab the moment it goes cross-origin. Assigning its location was a no-op.
A blank tab opened, nothing else happened.

The handle was never needed. window.open with 'noopener' asks for none,
and a click's user activation outlives the fetch, so the browser does
not treat it as a popup. The status line now also carries the sign-in
URL as an ordinary link, so there is a way through whatever any
particular browser decides about opening windows.

Verified the server side against the real Nextcloud first: the flow
starts, both returned URLs pass the SSRF guard and the same-host check.
The fault was entirely in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:36:51 +02:00

115 lines
6.2 KiB
JavaScript

// 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"/);
});