Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/idp/interactions.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export async function handleInteractionGet(request, reply, provider) {

// If we need login
if (prompt.name === 'login') {
return reply.type('text/html').send(loginPage(uid, params.client_id, interaction.lastError));
return reply.type('text/html').send(loginPage(uid, params.client_id, interaction.lastSubmission?.lastError));
}

// If we need consent
Expand Down Expand Up @@ -173,17 +173,26 @@ export async function handleLogin(request, reply, provider) {
return reply.code(404).type('text/html').send(errorPage('Session expired', 'Please try logging in again.'));
}

// Validate input
// Validate input.
//
// The error must live inside `lastSubmission` — oidc-provider's
// Interaction.save() persists ONLY the fields in the model's
// IN_PAYLOAD list (lib/models/interaction.js), and `lastSubmission`
// is the designated slot for form re-render state. A bare
// `interaction.lastError = …` survives in memory but is silently
// DROPPED on save, so the redirected GET re-rendered the form with
// no error and users retried blind (#514).
if (!identifier || !password) {
interaction.lastError = 'Username and password are required';
interaction.lastSubmission = { lastError: 'Username and password are required' };
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
return reply.redirect(`/idp/interaction/${uid}`);
}

// Authenticate
const account = await authenticate(identifier, password);
if (!account) {
interaction.lastError = 'Invalid username or password';
// See the IN_PAYLOAD note above — must ride in lastSubmission.
interaction.lastSubmission = { lastError: 'Invalid username or password' };
await interaction.save(interaction.exp - Math.floor(Date.now() / 1000));
return reply.redirect(`/idp/interaction/${uid}`);
}
Expand Down Expand Up @@ -404,7 +413,7 @@ export async function handleSwitchAccount(request, reply, provider) {
interaction.session = undefined;
interaction.result = undefined;
interaction.prompt = { name: 'login', reasons: ['no_session'], details: {} };
interaction.lastError = undefined;
interaction.lastSubmission = undefined;
const ttl = Math.max(1, interaction.exp - Math.floor(Date.now() / 1000));
await interaction.save(ttl);

Expand Down
155 changes: 155 additions & 0 deletions test/idp-login-error.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Login-form error rendering (#514).
*
* Submitting the IdP sign-in form with bad credentials set
* `interaction.lastError` and redirected back to the form — but
* oidc-provider's Interaction.save() persists ONLY the fields in the
* model's IN_PAYLOAD list, and `lastError` isn't one of them. The
* property survived in memory, was silently dropped on save, and the
* redirected GET re-rendered a pristine form: no error banner, user
* retries blind (the "credibility cliff" in the issue).
*
* Fix: the message rides in `lastSubmission` — the IN_PAYLOAD slot
* oidc-provider designates for form re-render state.
*
* The test drives the real OIDC interaction flow over HTTP (register
* client → /idp/auth → interaction redirect → POST bad credentials →
* follow redirect) with manual cookie threading, and asserts the
* re-rendered form carries the error.
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import { createServer } from '../src/server.js';
import { createServer as createNetServer } from 'net';
import fs from 'fs-extra';

const TEST_HOST = 'localhost';
const DATA_DIR = './test-data-idp-login-error';

function getAvailablePort() {
return new Promise((resolve, reject) => {
const srv = createNetServer();
srv.on('error', reject);
srv.listen(0, TEST_HOST, () => {
const port = srv.address().port;
srv.close(() => resolve(port));
});
});
}

// Headers.getSetCookie() landed in Node 18.15 / 19.7. The engines
// field still declares >=18.0.0 (bump deferred — #541), so guard
// explicitly rather than collecting zero cookies and failing at a
// confusing distance. Skipping costs nothing on those runtimes: the
// IdP itself cannot run on Node 18 at all (oidc-provider uses
// Array#toReversed and crypto.hash — #523), so every IdP test is
// already broken there.
const HAS_GET_SET_COOKIE = typeof new Headers().getSetCookie === 'function';

// Collect cookies from a response and merge into a name→value jar.
function absorbCookies(jar, res) {
for (const c of res.headers.getSetCookie()) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
Comment on lines +50 to +56

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a7ea839 — with an explicit guard rather than a quiet fallback: the test detects Headers.getSetCookie support up front and skips with a self-documenting reason on older Node. Context that makes the skip free: the IdP itself cannot run on Node 18 at all (oidc-provider uses Array#toReversed and crypto.hash#523, engines bump deferred as #541), so every IdP test is already broken on those runtimes; this one now states why instead of failing at a confusing distance. On supported runtimes the jar call is now direct (no optional chaining) since the guard owns the decision. Suite still green.

}
function cookieHeader(jar) {
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
}

describe('IdP login form error rendering (#514)', () => {
let server;
let baseUrl;
let originalDataRoot;

before(async () => {
originalDataRoot = process.env.DATA_ROOT;
await fs.remove(DATA_DIR);
await fs.ensureDir(DATA_DIR);

const port = await getAvailablePort();
baseUrl = `http://${TEST_HOST}:${port}`;

server = createServer({
logger: false,
root: DATA_DIR,
idp: true,
idpIssuer: baseUrl,
forceCloseConnections: true,
});
await server.listen({ port, host: TEST_HOST });

const res = await fetch(`${baseUrl}/.pods`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'alice', email: 'a@example.org', password: 'correct123' }),
});
assert.strictEqual(res.status, 201, 'prereq: pod creation');
});

after(async () => {
if (server) await server.close();
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
else process.env.DATA_ROOT = originalDataRoot;
await fs.remove(DATA_DIR);
});

it('re-renders the form WITH the error after a failed login', async (t) => {
if (!HAS_GET_SET_COOKIE) {
t.skip('Headers.getSetCookie unavailable (Node <18.15) — IdP requires Node 20+ anyway, see #523/#541');
return;
}
// 1. Dynamic client registration
const reg = await fetch(`${baseUrl}/idp/reg`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
redirect_uris: [`${baseUrl}/cb`],
token_endpoint_auth_method: 'none',
grant_types: ['authorization_code'],
response_types: ['code'],
}),
});
assert.strictEqual(reg.status, 201, 'client registration');
const { client_id: clientId } = await reg.json();

// 2. Start the auth flow → interaction redirect + session cookies
const jar = new Map();
const authUrl = `${baseUrl}/idp/auth?client_id=${encodeURIComponent(clientId)}` +
`&redirect_uri=${encodeURIComponent(`${baseUrl}/cb`)}` +
'&response_type=code&scope=openid+webid' +
'&code_challenge_method=S256&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&state=s1';
const auth = await fetch(authUrl, { redirect: 'manual' });
absorbCookies(jar, auth);
const interactionPath = auth.headers.get('location');
assert.ok(interactionPath?.includes('/idp/interaction/'),
`expected interaction redirect, got ${interactionPath}`);
const interactionUrl = interactionPath.startsWith('http')
? interactionPath : `${baseUrl}${interactionPath}`;

// 3. Submit WRONG credentials
const post = await fetch(`${interactionUrl}/login`, {
method: 'POST',
redirect: 'manual',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: cookieHeader(jar),
},
body: 'username=alice&password=definitely-wrong',
});
absorbCookies(jar, post);
assert.ok([302, 303].includes(post.status),
`failed login must redirect back to the form, got ${post.status}`);

// 4. Follow the redirect — the re-rendered form must carry the error
const rerender = await fetch(interactionUrl, {
headers: { Cookie: cookieHeader(jar) },
});
assert.strictEqual(rerender.status, 200);
const html = await rerender.text();
assert.match(html, /<div class="error">Invalid username or password<\/div>/,
're-rendered form must show the auth error (#514: lastError was dropped by Interaction.save)');
});
});