Skip to content

Commit 4bff6f7

Browse files
dhensbyclaude
andcommitted
fix!: normalise InvalidArgumentError to server_error on the wire
InvalidArgumentError carries the non-standard `invalid_argument` code (defined in neither RFC 6749 §5.2 nor §4.1.2.1) but extends OAuthError, so the token and authorize handler catch blocks let it slip through their "wrap any non-OAuthError as ServerError" guard. As a result, a misbehaving model that returns malformed token data (e.g. a non-Date `accessTokenExpiresAt`) or a falsy authorization code caused the server to emit `error=invalid_argument` with HTTP 500 to the client instead of a standard `server_error`. Treat InvalidArgumentError like any other internal error at the serialisation boundary: normalise it to ServerError before it reaches the client, preserving the original as `.inner`. Construction- and request-validation guards (missing options, model-capability checks, request/response instance checks) are thrown before the try block and are unaffected — they still surface InvalidArgumentError to the integrator as a descriptive developer-facing error. BREAKING CHANGE: when a model returns malformed token data or a falsy authorization code at request time, the OAuth error response now reports `error: server_error` with HTTP status 503 instead of `error: invalid_argument` with HTTP status 500. Setup-time InvalidArgumentErrors thrown to integrator code are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 76a4720 commit 4bff6f7

6 files changed

Lines changed: 150 additions & 4 deletions

File tree

docs/api/errors/invalid-argument-error.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
<a name="InvalidArgumentError"></a>
22

