Skip to content

Commit ac58a02

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

File tree

4 files changed

+332
-6
lines changed

4 files changed

+332
-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 {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
/**
4+
* Module dependencies.
5+
*/
6+
7+
const OAuthError = require('./oauth-error');
8+
9+
/**
10+
* Constructor.
11+
*
12+
* "The authorization server does not support
13+
* the revocation of the presented token type. That is, the
14+
* client tried to revoke an access token on a server not
15+
* supporting this feature."
16+
*
17+
* @see https://www.rfc-editor.org/rfc/rfc7009#section-2.2.1
18+
*/
19+
20+
class UnsupportedTokenTypeError extends OAuthError {
21+
constructor(message, properties) {
22+
properties = {
23+
code: 503,
24+
name: 'unsupported_token_type',
25+
...properties
26+
};
27+
28+
super(message, properties);
29+
}
30+
}
31+
32+
/**
33+
* Export constructor.
34+
*/
35+
36+
module.exports = UnsupportedTokenTypeError;

lib/handlers/revoke-handler.js

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

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)