From 84a45264507ae0563466165d132efc8e45cb4b68 Mon Sep 17 00:00:00 2001 From: Adam Savitzky Date: Wed, 25 Oct 2023 11:32:55 -0700 Subject: [PATCH 1/8] Add support for token revocation --- src/client.ts | 38 +++++++++++++++++++++++++++++++++++++- src/messages.ts | 7 +++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 424a071..60cd067 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,7 +5,10 @@ import { IntrospectionRequest, IntrospectionResponse, PasswordRequest, + OAuth2TokenTypeHint, RefreshRequest, + RevocationRequest, + RevocationResponse, ServerMetadataResponse, TokenResponse, } from './messages'; @@ -61,6 +64,14 @@ export interface ClientSettings { */ introspectionEndpoint?: string; + /** + * Revocatopm endpoint. + * + * Required for revoking tokens. Not supported by all servers. + * If not provided we'll try to discover it, or otherwise default to /revoke + */ + revocationEndpoint?: string; + /** * OAuth 2.0 Authorization Server Metadata endpoint or OpenID * Connect Discovery 1.0 endpoint. @@ -92,7 +103,7 @@ export interface ClientSettings { } -type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint'; +type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint' | 'revocationEndpoint'; export class OAuth2Client { @@ -197,6 +208,27 @@ export class OAuth2Client { } + /** + * Revoke a token + * + * This will revoke a token, provided that the server supports this feature. + * + * @see https://datatracker.ietf.org/doc/html/rfc7662 + */ + async revoke(token: OAuth2Token, tokenTypeHint: OAuth2TokenTypeHint = 'access_token'): Promise { + let tokenValue = token.accessToken; + if (tokenTypeHint === 'refresh_token') { + tokenValue = token.refreshToken!; + } + + const body: RevocationRequest = { + token: tokenValue, + token_type_hint: tokenTypeHint, + }; + return this.request('revocationEndpoint', body); + + } + /** * Returns a url for an OAuth2 endpoint. * @@ -230,6 +262,8 @@ export class OAuth2Client { return resolve('/.well-known/oauth-authorization-server', this.settings.server); case 'introspectionEndpoint': return resolve('/introspect', this.settings.server); + case 'revocationEndpoint': + return resolve('/revoke', this.settings.server); } } @@ -267,6 +301,7 @@ export class OAuth2Client { ['authorization_endpoint', 'authorizationEndpoint'], ['token_endpoint', 'tokenEndpoint'], ['introspection_endpoint', 'introspectionEndpoint'], + ['revocation_endpoint', 'revocationEndpoint'], ] as const; if (this.serverMetadata === null) return; @@ -287,6 +322,7 @@ export class OAuth2Client { */ async request(endpoint: 'tokenEndpoint', body: RefreshRequest | ClientCredentialsRequest | PasswordRequest | AuthorizationCodeRequest): Promise; async request(endpoint: 'introspectionEndpoint', body: IntrospectionRequest): Promise; + async request(endpoint: 'revocationEndpoint', body: RevocationRequest): Promise; async request(endpoint: OAuth2Endpoint, body: Record): Promise { const uri = await this.getEndpoint(endpoint); diff --git a/src/messages.ts b/src/messages.ts index 3d4636c..506f64a 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -266,3 +266,10 @@ export type IntrospectionResponse = { jti?: string; } + +export type RevocationRequest = { + token: string; + token_type_hint?: OAuth2TokenTypeHint +} + +export type RevocationResponse = {} From 4b22de662f462b1b4f8a2b0bf84c7c48a061a490 Mon Sep 17 00:00:00 2001 From: Adam Savitzky Date: Wed, 25 Oct 2023 11:43:09 -0700 Subject: [PATCH 2/8] Fix RFC url --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 60cd067..d12b845 100644 --- a/src/client.ts +++ b/src/client.ts @@ -213,7 +213,7 @@ export class OAuth2Client { * * This will revoke a token, provided that the server supports this feature. * - * @see https://datatracker.ietf.org/doc/html/rfc7662 + * @see https://datatracker.ietf.org/doc/html/rfc7009 */ async revoke(token: OAuth2Token, tokenTypeHint: OAuth2TokenTypeHint = 'access_token'): Promise { let tokenValue = token.accessToken; From 714eaeabc5ea6db95694e14c0eaa89c7a4922fdc Mon Sep 17 00:00:00 2001 From: Adam Savitzky Date: Mon, 30 Oct 2023 10:35:17 -0700 Subject: [PATCH 3/8] Fix typo in comments --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index d12b845..058c9e3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -65,7 +65,7 @@ export interface ClientSettings { introspectionEndpoint?: string; /** - * Revocatopm endpoint. + * Revocation endpoint. * * Required for revoking tokens. Not supported by all servers. * If not provided we'll try to discover it, or otherwise default to /revoke From c94f27f36b2c028b99b950b6625fc54a94a98b17 Mon Sep 17 00:00:00 2001 From: Adam Savitzky Date: Mon, 30 Oct 2023 11:00:05 -0700 Subject: [PATCH 4/8] Add tests for revocation --- test/client.ts | 71 +++++++++++++++++++++++++++++++++++++++++++++ test/test-server.ts | 26 +++++++++++++++++ 2 files changed, 97 insertions(+) create mode 100644 test/client.ts diff --git a/test/client.ts b/test/client.ts new file mode 100644 index 0000000..bc15b9d --- /dev/null +++ b/test/client.ts @@ -0,0 +1,71 @@ +import { testServer } from './test-server'; +import { OAuth2Client } from '../src'; +import { expect } from 'chai'; + +describe('OAuth2Client', () => { + const server = testServer(); + + describe('Token revocation', () => { + it('should revoke access token when requested', async () => { + + const client = new OAuth2Client({ + server: server.url, + tokenEndpoint: '/token', + revocationEndpoint: '/revoke', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }); + + const token = await client.clientCredentials(); + + describe('When token type hint is not specified', () => { + it('should assume token type is access token', async () => { + await client.revoke(token); + + const request = server.lastRequest(); + expect(request.body).to.eql({ + token: token.accessToken, + token_type_hint: 'access_token', + }); + }); + }); + + describe('When token type is specified as access token', () => { + it('should supply access token', async () => { + await client.revoke(token, 'access_token'); + + const request = server.lastRequest(); + expect(request.body).to.eql({ + token: token.accessToken, + token_type_hint: 'access_token', + }); + }); + }); + + describe('When token type is specified as refresh token', () => { + it('should supply access token', async () => { + await client.revoke(token, 'refresh_token'); + + const request = server.lastRequest(); + expect(request.body).to.eql({ + token: token.refreshToken, + token_type_hint: 'refresh_token', + }); + }); + }); + }); + }); + + describe('Discovery', () => { + const client = new OAuth2Client({ + server: server.url, + discoveryEndpoint: '/discover', + clientId: 'test-client-id', + }); + + it('Should discover revocation endpoint', async () => { + const result = await client.getEndpoint('revocationEndpoint'); + expect(result).to.equal(server.url + '/revoke'); + }); + }); +}); diff --git a/test/test-server.ts b/test/test-server.ts index df6ddb8..73f9adf 100644 --- a/test/test-server.ts +++ b/test/test-server.ts @@ -27,6 +27,8 @@ export function testServer() { return next(); }); app.use(issueToken); + app.use(revokeToken); + app.use(discover); const port = 40000 + Math.round(Math.random()*9999); const server = app.listen(port); @@ -65,3 +67,27 @@ const issueToken: Middleware = (ctx, next) => { }; }; + + +const revokeToken: Middleware = (ctx, next) => { + + if (ctx.path !== '/revoke') { + return next(); + } + + ctx.response.type = 'application/json'; + ctx.response.body = {}; +}; + + +const discover: Middleware = (ctx, next) => { + + if (ctx.path !== '/discover') { + return next(); + } + + ctx.response.type = 'application/json'; + ctx.response.body = { + revocation_endpoint: '/revoke', + }; +}; From 4a10935d3edf9fb761a4254c319cd47ac235842a Mon Sep 17 00:00:00 2001 From: Adam Savitzky Date: Mon, 30 Oct 2023 11:01:10 -0700 Subject: [PATCH 5/8] Fix linting errors --- src/messages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/messages.ts b/src/messages.ts index 506f64a..8d10d9b 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -269,7 +269,7 @@ export type IntrospectionResponse = { export type RevocationRequest = { token: string; - token_type_hint?: OAuth2TokenTypeHint + token_type_hint?: OAuth2TokenTypeHint; } -export type RevocationResponse = {} +export type RevocationResponse = Record; From 9335fbb37af595f2d56f123d59710fa53f1dd565 Mon Sep 17 00:00:00 2001 From: Evert Pot Date: Sat, 23 Dec 2023 11:52:24 -0500 Subject: [PATCH 6/8] Moving revoke tests into their own file --- test/client.ts | 71 -------------------------------------------------- test/revoke.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 71 deletions(-) delete mode 100644 test/client.ts create mode 100644 test/revoke.ts diff --git a/test/client.ts b/test/client.ts deleted file mode 100644 index bc15b9d..0000000 --- a/test/client.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { testServer } from './test-server'; -import { OAuth2Client } from '../src'; -import { expect } from 'chai'; - -describe('OAuth2Client', () => { - const server = testServer(); - - describe('Token revocation', () => { - it('should revoke access token when requested', async () => { - - const client = new OAuth2Client({ - server: server.url, - tokenEndpoint: '/token', - revocationEndpoint: '/revoke', - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - }); - - const token = await client.clientCredentials(); - - describe('When token type hint is not specified', () => { - it('should assume token type is access token', async () => { - await client.revoke(token); - - const request = server.lastRequest(); - expect(request.body).to.eql({ - token: token.accessToken, - token_type_hint: 'access_token', - }); - }); - }); - - describe('When token type is specified as access token', () => { - it('should supply access token', async () => { - await client.revoke(token, 'access_token'); - - const request = server.lastRequest(); - expect(request.body).to.eql({ - token: token.accessToken, - token_type_hint: 'access_token', - }); - }); - }); - - describe('When token type is specified as refresh token', () => { - it('should supply access token', async () => { - await client.revoke(token, 'refresh_token'); - - const request = server.lastRequest(); - expect(request.body).to.eql({ - token: token.refreshToken, - token_type_hint: 'refresh_token', - }); - }); - }); - }); - }); - - describe('Discovery', () => { - const client = new OAuth2Client({ - server: server.url, - discoveryEndpoint: '/discover', - clientId: 'test-client-id', - }); - - it('Should discover revocation endpoint', async () => { - const result = await client.getEndpoint('revocationEndpoint'); - expect(result).to.equal(server.url + '/revoke'); - }); - }); -}); diff --git a/test/revoke.ts b/test/revoke.ts new file mode 100644 index 0000000..0ea5851 --- /dev/null +++ b/test/revoke.ts @@ -0,0 +1,68 @@ +import { testServer } from './test-server'; +import { OAuth2Client } from '../src'; +import { expect } from 'chai'; + +describe('Token revocation', () => { + const server = testServer(); + describe('should revoke access token when requested', async () => { + + const client = new OAuth2Client({ + server: server.url, + tokenEndpoint: '/token', + revocationEndpoint: '/revoke', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }); + + const token = await client.clientCredentials(); + + describe('When token type hint is not specified', () => { + it('should assume token type is access token', async () => { + await client.revoke(token); + + const request = server.lastRequest(); + expect(request.body).to.eql({ + token: token.accessToken, + token_type_hint: 'access_token', + }); + }); + }); + + describe('When token type is specified as access token', () => { + it('should supply access token', async () => { + await client.revoke(token, 'access_token'); + + const request = server.lastRequest(); + expect(request.body).to.eql({ + token: token.accessToken, + token_type_hint: 'access_token', + }); + }); + }); + + describe('When token type is specified as refresh token', () => { + it('should supply access token', async () => { + await client.revoke(token, 'refresh_token'); + + const request = server.lastRequest(); + expect(request.body).to.eql({ + token: token.refreshToken, + token_type_hint: 'refresh_token', + }); + }); + }); + }); + + describe('Discovery', () => { + const client = new OAuth2Client({ + server: server.url, + discoveryEndpoint: '/discover', + clientId: 'test-client-id', + }); + + it('Should discover revocation endpoint', async () => { + const result = await client.getEndpoint('revocationEndpoint'); + expect(result).to.equal(server.url + '/revoke'); + }); + }); +}); From c38475a7e8f1e58b8da876336e06839bfb1ccab7 Mon Sep 17 00:00:00 2001 From: Evert Pot Date: Sat, 23 Dec 2023 12:01:07 -0500 Subject: [PATCH 7/8] Altering test to demonstrate issue with non returning JSON from revoke --- test/test-server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/test-server.ts b/test/test-server.ts index 73f9adf..40cdefa 100644 --- a/test/test-server.ts +++ b/test/test-server.ts @@ -75,8 +75,8 @@ const revokeToken: Middleware = (ctx, next) => { return next(); } - ctx.response.type = 'application/json'; - ctx.response.body = {}; + ctx.response.type = 'application/octet-stream'; + ctx.response.body = 'SUCCESS!'; }; From 08643ef74f61db3b690b1de4b76fcfd2cfe8c581 Mon Sep 17 00:00:00 2001 From: Evert Pot Date: Fri, 2 Feb 2024 20:12:57 -0500 Subject: [PATCH 8/8] Update changelog, readme --- README.md | 5 +++-- changelog.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index fd4257b..0ea5fbe 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ since Node 18 (but it works with Polyfills on Node 14 and 16). ## Highlights -* 11KB minified (3.7KB gzipped). +* 12KB minified (3.8KB gzipped). * No dependencies. * `authorization_code` grant with optional [PKCE][1] support. * `password` and `client_credentials` grant. @@ -18,7 +18,7 @@ since Node 18 (but it works with Polyfills on Node 14 and 16). * OAuth2 endpoint discovery via the Server metadata document ([RFC8414][2]). * OAuth2 Token Introspection ([RFC7662][3]). * Resource Indicators for OAuth 2.0 ([RFC8707][5]). - +* OAuth2 Token Revocation ([RFC7009][6]). ## Installation @@ -440,3 +440,4 @@ if (global.btoa === undefined) { [3]: https://datatracker.ietf.org/doc/html/rfc7662 "OAuth 2.0 Token Introspection" [4]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API "Web Crypto API" [5]: https://datatracker.ietf.org/doc/html/rfc8707 "https://datatracker.ietf.org/doc/html/rfc8707" +[6]: https://datatracker.ietf.org/doc/html/rfc7009 "OAuth 2.0 Token Revocation" diff --git a/changelog.md b/changelog.md index 61056c1..5cf406b 100644 --- a/changelog.md +++ b/changelog.md @@ -6,8 +6,9 @@ Changelog * Fix for #128: If there's no secret, we should never use Basic auth to encode the `client_id`. -* Support for the 'resource' parameter from RFC 8707. -* Add support for "scope" parameter to "refresh(). +* Support for the `resource` parameter from RFC 8707. +* Add support for `scope` parameter to `refresh()`. +* Support for RFC 7009, Token Revocation. 2.2.4 (2023-09-05)