Skip to content

Commit 16ca81d

Browse files
Implement REST token revocation
As described by spec at commit c2155c5. Resolves #989. TODO docs
1 parent e7ee8e5 commit 16ca81d

File tree

3 files changed

+301
-0
lines changed

3 files changed

+301
-0
lines changed

ably.d.ts

+36
Original file line numberDiff line numberDiff line change
@@ -1465,6 +1465,33 @@ declare namespace Types {
14651465
error: ErrorInfo;
14661466
}
14671467

1468+
/**
1469+
* The `TokenRevocationOptions` interface describes the additional options accepted by the following methods:
1470+
*
1471+
* - {@link AuthCallbacks.revokeTokens}
1472+
* - {@link AuthPromise.revokeTokens}
1473+
*/
1474+
interface TokenRevocationOptions {
1475+
issuedBefore?: number;
1476+
allowReauthMargin?: boolean;
1477+
}
1478+
1479+
interface TokenRevocationTargetSpecifier {
1480+
type: string;
1481+
value: string;
1482+
}
1483+
1484+
interface TokenRevocationSuccessResult {
1485+
target: string;
1486+
appliesAt: number;
1487+
issuedBefore: number;
1488+
}
1489+
1490+
interface TokenRevocationFailureResult {
1491+
target: string;
1492+
error: ErrorInfo;
1493+
}
1494+
14681495
// Common Listeners
14691496
/**
14701497
* A standard callback format used in most areas of the callback API.
@@ -2056,6 +2083,11 @@ declare namespace Types {
20562083
* @param callback - A function which, upon success, will be called with a {@link TokenDetails} object. Upon failure, the function will be called with information about the error.
20572084
*/
20582085
requestToken(callback?: tokenDetailsCallback): void;
2086+
revokeTokens(
2087+
specifiers: TokenRevocationTargetSpecifier[],
2088+
options?: TokenRevocationOptions,
2089+
callback?: StandardCallback<BatchResult<TokenRevocationSuccessResult | TokenRevocationFailureResult>>
2090+
): void;
20592091
}
20602092

20612093
/**
@@ -2086,6 +2118,10 @@ declare namespace Types {
20862118
* @returns A promise which, upon success, will be fulfilled with a {@link TokenDetails} object. Upon failure, the promise will be rejected with an {@link ErrorInfo} object which explains the error.
20872119
*/
20882120
requestToken(TokenParams?: TokenParams, authOptions?: AuthOptions): Promise<TokenDetails>;
2121+
revokeTokens(
2122+
specifier: TokenRevocationTargetSpecifier[],
2123+
options?: TokenRevocationOptions
2124+
): Promise<BatchResult<TokenRevocationSuccessResult | TokenRevocationFailureResult>>;
20892125
}
20902126

