Skip to content

Commit

Permalink
Implement REST token revocation
Browse files Browse the repository at this point in the history
As described by spec at commit c2155c5. Resolves #989.

TODO docs
  • Loading branch information
lawrence-forooghian committed Jul 25, 2023
1 parent e7ee8e5 commit 16ca81d
Show file tree
Hide file tree
Showing 3 changed files with 301 additions and 0 deletions.
36 changes: 36 additions & 0 deletions ably.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1465,6 +1465,33 @@ declare namespace Types {
error: ErrorInfo;
}

/**
* The `TokenRevocationOptions` interface describes the additional options accepted by the following methods:
*
* - {@link AuthCallbacks.revokeTokens}
* - {@link AuthPromise.revokeTokens}
*/
interface TokenRevocationOptions {
issuedBefore?: number;
allowReauthMargin?: boolean;
}

interface TokenRevocationTargetSpecifier {
type: string;
value: string;
}

interface TokenRevocationSuccessResult {
target: string;
appliesAt: number;
issuedBefore: number;
}

interface TokenRevocationFailureResult {
target: string;
error: ErrorInfo;
}

// Common Listeners
/**
* A standard callback format used in most areas of the callback API.
Expand Down Expand Up @@ -2056,6 +2083,11 @@ declare namespace Types {
* @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.
*/
requestToken(callback?: tokenDetailsCallback): void;
revokeTokens(
specifiers: TokenRevocationTargetSpecifier[],
options?: TokenRevocationOptions,
callback?: StandardCallback<BatchResult<TokenRevocationSuccessResult | TokenRevocationFailureResult>>
): void;
}

/**
Expand Down Expand Up @@ -2086,6 +2118,10 @@ declare namespace Types {
* @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.
*/
requestToken(TokenParams?: TokenParams, authOptions?: AuthOptions): Promise<TokenDetails>;
revokeTokens(
specifier: TokenRevocationTargetSpecifier[],
options?: TokenRevocationOptions
): Promise<BatchResult<TokenRevocationSuccessResult | TokenRevocationFailureResult>>;
}

/**
Expand Down
83 changes: 83 additions & 0 deletions src/common/lib/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import ClientOptions from '../../types/ClientOptions';
import HttpMethods from '../../constants/HttpMethods';
import HttpStatusCodes from 'common/constants/HttpStatusCodes';
import Platform from '../../platform';
import Resource from './resource';

const MAX_TOKEN_LENGTH = Math.pow(2, 17);
function noop() {}
Expand Down Expand Up @@ -1054,6 +1055,88 @@ class Auth {
static isTokenErr(error: IPartialErrorInfo) {
return error.code && error.code >= 40140 && error.code < 40150;
}

revokeTokens(
specifiers: API.Types.TokenRevocationTargetSpecifier[],
options?: API.Types.TokenRevocationOptions,
callback?: API.Types.StandardCallback<
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
>
): void;
revokeTokens(
specifiers: API.Types.TokenRevocationTargetSpecifier[],
options?: API.Types.TokenRevocationOptions
): Promise<API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>>;
revokeTokens(
specifiers: API.Types.TokenRevocationTargetSpecifier[],
optionsOrCallbackArg?:
| API.Types.TokenRevocationOptions
| API.Types.StandardCallback<
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
>,
callbackArg?: API.Types.StandardCallback<
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
>
): void | Promise<
API.Types.BatchResult<API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult>
> {
if (useTokenAuth(this.client.options)) {
throw new ErrorInfo('Cannot revoke tokens when using token auth', 40162, 401);
}

const keyName = this.client.options.keyName!;

let options: API.Types.TokenRevocationOptions;

if (typeof optionsOrCallbackArg === 'object') {
options = optionsOrCallbackArg;
} else {
callbackArg = optionsOrCallbackArg;
options = {};
}

if (callbackArg === undefined) {
if (this.client.options.promises) {
return Utils.promisify(this, 'batchPublish', [specifiers, callbackArg]);
}
callbackArg = noop;
}

const callback = callbackArg;

const requestBodyDTO = {
targets: specifiers.map((specifier) => `${specifier.type}:${specifier.value}`),
...options,
};

const format = this.client.options.useBinaryProtocol ? Utils.Format.msgpack : Utils.Format.json,
headers = Utils.defaultPostHeaders(this.client.options, format);

if (this.client.options.headers) Utils.mixin(headers, this.client.options.headers);

const requestBody = Utils.encodeBody(requestBodyDTO, format);
Resource.post(
this.client,
`/keys/${keyName}/revokeTokens`,
requestBody,
headers,
{ newBatchResponse: 'true' },
null,
(err, body, headers, unpacked) => {
if (err) {
// TODO remove this type assertion after fixing https://github.com/ably/ably-js/issues/1405
callback(err as API.Types.ErrorInfo);
return;
}

const batchResult = (unpacked ? body : Utils.decodeBody(body, format)) as API.Types.BatchResult<
API.Types.TokenRevocationSuccessResult | API.Types.TokenRevocationFailureResult
>;

callback(null, batchResult);
}
);
}
}

export default Auth;
182 changes: 182 additions & 0 deletions test/rest/batch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,186 @@ define(['ably', 'shared_helper', 'async', 'chai'], function (Ably, helper, async
);
});
});

describe('rest/revokeTokens', function () {
this.timeout(60 * 1000);

before(function (done) {
helper.setupApp(function (err) {
if (err) {
done(err);
}
done();
});
});

it('revokes tokens matching the given specifiers', function (done) {
const testApp = helper.getTestApp();
const rest = helper.AblyRest({
key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */,
});

