From ac58a0217a3a3f33b383c6344e7474f6e3f15735 Mon Sep 17 00:00:00 2001 From: wille Date: Fri, 14 Nov 2025 15:58:34 +0100 Subject: [PATCH] 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); + } } /**