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) diff --git a/src/client.ts b/src/client.ts index 0e626a8..ae00b2b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,7 +5,9 @@ import { IntrospectionRequest, IntrospectionResponse, PasswordRequest, + OAuth2TokenTypeHint, RefreshRequest, + RevocationRequest, ServerMetadataResponse, TokenResponse, } from './messages'; @@ -104,6 +106,14 @@ export interface ClientSettings { */ introspectionEndpoint?: string; + /** + * 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 + */ + revocationEndpoint?: string; + /** * OAuth 2.0 Authorization Server Metadata endpoint or OpenID * Connect Discovery 1.0 endpoint. @@ -135,7 +145,7 @@ export interface ClientSettings { } -type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint'; +type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint' | 'revocationEndpoint'; export class OAuth2Client { @@ -244,6 +254,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/rfc7009 + */ + 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. * @@ -277,6 +308,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); } } @@ -314,6 +347,7 @@ export class OAuth2Client { ['authorization_endpoint', 'authorizationEndpoint'], ['token_endpoint', 'tokenEndpoint'], ['introspection_endpoint', 'introspectionEndpoint'], + ['revocation_endpoint', 'revocationEndpoint'], ] as const; if (this.serverMetadata === null) return; @@ -334,6 +368,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); @@ -378,24 +413,24 @@ export class OAuth2Client { headers, }); + let responseBody; + if (resp.status !== 204 && resp.headers.has('Content-Type') && resp.headers.get('Content-Type')!.startsWith('application/json')) { + responseBody = await resp.json(); + } if (resp.ok) { - return await resp.json(); + return responseBody; } - let jsonError; let errorMessage; let oauth2Code; - if (resp.headers.has('Content-Type') && resp.headers.get('Content-Type')!.startsWith('application/json')) { - jsonError = await resp.json(); - } - if (jsonError?.error) { + if (responseBody.error) { // This is likely an OAUth2-formatted error - errorMessage = 'OAuth2 error ' + jsonError.error + '.'; - if (jsonError.error_description) { - errorMessage += ' ' + jsonError.error_description; + errorMessage = 'OAuth2 error ' + responseBody.error + '.'; + if (responseBody.error_description) { + errorMessage += ' ' + responseBody.error_description; } - oauth2Code = jsonError.error; + oauth2Code = responseBody.error; } else { errorMessage = 'HTTP Error ' + resp.status + ' ' + resp.statusText; diff --git a/src/messages.ts b/src/messages.ts index 72adff3..0aa5961 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -281,6 +281,16 @@ export type IntrospectionResponse = { } +/** + * Revocaton request. + * + * https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 + */ +export type RevocationRequest = { + token: string; + token_type_hint?: OAuth2TokenTypeHint; +} + export type OAuth2ErrorCode = | 'invalid_request' | 'invalid_client' @@ -293,4 +303,3 @@ export type OAuth2ErrorCode = * RFC 8707 */ | 'invalid_target'; - 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'); + }); + }); +}); diff --git a/test/test-server.ts b/test/test-server.ts index df6ddb8..40cdefa 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/octet-stream'; + ctx.response.body = 'SUCCESS!'; +}; + + +const discover: Middleware = (ctx, next) => { + + if (ctx.path !== '/discover') { + return next(); + } + + ctx.response.type = 'application/json'; + ctx.response.body = { + revocation_endpoint: '/revoke', + }; +};