Skip to content

Commit 80a42aa

Browse files
fix(idp): RFC 9207 iss param matches discovery issuer — normalize in createProvider (JavaScriptSolidServer#524) (JavaScriptSolidServer#551)
The discovery handler normalizes the `issuer` field of /.well-known/openid-configuration to the trailing-slash form, but oidc-provider was constructed with the RAW configured issuer. The provider's issuer feeds the RFC 9207 `iss` authorization-response parameter and the `iss` claim in issued tokens, so with an issuer configured slash-free: discovery issuer → http://host:port/ callback iss → http://host:port RFC 9207 requires byte-identity; strict clients (solid-oidc's handleRedirectFromLogin) rejected the callback BEFORE the token request fired and sign-in silently bounced. Reproduced on Android / nodejs-mobile (JavaScriptSolidServer#522), where it was the final blocker to a working on-device login. Fix: normalize inside createProvider with the same expression the discovery handler uses, with bidirectional sync comments on both sites so neither normalization can drift silently. Blast-radius notes (verified before landing): - Internal verification is slash-tolerant: addTrustedIssuer and getOidcConfig strip trailing slashes; JWKS verification uses the token's own iss (self-consistent). - The discovery doc is unchanged (idp.test.js:62 keeps asserting issuer === baseUrl + '/'). - handleCredentials' programmatic-token iss stamp (credentials.js:119) is deliberately NOT touched: those tokens have their own verification path and no RFC 9207 involvement. Tests (test/idp-issuer-normalization.test.js, 3 cases): slash-free issuer gains the slash, already-slashed passes through, and the cross-component pin — provider.issuer byte-equals the discovery issuer fetched from a running server, the exact comparison strict clients perform. Full suite: 937/937 passing. Closes JavaScriptSolidServer#524.
1 parent 666f7db commit 80a42aa

3 files changed

Lines changed: 116 additions & 1 deletion

File tree

src/idp/index.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,11 @@ export async function idpPlugin(fastify, options) {
219219

220220
// /.well-known/openid-configuration
221221
fastify.get('/.well-known/openid-configuration', async (request, reply) => {
222-
// Ensure issuer has trailing slash for CTH compatibility
222+
// Ensure issuer has trailing slash for CTH compatibility. Must stay
223+
// in sync with createProvider's normalization (src/idp/provider.js)
224+
// — the RFC 9207 `iss` authorization-response parameter and token
225+
// `iss` claims come from the provider's issuer, and strict clients
226+
// byte-compare them against this discovery field. See #524.
223227
const normalizedIssuer = issuer.endsWith('/') ? issuer : issuer + '/';
224228
// Base URL without trailing slash for building endpoint URLs
225229
const baseUrl = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;

src/idp/provider.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ async function fetchClientDocument(clientId) {
9191
* @returns {Promise<Provider>} - Configured oidc-provider instance
9292
*/
9393
export async function createProvider(issuer) {
94+
// Normalize to the trailing-slash form — the SAME normalization the
95+
// discovery handler applies to the `issuer` field of
96+
// /.well-known/openid-configuration (src/idp/index.js, "Ensure
97+
// issuer has trailing slash"). The provider's issuer feeds the
98+
// RFC 9207 `iss` authorization-response parameter and the `iss`
99+
// claim in issued tokens; RFC 9207 requires the param to be
100+
// byte-identical to the advertised issuer, and strict clients
101+
// (e.g. solid-oidc's handleRedirectFromLogin) reject the callback
102+
// on mismatch — sign-in silently bounces before the token request
103+
// ever fires. Keep the two normalizations in sync. See #524.
104+
issuer = issuer.endsWith('/') ? issuer : issuer + '/';
94105
const jwks = await getJwks();
95106
const cookieKeys = await getCookieKeys();
96107

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Issuer normalization (#524).
3+
*
4+
* RFC 9207 requires the `iss` authorization-response parameter to be
5+
* byte-identical to the issuer identifier the client learned from
6+
* discovery. JSS's discovery handler normalizes the `issuer` field to
7+
* the trailing-slash form ("CTH compatibility"), but the oidc-provider
8+
* instance — which emits the RFC 9207 `iss` param and the `iss` claim
9+
* in tokens — was constructed with the RAW configured issuer. With an
10+
* issuer configured slash-free, strict clients (e.g. solid-oidc's
11+
* handleRedirectFromLogin) saw:
12+
*
13+
* discovery issuer → http://host:port/
14+
* callback iss → http://host:port
15+
*
16+
* and rejected the callback before the token request ever fired —
17+
* sign-in silently bounced (reproduced on Android/nodejs-mobile, #522).
18+
*
19+
* The fix normalizes inside createProvider with the SAME expression
20+
* the discovery handler uses. These tests pin both halves and their
21+
* equality so the two normalizations can't drift apart silently.
22+
*/
23+
24+
import { describe, it, before, after } from 'node:test';
25+
import assert from 'node:assert';
26+
import { createServer } from '../src/server.js';
27+
import { createProvider } from '../src/idp/provider.js';
28+
import { createServer as createNetServer } from 'net';
29+
import fs from 'fs-extra';
30+
31+
const TEST_HOST = 'localhost';
32+
const DATA_DIR = './test-data-idp-issuer-norm';
33+
34+
function getAvailablePort() {
35+
return new Promise((resolve, reject) => {
36+
const srv = createNetServer();
37+
srv.on('error', reject);
38+
srv.listen(0, TEST_HOST, () => {
39+
const port = srv.address().port;
40+
srv.close(() => resolve(port));
41+
});
42+
});
43+
}
44+
45+
describe('IdP issuer normalization (#524)', () => {
46+
let server;
47+
let baseUrl; // deliberately WITHOUT a trailing slash — the bug's trigger
48+
let originalDataRoot;
49+
50+
before(async () => {
51+
originalDataRoot = process.env.DATA_ROOT;
52+
await fs.remove(DATA_DIR);
53+
await fs.ensureDir(DATA_DIR);
54+
55+
const port = await getAvailablePort();
56+
baseUrl = `http://${TEST_HOST}:${port}`;
57+
58+
server = createServer({
59+
logger: false,
60+
root: DATA_DIR,
61+
idp: true,
62+
idpIssuer: baseUrl, // no trailing slash
63+
forceCloseConnections: true,
64+
});
65+
await server.listen({ port, host: TEST_HOST });
66+
});
67+
68+
after(async () => {
69+
if (server) await server.close();
70+
if (originalDataRoot === undefined) delete process.env.DATA_ROOT;
71+
else process.env.DATA_ROOT = originalDataRoot;
72+
await fs.remove(DATA_DIR);
73+
});
74+
75+
it('createProvider normalizes a slash-free issuer to the trailing-slash form', async () => {
76+
const provider = await createProvider(baseUrl);
77+
assert.strictEqual(provider.issuer, baseUrl + '/',
78+
'provider issuer must gain the trailing slash so the RFC 9207 iss param matches discovery');
79+
});
80+
81+
it('createProvider leaves an already-slashed issuer unchanged', async () => {
82+
const provider = await createProvider(baseUrl + '/');
83+
assert.strictEqual(provider.issuer, baseUrl + '/',
84+
'an already-canonical issuer must pass through untouched');
85+
});
86+
87+
it('provider issuer is byte-identical to the discovery `issuer` field (RFC 9207 contract)', async () => {
88+
// The cross-component pin: both sides normalize the same raw input
89+
// to the same string. If either normalization drifts, strict
90+
// clients break — this is the exact comparison solid-oidc's
91+
// handleRedirectFromLogin performs.
92+
const res = await fetch(`${baseUrl}/.well-known/openid-configuration`);
93+
assert.strictEqual(res.status, 200);
94+
const discovery = await res.json();
95+
96+
const provider = await createProvider(baseUrl);
97+
assert.strictEqual(provider.issuer, discovery.issuer,
98+
`RFC 9207: provider iss (${provider.issuer}) must equal discovery issuer (${discovery.issuer})`);
99+
});
100+
});

0 commit comments

Comments
 (0)