let clientId1TokenDetails;
let clientId2TokenDetails;

async.series(
[
function (cb) {
// First, we fetch tokens for a couple of different clientIds...
async.parallel(
[
function (cb) {
rest.auth.requestToken({ clientId: 'clientId1' }, function (err, tokenDetails) {
if (err) {
cb(err);
return;
}

clientId1TokenDetails = tokenDetails;
cb();
});
},
function (cb) {
rest.auth.requestToken({ clientId: 'clientId2' }, function (err, tokenDetails) {
if (err) {
cb(err);
return;
}

clientId2TokenDetails = tokenDetails;
cb();
});
},
],
cb
);
},
function (cb) {
// ...then, we revoke all tokens for these clientIds...
const specifiers = [
{ type: 'clientId', value: 'clientId1' },
{ type: 'clientId', value: 'clientId2' },
{ type: 'invalidType', value: 'abc' }, // we include an invalid specifier type to provoke a non-zero failureCount
];

rest.auth.revokeTokens(specifiers, function (err, result) {
if (err) {
cb(err);
return;
}

// ...and check the response from the revocation request...
expect(result.successCount).to.equal(2);
expect(result.failureCount).to.equal(1);
expect(result.results).to.have.lengthOf(3);

expect(result.results[0].target).to.equal('clientId:clientId1');
expect(typeof result.results[0].issuedBefore).to.equal('number');
expect(typeof result.results[0].appliesAt).to.equal('number');
expect('error' in result.results[0]).to.be.false;

expect(result.results[1].target).to.equal('clientId:clientId2');
expect(typeof result.results[1].issuedBefore).to.equal('number');
expect(typeof result.results[1].appliesAt).to.equal('number');
expect('error' in result.results[1]).to.be.false;

expect(result.results[2].target).to.equal('invalidType:abc');
expect(result.results[2].error.statusCode).to.equal(400);

cb();
});
},
// ...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.
//
// 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.
function (cb) {
async.parallel(
[
function (cb) {
const rest = helper.AblyRest({ token: clientId1TokenDetails });
rest.channels.get('channel').publish('test', 'test', function (err) {
expect(err.code).to.equal(40171);
cb();
});
},
function (cb) {
const rest = helper.AblyRest({ token: clientId2TokenDetails });
rest.channels.get('channel').publish('test', 'test', function (err) {
expect(err.code).to.equal(40171);
cb();
});
},
],
cb
);
},
],
done
);
});

it('accepts optional issuedBefore and allowReauthMargin parameters', function (done) {
const testApp = helper.getTestApp();
const rest = helper.AblyRest({
key: testApp.keys[4].keyStr /* this key has revocableTokens enabled */,
});

let serverTimeAtStartOfTest;

async.series(
[
function (cb) {
rest.time(function (err, time) {
if (err) {
cb(err);
return;
}
serverTimeAtStartOfTest = time;
cb();
});
},
function (cb) {
const issuedBefore = serverTimeAtStartOfTest - 20 * 60 * 1000; // i.e. ~20 minutes ago

rest.auth.revokeTokens(
[{ type: 'clientId', value: 'clientId1' }],
{ issuedBefore, allowReauthMargin: true },
function (err, result) {
if (err) {
cb(err);
return;
}

expect(result.results[0].issuedBefore).to.equal(issuedBefore);

// Verify the expected side effect of allowReauthMargin, which is to delay the revocation by 30 seconds
const serverTimeThirtySecondsAfterStartOfTest = serverTimeAtStartOfTest + 30 * 1000;
expect(result.results[0].appliesAt).to.be.greaterThan(serverTimeThirtySecondsAfterStartOfTest);

cb();
}
);
},
],
done
);
});

it('throws an error when using token auth', function () {
const rest = helper.AblyRest({
useTokenAuth: true,
});

let verifiedError = false;
try {
rest.auth.revokeTokens([{ type: 'clientId', value: 'clientId1' }], function () {});
} catch (err) {
expect(err.statusCode).to.equal(401);
expect(err.code).to.equal(40162);
verifiedError = true;
}

expect(verifiedError).to.be.true;
});
});
});

0 comments on commit 16ca81d

Please sign in to comment.