Skip to content

Commit

Permalink
feat(azure-iot-provisioning-service): support TokenCredential auth (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
vishnureddy17 authored and anthonyvercolano committed Oct 22, 2021
1 parent cd63a9a commit 1a9a984
Show file tree
Hide file tree
Showing 14 changed files with 136 additions and 32 deletions.
5 changes: 3 additions & 2 deletions common/transport/http/src/rest_api_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ export interface HttpTransportError extends Error {
* @throws {ArgumentError} If the config argument is missing a host or sharedAccessSignature error
*/
export class RestApiClient {
private _iotHubPublicScope: string = 'https://iothubs.azure.net/.default';
private _BearerTokenPrefix: string = 'Bearer ';
private _MinutesBeforeProactiveRenewal: number = 9;
private _MillisecsBeforeProactiveRenewal: number = this._MinutesBeforeProactiveRenewal * 60000;
Expand All @@ -52,6 +51,7 @@ export class RestApiClient {
if (!config.host) throw new errors.ArgumentError('config.host cannot be \'' + config.host + '\'');
/*Codes_SRS_NODE_IOTHUB_REST_API_CLIENT_18_001: [The `RestApiClient` constructor shall throw a `ReferenceError` if `userAgent` is falsy.]*/
if (!userAgent) throw new ReferenceError('userAgent cannot be \'' + userAgent + '\'');
if (config.tokenCredential && !config.tokenScope) throw new errors.ArgumentError('config.tokenScope must be defined if config.tokenCredential is defined');

this._config = config;
this._userAgent = userAgent;
Expand Down Expand Up @@ -171,7 +171,7 @@ export class RestApiClient {
*/
async getToken(): Promise<string> {
if ((!this._accessToken) || this.isAccessTokenCloseToExpiry(this._accessToken)) {
this._accessToken = await this._config.tokenCredential.getToken(this._iotHubPublicScope) as any;
this._accessToken = await this._config.tokenCredential.getToken(this._config.tokenScope) as any;
}
if (!this._accessToken) {
throw new Error('AccessToken creation failed');
Expand Down Expand Up @@ -373,6 +373,7 @@ export namespace RestApiClient {
sharedAccessSignature?: string | SharedAccessSignature;
x509?: X509;
tokenCredential?: TokenCredential;
tokenScope?: string | string[];
}

export type ResponseCallback = (err: Error, responseBody?: any, response?: any) => void;
Expand Down
34 changes: 28 additions & 6 deletions common/transport/http/test/_rest_api_client_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,26 @@ describe('RestApiClient', function() {
});
});

[undefined, null, ''].forEach(function(tokenScope) {
it('throws a ArgumentError if \'config.TokenCredential\' is defined but \'config.TokenScope\' is falsy', function() {
assert.throws(function() {
return new RestApiClient(
{
host: 'host',
tokenCredential: {
getToken: sinon.stub().resolves({
token: 'fake_token',
expiresOnTimestamp: Date.now() + 3600000
})
},
tokenScope
},
fakeAgent
);
}, errors.ArgumentError);
});
});

/*Tests_SRS_NODE_IOTHUB_REST_API_CLIENT_16_002: [The `RestApiClient` constructor shall throw an `ArgumentError` if config is missing a `host` property.]*/
['host'].forEach(function(badPropName) {
[undefined, null, ''].forEach(function(badPropValue) {
Expand Down Expand Up @@ -551,12 +571,13 @@ describe('RestApiClient', function() {
})
}
var fakeConfig = {
host: "fake_host.com",
tokenCredential: fakeTokenCredential
host: 'fake_host.com',
tokenCredential: fakeTokenCredential,
tokenScope: 'fake_scope.com'
}
var fakeHttpHelper = {
buildRequest: function(method, path, headers, host, requestCallback) {
assert(fakeTokenCredential.getToken.calledOnceWithExactly('https://iothubs.azure.net/.default'))
assert(fakeTokenCredential.getToken.calledOnceWithExactly('fake_scope.com'))
return {
write: function() { },
end: function() {
Expand All @@ -578,7 +599,8 @@ describe('RestApiClient', function() {
token: "fakeToken",
expiresOnTimestamp: Date.now() + 3600000 //One hour from now
}))
}
},
tokenScope: "fake_scope.com"
};
var fakeHttpHelper = {
buildRequest: function(method, path, headers, host, requestCallback) {
Expand Down Expand Up @@ -621,8 +643,8 @@ describe('RestApiClient', function() {
);

assert(
fakeConfig.tokenCredential.getToken.alwaysCalledWithExactly('https://iothubs.azure.net/.default'),
"getToken() was called with the incorrect arguments"
fakeConfig.tokenCredential.getToken.alwaysCalledWithExactly('fake_scope.com'),
'getToken() was called with the incorrect arguments'
);
} finally {
clock.restore();
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion provisioning/service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"dependencies": {
"azure-iot-common": "1.12.12",
"azure-iot-http-base": "1.11.12",
"debug": "^4.3.1"
"debug": "^4.3.1",
"@azure/core-http": "1.2.3"
},
"devDependencies": {
"@types/debug": "^4.1.5",
Expand Down
32 changes: 26 additions & 6 deletions provisioning/service/src/provisioningserviceclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { QuerySpecification, Query, QueryResult } from './query';
// tslint:disable-next-line:no-unused-variable
import { IndividualEnrollment, EnrollmentGroup, DeviceRegistrationState, BulkEnrollmentOperation, BulkEnrollmentOperationResult, AttestationMechanism } from './interfaces';
import { ErrorCallback, errorCallbackToPromise, HttpResponseCallback, ResultWithHttpResponse } from 'azure-iot-common';
import { TokenCredential } from '@azure/core-http';

// tslint:disable-next-line:no-var-requires
const packageJson = require('../package.json');
Expand All @@ -27,11 +28,12 @@ export class ProvisioningServiceClient {
if (!config) {
/*Codes_SRS_NODE_PROVISIONING_SERVICE_CLIENT_06_001: [The `ProvisioningServiceClient` construction shall throw a `ReferenceError` if the `config` object is falsy.] */
throw new ReferenceError('The \'config\' parameter cannot be \'' + config + '\'');
} else if (!config.host || !config.sharedAccessSignature) {
/*Codes_SRS_NODE_PROVISIONING_SERVICE_CLIENT_06_002: [The `ProvisioningServiceClient` constructor shall throw an `ArgumentError` if the `config` object is missing one or more of the following properties:
- `host`: the IoT Hub hostname
- `sharedAccessSignature`: shared access signature with the permissions for the desired operations.] */
throw new ArgumentError('The \'config\' argument is missing either the host or the sharedAccessSignature property');
} else if (!config.host) {
throw new ArgumentError('The \'config\' argument is missing the host property');
} else if (!config.sharedAccessSignature && !config.tokenCredential) {
throw new ArgumentError('The \'config\' argument must define either the sharedAccessSignature or tokenCredential property');
} else if (config.tokenCredential && !config.tokenScope) {
throw new ArgumentError('The \'config\' argument must define the tokenScope property if it defines the tokenCredential property');
}

/*Codes_SRS_NODE_PROVISIONING_SERVICE_CLIENT_06_003: [The `ProvisioningServiceClient` constructor shall use the `restApiClient` provided as a second argument if it is provided.] */
Expand Down Expand Up @@ -349,7 +351,7 @@ export class ProvisioningServiceClient {
}

private _versionQueryString(): string {
return '?api-version=2019-03-31';
return '?api-version=2021-10-01';
}

private _createOrUpdate(endpointPrefix: string, enrollment: any, callback?: (err: Error, enrollmentResponse?: any, response?: any) => void): void {
Expand Down Expand Up @@ -606,6 +608,24 @@ export class ProvisioningServiceClient {
return new ProvisioningServiceClient(config);
}

/**
* @method module:azure-iot-provisioning-service.ProvisioningServiceClient#fromTokenCredential
* @description Constructs a ProvisioningServiceClient object from the given Azure TokenCredential
* using the default transport
* ({@link module:azure-iothub.Http|Http}).
* @param {String} hostName Host name of the Azure service.
* @param {String} tokenCredential An Azure TokenCredential used to authenticate
* with the Azure service
* @returns {module:azure-iot-provisioning-service.ProvisioningServiceClient}
*/
static fromTokenCredential(hostName: string, tokenCredential: TokenCredential): ProvisioningServiceClient {
const config: RestApiClient.TransportConfig = {
host: hostName,
tokenCredential,
tokenScope: 'https://azure-devices-provisioning.net/.default'
};
return new ProvisioningServiceClient(config);
}
}

export type _tsLintWorkaround = { query: QueryResult, results: BulkEnrollmentOperationResult };
54 changes: 52 additions & 2 deletions provisioning/service/test/_provisioningserviceclient_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ var fakeRegistrationNoEtag = {
};

function _versionQueryString() {
return '?api-version=2019-03-31';
return '?api-version=2021-10-01';
}


Expand Down Expand Up @@ -162,14 +162,42 @@ describe('ProvisioningServiceClient', function () {
}, errors.ArgumentError);
});

it('Throws if \'config.sharedAccessSignature\' is \'' + badConfigProperty + '\'', function () {
it('Throws if \'config.sharedAccessSignature\' is \'' + badConfigProperty + '\' and \'config.tokenCredential\' is not defined', function () {
assert.throws(function () {
return new ProvisioningServiceClient({
host: 'host',
sharedAccessSignature: badConfigProperty
});
}, errors.ArgumentError);
});

it('Throws if \'config.tokenCredential\' is \'' + badConfigProperty + '\' and \'config.sharedAccessSignature\' is not defined', function () {
assert.throws(function () {
return new ProvisioningServiceClient({
host: 'host',
tokenCredential: badConfigProperty
});
}, errors.ArgumentError);
});

it('Throws if \'config.tokenCredential\' is \'' + badConfigProperty + '\' and \'config.sharedAccessSignature\' is not defined', function () {
assert.throws(function () {
return new ProvisioningServiceClient({
host: 'host',
tokenCredential: badConfigProperty
});
}, errors.ArgumentError);
});

it('Throws if \'config.tokenCredential\' is defined and \'config.tokenScope\' is falsy', function () {
assert.throws(function () {
return new ProvisioningServiceClient({
host: 'host',
tokenCredential: {getToken: () => Promise.resolve({token: "fake_token", expiresOnTimestamp: 2456})},
tokenScope: badConfigProperty
});
}, errors.ArgumentError);
});
});

/*Tests_SRS_NODE_PROVISIONING_SERVICE_CLIENT_06_003: [The `ProvisioningServiceClient` constructor shall use the `restApiClient` provided as a second argument if it is provided.] */
Expand All @@ -194,6 +222,28 @@ describe('ProvisioningServiceClient', function () {
});
});

describe('#fromTokenCredential', function () {
it('Returns a new instance of the ProvisioningServiceClient object', function () {
const client = ProvisioningServiceClient.fromTokenCredential(
'my_host.com',
{getToken: () => Promise.resolve({token: 'fake_token', expiresOnTimestamp: 342})}
);
assert.instanceOf(client, ProvisioningServiceClient);
});

it('Correctly creates the config for the RestApiClient', async function () {
const client = ProvisioningServiceClient.fromTokenCredential(
'my_host.com',
{getToken: () => Promise.resolve({token: 'fake_token', expiresOnTimestamp: 342})}
);
assert.strictEqual(client._restApiClient._config.host, 'my_host.com');
assert.strictEqual(client._restApiClient._config.tokenScope, 'https://azure-devices-provisioning.net/.default');
const token = await client._restApiClient._config.tokenCredential.getToken(client._restApiClient._config.tokenScope);
assert.strictEqual(token.token, 'fake_token');
assert.strictEqual(token.expiresOnTimestamp, 342);
});
});

describe('#createOrUpdateIndividualEnrollment', function () {

/*Tests_SRS_NODE_PROVISIONING_SERVICE_CLIENT_06_009: [The `createOrUpdateIndividualEnrollment` method shall throw `ReferenceError` if the `enrollment` argument is falsy.]*/
Expand Down
7 changes: 3 additions & 4 deletions service/src/amqp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ export class Amqp extends EventEmitter implements Client.Transport {
private _fileNotificationEndpoint: string = '/messages/serviceBound/filenotifications';
private _fileNotificationReceiver: ServiceReceiver;
private _fileNotificationErrorListener: (err: Error) => void;
private _iotHubPublicScope: string = 'https://iothubs.azure.net/.default';
private _bearerTokenPrefix: string = 'Bearer ';

/**
Expand Down Expand Up @@ -223,7 +222,7 @@ export class Amqp extends EventEmitter implements Client.Transport {
} else if (this._config.tokenCredential) {
this.getToken().then((accessToken) => {
const tokenValue = this._bearerTokenPrefix + accessToken.token;
this._amqp.putToken(this._iotHubPublicScope, tokenValue, (err) => {
this._amqp.putToken(this._config.tokenScope, tokenValue, (err) => {
if (err) {
/*Codes_SRS_NODE_IOTHUB_SERVICE_AMQP_06_004: [** If `putToken` is not successful then the client will remain disconnected and the callback, if provided, will be invoked with an error object.]*/
this._fsm.transition('disconnecting', err, callback);
Expand Down Expand Up @@ -329,7 +328,7 @@ export class Amqp extends EventEmitter implements Client.Transport {
this._amqp.putToken(audience, updatedSAS, callback);
},
updateAccessToken: (tokenValue, callback) => {
this._amqp.putToken(this._iotHubPublicScope, tokenValue, callback);
this._amqp.putToken(this._config.tokenScope, tokenValue, callback);
},
amqpError: (err) => {
this._fsm.transition('disconnecting', err);
Expand Down Expand Up @@ -580,7 +579,7 @@ export class Amqp extends EventEmitter implements Client.Transport {
* @returns {Promise<AccessToken>} The access token string.
*/
async getToken(): Promise<AccessToken> {
const accessToken = await this._config.tokenCredential.getToken(this._iotHubPublicScope);
const accessToken = await this._config.tokenCredential.getToken(this._config.tokenScope);
if (!accessToken) {
throw new Error('AccessToken creation failed');
}
Expand Down
8 changes: 7 additions & 1 deletion service/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -488,7 +488,8 @@ export class Client extends EventEmitter {
host: hostName,
keyName: '',
sharedAccessSignature: undefined,
tokenCredential: tokenCredential
tokenCredential,
tokenScope: 'https://iothubs.azure.net/.default'
};
return new Client(new transportCtor(config), new RestApiClient(config, packageJson.name + '/' + packageJson.version));
}
Expand Down Expand Up @@ -518,6 +519,11 @@ export namespace Client {
* The token credential used to authenticate the connection with the Azure IoT hub.
*/
tokenCredential: TokenCredential;

/**
* The token scope used to get the token from the TokenCredential object
*/
tokenScope?: string;
}

export interface ServiceReceiver extends Receiver {
Expand Down
5 changes: 2 additions & 3 deletions service/src/job_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,9 +448,8 @@ export class JobClient {
static fromTokenCredential(hostName: string, tokenCredential: TokenCredential): JobClient {
const config = {
host: hostName,
keyName: '',
sharedAccessSignature: undefined,
tokenCredential: tokenCredential
tokenCredential,
tokenScope: 'https://iothubs.azure.net/.default'
};
return new JobClient(new RestApiClient(config, packageJson.name + '/' + packageJson.version));
}
Expand Down
7 changes: 3 additions & 4 deletions service/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1661,11 +1661,10 @@ export class Registry {
* @returns {module:azure-iothub.Registry}
*/
static fromTokenCredential(hostName: string, tokenCredential: TokenCredential): Registry {

const config: Registry.TransportConfig = {
const config = {
host: hostName,
sharedAccessSignature: undefined,
tokenCredential: tokenCredential
tokenCredential,
tokenScope: 'https://iothubs.azure.net/.default'
};
return new Registry(config);
}
Expand Down
9 changes: 6 additions & 3 deletions service/test/_amqp_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ describe('Amqp', function() {
token: "fakeToken",
expiresOnTimestamp: Date.now() + 3600000 //One hour from now
}))
}
},
tokenScope: 'https://iothubs.azure.net/.default'
};
var clock = sinon.useFakeTimers();
try {
Expand Down Expand Up @@ -235,7 +236,8 @@ describe('Amqp', function() {
host: 'hub.host.name',
tokenCredential: {
getToken: sinon.stub().rejects()
}
},
tokenScope: 'https://iothubs.azure.net/.default'
};
var amqp = new Amqp(tokenCredentialConfig, fakeAmqpBase);
try {
Expand Down Expand Up @@ -279,7 +281,8 @@ describe('Amqp', function() {
token: "fakeToken",
expiresOnTimestamp: Date.now() + 3600000 //One hour from now
}))
}
},
tokenScope: 'https://iothubs.azure.net/.default'
};
var transport = new Amqp(tokenCredentialConfig, fakeAmqpBase);
await transport.connect();
Expand Down
1 change: 1 addition & 0 deletions service/test/_client_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ describe('Client', function () {
assert.equal(client._transport._config.tokenCredential, fakeTokenCredential);
assert.equal(client._restApiClient._config.host, 'hub.host.tv');
assert.equal(client._restApiClient._config.tokenCredential, fakeTokenCredential);
assert.equal(client._restApiClient._config.tokenScope, 'https://iothubs.azure.net/.default');
});
});

Expand Down
1 change: 1 addition & 0 deletions service/test/_job_client_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ describe('JobClient', function() {
var client = JobClient.fromTokenCredential("hub.host.tv", fakeTokenCredential);
assert.equal(client._restApiClient._config.host, 'hub.host.tv');
assert.equal(client._restApiClient._config.tokenCredential, fakeTokenCredential);
assert.equal(client._restApiClient._config.tokenScope, 'https://iothubs.azure.net/.default');
});
});

Expand Down
1 change: 1 addition & 0 deletions service/test/_registry_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ describe('Registry', function () {
var registry = Registry.fromTokenCredential("hub.host.tv", fakeTokenCredential);
assert.equal(registry._restApiClient._config.host, 'hub.host.tv');
assert.equal(registry._restApiClient._config.tokenCredential, fakeTokenCredential);
assert.equal(registry._restApiClient._config.tokenScope, 'https://iothubs.azure.net/.default');
});
});

Expand Down

0 comments on commit 1a9a984

Please sign in to comment.