screentinker/server/test/oidc-account-linking.test.js
ScreenTinker 184ff71dee Let an existing account move to SSO, and ask who you are before how
Two halves of the same problem: an account created with a password could never
use single sign-on, and the login page offered a credential before it knew
which one applied.

LINKING. Signing in with a provider never adopts an account that already has a
password -- that is the takeover the login path exists to refuse. The README
promised the way out ("the owner signs in locally and links from Settings") but
nothing had ever been built, so the refusal was a dead end rather than a
redirection. Settings now has a Sign-in method block: an account with a password
can link an instance-wide provider, and one on a provider can unlink back to a
password.

The account being linked comes from the SIGNED TRANSACTION -- the session that
started it -- never from the email in the returned token. That distinction is
the whole feature: taking it from the token would be the same email-keyed
takeover under a friendlier name. The email must still match the account's own,
because login resolves accounts by the asserted address, and one provider
subject may not be linked to two accounts.

Linking DELETES the password rather than keeping it alongside. One credential at
a time, and the confirmation says so in those words, because a password left
behind is a second way in that the user believes they replaced. Unlink therefore
takes the new password up front and writes it in the SAME statement as the
unlink -- never unlink now and set a password after, which leaves an account
briefly, or on failure permanently, with no way in.

Instance-wide providers only. An organization's provider is chosen by a
customer; letting one attach itself to a platform account would hand that
customer whatever the account can do.

IDENTIFIER-FIRST. The password box now appears only after an address has been
submitted, which is what lets the organization lookup happen before a credential
is offered: someone whose company requires its own provider is shown that,
rather than a password box that will be refused. Editing the address returns to
the identifier step so a corrected domain gets a fresh answer.

The per-keystroke lookup is gone with it. It answered for half-typed domains,
changed the form under someone mid-address, and spent a 10/min per-IP budget on
people who had not finished typing -- an office behind one address could exhaust
it without a single sign-in attempt.

Instance-wide providers stay visible at all times now, by decision: the server
refuses them for an SSO-only organization anyway, and hiding them made the page
change shape while typing.

Verified in a real browser, not only by rendering: password hidden -> submit ->
visible and focused -> edit the address -> hidden again, with no page errors.
Four mutations of the linking rules fail the tests (account from the email
instead of the session, keeping the password, allowing org providers, dropping
requireAuth).
2026-08-12 11:48:11 -05:00

100 lines
5.2 KiB
JavaScript

'use strict';
/*
* Linking an existing account to an instance-wide provider (#258).
*
* Signing in with a provider never adopts an account that already has a password — that is the
* takeover the login path exists to refuse. The README promised an escape hatch ("the owner signs
* in locally and links from Settings") that was never built, so an account created with a password
* could never use SSO at all.
*
* The rules this pins down, all of which are load-bearing:
* - the account being linked comes from the SIGNED TRANSACTION (i.e. the session that started the
* link), never from the email in the returned token. Otherwise "linking" is the same email-keyed
* takeover under a friendlier name;
* - the provider's email must still equal the account's, because login resolves accounts by the
* asserted address;
* - one provider subject may not be linked to two accounts;
* - linking DELETES the password: one credential at a time;
* - unlinking SETS a password in the same statement, so the account is never between credentials;
* - ORG providers are not linkable at all.
*/
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const AUTH = fs.readFileSync(path.join(__dirname, '..', 'routes', 'auth.js'), 'utf8');
/** Body of a route handler, from its `router.<verb>('<route>'` to the next `router.`. */
function handler(verb, route) {
const start = AUTH.indexOf(`router.${verb}('${route}'`);
assert.notEqual(start, -1, `route ${verb.toUpperCase()} ${route} not found`);
const rest = AUTH.slice(start + 1);
const end = rest.indexOf('\nrouter.');
return end === -1 ? rest : rest.slice(0, end);
}
test('link start requires authentication and refuses org providers', () => {
const body = handler('get', '/oidc/:slug/link/start');
assert.match(AUTH, /router\.get\('\/oidc\/:slug\/link\/start', requireAuth/,
'the link must be startable only by someone already signed in — that is the proof of ownership');
assert.match(body, /provider\.organizationId.*not_linkable/s,
"an organization's provider must never attach itself to a platform account");
assert.match(body, /link: req\.user\.id/,
'the account must come from the session, not from anything the browser can set');
});
test('the linked account is taken from the transaction, never from the returned email', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /WHERE id = \?'\)\.get\(tx\.link\)/,
'the target account is looked up by tx.link (the session that started it)');
// The email is still checked, but as a constraint on the link — not as the way the account is found.
assert.match(cb, /target\.email\.toLowerCase\(\) !== email/, 'email must match the account being linked');
assert.match(cb, /link_email_mismatch/);
});
test('one provider subject cannot be linked to two accounts', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /provider_id = \? AND auth_provider = \? AND id != \?/,
'must check whether this provider identity already belongs to another account');
assert.match(cb, /link_already_used/);
});
test('linking deletes the password — one credential at a time', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /UPDATE users SET auth_provider = \?, provider_id = \?, password_hash = NULL/,
'the password must be cleared in the same statement that attaches the provider');
});
test('unlinking sets a password in the SAME statement', () => {
const body = handler('post', '/oidc/unlink');
assert.match(body, /UPDATE users SET auth_provider = 'local', provider_id = NULL, password_hash = \?/,
'unlink and set-password must be one write — never unlink first and set a password after');
assert.match(body, /password\.length < passwordReset\.MIN_PASSWORD_LENGTH/,
'the replacement password must meet the same minimum as a reset');
assert.match(body, /auth_provider === 'local'/, 'refuse unlinking an account that has no provider');
});
test('both link and unlink are recorded in the activity log', () => {
assert.match(handler('get', '/oidc/:slug/callback'), /logActivity\([^)]*'auth:sso_linked'/);
assert.match(handler('post', '/oidc/unlink'), /logActivity\([^)]*'auth:sso_unlinked'/);
});
test('link failures return to Settings, not the login page', () => {
const cb = handler('get', '/oidc/:slug/callback');
assert.match(cb, /const fail = linking \? backToSettings : backToApp/,
'an authenticated user must not be bounced to a login screen to be told the link failed');
assert.match(AUTH, /function backToSettings\(res, params\)[\s\S]{0,200}#\/settings/);
});
test('login and link share one flow, so verification cannot drift between them', () => {
// beginOidc is the single place PKCE/state/nonce are minted; both entry points call it.
assert.match(AUTH, /async function beginOidc\(req, res, provider, extra = \{\}/);
const login = handler('get', '/oidc/:slug/start');
const link = handler('get', '/oidc/:slug/link/start');
assert.match(login, /beginOidc\(req, res, provider\)/);
assert.match(link, /beginOidc\(req, res, provider, \{ link: req\.user\.id \}/);
});