Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added websockets authorizer support #5867

Merged
merged 4 commits into from
Feb 28, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
89 changes: 66 additions & 23 deletions docs/providers/aws/events/websocket.md
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ service: serverless-ws-test
provider: provider:
name: aws name: aws
runtime: nodejs8.10 runtime: nodejs8.10
websocketApiRouteSelectionExpression: $request.body.action # custom routes are selected by the value of the action property in the body websocketsApiName: custom-websockets-api-name
websocketsApiRouteSelectionExpression: $request.body.action # custom routes are selected by the value of the action property in the body


functions: functions:
connectionHandler: connectionHandler:
Expand All @@ -82,31 +83,73 @@ functions:
route: foo # will trigger if $request.body.action === "foo" route: foo # will trigger if $request.body.action === "foo"
``` ```


## Protect your Websocket backend ## Using Authorizers
To protect your websocket connection use an authorizer function on the `$connect`-route handler. It is only possible to use an authorizer function on this route, as this is the only point in time, where it is possible to prevent the ws-client to connect to our backend at all. As the client is not able to connect, the client can also not use the other websocket routes. You can enable an authorizer for your connect route by specifying the `authorizer` key in the websocket event definition.


It is also possible to return a "500" in the connection handler, to prevent the ws-client from connecting. **Note:** AWS only supports authorizers for the `$connect` route.


See this example: ```yml
functions:
connectHandler:
handler: handler.connectHandler
events:
- websocket:
route: $connect
authorizer: auth # references the auth function below
auth:
handler: handler.auth
```


```js Or, if your authorizer function is not managed by this service, you can provide an arn instead:
module.exports.connectionHandler = async (event, context) => {

if(event.requestContext.routeKey === '$connect'){
console.log("NEW CONNECTION INCOMMING");
if (event.queryStringParameters.token !== 'abc') {
console.log('Connection blocked');
return {
statusCode: 500 // currently it is not possible to respond with a 4XX
};
}
}


console.log('Connection ok'); ```yml
return { functions:
statusCode: 200 connectHandler:
}; handler: handler.connectHandler
} events:
- websocket:
route: $connect
authorizer: arn:aws:lambda:us-east-1:1234567890:function:auth
```

By default, the `identitySource` property is set to `route.request.header.Auth`, meaning that your request must include the auth token in the `Auth` header of the request. You can overwrite this by specifying your own `identitySource` configuration:


```yml
functions:
connectHandler:
handler: handler.connectHandler
events:
- websocket:
route: $connect
authorizer:
name: auth
identitySource:
- 'route.request.header.Auth'
- 'route.request.querystring.Auth'

auth:
handler: handler.auth
```
With the above configuration, you can now must pass the auth token in both the `Auth` query string as well as the `Auth` header.

You can also supply an ARN instead of the name when using the object syntax for the authorizer:

```yml
functions:
connectHandler:
handler: handler.connectHandler
events:
- websocket:
route: $connect
authorizer:
arn: arn:aws:lambda:us-east-1:1234567890:function:auth
identitySource:
- 'route.request.header.Auth'
- 'route.request.querystring.Auth'

auth:
handler: handler.auth
``` ```


## Send a message to a ws-client ## Send a message to a ws-client
Expand Down Expand Up @@ -142,4 +185,4 @@ module.exports.defaultHandler = async (event, context) => {
statusCode: 200 statusCode: 200
}; };
} }
``` ```
8 changes: 8 additions & 0 deletions docs/providers/aws/guide/serverless.yml.md
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ provider:
region: ${opt:region, 'us-east-1'} # Overwrite the default region used. Default is us-east-1 region: ${opt:region, 'us-east-1'} # Overwrite the default region used. Default is us-east-1
stackName: custom-stack-name # Use a custom name for the CloudFormation stack stackName: custom-stack-name # Use a custom name for the CloudFormation stack
apiName: custom-api-name # Use a custom name for the API Gateway API apiName: custom-api-name # Use a custom name for the API Gateway API
websocketsApiName: custom-websockets-api-name # Use a custom name for the websockets API
websocketsApiRouteSelectionExpression: $request.body.route # custom route selection expression
profile: production # The default profile to use with this service profile: production # The default profile to use with this service
memorySize: 512 # Overwrite the default memory size. Default is 1024 memorySize: 512 # Overwrite the default memory size. Default is 1024
timeout: 10 # The default is 6 seconds. Note: API Gateway current maximum is 30 seconds timeout: 10 # The default is 6 seconds. Note: API Gateway current maximum is 30 seconds
Expand Down Expand Up @@ -175,6 +177,12 @@ functions:
identityValidationExpression: someRegex identityValidationExpression: someRegex
- websocket: - websocket:
route: $connect route: $connect
authorizer:
# name: auth NOTE: you can either use "name" or arn" properties
arn: arn:aws:lambda:us-east-1:1234567890:function:auth
identitySource:
- 'route.request.header.Auth'
- 'route.request.querystring.Auth'
- s3: - s3:
bucket: photos bucket: photos
event: s3:ObjectCreated:* event: s3:ObjectCreated:*
Expand Down
4 changes: 4 additions & 0 deletions lib/plugins/aws/lib/naming.js
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ module.exports = {
return 'WebsocketsDeploymentStage'; return 'WebsocketsDeploymentStage';
}, },


