From ac58a0217a3a3f33b383c6344e7474f6e3f15735 Mon Sep 17 00:00:00 2001 From: wille Date: Fri, 14 Nov 2025 15:58:34 +0100 Subject: [PATCH 1/7] OAuth 2.0 Token Revocation --- index.d.ts | 20 +- lib/errors/unsupported-token-type-error.js | 36 +++ lib/handlers/revoke-handler.js | 277 +++++++++++++++++++++ lib/server.js | 5 + 4 files changed, 332 insertions(+), 6 deletions(-) create mode 100644 lib/errors/unsupported-token-type-error.js create mode 100644 lib/handlers/revoke-handler.js diff --git a/index.d.ts b/index.d.ts index 9c9f3cf2..248ff4ed 100644 --- a/index.d.ts +++ b/index.d.ts @@ -45,6 +45,14 @@ declare class OAuth2Server { response: OAuth2Server.Response, options?: OAuth2Server.TokenOptions ): Promise; + + /** + * Revokes a token (RFC 7009). + */ + revoke( + request: OAuth2Server.Request, + response: OAuth2Server.Response, + ): Promise; } declare namespace OAuth2Server { @@ -265,6 +273,12 @@ declare namespace OAuth2Server { * */ saveToken(token: Token, client: Client, user: User): Promise; + + /** + * Invoked to revoke a token. + * + */ + revokeToken(token: Token | RefreshToken): Promise; } interface RequestAuthenticationModel { @@ -362,12 +376,6 @@ declare namespace OAuth2Server { * */ getRefreshToken(refreshToken: string): Promise; - - /** - * Invoked to revoke a refresh token. - * - */ - revokeToken(token: RefreshToken): Promise; } interface ClientCredentialsModel extends BaseModel, RequestAuthenticationModel { diff --git a/lib/errors/unsupported-token-type-error.js b/lib/errors/unsupported-token-type-error.js new file mode 100644 index 00000000..53a86778 --- /dev/null +++ b/lib/errors/unsupported-token-type-error.js @@ -0,0 +1,36 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const OAuthError = require('./oauth-error'); + +/** + * Constructor. + * + * "The authorization server does not support + * the revocation of the presented token type. That is, the + * client tried to revoke an access token on a server not + * supporting this feature." + * + * @see https://www.rfc-editor.org/rfc/rfc7009#section-2.2.1 + */ + +class UnsupportedTokenTypeError extends OAuthError { + constructor(message, properties) { + properties = { + code: 503, + name: 'unsupported_token_type', + ...properties + }; + + super(message, properties); + } +} + +/** + * Export constructor. + */ + +module.exports = UnsupportedTokenTypeError; diff --git a/lib/handlers/revoke-handler.js b/lib/handlers/revoke-handler.js new file mode 100644 index 00000000..de0c8b64 --- /dev/null +++ b/lib/handlers/revoke-handler.js @@ -0,0 +1,277 @@ +'use strict'; + +/** + * Module dependencies. + */ + +const InvalidArgumentError = require('../errors/invalid-argument-error'); +const InvalidClientError = require('../errors/invalid-client-error'); +const InvalidRequestError = require('../errors/invalid-request-error'); +const OAuthError = require('../errors/oauth-error'); +const UnsupportedTokenTypeError = require('../errors/unsupported-token-type-error'); +const Request = require('../request'); +const Response = require('../response'); +const ServerError = require('../errors/server-error'); +const auth = require('basic-auth'); +const isFormat = require('@node-oauth/formats'); + +/** + * Constructor. + */ + +class RevokeHandler { + constructor (options) { + options = options || {}; + + if (!options.model) { + throw new InvalidArgumentError('Missing parameter: `model`'); + } + + if (!options.model.getClient) { + throw new InvalidArgumentError('Invalid argument: model does not implement `getClient()`'); + } + + if (!options.model.revokeToken) { + throw new InvalidArgumentError('Invalid argument: model does not implement `revokeToken()`'); + } + + this.model = options.model; + } + + /** + * Revoke Handler. + * + * @see https://tools.ietf.org/html/rfc7009 + */ + + async handle (request, response) { + if (!(request instanceof Request)) { + throw new InvalidArgumentError('Invalid argument: `request` must be an instance of Request'); + } + + if (!(response instanceof Response)) { + throw new InvalidArgumentError('Invalid argument: `response` must be an instance of Response'); + } + + if (request.method !== 'POST') { + throw new InvalidRequestError('Invalid request: method must be POST'); + } + + try { + const client = await this.getClient(request, response); + + if (!client) { + throw new InvalidClientError('Invalid client: client is invalid'); + } + + const token = request.body.token; + + // An invalid token type hint value is ignored by the authorization + // server and does not influence the revocation response. + const tokenTypeHint = request.body.token_type_hint; + + if (!token) { + throw new InvalidRequestError('Missing parameter: `token`'); + } + + if (!isFormat.vschar(token)) { + throw new InvalidRequestError('Invalid parameter: `token`'); + } + + // Validate token_type_hint if provided + if (tokenTypeHint && tokenTypeHint !== 'access_token' && tokenTypeHint !== 'refresh_token') { + throw new UnsupportedTokenTypeError('Unsupported token_type_hint: ' + tokenTypeHint); + } + + // Try to find and revoke the token + await this.revokeToken(token, tokenTypeHint, client); + + // Per RFC 7009 section 2.2: return 200 OK even if token was invalid + // This prevents token enumeration attacks + this.updateSuccessResponse(response); + } catch (e) { + let error = e; + + if (!(error instanceof OAuthError)) { + error = new ServerError(error); + } + + // Include the "WWW-Authenticate" response header field if the client + // attempted to authenticate via the "Authorization" request header. + // + // @see https://tools.ietf.org/html/rfc6749#section-5.2. + if (error instanceof InvalidClientError && request.get('authorization')) { + response.set('WWW-Authenticate', 'Basic realm="Service"'); + throw new InvalidClientError(error, { code: 401 }); + } + + // For other errors, update the response but don't throw + // RFC 7009 says to return 200 OK even for invalid tokens, but we should + // still return errors for malformed requests or authentication failures + if (error instanceof InvalidRequestError || error instanceof InvalidClientError) { + this.updateErrorResponse(response, error); + throw error; + } + + // For other errors (like server errors), still return 200 OK per RFC 7009 + // but log the error + this.updateSuccessResponse(response); + } + } + + /** + * Get the client from the model. + */ + + async getClient (request, response) { + const credentials = await this.getClientCredentials(request); + + if (!credentials.clientId) { + throw new InvalidRequestError('Missing parameter: `client_id`'); + } + + if (!isFormat.vschar(credentials.clientId)) { + throw new InvalidRequestError('Invalid parameter: `client_id`'); + } + + if (credentials.clientSecret && !isFormat.vschar(credentials.clientSecret)) { + throw new InvalidRequestError('Invalid parameter: `client_secret`'); + } + + try { + const client = await this.model.getClient(credentials.clientId, credentials.clientSecret); + + if (!client) { + throw new InvalidClientError('Invalid client: client is invalid'); + } + + return client; + } catch (e) { + // Include the "WWW-Authenticate" response header field if the client + // attempted to authenticate via the "Authorization" request header. + // + // @see https://tools.ietf.org/html/rfc6749#section-5.2. + if ((e instanceof InvalidClientError) && request.get('authorization')) { + response.set('WWW-Authenticate', 'Basic realm="Service"'); + throw new InvalidClientError(e, { code: 401 }); + } + + throw e; + } + } + + /** + * Get client credentials. + * + * The client credentials may be sent using the HTTP Basic authentication scheme or, alternatively, + * the `client_id` and `client_secret` can be embedded in the body. + * + * @see https://tools.ietf.org/html/rfc6749#section-2.3.1 + */ + + getClientCredentials (request) { + const credentials = auth(request); + + if (credentials) { + return { clientId: credentials.name, clientSecret: credentials.pass }; + } + + if (request.body.client_id) { + return { clientId: request.body.client_id, clientSecret: request.body.client_secret }; + } + + throw new InvalidClientError('Invalid client: cannot retrieve client credentials'); + } + + /** + * Revoke the token. + * + * Attempts to find the token using the token_type_hint, then calls model.revokeToken(). + * Per RFC 7009, if the token cannot be found, we still return success to prevent + * token enumeration attacks. + */ + + async revokeToken (token, tokenTypeHint, client) { + let tokenToRevoke = null; + + // Try to find the token based on the hint + if (tokenTypeHint === 'refresh_token') { + // Try to get refresh token if model supports it + if (this.model.getRefreshToken) { + const refreshToken = await this.model.getRefreshToken(token); + if (refreshToken) { + // Verify the token belongs to the client + if (refreshToken.client && refreshToken.client.id === client.id) { + tokenToRevoke = refreshToken; + } + } + } + } else if (tokenTypeHint === 'access_token') { + // Try to get access token if model supports it + if (this.model.getAccessToken) { + const accessToken = await this.model.getAccessToken(token); + if (accessToken) { + // Verify the token belongs to the client + if (accessToken.client && accessToken.client.id === client.id) { + tokenToRevoke = accessToken; + } + } + } + } else { + // No hint provided, try both access token and refresh token + if (this.model.getAccessToken) { + const accessToken = await this.model.getAccessToken(token); + if (accessToken && accessToken.client && accessToken.client.id === client.id) { + tokenToRevoke = accessToken; + } + } + + // If access token not found, try refresh token + if (!tokenToRevoke && this.model.getRefreshToken) { + const refreshToken = await this.model.getRefreshToken(token); + if (refreshToken && refreshToken.client && refreshToken.client.id === client.id) { + tokenToRevoke = refreshToken; + } + } + } + + // If we found a token, revoke it + if (tokenToRevoke) { + await this.model.revokeToken(tokenToRevoke); + } + + // Per RFC 7009, we return success even if token was not found + // This prevents token enumeration attacks + } + + /** + * Update response when token is revoked successfully. + */ + + updateSuccessResponse (response) { + response.body = {}; + response.status = 200; + response.set('Cache-Control', 'no-store'); + response.set('Pragma', 'no-cache'); + } + + /** + * Update response when an error is thrown. + */ + + updateErrorResponse (response, error) { + response.body = { + error: error.name, + error_description: error.message + }; + + response.status = error.code; + } +} + +/** + * Export constructor. + */ + +module.exports = RevokeHandler; + diff --git a/lib/server.js b/lib/server.js index a2e31878..b5a86a3a 100644 --- a/lib/server.js +++ b/lib/server.js @@ -7,6 +7,7 @@ const AuthenticateHandler = require('./handlers/authenticate-handler'); const AuthorizeHandler = require('./handlers/authorize-handler'); const InvalidArgumentError = require('./errors/invalid-argument-error'); +const RevokeHandler = require('./handlers/revoke-handler'); const TokenHandler = require('./handlers/token-handler'); /** @@ -65,6 +66,10 @@ class OAuth2Server { return new TokenHandler(options).handle(request, response); } + + revoke (request, response) { + return new RevokeHandler(this.options).handle(request, response); + } } /** From 4abe326743823e652d33e00e89a39630ce1af0ef Mon Sep 17 00:00:00 2001 From: Rune Botten Date: Wed, 7 Jan 2026 13:02:03 -0800 Subject: [PATCH 2/7] fix(refresh-token): validate scope before revoking token #390 --- lib/grant-types/refresh-token-grant-type.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/grant-types/refresh-token-grant-type.js b/lib/grant-types/refresh-token-grant-type.js index 45237dbc..c66fee28 100644 --- a/lib/grant-types/refresh-token-grant-type.js +++ b/lib/grant-types/refresh-token-grant-type.js @@ -54,10 +54,12 @@ class RefreshTokenGrantType extends AbstractGrantType { let token; token = await this.getRefreshToken(request, client); - token = await this.revokeToken(token); + // Validate scope before revoking token to prevent destroying tokens on scope validation errors const scope = this.getScope(request, token); + token = await this.revokeToken(token); + return this.saveToken(token.user, client, scope); } From e971cfbd6b89b0de5a23ebcae081da75c53d7754 Mon Sep 17 00:00:00 2001 From: Rune Botten Date: Wed, 7 Jan 2026 14:47:25 -0800 Subject: [PATCH 3/7] test(refresh-token): add test for extra scope validation #390 --- .../refresh-token-grant-type_test.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/integration/grant-types/refresh-token-grant-type_test.js b/test/integration/grant-types/refresh-token-grant-type_test.js index 0619fefd..597e47b0 100644 --- a/test/integration/grant-types/refresh-token-grant-type_test.js +++ b/test/integration/grant-types/refresh-token-grant-type_test.js @@ -7,6 +7,7 @@ const InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); const InvalidGrantError = require('../../../lib/errors/invalid-grant-error'); const InvalidRequestError = require('../../../lib/errors/invalid-request-error'); +const InvalidScopeError = require('../../../lib/errors/invalid-scope-error'); const RefreshTokenGrantType = require('../../../lib/grant-types/refresh-token-grant-type'); const Request = require('../../../lib/request'); const ServerError = require('../../../lib/errors/server-error'); @@ -182,6 +183,34 @@ describe('RefreshTokenGrantType integration', function() { grantType.handle(request, client).should.be.an.instanceOf(Promise); }); + + it('should throw an error if extra `scope` is requested', async function() { + const client = { id: 123 }; + const token = { + accessToken: 'foo', + client: { id: 123 }, + user: { name: 'foo' }, + refreshTokenExpiresAt: new Date(new Date() * 2) + }; + const model = { + getRefreshToken: async function() { + return token; + }, + revokeToken: () => should.fail(), + saveToken: () => should.fail() + }; + const grantType = new RefreshTokenGrantType({ accessTokenLifetime: 123, model }); + const request = new Request({ body: { refresh_token: 'foobar', scope: 'read' }, headers: {}, method: {}, query: {} }); + + try { + await grantType.handle(request, client); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidScopeError); + e.message.should.equal('Invalid scope: Unable to add extra scopes'); + } + }); }); describe('getRefreshToken()', function() { From 0565522a7567094a96aceb9c9a27f0cb73338fc8 Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Mon, 12 Jan 2026 12:40:34 +0100 Subject: [PATCH 4/7] release 5.2.2-rc.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4eddd526..1953980a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@node-oauth/oauth2-server", - "version": "5.2.0", + "version": "5.2.2-rc.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@node-oauth/oauth2-server", - "version": "5.2.0", + "version": "5.2.2-rc.0", "license": "MIT", "dependencies": { "@node-oauth/formats": "1.0.0", diff --git a/package.json b/package.json index 3e773f5f..6ad85d9c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@node-oauth/oauth2-server", "description": "Complete, framework-agnostic, compliant and well tested module for implementing an OAuth2 Server in node.js", - "version": "5.2.0", + "version": "5.2.2-rc.0", "keywords": [ "oauth", "oauth2" From f3b5610d0dff3ddaf048a83e1b6663816bca3e28 Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Fri, 23 Jan 2026 08:48:18 +0100 Subject: [PATCH 5/7] fix: eslint fix --- lib/handlers/revoke-handler.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/handlers/revoke-handler.js b/lib/handlers/revoke-handler.js index de0c8b64..6bb6f4df 100644 --- a/lib/handlers/revoke-handler.js +++ b/lib/handlers/revoke-handler.js @@ -1,6 +1,6 @@ 'use strict'; -/** +/* * Module dependencies. */ @@ -16,10 +16,18 @@ const auth = require('basic-auth'); const isFormat = require('@node-oauth/formats'); /** - * Constructor. + * A revocation request will invalidate the actual token and, if applicable, other + * tokens based on the same authorization grant and the authorization + * grant itself. + * + * @see https://tools.ietf.org/html/rfc7009 */ - class RevokeHandler { + /** + * Constructor. + * @constructor + * @param options + */ constructor (options) { options = options || {}; @@ -237,7 +245,7 @@ class RevokeHandler { // If we found a token, revoke it if (tokenToRevoke) { - await this.model.revokeToken(tokenToRevoke); + await this.model.revokeToken(tokenToRevoke); } // Per RFC 7009, we return success even if token was not found From e718147add57d7ca6f26a1de63fdcf72f627690a Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Mon, 26 Jan 2026 12:00:30 +0100 Subject: [PATCH 6/7] tests: improve converage for RevokeHandler --- lib/handlers/revoke-handler.js | 68 +++--- lib/server.js | 6 + test/unit/handlers/revoke-handler_test.js | 264 ++++++++++++++++++++++ 3 files changed, 311 insertions(+), 27 deletions(-) create mode 100644 test/unit/handlers/revoke-handler_test.js diff --git a/lib/handlers/revoke-handler.js b/lib/handlers/revoke-handler.js index 6bb6f4df..5e00ff6d 100644 --- a/lib/handlers/revoke-handler.js +++ b/lib/handlers/revoke-handler.js @@ -26,7 +26,9 @@ class RevokeHandler { /** * Constructor. * @constructor - * @param options + * @param options {object} + * @param options.model {object} An object containing the required model methods. + * @throws {InvalidArgumentError} Thrown if required model methods are missing. */ constructor (options) { options = options || {}; @@ -46,6 +48,14 @@ class RevokeHandler { this.model = options.model; } + /** + * The supported token types that may be revoked. + * @return {string[]} + */ + static get TOKEN_TYPES () { + return ['access_token', 'refresh_token']; + } + /** * Revoke Handler. * @@ -67,32 +77,10 @@ class RevokeHandler { try { const client = await this.getClient(request, response); - - if (!client) { - throw new InvalidClientError('Invalid client: client is invalid'); - } - - const token = request.body.token; - - // An invalid token type hint value is ignored by the authorization - // server and does not influence the revocation response. - const tokenTypeHint = request.body.token_type_hint; - - if (!token) { - throw new InvalidRequestError('Missing parameter: `token`'); - } - - if (!isFormat.vschar(token)) { - throw new InvalidRequestError('Invalid parameter: `token`'); - } - - // Validate token_type_hint if provided - if (tokenTypeHint && tokenTypeHint !== 'access_token' && tokenTypeHint !== 'refresh_token') { - throw new UnsupportedTokenTypeError('Unsupported token_type_hint: ' + tokenTypeHint); - } + const { token, tokenTypeHint } = this.getToken(request); // Try to find and revoke the token - await this.revokeToken(token, tokenTypeHint, client); + await this.revokeToken({ token, tokenTypeHint, client }); // Per RFC 7009 section 2.2: return 200 OK even if token was invalid // This prevents token enumeration attacks @@ -128,7 +116,7 @@ class RevokeHandler { } /** - * Get the client from the model. + * Get the client from the model and validate it. */ async getClient (request, response) { @@ -191,6 +179,32 @@ class RevokeHandler { throw new InvalidClientError('Invalid client: cannot retrieve client credentials'); } + /** + * Extract and validate token from request + * @param request {Request} + * @return {{ token: string, token_type_hint: string|undefined}} An object containing the token and token type hint. + */ + getToken (request) { + const token = request.body.token; + + if (!token) { + throw new InvalidRequestError('Missing parameter: `token`'); + } + + if (!isFormat.vschar(token)) { + throw new InvalidRequestError('Invalid parameter: `token`'); + } + + // An invalid token type hint value is ignored by the authorization + // server and does not influence the revocation response. + let tokenTypeHint = request.body.token_type_hint; + if (!RevokeHandler.TOKEN_TYPES.includes(tokenTypeHint)) { + tokenTypeHint = undefined; + } + + return { token, tokenTypeHint }; + } + /** * Revoke the token. * @@ -199,7 +213,7 @@ class RevokeHandler { * token enumeration attacks. */ - async revokeToken (token, tokenTypeHint, client) { + async revokeToken ({ token, tokenTypeHint, client }) { let tokenToRevoke = null; // Try to find the token based on the hint diff --git a/lib/server.js b/lib/server.js index 7a136a04..a3622be4 100644 --- a/lib/server.js +++ b/lib/server.js @@ -235,6 +235,12 @@ class OAuth2Server { return new TokenHandler(options).handle(request, response); } + /** + * Revokes an access or refresh token, as defined in RFC 7009. + * @param request {Request} + * @param response {Response} + * @return {Promise} + */ revoke (request, response) { return new RevokeHandler(this.options).handle(request, response); } diff --git a/test/unit/handlers/revoke-handler_test.js b/test/unit/handlers/revoke-handler_test.js new file mode 100644 index 00000000..7a162ca8 --- /dev/null +++ b/test/unit/handlers/revoke-handler_test.js @@ -0,0 +1,264 @@ +'use strict'; + +/* + * Module dependencies. + */ +const Request = require('../../../lib/request'); +const Model = require('../../../lib/model'); +const RevokeHandler = require('../../../lib/handlers/revoke-handler'); +const sinon = require('sinon'); +const InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); +const InvalidClientError = require('../../../lib/errors/invalid-client-error') +const InvalidRequestError = require('../../../lib/errors/invalid-request-error') +const should = require('chai').should(); + +/** + * Test `TokenHandler`. + */ + +describe('RevokeHandler', () => { + describe('constructor()', () => { + it('should throw an error if `model` is missing', () => { + (() => { + new RevokeHandler({}); + }).should.throw(InvalidArgumentError, 'Missing parameter: `model`'); + }); + it('should throw an error if `model` does not implement `getClient()`', () => { + (() => { + new RevokeHandler({ model: {} }); + }).should.throw(InvalidArgumentError, 'Invalid argument: model does not implement `getClient()`'); + }); + it('should throw an error if `model` does not implement `revokeToken()`', () => { + (() => { + new RevokeHandler({ + model: { + getClient: () => {} + } + }); + }).should.throw(InvalidArgumentError, 'Invalid argument: model does not implement `revokeToken()`'); + }); + }); + describe('getClient()', () => { + it('should call `model.getClient()`', async () => { + const model = Model.from({ + getClient: sinon.stub().returns({ grants: ['password'] }), + saveToken: () => { + }, + revokeToken: () => { + } + }); + const handler = new RevokeHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + const request = new Request({ + body: { client_id: 12345, client_secret: 'secret' }, + headers: {}, + method: {}, + query: {} + }); + + await handler.getClient(request); + model.getClient.callCount.should.equal(1); + model.getClient.firstCall.args.should.have.length(2); + model.getClient.firstCall.args[0].should.equal(12345); + model.getClient.firstCall.args[1].should.equal('secret'); + model.getClient.firstCall.thisValue.should.equal(model); + }); + it('throws an error if the client is invalid', async () => { + const model = Model.from({ + getClient: sinon.stub().returns(null), + saveToken: () => { + }, + revokeToken: () => { + } + }); + const handler = new RevokeHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + const request = new Request({ + body: { client_id: 12345, client_secret: 'secret' }, + headers: {}, + method: {}, + query: {} + }); + + try { + await handler.getClient(request); + should.fail(); + } catch (e) { + e.should.be.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: client is invalid'); + } + }); + it('throws an error if client id is using invalid chars', async () => { + const model = Model.from({ + getClient: sinon.stub().returns({ grants: ['password'] }), + saveToken: () => { + }, + revokeToken: () => { + } + }); + const handler = new RevokeHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + const request = new Request({ + body: { client_id: '12😵345', client_secret: 'secret' }, + headers: {}, + method: {}, + query: {} + }); + + try { + await handler.getClient(request); + should.fail(); + } catch (e) { + e.should.be.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `client_id`'); + } + }); + it ('throws an error if client_secret is usind invalid chars', async () => { + const model = Model.from({ + getClient: sinon.stub().returns({ grants: ['password'] }), + saveToken: () => { + }, + revokeToken: () => { + } + }); + const handler = new RevokeHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + const request = new Request({ + body: { client_id: '12345', client_secret: 'sec😵ret' }, + headers: {}, + method: {}, + query: {} + }); + + try { + await handler.getClient(request); + should.fail(); + } catch (e) { + e.should.be.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `client_secret`'); + } + }); + }); + describe('getClientCredentials()', () => { + it('should throw an error if client credentials are missing on confidential clients', async () => { + const model = Model.from({ + getClient: sinon.stub().returns({ grants: ['client_credentials'] }), + saveToken: () => { + }, + revokeToken: () => { + } + }); + const handler = new RevokeHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + const request = new Request({ body: {}, headers: {}, method: {}, query: {} }); + + try { + await handler.getClientCredentials(request); + should.fail(); + } catch (e) { + e.should.be.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: cannot retrieve client credentials'); + } + }); + }); + describe('updateSuccessResponse', () => { + it('updates the response with success information', () => { + const model = Model.from({ + getClient: sinon.stub().returns({ grants: ['password'] }), + saveToken: () => { + }, + revokeToken: () => { + } + }); + const handler = new RevokeHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + const response = { + set: sinon.spy() + }; + + handler.updateSuccessResponse(response); + response.body.should.deep.equal({}); + response.status.should.equal(200); + response.set.callCount.should.equal(2); + response.set.firstCall.args.should.have.length(2); + response.set.firstCall.args[0].should.equal('Cache-Control'); + response.set.firstCall.args[1].should.equal('no-store'); + response.set.secondCall.args.should.have.length(2); + response.set.secondCall.args[0].should.equal('Pragma'); + response.set.secondCall.args[1].should.equal('no-cache'); + }); + }); + describe('updateErrorResponse', () => { + it('updates the response with error information', () => { + const model = Model.from({ + getClient: sinon.stub().returns({ grants: ['password'] }), + saveToken: () => { + }, + revokeToken: () => { + } + }); + const handler = new RevokeHandler({ model: model }); + const response = { + set: sinon.spy() + }; + + handler.updateErrorResponse(response, new InvalidRequestError('Invalid request 123')); + response.body.should.deep.equal({ + error: 'invalid_request', + error_description: 'Invalid request 123' + }); + response.status.should.equal(400); + }); + }); + describe('getToken', () => { + const model = Model.from({ + getClient: () => {}, + saveToken: () => {}, + revokeToken: () => {} + }); + const handler = new RevokeHandler({ model}); + + it('throws an error if the token is missing'); + it('throws an error if the token is in an invalid format', () => { + const missing = [false, null, undefined, '', 0]; + missing.forEach((token) => { + try { + handler.getToken({ body: { token } }); + should.fail(); + } catch (e) { + e.should.be.instanceOf(InvalidRequestError); + e.message.should.equal('Missing parameter: `token`'); + } + }); + const invalid = ['123❤️45', {}, [], true]; + invalid.forEach((token) => { + try { + handler.getToken({ body: { token } }); + should.fail(); + } catch (e) { + e.should.be.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `token`'); + } + }); + }); + it('returns the token and token_type_hint from the request body, if defined and valid', () => { + + const token = '123445'; + const invalid = [undefined, null, 'invalid', 'refresh-token', 'access-token']; + invalid.forEach((tokenTypeHint) => { + const result = handler.getToken({ body: { token, token_type_hing: tokenTypeHint } }); + result.token.should.equal(token); + should.equal(result.tokenTypeHint, undefined); + }); + + const valid = ['access_token', 'refresh_token']; + valid.forEach((tokenTypeHint) => { + const result = handler.getToken({ body: { token, token_type_hint: tokenTypeHint } }); + result.token.should.equal(token); + result.tokenTypeHint.should.equal(tokenTypeHint); + }); + }); + }); + describe('revokeToken', () => { + it('it ignores invalid token_type_hint values'); + it('it revokes access tokens when token_type_hint is access_token'); + it('it revokes refresh tokens when token_type_hint is refresh_token'); + it('it revokes tokens without a token_type_hint'); + it('it does not revoke tokens belonging to other clients'); + it('it does not throw an error if the token to be revoked is not found'); + }); +}); \ No newline at end of file From 6bd6d039b913e73a15f7dee4e01dc8760845b9bb Mon Sep 17 00:00:00 2001 From: jankapunkt Date: Mon, 26 Jan 2026 12:01:35 +0100 Subject: [PATCH 7/7] fix: lint fix --- lib/handlers/revoke-handler.js | 3 ++- test/unit/handlers/revoke-handler_test.js | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/handlers/revoke-handler.js b/lib/handlers/revoke-handler.js index 5e00ff6d..0c8dfa85 100644 --- a/lib/handlers/revoke-handler.js +++ b/lib/handlers/revoke-handler.js @@ -8,7 +8,8 @@ const InvalidArgumentError = require('../errors/invalid-argument-error'); const InvalidClientError = require('../errors/invalid-client-error'); const InvalidRequestError = require('../errors/invalid-request-error'); const OAuthError = require('../errors/oauth-error'); -const UnsupportedTokenTypeError = require('../errors/unsupported-token-type-error'); +// const UnsupportedTokenTypeError = require('../errors/unsupported-token-type-error'); +// TODO: check RFC about this error and where to use it const Request = require('../request'); const Response = require('../response'); const ServerError = require('../errors/server-error'); diff --git a/test/unit/handlers/revoke-handler_test.js b/test/unit/handlers/revoke-handler_test.js index 7a162ca8..e8fd7ec9 100644 --- a/test/unit/handlers/revoke-handler_test.js +++ b/test/unit/handlers/revoke-handler_test.js @@ -8,8 +8,8 @@ const Model = require('../../../lib/model'); const RevokeHandler = require('../../../lib/handlers/revoke-handler'); const sinon = require('sinon'); const InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); -const InvalidClientError = require('../../../lib/errors/invalid-client-error') -const InvalidRequestError = require('../../../lib/errors/invalid-request-error') +const InvalidClientError = require('../../../lib/errors/invalid-client-error'); +const InvalidRequestError = require('../../../lib/errors/invalid-request-error'); const should = require('chai').should(); /**