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/grant-types/refresh-token-grant-type.js b/lib/grant-types/refresh-token-grant-type.js index 1f62f7ec..54b04222 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); } diff --git a/lib/handlers/revoke-handler.js b/lib/handlers/revoke-handler.js new file mode 100644 index 00000000..6bb6f4df --- /dev/null +++ b/lib/handlers/revoke-handler.js @@ -0,0 +1,285 @@ +'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'); + +/** + * 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 || {}; + + 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 34658335..7a136a04 100644 --- a/lib/server.js +++ b/lib/server.js @@ -6,6 +6,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'); // we require the model only for JSDoc linking require('./model'); @@ -233,6 +234,10 @@ class OAuth2Server { return new TokenHandler(options).handle(request, response); } + + revoke (request, response) { + return new RevokeHandler(this.options).handle(request, response); + } } module.exports = OAuth2Server; diff --git a/package-lock.json b/package-lock.json index ac2ad4ed..7a36c49a 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 ea25a5b4..c3811282 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" 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 658983aa..98a2f852 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 Model = require('../../../lib/model'); const Request = require('../../../lib/request'); @@ -183,6 +184,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() {