Skip to content

Commit

Permalink
[service-bus, event-hubs] Allowing for a SAS key to be directly speci…
Browse files Browse the repository at this point in the history
…fied in a connection string. (#10951)

Allow for connection strings to be passed that contain a Shared Access Signature. 

This expands core-amqp so it can parse the connection string into a SharedKeyCredential. So connection strings of this form are valid:

`Endpoint=<endpoint>;SharedAccessSignature=SharedAccessSignature sr=<resource>...`

This results in a token that is not renewable and that returns the SAS verbatim.

Fixes #10832
  • Loading branch information
richardpark-msft authored Sep 3, 2020
1 parent fcca834 commit 43bb11a
Show file tree
Hide file tree
Showing 9 changed files with 302 additions and 16 deletions.
44 changes: 42 additions & 2 deletions sdk/core/core-amqp/src/auth/sas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,47 @@ export class SharedKeyCredential {
* @param {string} connectionString - The EventHub/ServiceBus connection string
*/
static fromConnectionString(connectionString: string): SharedKeyCredential {
const parsed = parseConnectionString<ServiceBusConnectionStringModel>(connectionString);
return new SharedKeyCredential(parsed.SharedAccessKeyName, parsed.SharedAccessKey);
const parsed = parseConnectionString<
ServiceBusConnectionStringModel & { SharedAccessSignature: string }
>(connectionString);

if (parsed.SharedAccessSignature == null) {
return new SharedKeyCredential(parsed.SharedAccessKeyName, parsed.SharedAccessKey);
} else {
return new SharedAccessSignatureCredential(parsed.SharedAccessSignature);
}
}
}

/**
* A credential that takes a SharedAccessSignature:
* `SharedAccessSignature sr=<resource>&sig=<signature>&se=<expiry>&skn=<keyname>`
*
* @internal
* @ignore
*/
export class SharedAccessSignatureCredential extends SharedKeyCredential {
private _accessToken: AccessToken;

/**
* @param sharedAccessSignature A shared access signature of the form
* `SharedAccessSignature sr=<resource>&sig=<signature>&se=<expiry>&skn=<keyname>`
*/
constructor(sharedAccessSignature: string) {
super("", "");

this._accessToken = {
token: sharedAccessSignature,
expiresOnTimestamp: 0
};
}

/**
* Retrieve a valid token for authenticaton.
*
* @param _audience Not applicable in SharedAccessSignatureCredential as the token is not re-generated at every invocation of the method
*/
getToken(_audience: string): AccessToken {
return this._accessToken;
}
}
26 changes: 18 additions & 8 deletions sdk/core/core-amqp/src/connectionConfig/connectionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,24 @@ export const ConnectionConfig = {
}
config.entityPath = String(config.entityPath);

if (!config.sharedAccessKeyName) {
throw new TypeError("Missing 'sharedAccessKeyName' in configuration");
if (!isSharedAccessSignature(config.connectionString)) {
if (!config.sharedAccessKeyName) {
throw new TypeError("Missing 'sharedAccessKeyName' in configuration");
}
config.sharedAccessKeyName = String(config.sharedAccessKeyName);

if (!config.sharedAccessKey) {
throw new TypeError("Missing 'sharedAccessKey' in configuration");
}
config.sharedAccessKey = String(config.sharedAccessKey);
}
config.sharedAccessKeyName = String(config.sharedAccessKeyName);

if (!config.sharedAccessKey) {
throw new TypeError("Missing 'sharedAccessKey' in configuration");
}
config.sharedAccessKey = String(config.sharedAccessKey);
}
};

/**
* @internal
* @ignore
*/
export function isSharedAccessSignature(connectionString: string): boolean {
return connectionString.match(/;{0,1}SharedAccessSignature=SharedAccessSignature /) != null;
}
56 changes: 56 additions & 0 deletions sdk/core/core-amqp/test/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import { ConnectionConfig, EventHubConnectionConfig, IotHubConnectionConfig } from "../src";
import * as chai from "chai";
import { isSharedAccessSignature } from "../src/connectionConfig/connectionConfig";
import { SharedAccessSignatureCredential } from "../src/auth/sas";
const should = chai.should();

describe("ConnectionConfig", function() {
Expand Down Expand Up @@ -429,4 +431,58 @@ describe("ConnectionConfig", function() {
});
});
});

