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
6 changes: 5 additions & 1 deletion src/idp/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,11 @@ export async function idpPlugin(fastify, options) {

// /.well-known/openid-configuration
fastify.get('/.well-known/openid-configuration', async (request, reply) => {
// Ensure issuer has trailing slash for CTH compatibility
// Ensure issuer has trailing slash for CTH compatibility. Must stay
// in sync with createProvider's normalization (src/idp/provider.js)
// — the RFC 9207 `iss` authorization-response parameter and token
// `iss` claims come from the provider's issuer, and strict clients
// byte-compare them against this discovery field. See #524.
const normalizedIssuer = issuer.endsWith('/') ? issuer : issuer + '/';
// Base URL without trailing slash for building endpoint URLs
const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
Expand Down
11 changes: 11 additions & 0 deletions src/idp/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ async function fetchClientDocument(clientId) {
* @returns {Promise<Provider>} - Configured oidc-provider instance
*/
export async function createProvider(issuer) {
// Normalize to the trailing-slash form — the SAME normalization the
// discovery handler applies to the `issuer` field of
// /.well-known/openid-configuration (src/idp/index.js, "Ensure
// issuer has trailing slash"). The provider's issuer feeds the
// RFC 9207 `iss` authorization-response parameter and the `iss`
// claim in issued tokens; RFC 9207 requires the param to be
// byte-identical to the advertised issuer, and strict clients
// (e.g. solid-oidc's handleRedirectFromLogin) reject the callback
// on mismatch — sign-in silently bounces before the token request
// ever fires. Keep the two normalizations in sync. See #524.
issuer = issuer.endsWith('/') ? issuer : issuer + '/';
const jwks = await getJwks();
const cookieKeys = await getCookieKeys();

Expand Down
100 changes: 100 additions & 0 deletions test/idp-issuer-normalization.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* Issuer normalization (#524).
*
* RFC 9207 requires the `iss` authorization-response parameter to be
* byte-identical to the issuer identifier the client learned from
* discovery. JSS's discovery handler normalizes the `issuer` field to
* the trailing-slash form ("CTH compatibility"), but the oidc-provider
* instance — which emits the RFC 9207 `iss` param and the `iss` claim
* in tokens — was constructed with the RAW configured issuer. With an
* issuer configured slash-free, strict clients (e.g. solid-oidc's
* handleRedirectFromLogin) saw:
*
* discovery issuer → http://host:port/
* callback iss → http://host:port
*
* and rejected the callback before the token request ever fired —
* sign-in silently bounced (reproduced on Android/nodejs-mobile, #522).
*
* The fix normalizes inside createProvider with the SAME expression
* the discovery handler uses. These tests pin both halves and their
* equality so the two normalizations can't drift apart silently.
*/

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

const TEST_HOST = 'localhost';
const DATA_DIR = './test-data-idp-issuer-norm';

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

describe('IdP issuer normalization (#524)', () => {
let server;
let baseUrl; // deliberately WITHOUT a trailing slash — the bug's trigger
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, // no trailing slash
forceCloseConnections: true,
});
await server.listen({ port, host: TEST_HOST });
});

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('createProvider normalizes a slash-free issuer to the trailing-slash form', async () => {
const provider = await createProvider(baseUrl);
assert.strictEqual(provider.issuer, baseUrl + '/',
'provider issuer must gain the trailing slash so the RFC 9207 iss param matches discovery');
});

it('createProvider leaves an already-slashed issuer unchanged', async () => {
const provider = await createProvider(baseUrl + '/');
assert.strictEqual(provider.issuer, baseUrl + '/',
'an already-canonical issuer must pass through untouched');
});

it('provider issuer is byte-identical to the discovery `issuer` field (RFC 9207 contract)', async () => {
// The cross-component pin: both sides normalize the same raw input
// to the same string. If either normalization drifts, strict
// clients break — this is the exact comparison solid-oidc's
// handleRedirectFromLogin performs.
const res = await fetch(`${baseUrl}/.well-known/openid-configuration`);
assert.strictEqual(res.status, 200);
const discovery = await res.json();

const provider = await createProvider(baseUrl);
assert.strictEqual(provider.issuer, discovery.issuer,
`RFC 9207: provider iss (${provider.issuer}) must equal discovery issuer (${discovery.issuer})`);
});
});