getWebsocketsAuthorizerLogicalId(functionName) {
return `${this.getNormalizedAuthorizerName(functionName)}WebsocketsAuthorizer`;
},

// API Gateway // API Gateway
getApiGatewayName() { getApiGatewayName() {
if (this.provider.serverless.service.provider.apiName && if (this.provider.serverless.service.provider.apiName &&
Expand Down
7 changes: 7 additions & 0 deletions lib/plugins/aws/lib/naming.test.js
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ describe('#naming()', () => {
}); });
}); });


describe('#getWebsocketsAuthorizerLogicalId()', () => {
it('should return the websockets authorizer logical id', () => {
expect(sdk.naming.getWebsocketsAuthorizerLogicalId('auth'))
.to.equal('AuthWebsocketsAuthorizer');
});
});

describe('#getApiGatewayName()', () => { describe('#getApiGatewayName()', () => {
it('should return the composition of stage & service name if custom name not provided', () => { it('should return the composition of stage & service name if custom name not provided', () => {
serverless.service.service = 'myService'; serverless.service.service = 'myService';
Expand Down
3 changes: 3 additions & 0 deletions lib/plugins/aws/package/compile/events/websockets/index.js
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const compilePermissions = require('./lib/permissions');
const compileRoutes = require('./lib/routes'); const compileRoutes = require('./lib/routes');
const compileDeployment = require('./lib/deployment'); const compileDeployment = require('./lib/deployment');
const compileStage = require('./lib/stage'); const compileStage = require('./lib/stage');
const compileAuthorizers = require('./lib/authorizers');


class AwsCompileWebsockets { class AwsCompileWebsockets {
constructor(serverless, options) { constructor(serverless, options) {
Expand All @@ -21,6 +22,7 @@ class AwsCompileWebsockets {
validate, validate,
compileApi, compileApi,
compileIntegrations, compileIntegrations,
compileAuthorizers,
compilePermissions, compilePermissions,
compileRoutes, compileRoutes,
compileDeployment, compileDeployment,
Expand All @@ -38,6 +40,7 @@ class AwsCompileWebsockets {
return BbPromise.bind(this) return BbPromise.bind(this)
.then(this.compileApi) .then(this.compileApi)
.then(this.compileIntegrations) .then(this.compileIntegrations)
.then(this.compileAuthorizers)
.then(this.compilePermissions) .then(this.compilePermissions)
.then(this.compileRoutes) .then(this.compileRoutes)
.then(this.compileDeployment) .then(this.compileDeployment)
Expand Down
Original file line number Original file line Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('AwsCompileWebsocketsEvents', () => {
describe('#constructor()', () => { describe('#constructor()', () => {
let compileApiStub; let compileApiStub;
let compileIntegrationsStub; let compileIntegrationsStub;
let compileAuthorizersStub;
let compilePermissionsStub; let compilePermissionsStub;
let compileRoutesStub; let compileRoutesStub;
let compileDeploymentStub; let compileDeploymentStub;
Expand All @@ -45,6 +46,8 @@ describe('AwsCompileWebsocketsEvents', () => {
.stub(awsCompileWebsocketsEvents, 'compileApi').resolves(); .stub(awsCompileWebsocketsEvents, 'compileApi').resolves();
compileIntegrationsStub = sinon compileIntegrationsStub = sinon
.stub(awsCompileWebsocketsEvents, 'compileIntegrations').resolves(); .stub(awsCompileWebsocketsEvents, 'compileIntegrations').resolves();
compileAuthorizersStub = sinon
.stub(awsCompileWebsocketsEvents, 'compileAuthorizers').resolves();
compilePermissionsStub = sinon compilePermissionsStub = sinon
.stub(awsCompileWebsocketsEvents, 'compilePermissions').resolves(); .stub(awsCompileWebsocketsEvents, 'compilePermissions').resolves();
compileRoutesStub = sinon compileRoutesStub = sinon
Expand All @@ -58,6 +61,7 @@ describe('AwsCompileWebsocketsEvents', () => {
afterEach(() => { afterEach(() => {
awsCompileWebsocketsEvents.compileApi.restore(); awsCompileWebsocketsEvents.compileApi.restore();
awsCompileWebsocketsEvents.compileIntegrations.restore(); awsCompileWebsocketsEvents.compileIntegrations.restore();
awsCompileWebsocketsEvents.compileAuthorizers.restore();
awsCompileWebsocketsEvents.compilePermissions.restore(); awsCompileWebsocketsEvents.compilePermissions.restore();
awsCompileWebsocketsEvents.compileRoutes.restore(); awsCompileWebsocketsEvents.compileRoutes.restore();
awsCompileWebsocketsEvents.compileDeployment.restore(); awsCompileWebsocketsEvents.compileDeployment.restore();
Expand Down Expand Up @@ -91,7 +95,8 @@ describe('AwsCompileWebsocketsEvents', () => {
expect(validateStub.calledOnce).to.be.equal(true); expect(validateStub.calledOnce).to.be.equal(true);
expect(compileApiStub.calledAfter(validateStub)).to.be.equal(true); expect(compileApiStub.calledAfter(validateStub)).to.be.equal(true);
expect(compileIntegrationsStub.calledAfter(compileApiStub)).to.be.equal(true); expect(compileIntegrationsStub.calledAfter(compileApiStub)).to.be.equal(true);
expect(compilePermissionsStub.calledAfter(compileIntegrationsStub)).to.be.equal(true); expect(compileAuthorizersStub.calledAfter(compileIntegrationsStub)).to.be.equal(true);
expect(compilePermissionsStub.calledAfter(compileAuthorizersStub)).to.be.equal(true);
expect(compileRoutesStub.calledAfter(compilePermissionsStub)).to.be.equal(true); expect(compileRoutesStub.calledAfter(compilePermissionsStub)).to.be.equal(true);
expect(compileDeploymentStub.calledAfter(compileRoutesStub)).to.be.equal(true); expect(compileDeploymentStub.calledAfter(compileRoutesStub)).to.be.equal(true);
expect(compileStageStub.calledAfter(compileDeploymentStub)).to.be.equal(true); expect(compileStageStub.calledAfter(compileDeploymentStub)).to.be.equal(true);
Expand Down
Original file line number Original file line Diff line number Diff line change
@@ -0,0 +1,33 @@
'use strict';

const _ = require('lodash');
const BbPromise = require('bluebird');

module.exports = {
compileAuthorizers() {
this.validated.events.forEach(event => {
if (!event.authorizer) {
return;
}
const websocketsAuthorizerLogicalId = this.provider.naming
.getWebsocketsAuthorizerLogicalId(event.authorizer.name);

_.merge(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
[websocketsAuthorizerLogicalId]: {
Type: 'AWS::ApiGatewayV2::Authorizer',
Properties: {
ApiId: {
Ref: this.websocketsApiLogicalId,
},
Name: event.authorizer.name,
AuthorizerType: 'REQUEST',
AuthorizerUri: event.authorizer.uri,
IdentitySource: event.authorizer.identitySource,
},
},
});
});

return BbPromise.resolve();
},
};
Original file line number Original file line Diff line number Diff line change
@@ -0,0 +1,109 @@
'use strict';

const expect = require('chai').expect;
const AwsCompileWebsocketsEvents = require('../index');
const Serverless = require('../../../../../../../Serverless');
const AwsProvider = require('../../../../../provider/awsProvider');

describe('#compileAuthorizers()', () => {
let awsCompileWebsocketsEvents;

beforeEach(() => {
const serverless = new Serverless();
serverless.setProvider('aws', new AwsProvider(serverless));
serverless.service.provider.compiledCloudFormationTemplate = { Resources: {} };

awsCompileWebsocketsEvents = new AwsCompileWebsocketsEvents(serverless);

awsCompileWebsocketsEvents.websocketsApiLogicalId
= awsCompileWebsocketsEvents.provider.naming.getWebsocketsApiLogicalId();
});

it('should create an authorizer resource for routes with authorizer definition', () => {
awsCompileWebsocketsEvents.validated = {
events: [
{
functionName: 'First',
route: '$connect',
authorizer: {
name: 'auth',
uri: {
'Fn::Join': ['',
[
'arn:',
{ Ref: 'AWS::Partition' },
':apigateway:',
{ Ref: 'AWS::Region' },
':lambda:path/2015-03-31/functions/',
{ 'Fn::GetAtt': ['AuthLambdaFunction', 'Arn'] },
'/invocations',
],
],
},
identitySource: ['route.request.header.Auth'],
},
},
],
};

return awsCompileWebsocketsEvents.compileAuthorizers().then(() => {
const resources = awsCompileWebsocketsEvents.serverless.service.provider
.compiledCloudFormationTemplate.Resources;

expect(resources).to.deep.equal({
AuthWebsocketsAuthorizer: {
Type: 'AWS::ApiGatewayV2::Authorizer',
Properties: {
ApiId: {
Ref: 'WebsocketsApi',
},
Name: 'auth',
AuthorizerType: 'REQUEST',
AuthorizerUri: {
'Fn::Join': [
'',
[
'arn:',
{
Ref: 'AWS::Partition',
},
':apigateway:',
{
Ref: 'AWS::Region',
},
':lambda:path/2015-03-31/functions/',
{
'Fn::GetAtt': [
'AuthLambdaFunction',
'Arn',
],
},
'/invocations',
],
],
},
IdentitySource: ['route.request.header.Auth'],
},
},
});
});
});

it('should NOT create an authorizer resource for routes with not authorizer definition', () => {
awsCompileWebsocketsEvents.validated = {
events: [
{
functionName: 'First',
route: '$connect',
},
],
};

return awsCompileWebsocketsEvents.compileAuthorizers().then(() => {
const resources = awsCompileWebsocketsEvents.serverless.service.provider
.compiledCloudFormationTemplate.Resources;

expect(resources).to.deep.equal({});
});
});
});