Skip to content
Open
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
17 changes: 14 additions & 3 deletions lib/grant-types/authorization-code-grant-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ const InvalidRequestError = require('../errors/invalid-request-error');
const ServerError = require('../errors/server-error');
const isFormat = require('@node-oauth/formats');
const pkce = require('../pkce/pkce');
const { isDefined, toString, isInTypes } = require('../utils/param-util');
const isStringOrNumber = isInTypes('string', 'number');
const isString = isInTypes('string');

/**
* @class
Expand Down Expand Up @@ -73,14 +76,22 @@ class AuthorizationCodeGrantType extends AbstractGrantType {
*/

async getAuthorizationCode(request, client) {
if (!request.body.code) {
if (!isDefined(request.body.code)) {
throw new InvalidRequestError('Missing parameter: `code`');
}

if (!isFormat.vschar(request.body.code)) {
if (!isStringOrNumber(request.body.code)) {
throw new InvalidRequestError('Invalid parameter: `code`');
}

// normalize string|number to string
const requestCode = toString(request.body.code);

if (!isFormat.vschar(requestCode)) {
throw new InvalidRequestError('Invalid parameter: `code`');
}

// XXX: still passing the original value from request to model
const code = await this.model.getAuthorizationCode(request.body.code);

if (!code) {
Expand Down Expand Up @@ -163,7 +174,7 @@ class AuthorizationCodeGrantType extends AbstractGrantType {

const redirectUri = request.body.redirect_uri || request.query.redirect_uri;

if (!isFormat.uri(redirectUri)) {
if (!redirectUri || !isString(redirectUri) || !isFormat.uri(redirectUri)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Guarding the request-side redirect_uri here is good — but the bump to @node-oauth/formats@1.1.0 also changes behaviour at the isFormat.* call sites this PR didn't touch, because 1.1.0 now throws on non-strings instead of returning false.

The one that matters is line 121: if (code.redirectUri && !isFormat.uri(code.redirectUri)), where code.redirectUri comes from the model. A loosely-typed model returning a non-string truthy value now gets a raw TypeError → 503 instead of the previous clean InvalidGrantError (400). Worth guarding that one with isString too.

More generally, adopting 1.1.0 means we should audit every remaining isFormat caller (e.g. token-handler nchar(grant_type)/uri(grant_type)), not just the request-param sites retrofitted here.

throw new InvalidRequestError('Invalid request: `redirect_uri` is not a valid URI');
}

Expand Down
10 changes: 6 additions & 4 deletions lib/grant-types/password-grant-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const InvalidArgumentError = require('../errors/invalid-argument-error');
const InvalidGrantError = require('../errors/invalid-grant-error');
const InvalidRequestError = require('../errors/invalid-request-error');
const isFormat = require('@node-oauth/formats');
const { isDefined, isInTypes } = require('../utils/param-util');
const isString = isInTypes('string');

/**
* Constructor.
Expand Down Expand Up @@ -58,19 +60,19 @@ class PasswordGrantType extends AbstractGrantType {
*/

async getUser(request, client) {
if (!request.body.username) {
if (!isDefined(request.body.username)) {
throw new InvalidRequestError('Missing parameter: `username`');
}

if (!request.body.password) {
if (!isDefined(request.body.password)) {
throw new InvalidRequestError('Missing parameter: `password`');
}

if (!isFormat.uchar(request.body.username)) {
if (!isString(request.body.username) || !isFormat.uchar(request.body.username)) {
throw new InvalidRequestError('Invalid parameter: `username`');
}

if (!isFormat.uchar(request.body.password)) {
if (!isString(request.body.password) || !isFormat.uchar(request.body.password)) {
throw new InvalidRequestError('Invalid parameter: `password`');
}

Expand Down
15 changes: 12 additions & 3 deletions lib/grant-types/refresh-token-grant-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const InvalidRequestError = require('../errors/invalid-request-error');
const ServerError = require('../errors/server-error');
const isFormat = require('@node-oauth/formats');
const InvalidScopeError = require('../errors/invalid-scope-error');
const { isInTypes, toString, isDefined } = require('../utils/param-util');
const isStringOrNumber = isInTypes('string', 'number');

/**
* Constructor.
Expand Down Expand Up @@ -64,16 +66,23 @@ class RefreshTokenGrantType extends AbstractGrantType {
/**
* Get refresh token.
*/

async getRefreshToken(request, client) {
if (!request.body.refresh_token) {
if (!isDefined(request.body.refresh_token)) {
throw new InvalidRequestError('Missing parameter: `refresh_token`');
}

if (!isFormat.vschar(request.body.refresh_token)) {
if (!isStringOrNumber(request.body.refresh_token)) {
throw new InvalidRequestError('Invalid parameter: `refresh_token`');
}

// normalize string|number to string
const refreshToken = toString(request.body.refresh_token);

if (!isFormat.vschar(refreshToken)) {
throw new InvalidRequestError('Invalid parameter: `refresh_token`');
}

// XXX: still passing the original value from request to model
const token = await this.model.getRefreshToken(request.body.refresh_token);

if (!token) {
Expand Down
12 changes: 8 additions & 4 deletions lib/handlers/authorize-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const tokenUtil = require('../utils/token-util');
const url = require('url');
const pkce = require('../pkce/pkce');
const { parseScope } = require('../utils/scope-util');
const { isDefined, isInTypes } = require('../utils/param-util');
const isString = isInTypes('string');
const isStringOrNumber = isInTypes('string', 'number');

/**
* Response types.
Expand Down Expand Up @@ -160,17 +163,18 @@ class AuthorizeHandler {
const self = this;
const clientId = request.body.client_id || request.query.client_id;

if (!clientId) {
if (!isDefined(clientId)) {
throw new InvalidRequestError('Missing parameter: `client_id`');
}

if (!isFormat.vschar(clientId)) {
if (!isStringOrNumber(clientId) || !isFormat.vschar(clientId)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is the one number-accepting site that's missing the toString() wrap that token-handler uses. Because isStringOrNumber lets a number through, isFormat.vschar(clientId) is then called with a raw number — and under @node-oauth/formats@1.1.0 that throws TypeError: "Value must be a string" rather than returning false. Since getClient() runs before the try/catch in handle(), it escapes as a 500 rather than a clean 400. So a numeric client_id — exactly the case we deliberately want to keep supporting (numeric DB columns) — breaks here.

Reproduced: a numeric client_id throws on this branch but resolves on master.

// mirror token-handler:
if (!isStringOrNumber(clientId) || !isFormat.vschar(toString(clientId))) {

We should also add a numeric-client_id regression test — the fixtures were all flipped from 12345 to '12345', so nothing currently exercises this path, which is why CI stays green while this ships.

throw new InvalidRequestError('Invalid parameter: `client_id`');
}

const redirectUri = request.body.redirect_uri || request.query.redirect_uri;

if (redirectUri && !isFormat.uri(redirectUri)) {
// XXX: why is there no 'Missing parameter: `redirect_uri`' error?
if (isDefined(redirectUri) && (!isString(redirectUri) || !isFormat.uri(redirectUri))) {
throw new InvalidRequestError('Invalid request: `redirect_uri` is not a valid URI');
}

Expand Down Expand Up @@ -236,7 +240,7 @@ class AuthorizeHandler {

getState (request) {
const state = request.body.state || request.query.state;
const stateExists = state && state.length > 0;
const stateExists = isString(state) && state.length > 0;
const stateIsValid = stateExists
? isFormat.vschar(state)
: this.allowEmptyState;
Expand Down
6 changes: 4 additions & 2 deletions lib/handlers/token-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const UnsupportedGrantTypeError = require('../errors/unsupported-grant-type-erro
const auth = require('basic-auth');
const pkce = require('../pkce/pkce');
const isFormat = require('@node-oauth/formats');
const { isInTypes, toString } = require('../utils/param-util');
const isStringOrNumber = isInTypes('string', 'number');

/**
* Grant types.
Expand Down Expand Up @@ -123,11 +125,11 @@ class TokenHandler {
throw new InvalidRequestError('Missing parameter: `client_secret`');
}

if (!isFormat.vschar(credentials.clientId)) {
if (!isStringOrNumber(credentials.clientId) || !isFormat.vschar(toString(credentials.clientId))) {
throw new InvalidRequestError('Invalid parameter: `client_id`');
}

if (credentials.clientSecret && !isFormat.vschar(credentials.clientSecret)) {
if (credentials.clientSecret && (!isStringOrNumber(credentials.clientSecret) || !isFormat.vschar(toString(credentials.clientSecret)))) {
throw new InvalidRequestError('Invalid parameter: `client_secret`');
}

Expand Down
60 changes: 60 additions & 0 deletions lib/utils/param-util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
'use strict';

/**
* @module ParamUtil
*/

/**
* Create a function to check, whether a value is one of the given types.
* @param types {...string[]}
* @return {function(*): boolean}
*/
function isInTypes (...types) {
return function (value) {
return types.includes(typeof value);
};
}

/**
* Check if a value is defined (not missing)
* @param value
* @return {boolean}
*/
function isDefined (value) {
// TODO: in future versions, consider changing this to `value !== undefined && value !== null && value !== ''`,
// which might be a breaking changes for some users
return !!value;
}

/**
* Safely converts a value to a string in the following order:
* - If the value is already a string, return it.
* - If the value has a `toString` method, call it and return the result.
* - If the value is `null` or `undefined`, return an empty string.
* - Otherwise, use the global `String` function to convert the value.
* @param value {*}
* @return {string}
*/
function toString(value) {
Comment thread
jankapunkt marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two small things on this helper:

  1. The JSDoc just above describes the opposite of what the code does — it says "if the value has a toString method, call it", "null/undefined → empty string", "otherwise use the global String", but the implementation throws in all of those cases. That lenient-coercion mental model is arguably what led to the authorize client_id miss above. Worth rewriting the doc to match the real throw-semantics.

  2. There's no test/unit/utils/param-util_test.js. toString() has the most branches in this PR (number/bigint → String(), the NaN/Infinity throw, the null/undefined throw, and the catch-all throw) and none are covered directly — would be good to test it like scope-util_test.js does.

(Minor: isDefined is really an isTruthy check — fine given the documented intent to tighten it later, just a slightly misleading name.)

const type = typeof value;
if (type === 'string') {
return value;
}

if (type === 'undefined' || value === null) {
throw new TypeError(`Cannot convert ${value} to a string`);
}

if (type === 'number' || type === 'bigint') {
const val = String(value);
if (val === 'NaN' || val === 'Infinity' || val === '-Infinity') {
throw new TypeError(`Invalid numeric value ${value}, cannot be converted to a string (${val})`);
}
return val;
}


throw new TypeError(`Cannot convert value ${value} of type ${type} to a string`);
}

module.exports = { isInTypes, isDefined, toString };
4 changes: 3 additions & 1 deletion lib/utils/scope-util.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const isFormat = require('@node-oauth/formats');
const InvalidScopeError = require('../errors/invalid-scope-error');
const { isInTypes } = require('../utils/param-util');
const whiteSpace = /\s+/g;
const isString = isInTypes('string');

/**
* @module ScopeUtil
Expand All @@ -22,7 +24,7 @@ function parseScope (requestedScope) {
return undefined;
}

if (typeof requestedScope !== 'string') {
if (!isString(requestedScope)) {
throw new InvalidScopeError('Invalid parameter: `scope`');
}

Expand Down
Loading