33
## InvalidArgumentError
4-
"An argument to a function or constructor is missing or of wrong type"
4+
Thrown when an argument to a function or constructor is missing or of the
5+
wrong type — i.e. a programming or configuration error in the code that
6+
integrates this library (for example, a missing server option, or a `model`
7+
that does not implement a required method).
8+
9+
`InvalidArgumentError` is **not** an OAuth 2.0 protocol error: its
10+
`invalid_argument` code (HTTP 500) is not one of the error codes defined by
11+
RFC 6749 and must never be sent to an OAuth client. How it surfaces therefore
12+
depends on *when* it is raised:
13+
14+
- **At construction / configuration time** (missing options, a `model` missing
15+
a required method, or `request`/`response` not being instances of this
16+
library's `Request`/`Response`) it is thrown to your code unchanged, before
17+
any response is produced — so you can catch it while wiring up the server.
18+
- **While handling a request** (for example, when a `model` returns malformed
19+
token data) the `token` and `authorize` handlers normalise it to a
20+
`ServerError` (`server_error`) before the response reaches the client,
21+
preserving the original error as `.inner`. So when you `catch` errors from
22+
`OAuth2Server#token` or `OAuth2Server#authorize`, expect a `ServerError`
23+
(inspect its `.inner`) for these cases — not an `InvalidArgumentError`.
524

625
**Kind**: global class
726
<a name="new_InvalidArgumentError_new"></a>

lib/errors/invalid-argument-error.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,28 @@
77
const OAuthError = require('./oauth-error');
88

99
/**
10+
* Thrown when an argument to a function or constructor is missing or of the
11+
* wrong type — i.e. a programming or configuration error in the code that
12+
* integrates this library (for example, a missing server option, or a `model`
13+
* that does not implement a required method).
14+
*
15+
* `InvalidArgumentError` is **not** an OAuth 2.0 protocol error: its
16+
* `invalid_argument` code (HTTP 500) is not one of the error codes defined by
17+
* RFC 6749 and must never be sent to an OAuth client. How it surfaces therefore
18+
* depends on *when* it is raised:
19+
*
20+
* - **At construction / configuration time** (missing options, a `model` missing
21+
* a required method, or `request`/`response` not being instances of this
22+
* library's `Request`/`Response`) it is thrown to your code unchanged, before
23+
* any response is produced — so you can catch it while wiring up the server.
24+
* - **While handling a request** (for example, when a `model` returns malformed
25+
* token data) the `token` and `authorize` handlers normalise it to a
26+
* `ServerError` (`server_error`) before the response reaches the client,
27+
* preserving the original error as `.inner`. So when you `catch` errors from
28+
* `OAuth2Server#token` or `OAuth2Server#authorize`, expect a `ServerError`
29+
* (inspect its `.inner`) for these cases — not an `InvalidArgumentError`.
30+
*
1031
* @class
11-
* @classDesc "An argument to a function or constructor is missing or of wrong type"
1232
*/
1333

1434
class InvalidArgumentError extends OAuthError {

lib/handlers/authorize-handler.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,11 @@ class AuthorizeHandler {
128128
} catch (err) {
129129
let e = err;
130130

131-
if (!(e instanceof OAuthError)) {
131+
// `InvalidArgumentError` denotes a programming or configuration error and
132+
// its `invalid_argument` code is not a valid OAuth 2.0 error code, so -
133+
// like any non-OAuth error - it is normalised to a `server_error` before
134+
// reaching the client. The original error is retained as `.inner`.
135+
if (!(e instanceof OAuthError) || e instanceof InvalidArgumentError) {
132136
e = new ServerError(e);
133137
}
134138
const redirectUri = this.buildErrorRedirectUri(uri, e);

lib/handlers/token-handler.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ class TokenHandler {
100100
} catch (err) {
101101
let e = err;
102102

103-
if (!(e instanceof OAuthError)) {
103+
// `InvalidArgumentError` denotes a programming or configuration error and
104+
// its `invalid_argument` code is not a valid OAuth 2.0 error code, so -
105+
// like any non-OAuth error - it is normalised to a `server_error` before
106+
// reaching the client. The original error is retained as `.inner`.
107+
if (!(e instanceof OAuthError) || e instanceof InvalidArgumentError) {
104108
e = new ServerError(e);
105109
}
106110

test/integration/handlers/authorize-handler_test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,55 @@ describe('AuthorizeHandler integration', function () {
354354
}
355355
});
356356

357+
it('should redirect with a `server_error` if an `InvalidArgumentError` is thrown while handling the request', async function () {
358+
// A model returning a falsy `authorizationCode` makes `CodeResponseType`
359+
// throw an `InvalidArgumentError`. That is an internal error, not an OAuth
360+
// error, so it must reach the client as a standard `server_error` rather
361+
// than the non-standard `invalid_argument`.
362+
const model = createModel({
363+
getAccessToken: async function () {
364+
return {
365+
user: {},
366+
accessTokenExpiresAt: new Date(new Date().getTime() + 10000),
367+
};
368+
},
369+
getClient: async function () {
370+
return { grants: ['authorization_code'], redirectUris: ['http://example.com/cb'] };
371+
},
372+
saveAuthorizationCode: async function () {
373+
return { authorizationCode: undefined, client: {} };
374+
},
375+
});
376+
const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model });
377+
const request = new Request({
378+
body: {
379+
client_id: 12345,
380+
response_type: 'code',
381+
},
382+
headers: {
383+
Authorization: 'Bearer foo',
384+
},
385+
method: {},
386+
query: {
387+
state: 'foobar',
388+
},
389+
});
390+
const response = new Response({ body: {}, headers: {} });
391+
392+
try {
393+
await handler.handle(request, response);
394+
should.fail();
395+
} catch (e) {
396+
e.should.be.an.instanceOf(ServerError);
397+
e.inner.should.be.an.instanceOf(InvalidArgumentError);
398+
e.message.should.equal('Missing parameter: `code`');
399+
const location = new URL(response.get('location'));
400+
location.searchParams.get('error').should.equal('server_error');
401+
location.searchParams.get('error_description').should.equal('Missing parameter: `code`');
402+
location.searchParams.get('state').should.equal('foobar');
403+
}
404+
});
405+
357406
it('should redirect to a successful response with `code` and `state` if successful', async function () {
358407
const client = {
359408
id: 'client-12343434',

test/integration/handlers/token-handler_test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,56 @@ describe('TokenHandler integration', function () {
398398
});
399399
});
400400

401+
it('should normalise an `InvalidArgumentError` thrown while handling the request to a `server_error`', function () {
402+
// A model that returns malformed token data makes `TokenModel` throw an
403+
// `InvalidArgumentError`. That is an internal error, not an OAuth error, so
404+
// it must reach the client as a standard `server_error` rather than the
405+
// non-standard `invalid_argument`.
406+
const model = Model.from({
407+
getClient: function () {
408+
return { grants: ['password'] };
409+
},
410+
getUser: function () {
411+
return {};
412+
},
413+
saveToken: function () {
414+
return { accessToken: 'foo', client: {}, user: {}, accessTokenExpiresAt: 'not-a-date' };
415+
},
416+
validateScope: function () {
417+
return ['baz'];
418+
},
419+
});
420+
const handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 });
421+
const request = new Request({
422+
body: {
423+
client_id: 12345,
424+
client_secret: 'secret',
425+
username: 'foo',
426+
password: 'bar',
427+
grant_type: 'password',
428+
scope: 'baz',
429+
},
430+
headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' },
431+
method: 'POST',
432+
query: {},
433+
});
434+
const response = new Response({ body: {}, headers: {} });
435+
436+
return handler
437+
.handle(request, response)
438+
.then(should.fail)
439+
.catch(function (e) {
440+
e.should.be.an.instanceOf(ServerError);
441+
e.inner.should.be.an.instanceOf(InvalidArgumentError);
442+
e.message.should.equal('Invalid parameter: `accessTokenExpiresAt`');
443+
response.body.should.eql({
444+
error: 'server_error',
445+
error_description: 'Invalid parameter: `accessTokenExpiresAt`',
446+
});
447+
response.status.should.equal(503);
448+
});
449+
});
450+
401451
it('should return a bearer token if successful', function () {
402452
const token = {
403453
accessToken: 'foo',

0 commit comments

Comments
 (0)