describe("SharedAccessSignature", () => {
[
"Endpoint=hello;SharedAccessSignature=SharedAccessSignature sr=<resource>&sig=someb64=&se=<expiry>&skn=<keyname>",
"SharedAccessSignature=SharedAccessSignature sr=<resource>&sig=someb64=&se=<expiry>&skn=<keyname>"
].forEach((validCs, i) => {
it(`Valid shared access signatures[${i}]`, () => {
should.equal(isSharedAccessSignature(validCs), true);
});
});

[
"Endpoint=hello;HaredAccessSignature=SharedAccessSignature sr=<resource>&sig=someb64=&se=<expiry>&skn=<keyname>",
"SharedAccessSignature=haredAccessSignature sr=<resource>&sig=someb64=&se=<expiry>&skn=<keyname>;Endpoint=asdfasdf"
].forEach((invalidCs, i) => {
it(`Invalid shared access signature[${i}]`, () => {
should.equal(isSharedAccessSignature(invalidCs), false);
});
});

it("skip sharedAccessKey fields when using SharedAccessSignature", () => {
// skip validating the sharedKey related fields in connection config
ConnectionConfig.validate({
endpoint: "unused for this test",
host: "unused for this test",
sharedAccessKey: "",
sharedAccessKeyName: "",
connectionString: "Endpoint=hello;SharedAccessSignature=SharedAccessSignature hellosig"
});
});

it("SharedAccessSignatureCredential", () => {
const sasCred = new SharedAccessSignatureCredential("SharedAccessSignature se=<blah>");
const accessToken = sasCred.getToken("audience isn't used");

should.equal(
accessToken.token,
"SharedAccessSignature se=<blah>",
"SAS URI we were constructed with should just be returned verbatim without interpretation (and the audience is ignored)"
);

should.equal(
accessToken.expiresOnTimestamp,
0,
"SAS URI always returns 0 for expiry (ignoring what's in the SAS token)"
);

// these just exist because we're a SharedKeyCredential but we don't currently
// parse any attributes out (they're available but we've carved out a spot so
// they're not needed.)
should.equal(sasCred.key, "");
should.equal(sasCred.keyName, "");
});
});
});
3 changes: 2 additions & 1 deletion sdk/eventhub/event-hubs/src/connectionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,9 @@ export function createConnectionContext(
config = EventHubConnectionConfig.create(connectionString, eventHubName);
options = credentialOrOptions;
}

