Skip to content

Commit b388834

Browse files
Implement REST token revocation
As described by spec at commit c2155c5. Resolves #989. TODO docs
1 parent 5488e81 commit b388834

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
@@ -424,4 +424,186 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async
424424
});
425425
}
426426
});
427+
428+
describe('rest/revokeTokens', function () {
429+
this.timeout(60 * 1000);
430+
431+
before(function (done) {
432+
helper.setupApp(function (err) {
433+
if (err) {
434+
done(err);
435+
}
436+
done();
437+
});
438+
});
439+
440+
it('revokes tokens matching the given specifiers', function (done) {
441+
const testApp = helper.getTestApp();
442+
const rest = helper.AblyRest({
443+
key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */,
444+
});
445+
446+
let clientId1TokenDetails;
447+
let clientId2TokenDetails;
448+
449+
async.series(
450+
[
451+
function (cb) {
452+
// First, we fetch tokens for a couple of different clientIds...
453+
async.parallel(
454+
[
455+
function (cb) {
456+
rest.auth.requestToken({ clientId: 'clientId1' }, function (err, tokenDetails) {
457+
if (err) {
458+
cb(err);
459+
return;
460+
}
461+
462+
clientId1TokenDetails = tokenDetails;
463+
cb();
464+
});
465+
},
466+
function (cb) {
467+
rest.auth.requestToken({ clientId: 'clientId2' }, function (err, tokenDetails) {
468+
if (err) {
469+
cb(err);
470+
return;
471+
}
472+
473+
clientId2TokenDetails = tokenDetails;
474+
cb();
475+
});
476+
},
477+
],
478+
cb
479+
);
480+
},
481+
function (cb) {
482+
// ...then, we revoke all tokens for these clientIds...
483+
const specifiers = [
484+
{ type: 'clientId', value: 'clientId1' },
485+
{ type: 'clientId', value: 'clientId2' },
486+
{ type: 'invalidType', value: 'abc' }, // we include an invalid specifier type to provoke a non-zero failureCount
487+
];
488+
489+
rest.auth.revokeTokens(specifiers, function (err, result) {
490+
if (err) {
491+
cb(err);
492+
return;
493+
}
494+
495+
// ...and check the response from the revocation request...
496+
expect(result.successCount).to.equal(2);
497+
expect(result.failureCount).to.equal(1);
498+
expect(result.results).to.have.lengthOf(3);
499+
500+
expect(result.results[0].target).to.equal('clientId:clientId1');
501+
expect(typeof result.results[0].issuedBefore).to.equal('number');
502+
expect(typeof result.results[0].appliesAt).to.equal('number');
503+
expect('error' in result.results[0]).to.be.false;
504+
505+
expect(result.results[1].target).to.equal('clientId:clientId2');
506+
expect(typeof result.results[1].issuedBefore).to.equal('number');
507+
expect(typeof result.results[1].appliesAt).to.equal('number');
508+
expect('error' in result.results[1]).to.be.false;
509+
510+
expect(result.results[2].target).to.equal('invalidType:abc');
511+
expect(result.results[2].error.statusCode).to.equal(400);
512+
513+
cb();
514+
});
515+
},
516+
// ...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.
517+
//
518+
// 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.
519+
function (cb) {
520+
async.parallel(
521+
[
522+
function (cb) {
523+
const rest = helper.AblyRest({ token: clientId1TokenDetails });
524+
rest.channels.get('channel').publish('test', 'test', function (err) {
525+
expect(err.code).to.equal(40171);
526+
cb();
527+
});
528+
},
529+
function (cb) {
530+
const rest = helper.AblyRest({ token: clientId2TokenDetails });
531+
rest.channels.get('channel').publish('test', 'test', function (err) {
532+
expect(err.code).to.equal(40171);
533+
cb();
534+
});
535+
},
536+
],
537+
cb
538+
);
539+
},
540+
],
541+
done
542+
);
543+
});
544+
545+
it('accepts optional issuedBefore and allowReauthMargin parameters', function (done) {
546+
const testApp = helper.getTestApp();
547+
const rest = helper.AblyRest({
548+
key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */,
549+
});
550+
551+
let serverTimeAtStartOfTest;
552+
553+
async.series(
554+
[
555+
function (cb) {
556+
rest.time(function (err, time) {
557+
if (err) {
558+
cb(err);
559+
return;
560+
}
561+
serverTimeAtStartOfTest = time;
562+
cb();
563+
});
564+
},
565+
function (cb) {
566+
const issuedBefore = serverTimeAtStartOfTest - 20 * 60 * 1000; // i.e. ~20 minutes ago
567+
568+
rest.auth.revokeTokens(
569+
[{ type: 'clientId', value: 'clientId1' }],
570+
{ issuedBefore, allowReauthMargin: true },
571+
function (err, result) {
572+
if (err) {
573+
cb(err);
574+
return;
575+
}
576+
577+
expect(result.results[0].issuedBefore).to.equal(issuedBefore);
578+
579+
// Verify the expected side effect of allowReauthMargin, which is to delay the revocation by 30 seconds
580+
const serverTimeThirtySecondsAfterStartOfTest = serverTimeAtStartOfTest + 30 * 1000;
581+
expect(result.results[0].appliesAt).to.be.greaterThan(serverTimeThirtySecondsAfterStartOfTest);
582+
583+
cb();
584+
}
585+
);
586+
},
587+
],
588+
done
589+
);
590+
});
591+
592+
it('throws an error when using token auth', function () {
593+
const rest = helper.AblyRest({
594+
useTokenAuth: true,
595+
});
596+
597+
let verifiedError = false;
598+
try {
599+
rest.auth.revokeTokens([{ type: 'clientId', value: 'clientId1' }], function () {});
600+
} catch (err) {
601+
expect(err.statusCode).to.equal(401);
602+
expect(err.code).to.equal(40162);
603+
verifiedError = true;
604+
}
605+
606+
expect(verifiedError).to.be.true;
607+
});
608+
});
427609
});

0 commit comments

Comments
 (0)