Skip to content

Commit e099346

Browse files
committed
OAuth 2.0 Token Revocation
1 parent fa2dde4 commit e099346

File tree

3 files changed

+290
-6
lines changed

3 files changed

+290
-6
lines changed

index.d.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ declare class OAuth2Server {
4545
response: OAuth2Server.Response,
4646
options?: OAuth2Server.TokenOptions
4747
): Promise<OAuth2Server.Token>;
48+
49+
/**
50+
* Revokes a token (RFC 7009).
51+
*/
52+
revoke(
53+
request: OAuth2Server.Request,
54+
response: OAuth2Server.Response,
55+
): Promise<void>;
4856
}
4957

5058
declare namespace OAuth2Server {
@@ -265,6 +273,12 @@ declare namespace OAuth2Server {
265273
*
266274
*/
267275
saveToken(token: Token, client: Client, user: User): Promise<Token | Falsey>;
276+
277+
/**
278+
* Invoked to revoke a token.
279+
*
280+
*/
281+
revokeToken(token: Token | RefreshToken): Promise<boolean>;
268282
}
269283

270284
interface RequestAuthenticationModel {
@@ -362,12 +376,6 @@ declare namespace OAuth2Server {
362376
*
363377
*/
364378
getRefreshToken(refreshToken: string): Promise<RefreshToken | Falsey>;
365-
366-
/**
367-
* Invoked to revoke a refresh token.
368-
*
369-
*/
370-
revokeToken(token: RefreshToken): Promise<boolean>;
371379
}
372380

373381
interface ClientCredentialsModel extends BaseModel, RequestAuthenticationModel {

lib/handlers/revoke-handler.js

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
'use strict';
2+
3+
/**
4+
* Module dependencies.
5+
*/
6+
7+
const InvalidArgumentError = require('../errors/invalid-argument-error');
8+
const InvalidClientError = require('../errors/invalid-client-error');
9+
const InvalidRequestError = require('../errors/invalid-request-error');
10+
const OAuthError = require('../errors/oauth-error');
11+
const Request = require('../request');
12+
const Response = require('../response');
13+
const ServerError = require('../errors/server-error');
14+
const auth = require('basic-auth');
15+
const isFormat = require('@node-oauth/formats');
16+
17+
/**
18+
* Constructor.
19+
*/
20+
21+
class RevokeHandler {
22+
constructor (options) {
23+
options = options || {};
24+
25+
if (!options.model) {
26+
throw new InvalidArgumentError('Missing parameter: `model`');
27+
}
28+
29+
if (!options.model.getClient) {
30+
throw new InvalidArgumentError('Invalid argument: model does not implement `getClient()`');
31+
}
32+
33+
if (!options.model.revokeToken) {
34+
throw new InvalidArgumentError('Invalid argument: model does not implement `revokeToken()`');
35+
}
36+
37+
this.model = options.model;
38+
}
39+
40+
/**
41+
* Revoke Handler.
42+
*
43+
* @see https://tools.ietf.org/html/rfc7009
44+
*/
45+
46+
async handle (request, response) {
47+
if (!(request instanceof Request)) {
48+
throw new InvalidArgumentError('Invalid argument: `request` must be an instance of Request');
49+
}
50+
51+
if (!(response instanceof Response)) {
52+
throw new InvalidArgumentError('Invalid argument: `response` must be an instance of Response');
53+
}
54+
55+
if (request.method !== 'POST') {
56+
throw new InvalidRequestError('Invalid request: method must be POST');
57+
}
58+
59+
try {
60+
const client = await this.getClient(request, response);
61+
62+
if (!client) {
63+
throw new InvalidClientError('Invalid client: client is invalid');
64+
}
65+
66+
const token = request.body.token;
67+
68+
// An invalid token type hint value is ignored by the authorization
69+
// server and does not influence the revocation response.
70+
const tokenTypeHint = request.body.token_type_hint;
71+
72+
if (!token) {
73+
throw new InvalidRequestError('Missing parameter: `token`');
74+
}
75+
76+
if (!isFormat.vschar(token)) {
77+
throw new InvalidRequestError('Invalid parameter: `token`');
78+
}
79+
80+
// Try to find and revoke the token
81+
await this.revokeToken(token, tokenTypeHint, client);
82+
83+
// Per RFC 7009 section 2.2: return 200 OK even if token was invalid
84+
// This prevents token enumeration attacks
85+
this.updateSuccessResponse(response);
86+
} catch (e) {
87+
let error = e;
88+
89+
if (!(error instanceof OAuthError)) {
90+
error = new ServerError(error);
91+
}
92+
93+
// Include the "WWW-Authenticate" response header field if the client
94+
// attempted to authenticate via the "Authorization" request header.
95+
//
96+
// @see https://tools.ietf.org/html/rfc6749#section-5.2.
97+
if (error instanceof InvalidClientError && request.get('authorization')) {
98+
response.set('WWW-Authenticate', 'Basic realm="Service"');
99+
throw new InvalidClientError(error, { code: 401 });
100+
}
101+
102+
// For other errors, update the response but don't throw
103+
// RFC 7009 says to return 200 OK even for invalid tokens, but we should
104+
// still return errors for malformed requests or authentication failures
105+
if (error instanceof InvalidRequestError || error instanceof InvalidClientError) {
106+
this.updateErrorResponse(response, error);
107+
throw error;
108+
}
109+
110+
// For other errors (like server errors), still return 200 OK per RFC 7009
111+
// but log the error
112+
this.updateSuccessResponse(response);
113+
}
114+
}
115+
116+
/**
117+
* Get the client from the model.
118+
*/
119+
120+
async getClient (request, response) {
121+
const credentials = await this.getClientCredentials(request);
122+
123+
if (!credentials.clientId) {
124+
throw new InvalidRequestError('Missing parameter: `client_id`');
125+
}
126+
127+
if (!isFormat.vschar(credentials.clientId)) {
128+
throw new InvalidRequestError('Invalid parameter: `client_id`');
129+
}
130+
131+
if (credentials.clientSecret && !isFormat.vschar(credentials.clientSecret)) {
132+
throw new InvalidRequestError('Invalid parameter: `client_secret`');
133+
}
134+
135+
try {
136+
const client = await this.model.getClient(credentials.clientId, credentials.clientSecret);
137+
138+
if (!client) {
139+
throw new InvalidClientError('Invalid client: client is invalid');
140+
}
141+
142+
return client;
143+
} catch (e) {
144+
// Include the "WWW-Authenticate" response header field if the client
145+
// attempted to authenticate via the "Authorization" request header.
146+
//
147+
// @see https://tools.ietf.org/html/rfc6749#section-5.2.
148+
if ((e instanceof InvalidClientError) && request.get('authorization')) {
149+
response.set('WWW-Authenticate', 'Basic realm="Service"');
150+
throw new InvalidClientError(e, { code: 401 });
151+
}
152+
153+
throw e;
154+
}
155+
}
156+
157+
/**
158+
* Get client credentials.
159+
*
160+
* The client credentials may be sent using the HTTP Basic authentication scheme or, alternatively,
161+
* the `client_id` and `client_secret` can be embedded in the body.
162+
*
163+
* @see https://tools.ietf.org/html/rfc6749#section-2.3.1
164+
*/
165+
166+
getClientCredentials (request) {
167+
const credentials = auth(request);
168+
169+
if (credentials) {
170+
return { clientId: credentials.name, clientSecret: credentials.pass };
171+
}
172+
173+
if (request.body.client_id) {
174+
return { clientId: request.body.client_id, clientSecret: request.body.client_secret };
175+
}
176+
177+
throw new InvalidClientError('Invalid client: cannot retrieve client credentials');
178+
}
179+
180+
/**
181+
* Revoke the token.
182+
*
183+
* Attempts to find the token using the token_type_hint, then calls model.revokeToken().
184+
* Per RFC 7009, if the token cannot be found, we still return success to prevent
185+
* token enumeration attacks.
186+
*/
187+
188+
async revokeToken (token, tokenTypeHint, client) {
189+
let tokenToRevoke = null;
190+
191+
// Try to find the token based on the hint
192+
if (tokenTypeHint === 'refresh_token') {
193+
// Try to get refresh token if model supports it
194+
if (this.model.getRefreshToken) {
195+
const refreshToken = await this.model.getRefreshToken(token);
196+
if (refreshToken) {
197+
// Verify the token belongs to the client
198+
if (refreshToken.client && refreshToken.client.id === client.id) {
199+
tokenToRevoke = refreshToken;
200+
}
201+
}
202+
}
203+
} else if (tokenTypeHint === 'access_token') {
204+
// Try to get access token if model supports it
205+
if (this.model.getAccessToken) {
206+
const accessToken = await this.model.getAccessToken(token);
207+
if (accessToken) {
208+
// Verify the token belongs to the client
209+
if (accessToken.client && accessToken.client.id === client.id) {
210+
tokenToRevoke = accessToken;
211+
}
212+
}
213+
}
214+
} else {
215+
// No hint provided, try both access token and refresh token
216+
if (this.model.getAccessToken) {
217+
const accessToken = await this.model.getAccessToken(token);
218+
if (accessToken && accessToken.client && accessToken.client.id === client.id) {
219+
tokenToRevoke = accessToken;
220+
}
221+
}
222+
223+
// If access token not found, try refresh token
224+
if (!tokenToRevoke && this.model.getRefreshToken) {
225+
const refreshToken = await this.model.getRefreshToken(token);
226+
if (refreshToken && refreshToken.client && refreshToken.client.id === client.id) {
227+
tokenToRevoke = refreshToken;
228+
}
229+
}
230+
}
231+
232+
// If we found a token, revoke it
233+
if (tokenToRevoke) {
234+
await this.model.revokeToken(tokenToRevoke);
235+
}
236+
237+
// Per RFC 7009, we return success even if token was not found
238+
// This prevents token enumeration attacks
239+
}
240+
241+
/**
242+
* Update response when token is revoked successfully.
243+
*/
244+
245+
updateSuccessResponse (response) {
246+
response.body = {};
247+
response.status = 200;
248+
response.set('Cache-Control', 'no-store');
249+
response.set('Pragma', 'no-cache');
250+
}
251+
252+
/**
253+
* Update response when an error is thrown.
254+
*/
255+
256+
updateErrorResponse (response, error) {
257+
response.body = {
258+
error: error.name,
259+
error_description: error.message
260+
};
261+
262+
response.status = error.code;
263+
}
264+
}
265+
266+
/**
267+
* Export constructor.
268+
*/
269+
270+
module.exports = RevokeHandler;
271+

lib/server.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
const AuthenticateHandler = require('./handlers/authenticate-handler');
88
const AuthorizeHandler = require('./handlers/authorize-handler');
99
const InvalidArgumentError = require('./errors/invalid-argument-error');
10+
const RevokeHandler = require('./handlers/revoke-handler');
1011
const TokenHandler = require('./handlers/token-handler');
1112

1213
/**
@@ -65,6 +66,10 @@ class OAuth2Server {
6566

6667
return new TokenHandler(options).handle(request, response);
6768
}
69+
70+
revoke (request, response) {
71+
return new RevokeHandler(this.options).handle(request, response);
72+
}
6873
}
6974

7075
/**

0 commit comments

Comments
 (0)