pediatric-ai-scribe-v3/test/nextcloud-login-flow.test.js
Daniel 46112e1221
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m7s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: connect Nextcloud by signing in to Nextcloud
Asking someone to find Settings → Security → Create new app password is a poor
first run, and it is the step people give up on. Nextcloud has its own answer:
Login Flow v2. The person enters their server address, signs in on Nextcloud the
way they normally do — SSO, 2FA, a password manager — and Nextcloud generates an
app password for this app. We never see their real password.

Pasting an app password still works, behind "Use an app password instead". It is
the fallback, not the front door.

The security of this is all in what is trusted. The remote server chooses both
the login URL and the poll endpoint, so both are SSRF-checked and both must be
on the host the person actually typed — an endpoint pointing elsewhere would
make this a request-forgery gadget aimed at whatever it named. The server
Nextcloud reports at the end is re-checked before it is stored. The poll token
is a credential, so polling happens server-side and the browser holds only an
opaque handle bound to its own account.

Flows live in memory with a 20 minute life, matching Nextcloud's own expiry: a
login lasts minutes, and a restart mid-flow is a retry rather than a loss.
Starting a second flow replaces the first, which is what clicking again means.

The tab is opened from the click itself, before the request — opening it after
an await is what a popup blocker stops.

Removed with Learning Hub: the WebDAV browse path. Its field, its route and its
column are gone, since nothing browses Nextcloud any more. nextcloud_folder is a
different column and still in use.

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

90 lines
4.6 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, 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"/);
});