Skip to content

Commit 017ddb1

Browse files
authored
Merge pull request #332 from axa-group/ntk/fix_331
Prevent well known endpoints from containing double slashes
2 parents 715d71d + 5913353 commit 017ddb1

File tree

6 files changed

+1272
-1079
lines changed

6 files changed

+1272
-1079
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
66

7+
## [7.2.1](https://github.com/axa-group/oauth2-mock-server/compare/v7.2.0...v7.2.1) — 2025-04-30
8+
9+
### Fixed
10+
11+
- Fix paths of well known endpoints when issuer ends with a forward slash (reported in [#331](https://github.com/axa-group/oauth2-mock-server/issues/331) by [kikisaeba](https://github.com/kikisaeba))
12+
13+
### Changed
14+
15+
- Update dependencies
16+
717
## [7.2.0](https://github.com/axa-group/oauth2-mock-server/compare/v7.1.2...v7.2.0) — 2024-11-25
818

919
### Added

package.json

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
{
22
"name": "oauth2-mock-server",
3-
"version": "7.2.0",
3+
"version": "7.2.1",
44
"description": "OAuth 2 mock server",
5+
"type": "commonjs",
56
"keywords": [
67
"oauth",
78
"oauth2",
89
"oauth 2",
910
"mock",
11+
"fake",
12+
"stub",
1013
"server",
1114
"cli",
1215
"jwt",
1316
"oidc",
14-
"openid connect"
17+
"openid",
18+
"connect"
1519
],
1620
"author": {
1721
"name": "Jorge Poveda",
@@ -24,7 +28,7 @@
2428
},
2529
"repository": {
2630
"type": "git",
27-
"url": "https://github.com/axa-group/oauth2-mock-server.git"
31+
"url": "git+https://github.com/axa-group/oauth2-mock-server.git"
2832
},
2933
"main": "./dist/index.js",
3034
"types": "./dist/index.d.ts",
@@ -51,30 +55,30 @@
5155
"dependencies": {
5256
"basic-auth": "^2.0.1",
5357
"cors": "^2.8.5",
54-
"express": "^4.21.1",
58+
"express": "^4.21.2",
5559
"is-plain-object": "^5.0.0",
56-
"jose": "^5.9.6"
60+
"jose": "^5.10.0"
5761
},
5862
"devDependencies": {
5963
"@types/basic-auth": "^1.1.6",
6064
"@types/cors": "^2.8.17",
6165
"@types/express": "^4.17.21",
62-
"@types/node": "^18.19.64",
63-
"@types/supertest": "^6.0.2",
64-
"@typescript-eslint/eslint-plugin": "^8.15.0",
65-
"@typescript-eslint/parser": "^8.15.0",
66-
"@vitest/coverage-v8": "^2.1.5",
67-
"@vitest/eslint-plugin": "^1.1.10",
66+
"@types/node": "^18.19.87",
67+
"@types/supertest": "^6.0.3",
68+
"@typescript-eslint/eslint-plugin": "^8.31.1",
69+
"@typescript-eslint/parser": "^8.31.1",
70+
"@vitest/coverage-v8": "^3.1.2",
71+
"@vitest/eslint-plugin": "^1.1.43",
6872
"eslint": "^8.57.1",
6973
"eslint-config-prettier": "^9.1.0",
7074
"eslint-plugin-import": "^2.29.1",
71-
"eslint-plugin-jsdoc": "^50.5.0",
72-
"eslint-plugin-prettier": "^5.2.1",
73-
"prettier": "^3.1.1",
75+
"eslint-plugin-jsdoc": "^50.6.11",
76+
"eslint-plugin-prettier": "^5.2.6",
77+
"prettier": "^3.5.3",
7478
"rimraf": "^5.0.10",
75-
"supertest": "^7.0.0",
76-
"typescript": "^5.3.3",
77-
"vitest": "^2.1.5"
79+
"supertest": "^7.1.0",
80+
"typescript": "^5.8.3",
81+
"vitest": "^3.1.2"
7882
},
7983
"resolutions": {
8084
"@types/node": "^18"

src/lib/oauth2-service.ts

Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,15 @@ export class OAuth2Service extends EventEmitter {
158158
private openidConfigurationHandler: RequestHandler = (_req, res) => {
159159
assertIsString(this.issuer.url, 'Unknown issuer url.');
160160

161+
const normalizedIssuerUrl = trimPotentialTrailingSlash(this.issuer.url);
162+
161163
const openidConfig = {
162164
issuer: this.issuer.url,
163-
token_endpoint: `${this.issuer.url}${this.#endpoints.token}`,
164-
authorization_endpoint: `${this.issuer.url}${this.#endpoints.authorize}`,
165-
userinfo_endpoint: `${this.issuer.url}${this.#endpoints.userinfo}`,
165+
token_endpoint: `${normalizedIssuerUrl}${this.#endpoints.token}`,
166+
authorization_endpoint: `${normalizedIssuerUrl}${this.#endpoints.authorize}`,
167+
userinfo_endpoint: `${normalizedIssuerUrl}${this.#endpoints.userinfo}`,
166168
token_endpoint_auth_methods_supported: ['none'],
167-
jwks_uri: `${this.issuer.url}${this.#endpoints.jwks}`,
169+
jwks_uri: `${normalizedIssuerUrl}${this.#endpoints.jwks}`,
168170
response_types_supported: ['code'],
169171
grant_types_supported: [
170172
'client_credentials',
@@ -174,10 +176,10 @@ export class OAuth2Service extends EventEmitter {
174176
token_endpoint_auth_signing_alg_values_supported: ['RS256'],
175177
response_modes_supported: ['query'],
176178
id_token_signing_alg_values_supported: ['RS256'],
177-
revocation_endpoint: `${this.issuer.url}${this.#endpoints.revoke}`,
179+
revocation_endpoint: `${normalizedIssuerUrl}${this.#endpoints.revoke}`,
178180
subject_types_supported: ['public'],
179-
end_session_endpoint: `${this.issuer.url}${this.#endpoints.endSession}`,
180-
introspection_endpoint: `${this.issuer.url}${this.#endpoints.introspect}`,
181+
end_session_endpoint: `${normalizedIssuerUrl}${this.#endpoints.endSession}`,
182+
introspection_endpoint: `${normalizedIssuerUrl}${this.#endpoints.introspect}`,
181183
code_challenge_methods_supported: supportedPkceAlgorithms,
182184
};
183185

@@ -192,10 +194,7 @@ export class OAuth2Service extends EventEmitter {
192194
try {
193195
const tokenTtl = defaultTokenTtl;
194196

195-
res.set({
196-
'Cache-Control': 'no-store',
197-
Pragma: 'no-cache',
198-
});
197+
res.set({ 'Cache-Control': 'no-store', Pragma: 'no-cache' });
199198

200199
let xfn: ScopesOrTransform | undefined;
201200

@@ -207,9 +206,7 @@ export class OAuth2Service extends EventEmitter {
207206
const verifier = req.body['code_verifier'];
208207
const savedCodeChallenge = this.#codeChallenges.get(code);
209208
if (savedCodeChallenge === undefined) {
210-
throw new AssertionError({
211-
message: 'code_challenge required',
212-
});
209+
throw new AssertionError({ message: 'code_challenge required' });
213210
}
214211
this.#codeChallenges.delete(code);
215212
if (!isValidPkceCodeVerifier(verifier)) {
@@ -256,27 +253,17 @@ export class OAuth2Service extends EventEmitter {
256253
case 'authorization_code':
257254
scope = scope ?? 'dummy';
258255
xfn = (_header, payload) => {
259-
Object.assign(payload, {
260-
sub: 'johndoe',
261-
amr: ['pwd'],
262-
scope,
263-
});
256+
Object.assign(payload, { sub: 'johndoe', amr: ['pwd'], scope });
264257
};
265258
break;
266259
case 'refresh_token':
267260
scope = scope ?? 'dummy';
268261
xfn = (_header, payload) => {
269-
Object.assign(payload, {
270-
sub: 'johndoe',
271-
amr: ['pwd'],
272-
scope,
273-
});
262+
Object.assign(payload, { sub: 'johndoe', amr: ['pwd'], scope });
274263
};
275264
break;
276265
default:
277-
return res.status(400).json({
278-
error: 'invalid_grant',
279-
});
266+
return res.status(400).json({ error: 'invalid_grant' });
280267
}
281268

282269
const token = await this.buildToken(req, tokenTtl, xfn);
@@ -292,14 +279,9 @@ export class OAuth2Service extends EventEmitter {
292279
const clientId = credentials ? credentials.name : req.body.client_id;
293280

294281
const xfn: JwtTransform = (_header, payload) => {
295-
Object.assign(payload, {
296-
sub: 'johndoe',
297-
aud: clientId,
298-
});
282+
Object.assign(payload, { sub: 'johndoe', aud: clientId });
299283
if (reqBody.code !== undefined && this.#nonce[reqBody.code]) {
300-
Object.assign(payload, {
301-
nonce: this.#nonce[reqBody.code],
302-
});
284+
Object.assign(payload, { nonce: this.#nonce[reqBody.code] });
303285
delete this.#nonce[reqBody.code];
304286
}
305287
};
@@ -308,10 +290,7 @@ export class OAuth2Service extends EventEmitter {
308290
body['refresh_token'] = randomUUID();
309291
}
310292

311-
const tokenEndpointResponse: MutableResponse = {
312-
body,
313-
statusCode: 200,
314-
};
293+
const tokenEndpointResponse: MutableResponse = { body, statusCode: 200 };
315294

316295
/**
317296
* Before token response event.
@@ -417,9 +396,7 @@ export class OAuth2Service extends EventEmitter {
417396

418397
private userInfoHandler: RequestHandler = (req, res) => {
419398
const userInfoResponse: MutableResponse = {
420-
body: {
421-
sub: 'johndoe',
422-
},
399+
body: { sub: 'johndoe' },
423400
statusCode: 200,
424401
};
425402

@@ -435,9 +412,7 @@ export class OAuth2Service extends EventEmitter {
435412
};
436413

437414
private revokeHandler: RequestHandler = (req, res) => {
438-
const revokeResponse: StatusCodeMutableResponse = {
439-
statusCode: 200,
440-
};
415+
const revokeResponse: StatusCodeMutableResponse = { statusCode: 200 };
441416

442417
/**
443418
* Before revoke event.
@@ -473,9 +448,7 @@ export class OAuth2Service extends EventEmitter {
473448

474449
private introspectHandler: RequestHandler = (req, res) => {
475450
const introspectResponse: MutableResponse = {
476-
body: {
477-
active: true,
478-
},
451+
body: { active: true },
479452
statusCode: 200,
480453
};
481454

@@ -492,3 +465,7 @@ export class OAuth2Service extends EventEmitter {
492465
.json(introspectResponse.body);
493466
};
494467
}
468+
469+
const trimPotentialTrailingSlash = (url: string): string => {
470+
return url.endsWith('/') ? url.slice(0, -1) : url;
471+
};

test/oauth2-service.test.ts

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,18 @@ import {
1515
import * as testKeys from './keys';
1616
import { verifyTokenWithKey } from './lib/test_helpers';
1717

18-
describe('OAuth 2 service', () => {
18+
describe.each([
19+
'https://issuer.example.com',
20+
'https://issuer.example.com/'
21+
])
22+
('OAuth 2 service with issuer %s', (issuerUrl: string) => {
23+
1924
let issuer: OAuth2Issuer;
2025
let service: OAuth2Service;
2126

2227
beforeAll(async () => {
2328
issuer = new OAuth2Issuer();
24-
issuer.url = 'https://issuer.example.com';
29+
issuer.url = issuerUrl;
2530
await issuer.keys.add(testKeys.getParsed('test-rs256-key.json'));
2631

2732
service = new OAuth2Service(issuer);
@@ -42,15 +47,17 @@ describe('OAuth 2 service', () => {
4247
const res = await request(customService.requestHandler)
4348
.get('/custom-well-known')
4449
.expect(200);
45-
const { url } = customService.issuer;
50+
51+
const endpointsPrefix = wellKnownEndpointsPrefixFrom(customService.issuer);
52+
4653
expect(res.body).toMatchObject({
47-
jwks_uri: `${url!}/custom-jwks`,
48-
token_endpoint: `${url!}/custom-token`,
49-
authorization_endpoint: `${url!}/custom-authorize`,
50-
userinfo_endpoint: `${url!}/custom-userinfo`,
51-
revocation_endpoint: `${url!}/revoke`,
52-
end_session_endpoint: `${url!}/endsession`,
53-
introspection_endpoint: `${url!}/custom-introspect`,
54+
jwks_uri: `${endpointsPrefix}/custom-jwks`,
55+
token_endpoint: `${endpointsPrefix}/custom-token`,
56+
authorization_endpoint: `${endpointsPrefix}/custom-authorize`,
57+
userinfo_endpoint: `${endpointsPrefix}/custom-userinfo`,
58+
revocation_endpoint: `${endpointsPrefix}/revoke`,
59+
end_session_endpoint: `${endpointsPrefix}/endsession`,
60+
introspection_endpoint: `${endpointsPrefix}/custom-introspect`,
5461
});
5562

5663
const getTestCases: [string, number, string?][] = [
@@ -86,32 +93,40 @@ describe('OAuth 2 service', () => {
8693
}
8794
});
8895

96+
const wellKnownEndpointsPrefixFrom = (issuer: OAuth2Issuer) => {
97+
const { url } = issuer;
98+
expect(url).not.toBeNull();
99+
100+
return url!.endsWith('/') ? url!.slice(0, -1) : url;
101+
};
102+
89103
it('should expose an OpenID configuration endpoint', async () => {
90104
const res = await request(service.requestHandler)
91105
.get('/.well-known/openid-configuration')
92106
.expect(200);
93107

94-
const { url } = service.issuer;
95-
expect(url).not.toBeNull();
108+
const endpointsPrefix = wellKnownEndpointsPrefixFrom(service.issuer);
96109

97110
expect(res.body).toEqual({
98-
issuer: url,
99-
token_endpoint: `${url!}/token`,
100-
authorization_endpoint: `${url!}/authorize`,
101-
userinfo_endpoint: `${url!}/userinfo`,
111+
issuer: service.issuer.url,
112+
token_endpoint: `${endpointsPrefix}/token`,
113+
authorization_endpoint: `${endpointsPrefix}/authorize`,
114+
userinfo_endpoint: `${endpointsPrefix}/userinfo`,
102115
token_endpoint_auth_methods_supported: ['none'],
103-
jwks_uri: `${url!}/jwks`,
116+
jwks_uri: `${endpointsPrefix}/jwks`,
104117
response_types_supported: ['code'],
105118
grant_types_supported: ['client_credentials', 'authorization_code', 'password'],
106119
token_endpoint_auth_signing_alg_values_supported: ['RS256'],
107120
response_modes_supported: ['query'],
108121
id_token_signing_alg_values_supported: ['RS256'],
109-
revocation_endpoint: `${url!}/revoke`,
122+
revocation_endpoint: `${endpointsPrefix}/revoke`,
110123
subject_types_supported: ['public'],
111-
introspection_endpoint: `${url!}/introspect`,
124+
introspection_endpoint: `${endpointsPrefix}/introspect`,
112125
code_challenge_methods_supported: ['plain', 'S256'],
113-
end_session_endpoint: `${url!}/endsession`,
126+
end_session_endpoint: `${endpointsPrefix}/endsession`,
114127
});
128+
129+
expect(JSON.stringify(res.body)).not.toMatch(/(?<!https:|http:)\/\//);
115130
});
116131

117132
it('should expose an JWKS endpoint', async () => {
File renamed without changes.

0 commit comments

Comments
 (0)