20912127
/**

src/common/lib/client/auth.ts

+83
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import ClientOptions from '../../types/ClientOptions';
1414
import HttpMethods from '../../constants/HttpMethods';
1515
import HttpStatusCodes from 'common/constants/HttpStatusCodes';
1616
import Platform from '../../platform';
17+
import Resource from './resource';
1718

1819
const MAX_TOKEN_LENGTH = Math.pow(2, 17);
1920
function noop() {}
@@ -1054,6 +1055,88 @@ class Auth {
10541055
static isTokenErr(error: IPartialErrorInfo) {
10551056
return error.code && error.code >= 40140 && error.code < 40150;
10561057
}
1058+
1059+
revokeTokens(
1060+
specifiers: API.Types.TokenRevocationTargetSpecifier[],
1061+
options?: API.Types.TokenRevocationOptions,
1062+
callback?: API.Types.StandardCallback<
1063+
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
1064+
>
1065+
): void;
1066+
revokeTokens(
1067+
specifiers: API.Types.TokenRevocationTargetSpecifier[],
1068+
options?: API.Types.TokenRevocationOptions
1069+
): Promise<API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>>;
1070+
revokeTokens(
1071+
specifiers: API.Types.TokenRevocationTargetSpecifier[],
1072+
optionsOrCallbackArg?:
1073+
| API.Types.TokenRevocationOptions
1074+
| API.Types.StandardCallback<
1075+
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
1076+
>,
1077+
callbackArg?: API.Types.StandardCallback<
1078+
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
1079+
>
1080+
): void | Promise<
1081+
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
1082+
> {
1083+
if (useTokenAuth(this.client.options)) {
1084+
throw new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401);
1085+
}
1086+
1087+
const keyName = this.client.options.keyName!;
1088+
1089+
let options: API.Types.TokenRevocationOptions;
1090+
1091+
if (typeof optionsOrCallbackArg === 'object') {
1092+
options = optionsOrCallbackArg;
1093+
} else {
1094+
callbackArg = optionsOrCallbackArg;
1095+
options = {};
1096+
}
1097+
1098+
if (callbackArg === undefined) {
1099+
if (this.client.options.promises) {
1100+
return Utils.promisify(this, 'batchPublish', [specifiers, callbackArg]);
1101+
}
1102+
callbackArg = noop;
1103+
}
1104+
1105+
const callback = callbackArg;
1106+
1107+
const requestBodyDTO = {
1108+
targets: specifiers.map((specifier) => `${specifier.type}:${specifier.value}`),
1109+
...options,
1110+
};
1111+
1112+
const format = this.client.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json,
1113+
headers = Utils.defaultPostHeaders(this.client.options, format);
1114+
1115+
if (this.client.options.headers) Utils.mixin(headers, this.client.options.headers);
1116+
1117+
const requestBody = Utils.encodeBody(requestBodyDTO, format);
1118+
Resource.post(
1119+
this.client,
1120+
`/keys/${keyName}/revokeTokens`,
1121+
requestBody,
1122+
headers,
1123+
{ newBatchResponse: 'true' },
1124+
null,
1125+
(err, body, headers, unpacked) => {
1126+
if (err) {
1127+
// TODO remove this type assertion after fixing https://github.com/ably/ably-js/issues/1405
1128+
callback(err as API.Types.ErrorInfo);
1129+
return;
1130+
}
1131+
1132+
const batchResult = (unpacked ? body : Utils.decodeBody(body, format)) as API.Types.BatchResult<
1133+
API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult
1134+
>;
1135+
1136+
callback(null, batchResult);
1137+
}
1138+
);
1139+
}
10571140
}
10581141

10591142
export default Auth;

test/rest/batch.test.js

+182
Original file line numberDiff line numberDiff line change
@@ -253,4 +253,186 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async
253253
);
254254
});
255255
});
256+
257+
describe('rest/revokeTokens', function () {
258+
this.timeout(60 * 1000);
259+
260+
before(function (done) {
261+
helper.setupApp(function (err) {
262+
if (err) {
263+
done(err);
264+
}
265+
done();
266+
});
267+
});
268+
269+
it('revokes tokens matching the given specifiers', function (done) {
270+
const testApp = helper.getTestApp();
271+
const rest = helper.AblyRest({
272+
key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */,
273+
});
274+
275+
let clientId1TokenDetails;
276+
let clientId2TokenDetails;
277+
278+
async.series(
279+
[
280+
function (cb) {
281+
// First, we fetch tokens for a couple of different clientIds...
282+
async.parallel(
283+
[
284+
function (cb) {
285+
rest.auth.requestToken({ clientId: 'clientId1' }, function (err, tokenDetails) {
286+
if (err) {
287+
cb(err);
288+
return;
289+
}
290+
291+
clientId1TokenDetails = tokenDetails;
292+
cb();
293+
});
294+
},
295+
function (cb) {
296+
rest.auth.requestToken({ clientId: 'clientId2' }, function (err, tokenDetails) {
297+
if (err) {
298+
cb(err);
299+
return;
300+
}
301+
302+
clientId2TokenDetails = tokenDetails;
303+
cb();
304+
});
305+
},
306+
],
307+
cb
308+
);
309+
},
310+
function (cb) {
311+
// ...then, we revoke all tokens for these clientIds...
312+
const specifiers = [
313+
{ type: 'clientId', value: 'clientId1' },
314+
{ type: 'clientId', value: 'clientId2' },
315+
{ type: 'invalidType', value: 'abc' }, // we include an invalid specifier type to provoke a non-zero failureCount
316+
];
317+
318+
rest.auth.revokeTokens(specifiers, function (err, result) {
319+
if (err) {
320+
cb(err);
321+
return;
322+
}
323+
324+
// ...and check the response from the revocation request...
325+
expect(result.successCount).to.equal(2);
326+
expect(result.failureCount).to.equal(1);
327+
expect(result.results).to.have.lengthOf(3);
328+
329+
expect(result.results[0].target).to.equal('clientId:clientId1');
330+
expect(typeof result.results[0].issuedBefore).to.equal('number');
331+
expect(typeof result.results[0].appliesAt).to.equal('number');
332+
expect('error' in result.results[0]).to.be.false;
333+
334+
expect(result.results[1].target).to.equal('clientId:clientId2');
335+
expect(typeof result.results[1].issuedBefore).to.equal('number');
336+
expect(typeof result.results[1].appliesAt).to.equal('number');
337+
expect('error' in result.results[1]).to.be.false;
338+
339+
expect(result.results[2].target).to.equal('invalidType:abc');
340+
expect(result.results[2].error.statusCode).to.equal(400);
341+
342+
cb();
343+
});
344+
},
345+
// ...and then, we create new instances of Rest, configured to use the tokens we fetched pre-revocation, and check that when we try to perform a REST request using these tokens, it results in a "token revoked" (40141) error.
346+
//
347+
// TODO we're currently not actually able to check for a 40141 error, since it's being absorbed due to https://github.com/ably/ably-js/issues/1409. So for now we just have to use the fact that the library is performing a re-auth as our signal that the token must have been revoked. This is not ideal, and we should fix it as part of the mentioned issue.
348+
function (cb) {
349+
async.parallel(
350+
[
351+
function (cb) {
352+
const rest = helper.AblyRest({ token: clientId1TokenDetails });
353+
rest.channels.get('channel').publish('test', 'test', function (err) {
354+
expect(err.code).to.equal(40171);
355+
cb();
356+
});
357+
},
358+
function (cb) {
359+
const rest = helper.AblyRest({ token: clientId2TokenDetails });
360+
rest.channels.get('channel').publish('test', 'test', function (err) {
361+
expect(err.code).to.equal(40171);
362+
cb();
363+
});
364+
},
365+
],
366+
cb
367+
);
368+
},
369+
],
370+
done
371+
);
372+
});
373+
374+
it('accepts optional issuedBefore and allowReauthMargin parameters', function (done) {
375+
const testApp = helper.getTestApp();
376+
const rest = helper.AblyRest({
377+
key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */,
378+
});
379+
380+
let serverTimeAtStartOfTest;
381+
382+
async.series(
383+
[
384+
function (cb) {
385+
rest.time(function (err, time) {
386+
if (err) {
387+
cb(err);
388+
return;
389+
}
390+
serverTimeAtStartOfTest = time;
391+
cb();
392+
});
393+
},
394+
function (cb) {
395+
const issuedBefore = serverTimeAtStartOfTest - 20 * 60 * 1000; // i.e. ~20 minutes ago
396+
397+
rest.auth.revokeTokens(
398+
[{ type: 'clientId', value: 'clientId1' }],
399+
{ issuedBefore, allowReauthMargin: true },
400+
function (err, result) {
401+
if (err) {
402+
cb(err);
403+
return;
404+
}
405+
406+
expect(result.results[0].issuedBefore).to.equal(issuedBefore);
407+
408+
// Verify the expected side effect of allowReauthMargin, which is to delay the revocation by 30 seconds
409+
const serverTimeThirtySecondsAfterStartOfTest = serverTimeAtStartOfTest + 30 * 1000;
410+
expect(result.results[0].appliesAt).to.be.greaterThan(serverTimeThirtySecondsAfterStartOfTest);
411+
412+
cb();
413+
}
414+
);
415+
},
416+
],
417+
done
418+
);
419+
});
420+
421+
it('throws an error when using token auth', function () {
422+
const rest = helper.AblyRest({
423+
useTokenAuth: true,
424+
});
425+
426+
let verifiedError = false;
427+
try {
428+
rest.auth.revokeTokens([{ type: 'clientId', value: 'clientId1' }], function () {});
429+
} catch (err) {
430+
expect(err.statusCode).to.equal(401);
431+
expect(err.code).to.equal(40162);
432+
verifiedError = true;
433+
}
434+
435+
expect(verifiedError).to.be.true;
436+
});
437+
});
256438
});

0 commit comments

Comments
 (0)