Skip to content

Commit

Permalink
Merge pull request #126 from adambom/adambom-revoke
Browse files Browse the repository at this point in the history
Add support for token revocation
  • Loading branch information
evert authored Feb 3, 2024
2 parents a761025 + 08643ef commit f53cfd0
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 16 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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.
* a `fetch()` wrapper that automatically adds Bearer tokens and refreshes them.
* 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

Expand Down Expand Up @@ -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"
5 changes: 3 additions & 2 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 46 additions & 11 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
IntrospectionRequest,
IntrospectionResponse,
PasswordRequest,
OAuth2TokenTypeHint,
RefreshRequest,
RevocationRequest,
ServerMetadataResponse,
TokenResponse,
} from './messages';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -135,7 +145,7 @@ export interface ClientSettings {
}


type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint';
type OAuth2Endpoint = 'tokenEndpoint' | 'authorizationEndpoint' | 'discoveryEndpoint' | 'introspectionEndpoint' | 'revocationEndpoint';

export class OAuth2Client {

Expand Down Expand Up @@ -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<void> {
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.
*
Expand Down Expand Up @@ -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);
}

}
Expand Down Expand Up @@ -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;
Expand All @@ -334,6 +368,7 @@ export class OAuth2Client {
*/
async request(endpoint: 'tokenEndpoint', body: RefreshRequest | ClientCredentialsRequest | PasswordRequest | AuthorizationCodeRequest): Promise<TokenResponse>;
async request(endpoint: 'introspectionEndpoint', body: IntrospectionRequest): Promise<IntrospectionResponse>;
async request(endpoint: 'revocationEndpoint', body: RevocationRequest): Promise<void>;
async request(endpoint: OAuth2Endpoint, body: Record<string, any>): Promise<unknown> {

const uri = await this.getEndpoint(endpoint);
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -293,4 +303,3 @@ export type OAuth2ErrorCode =
* RFC 8707
*/
| 'invalid_target';

68 changes: 68 additions & 0 deletions test/revoke.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
26 changes: 26 additions & 0 deletions test/test-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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',
};
};

0 comments on commit f53cfd0

Please sign in to comment.