// Since connectionstring was passed, create a SharedKeyCredential
credential = new SharedKeyCredential(config.sharedAccessKeyName, config.sharedAccessKey);
credential = SharedKeyCredential.fromConnectionString(connectionString);
} else {
// host, eventHubName, a TokenCredential and/or options were passed to constructor
const eventHubName = eventHubNameOrOptions;
Expand Down
9 changes: 7 additions & 2 deletions sdk/eventhub/event-hubs/src/linkEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,13 @@ export class LinkEntity {
if (this._context.tokenCredential instanceof SharedKeyCredential) {
tokenObject = this._context.tokenCredential.getToken(this.audience);
tokenType = TokenType.CbsTokenTypeSas;
// renew sas token in every 45 minutess
this._tokenTimeoutInMs = (3600 - 900) * 1000;

// expiresOnTimestamp can be 0 if the token is not meant to be renewed
// (ie, SharedAccessSignatureCredential)
if (tokenObject.expiresOnTimestamp > 0) {
// renew sas token in every 45 minutess
this._tokenTimeoutInMs = (3600 - 900) * 1000;
}
} else {
const aadToken = await this._context.tokenCredential.getToken(Constants.aadEventHubsScope);
if (!aadToken) {
Expand Down
70 changes: 70 additions & 0 deletions sdk/eventhub/event-hubs/test/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import {
parseConnectionString,
ServiceBusConnectionStringModel,
SharedKeyCredential
} from "@azure/core-amqp";
import { EventHubConsumerClient } from "../src/eventHubConsumerClient";
import { EnvVarKeys, getEnvVars } from "./utils/testUtils";
import chai from "chai";
import { EventHubProducerClient } from "../src";

const should = chai.should();
const env = getEnvVars();

describe("Authentication via SAS", () => {
const service = {
connectionString: env[EnvVarKeys.EVENTHUB_CONNECTION_STRING],
path: env[EnvVarKeys.EVENTHUB_NAME],
fqdn: parseConnectionString<ServiceBusConnectionStringModel>(
env[EnvVarKeys.EVENTHUB_CONNECTION_STRING]
).Endpoint.replace(/\/+$/, "")
};

before(() => {
should.exist(
env[EnvVarKeys.EVENTHUB_CONNECTION_STRING],
"define EVENTHUB_CONNECTION_STRING in your environment before running integration tests."
);
should.exist(
env[EnvVarKeys.EVENTHUB_NAME],
"define EVENTHUB_NAME in your environment before running integration tests."
);
});

it("EventHubConsumerClient", async () => {
const sasConnectionString = getSasConnectionString();

const consumerClient = new EventHubConsumerClient(
"$Default",
sasConnectionString,
service.path
);

const properties = await consumerClient.getEventHubProperties();
should.exist(properties);

await consumerClient.close();
});

it("EventHubProducerClient", async () => {
const sasConnectionString = getSasConnectionString();

const producerClient = new EventHubProducerClient(sasConnectionString, service.path);

const properties = await producerClient.getEventHubProperties();
should.exist(properties);

await producerClient.close();
});

function getSasConnectionString(): string {
const sas = SharedKeyCredential.fromConnectionString(service.connectionString).getToken(
`${service.fqdn}/${service.path}`
).token;

return `Endpoint=${service.fqdn}/;SharedAccessSignature=${sas}`;
}
});
3 changes: 2 additions & 1 deletion sdk/servicebus/service-bus/src/constructorHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ export function createConnectionContextForConnectionString(
config.webSocketEndpointPath = "$servicebus/websocket";
config.webSocketConstructorOptions = options?.webSocketOptions?.webSocketConstructorOptions;

const credential = new SharedKeyCredential(config.sharedAccessKeyName, config.sharedAccessKey);
const credential = SharedKeyCredential.fromConnectionString(connectionString);

validate(config);
return ConnectionContext.create(config, credential, options);
}
Expand Down
9 changes: 7 additions & 2 deletions sdk/servicebus/service-bus/src/core/linkEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,13 @@ export abstract class LinkEntity<LinkT extends Receiver | AwaitableSender | Requ
if (this._context.tokenCredential instanceof SharedKeyCredential) {
tokenObject = this._context.tokenCredential.getToken(this.audience);
tokenType = TokenType.CbsTokenTypeSas;
// renew sas token in every 45 minutess
this._tokenTimeout = (3600 - 900) * 1000;

// expiresOnTimestamp can be 0 if the token is not meant to be renewed
// (ie, SharedAccessSignatureCredential)
if (tokenObject.expiresOnTimestamp > 0) {
// renew sas token in every 45 minutes
this._tokenTimeout = (3600 - 900) * 1000;
}
} else {
const aadToken = await this._context.tokenCredential.getToken(Constants.aadServiceBusScope);
if (!aadToken) {
Expand Down
98 changes: 98 additions & 0 deletions sdk/servicebus/service-bus/test/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import { ServiceBusConnectionStringModel, SharedKeyCredential } from "@azure/core-amqp";
import chai from "chai";
import { parseConnectionString } from "rhea-promise";
import { ServiceBusReceiver } from "../src/receivers/receiver";
import { ServiceBusClient } from "../src/serviceBusClient";
import { ReceivedMessage } from "../src/serviceBusMessage";
import { getEnvVars } from "./utils/envVarUtils";
import { TestClientType } from "./utils/testUtils";
import {
createServiceBusClientForTests,
ServiceBusClientForTests,
ServiceBusTestHelpers
} from "./utils/testutils2";
const assert = chai.assert;

type UnpackReturnType<T extends (...args: any) => any> = ReturnType<T> extends Promise<infer U>
? U
: never;

[TestClientType.UnpartitionedQueue, TestClientType.UnpartitionedSubscription].forEach(
(entityType) => {
describe(`Authentication via SAS to ${TestClientType[entityType]}`, () => {
let tempClient: ServiceBusClientForTests;
let entities: UnpackReturnType<ServiceBusTestHelpers["createTestEntities"]>;
let sasConnectionString: string;

before(async () => {
tempClient = await createServiceBusClientForTests();
entities = await tempClient.test.createTestEntities(entityType);

const { SERVICEBUS_CONNECTION_STRING: serviceBusConnectionString } = getEnvVars();

const { Endpoint: fqdn } = parseConnectionString<ServiceBusConnectionStringModel>(
serviceBusConnectionString
);

sasConnectionString = getSasConnectionString(
serviceBusConnectionString,
entities.queue ?? `${entities.topic!}`,
fqdn.replace(/\/+$/, "")
);
});

after(async () => {
await tempClient.test.afterEach();
await tempClient.test.after();
});

it("ServiceBusClient", async () => {
const client = new ServiceBusClient(sasConnectionString);

const sender = await tempClient.createSender(entities.queue ?? entities.topic!);

await sender.sendMessages({
body: "Hello"
});

await sender.close();

let receiver: ServiceBusReceiver<ReceivedMessage>;

if (entities.queue) {
receiver = client.createReceiver(entities.queue!, {
receiveMode: "receiveAndDelete"
});
} else {
receiver = client.createReceiver(entities.topic!, entities.subscription!, {
receiveMode: "receiveAndDelete"
});
}

const messages = await receiver.receiveMessages(1, {
maxWaitTimeInMs: 10 * 1000
});

await receiver.close();

assert.equal(messages.length, 1, "Should have received at least one message");
await client.close();
});

function getSasConnectionString(
connectionString: string,
path: string,
fqdn: string
): string {
const sas = SharedKeyCredential.fromConnectionString(connectionString).getToken(
`${fqdn}/${path}`
).token;

return `Endpoint=${fqdn};SharedAccessSignature=${sas}`;
}
});
}
);

0 comments on commit 43bb11a

Please sign in to comment.