aws-classify-server 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +17 -331
  2. package/lib/cjs/AuthenticatedSessions.d.ts +6 -0
  3. package/lib/cjs/AuthenticatedSessions.js +93 -0
  4. package/lib/cjs/Authentication.d.ts +11 -0
  5. package/lib/cjs/Authentication.js +37 -0
  6. package/lib/cjs/ClassDef.d.ts +2 -1
  7. package/lib/cjs/ClassifyServerless.d.ts +15 -4
  8. package/lib/cjs/ClassifyServerless.js +82 -28
  9. package/lib/cjs/SessionStore.d.ts +3 -0
  10. package/lib/cjs/SessionStore.js +8 -0
  11. package/lib/cjs/aws-classify-common/EndPointsLogging.d.ts +1 -8
  12. package/lib/cjs/aws-classify-common/EndPointsLogging.js +2 -8
  13. package/lib/cjs/aws-classify-common/LambdaRequest.d.ts +1 -6
  14. package/lib/cjs/aws-classify-common/LambdaResponse.d.ts +1 -6
  15. package/lib/cjs/aws-classify-common/index.d.ts +1 -4
  16. package/lib/cjs/aws-classify-common/index.js +16 -5
  17. package/lib/cjs/dispatch.d.ts +4 -2
  18. package/lib/cjs/dispatch.js +24 -7
  19. package/lib/cjs/index.d.ts +3 -1
  20. package/lib/cjs/index.js +2 -1
  21. package/lib/esm/AuthenticatedSessions.d.ts +6 -0
  22. package/lib/esm/AuthenticatedSessions.js +85 -0
  23. package/lib/esm/Authentication.d.ts +11 -0
  24. package/lib/esm/Authentication.js +32 -0
  25. package/lib/esm/ClassDef.d.ts +2 -1
  26. package/lib/esm/ClassifyServerless.d.ts +15 -4
  27. package/lib/esm/ClassifyServerless.js +76 -22
  28. package/lib/esm/SessionStore.d.ts +3 -0
  29. package/lib/esm/SessionStore.js +4 -0
  30. package/lib/esm/aws-classify-common/EndPointsLogging.d.ts +1 -8
  31. package/lib/esm/aws-classify-common/EndPointsLogging.js +1 -8
  32. package/lib/esm/aws-classify-common/LambdaRequest.d.ts +1 -6
  33. package/lib/esm/aws-classify-common/LambdaResponse.d.ts +1 -6
  34. package/lib/esm/aws-classify-common/index.d.ts +1 -4
  35. package/lib/esm/aws-classify-common/index.js +2 -2
  36. package/lib/esm/dispatch.d.ts +4 -2
  37. package/lib/esm/dispatch.js +22 -6
  38. package/lib/esm/index.d.ts +3 -1
  39. package/lib/esm/index.js +1 -1
  40. package/package.json +20 -13
  41. package/yml/authentication-config.js +18 -0
  42. package/yml/functions-authenticated.yml +34 -0
  43. package/yml/provider-iam.yml +1 -0
  44. package/yml/resources-custom-website.yml +5 -0
  45. package/yml/resources-website-authenticated.yml +69 -0
  46. package/yml/resources-website.yml +5 -0
@@ -1,6 +1,16 @@
1
1
  import { ClassDef } from "./ClassDef";
2
- import { APIGatewayProxyEvent, Context } from "aws-lambda";
2
+ import type { RequestContext } from "aws-classify-common";
3
+ import { HttpEvent, ServerAuthenticationOptions } from "./Authentication";
4
+ import { Context } from "aws-lambda";
3
5
  export declare class ClassifyServerless {
6
+ private authentication;
7
+ private readonly contexts;
8
+ private readonly activeContexts;
9
+ private readonly responseContexts;
10
+ constructor();
11
+ configureAuthentication(options: ServerAuthenticationOptions): void;
12
+ get authenticationEnabled(): boolean;
13
+ getRequestContext(response?: object): RequestContext | undefined;
4
14
  logLevel: Partial<typeof EndPointsLogging>;
5
15
  classDefs: Map<any, ClassDef<any, any>>;
6
16
  requests: Map<any, any>;
@@ -9,8 +19,9 @@ export declare class ClassifyServerless {
9
19
  setExpirationMinutes(minutes: number): void;
10
20
  setLogger(log: (_message: string) => void): void;
11
21
  setLogLevel(logLevel: Partial<typeof EndPointsLogging>): void;
12
- registerResponse<T>(responseClass: new () => T, authorizer?: (_endPoint: T, _method: string, _args: IArguments) => Promise<boolean>): void;
13
- dispatch(ev: APIGatewayProxyEvent, context: Context, classes?: {}): Promise<string>;
22
+ registerResponse<T>(responseClass: new () => T, authorizer?: (_endPoint: T, _method: string, _args: IArguments, _context?: RequestContext) => Promise<boolean>): void;
23
+ dispatch(ev: HttpEvent, context: Context, classes?: {}, entry?: 'protected' | 'public'): Promise<string>;
24
+ private dispatchRequest;
14
25
  registerRequest<T>(requestClass: new () => T, classes?: any): new () => T;
15
26
  getSessionId<T>(responseObj: T): any;
16
27
  getUserId<T>(responseObj: T): any;
@@ -24,7 +35,7 @@ export declare class ClassifyServerless {
24
35
  deleteSessions(): Promise<void>;
25
36
  }
26
37
  export declare function getSessionData(sessionId: string, interfaceName?: string): Promise<Record<string, any> | undefined>;
27
- export declare function saveSessionData(sessionId: string, interfaceName?: string, sessionData?: string, connectionId?: string, userId?: string, expirationMinutes?: number): Promise<string>;
38
+ export declare function saveSessionData(sessionId: string, interfaceName?: string, sessionData?: string, connectionId?: string, userId?: string, expirationMinutes?: number, authOwner?: string): Promise<string>;
28
39
  export declare const EndPointsLogging: {
29
40
  create: boolean;
30
41
  connect: boolean;
@@ -8,19 +8,40 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import { deserialize, serialize } from "js-freeze-dry";
11
- import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb";
12
- import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
11
+ import { sessionStore as ddbDocClient } from "./SessionStore";
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { validatePublicSuffix } from "aws-classify-common";
14
+ import { requestContext } from "./Authentication";
15
+ import { ownedSession, sessionOwner, issueConnectionCredential } from "./AuthenticatedSessions";
13
16
  import { ApiGatewayManagementApiClient, PostToConnectionCommand } from "@aws-sdk/client-apigatewaymanagementapi";
14
- const ddbClient = new DynamoDBClient({ region: process.env.DD_REGION, endpoint: process.env.DD_ENDPOINT });
15
- const ddbDocClient = DynamoDBDocument.from(ddbClient);
16
17
  const updateDebounceInterval = 1000 * 60 * 10; // Don't update session unless 10 minutes have passed or data changed
17
18
  export class ClassifyServerless {
18
19
  constructor() {
20
+ this.contexts = new AsyncLocalStorage();
21
+ this.activeContexts = new Set();
22
+ this.responseContexts = new WeakMap();
19
23
  this.logLevel = {};
20
24
  this.classDefs = new Map();
21
25
  this.requests = new Map();
22
26
  this.expirationMinutes = 60 * 24;
23
27
  this.log = msg => console.log(msg);
28
+ if (process.env.AWS_CLASSIFY_AUTHENTICATION === 'true')
29
+ this.configureAuthentication({ publicSuffix: process.env.AWS_CLASSIFY_PUBLIC_SUFFIX || '' });
30
+ }
31
+ configureAuthentication(options) {
32
+ var _a;
33
+ validatePublicSuffix(options.publicSuffix);
34
+ const seconds = (_a = options.connectionCredentialSeconds) !== null && _a !== void 0 ? _a : 60;
35
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300)
36
+ throw new Error('connectionCredentialSeconds must be 1–300');
37
+ this.authentication = Object.freeze(Object.assign(Object.assign({}, options), { connectionCredentialSeconds: seconds }));
38
+ }
39
+ get authenticationEnabled() { return !!this.authentication; }
40
+ getRequestContext(response) {
41
+ const current = this.contexts.getStore();
42
+ if (!current || !this.activeContexts.has(current))
43
+ return undefined;
44
+ return response ? (this.responseContexts.get(response) === current ? current : undefined) : current;
24
45
  }
25
46
  setExpirationMinutes(minutes) {
26
47
  this.expirationMinutes = minutes;
@@ -45,14 +66,37 @@ export class ClassifyServerless {
45
66
  this.classDefs.set(interfaceName, { serverClass: responseClass, clientClass, authorizer });
46
67
  responseClass.__interfaceName__ = interfaceName;
47
68
  }
48
- dispatch(ev, context, classes = {}) {
69
+ dispatch(ev, context, classes = {}, entry = 'protected') {
70
+ return __awaiter(this, void 0, void 0, function* () {
71
+ if (entry === 'public' && !this.authentication)
72
+ throw new Error('Public dispatch requires authentication configuration');
73
+ const trusted = requestContext(ev, this.authentication ? entry : 'legacy', this.authentication);
74
+ this.activeContexts.add(trusted);
75
+ try {
76
+ return yield this.contexts.run(trusted, () => this.dispatchRequest(ev, context, classes, trusted));
77
+ }
78
+ finally {
79
+ this.activeContexts.delete(trusted);
80
+ }
81
+ });
82
+ }
83
+ dispatchRequest(ev, context, classes, trusted) {
49
84
  return __awaiter(this, void 0, void 0, function* () {
50
85
  const request = deserialize(ev.body, classes);
86
+ if (!request || typeof request.interfaceName !== 'string' || typeof request.methodName !== 'string' || !Array.isArray(request.args) || typeof request.sessionId !== 'string')
87
+ throw new Error('Malformed dispatch request');
51
88
  const logName = `${request.interfaceName}.${request.methodName}`;
52
89
  let sessionId = request.sessionId; // May be empty
53
90
  // Pseudo interface/method to request socket access and return a session id to use in connect
54
- // session id will be passed in sec-websocket-protocol on connect request
91
+ // Legacy mode passes the session id; authenticated mode issues a separate credential.
55
92
  if (logName === '$WebSocket.$authorize') {
93
+ if (this.authentication) {
94
+ if (trusted.dispatch !== 'protected')
95
+ throw new Error('Socket authorization requires protected dispatch');
96
+ const session = yield ownedSession(sessionId, sessionOwner(trusted), this.expirationMinutes);
97
+ const data = yield issueConnectionCredential(session, this.authentication.connectionCredentialSeconds, process.env.IS_OFFLINE ? 'ws://localhost:3001' : `wss://${process.env.APIG_ENDPOINT}`);
98
+ return serialize({ data, sessionId: session.sessionId, exception: undefined, cargo: undefined }, classes);
99
+ }
56
100
  const socketResult = yield getSessionData(sessionId, "");
57
101
  if (!socketResult) {
58
102
  sessionId = context.awsRequestId;
@@ -73,15 +117,19 @@ export class ClassifyServerless {
73
117
  const classDef = this.classDefs.get(request.interfaceName);
74
118
  if (!classDef)
75
119
  throw new Error(`No Response defined for ${request.interfaceName}`);
76
- if (!classDef.clientClass.prototype[request.methodName])
120
+ if (request.methodName === 'constructor' || !Object.prototype.hasOwnProperty.call(classDef.clientClass.prototype, request.methodName) || typeof classDef.clientClass.prototype[request.methodName] !== 'function')
77
121
  throw new Error(`${request.methodName} not found in ${request.interfaceName} client request class`);
122
+ if (this.authentication && request.methodName.endsWith(this.authentication.publicSuffix) !== (trusted.dispatch === 'public'))
123
+ throw new Error('Member is not permitted on this dispatch endpoint');
78
124
  // If authorizer passed in make sure request is authorized
79
- if (!classDef.authorizer || (yield classDef.authorizer(request.interfaceName, request.methodName, request.args))) {
125
+ if (!classDef.authorizer || (yield (this.authentication ? classDef.authorizer(request.interfaceName, request.methodName, request.args, trusted) : classDef.authorizer(request.interfaceName, request.methodName, request.args)))) {
80
126
  // Instantiate the class
81
127
  const obj = new classDef.serverClass();
82
128
  // Retrieve session data from DynamoDB base on sessionId in request
83
129
  let orignalSessionData = "";
84
- const result = yield getSessionData(sessionId, request.interfaceName);
130
+ const result = this.authentication ? yield ownedSession(sessionId, sessionOwner(trusted), this.expirationMinutes) : yield getSessionData(sessionId, request.interfaceName);
131
+ if (this.authentication)
132
+ sessionId = result.sessionId;
85
133
  if (!result)
86
134
  sessionId = context.awsRequestId;
87
135
  else {
@@ -91,6 +139,7 @@ export class ClassifyServerless {
91
139
  Object.assign(obj, orignalSessionData);
92
140
  }
93
141
  }
142
+ this.responseContexts.set(obj, trusted);
94
143
  obj.__sessionId__ = sessionId;
95
144
  obj.__connectionId__ = result === null || result === void 0 ? void 0 : result.connectionId;
96
145
  // Call the class method and catch any exceptions it might throw to pass back
@@ -111,7 +160,7 @@ export class ClassifyServerless {
111
160
  }
112
161
  const updatedSessionData = serialize(obj, classes);
113
162
  if (!result || updatedSessionData !== orignalSessionData || (Date.now() > (result.updated + updateDebounceInterval)))
114
- sessionId = yield saveSessionData(sessionId, request.interfaceName, updatedSessionData, undefined, obj.__userId__, this.expirationMinutes);
163
+ sessionId = yield saveSessionData(sessionId, request.interfaceName, updatedSessionData, undefined, obj.__userId__, this.expirationMinutes, this.authentication ? sessionOwner(trusted) : undefined);
115
164
  // Formulate response
116
165
  const lambdaResponse = {
117
166
  data: fnResponse,
@@ -146,7 +195,13 @@ export class ClassifyServerless {
146
195
  try {
147
196
  // Build request
148
197
  const sessionId = this['__sessionId__'];
149
- const connectionId = this['__connectionId__'];
198
+ let connectionId = this['__connectionId__'];
199
+ if (expressServer.authentication && sessionId) {
200
+ const live = yield getSessionData(sessionId);
201
+ if (!(live === null || live === void 0 ? void 0 : live.authOwner) || live.authOwner === 'public' || !(live.expires > Math.floor(Date.now() / 1000)))
202
+ throw new Error('Invalid authenticated notification session');
203
+ connectionId = live.connectionId;
204
+ }
150
205
  if (!sessionId)
151
206
  throw new Error(`You must instantiate ${interfaceName} using the ClassifyServerless.createRequest`);
152
207
  if (!connectionId)
@@ -213,6 +268,8 @@ export class ClassifyServerless {
213
268
  const result = yield getSessionData(sessionId);
214
269
  if (!result)
215
270
  throw new Error(`ClassifyResponse.createRequestForSession: invalid session id`);
271
+ if (this.authentication && (!result.authOwner || result.authOwner === 'public' || !(result.expires > Math.floor(Date.now() / 1000))))
272
+ throw new Error('Invalid authenticated notification session');
216
273
  if (!result.connectionId)
217
274
  throw new Error(`WebSocket handshake not established for session`);
218
275
  // Create request
@@ -230,6 +287,8 @@ export class ClassifyServerless {
230
287
  const result = yield getSessionData(sessionId, interfaceName);
231
288
  if (!result)
232
289
  throw new Error(`ClassifyResponse.createResponse: invalid session id for interface ${interfaceName}`);
290
+ if (this.authentication && (!result.authOwner || result.authOwner === 'public' || !(result.expires > Math.floor(Date.now() / 1000))))
291
+ throw new Error('Invalid authenticated notification session');
233
292
  const sessionData = result[`interface_${interfaceName}`];
234
293
  // Instantiate the class
235
294
  const obj = (new responseClass());
@@ -246,7 +305,7 @@ export class ClassifyServerless {
246
305
  if (updatedSessionData !== sessionData || (Date.now() > (result.updated + updateDebounceInterval))) {
247
306
  if (this.logLevel.calls)
248
307
  this.log(`saving session data for ${interfaceName} with sessionId=${sessionId} connectionId= ${obj.__connectionId__} sessionData=${updatedSessionData}`);
249
- yield saveSessionData(sessionId, interfaceName, updatedSessionData, undefined, undefined, this.expirationMinutes);
308
+ yield saveSessionData(sessionId, interfaceName, updatedSessionData, undefined, undefined, this.expirationMinutes, this.authentication ? result.authOwner : undefined);
250
309
  }
251
310
  return ret;
252
311
  });
@@ -263,7 +322,7 @@ export class ClassifyServerless {
263
322
  },
264
323
  ProjectionExpression: `sessionId`
265
324
  });
266
- return data.Items ? data.Items.map(i => i.sessionId) : [];
325
+ return data.Items ? data.Items.filter(i => !this.authentication || !i.sessionId.startsWith('connection#')).map(i => i.sessionId) : [];
267
326
  }
268
327
  return [];
269
328
  });
@@ -274,7 +333,7 @@ export class ClassifyServerless {
274
333
  TableName: `classifySessionStore.${process.env.DOMAIN}`,
275
334
  ProjectionExpression: `sessionId`
276
335
  });
277
- return data.Items ? data.Items.map(i => i.sessionId) : [];
336
+ return data.Items ? data.Items.filter(i => !this.authentication || !i.sessionId.startsWith('connection#')).map(i => i.sessionId) : [];
278
337
  });
279
338
  }
280
339
  deleteSessionsForUserId(userId) {
@@ -322,14 +381,14 @@ export function getSessionData(sessionId, interfaceName = "") {
322
381
  TableName: `classifySessionStore.${process.env.DOMAIN}`,
323
382
  Key: { sessionId },
324
383
  ConsistentRead: true,
325
- ProjectionExpression: `interface_${interfaceName}, connectionId, userId, updated, expires`
384
+ ProjectionExpression: `interface_${interfaceName}, connectionId, userId, updated, expires, authOwner`
326
385
  });
327
386
  return data.Item;
328
387
  }
329
388
  return undefined;
330
389
  });
331
390
  }
332
- export function saveSessionData(sessionId, interfaceName, sessionData, connectionId, userId, expirationMinutes) {
391
+ export function saveSessionData(sessionId, interfaceName, sessionData, connectionId, userId, expirationMinutes, authOwner) {
333
392
  return __awaiter(this, void 0, void 0, function* () {
334
393
  const updateExpressionComponents = ['updated = :time'];
335
394
  const expressionAttributeValues = { ':time': new Date().getTime() };
@@ -352,12 +411,7 @@ export function saveSessionData(sessionId, interfaceName, sessionData, connectio
352
411
  expressionAttributeValues[':userId'] = userId;
353
412
  }
354
413
  // First try and out data only if the session id exists
355
- yield ddbDocClient.update({
356
- TableName: `classifySessionStore.${process.env.DOMAIN}`,
357
- Key: { sessionId },
358
- UpdateExpression: `set ${updateExpressionComponents.join(", ")}`,
359
- ExpressionAttributeValues: expressionAttributeValues
360
- });
414
+ yield ddbDocClient.update(Object.assign({ TableName: `classifySessionStore.${process.env.DOMAIN}`, Key: { sessionId }, UpdateExpression: `set ${updateExpressionComponents.join(", ")}`, ExpressionAttributeValues: Object.assign(Object.assign({}, expressionAttributeValues), (authOwner ? { ':owner': authOwner, ':now': Math.floor(Date.now() / 1000) } : {})) }, (authOwner ? { ConditionExpression: 'authOwner = :owner AND expires > :now' } : {})));
361
415
  return sessionId;
362
416
  });
363
417
  }
@@ -0,0 +1,3 @@
1
+ import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
2
+ export declare const sessionStore: DynamoDBDocument;
3
+ export declare const sessionTable: () => string;
@@ -0,0 +1,4 @@
1
+ import { DynamoDBDocument } from '@aws-sdk/lib-dynamodb';
2
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
3
+ export const sessionStore = DynamoDBDocument.from(new DynamoDBClient({ region: process.env.DD_REGION, endpoint: process.env.DD_ENDPOINT }));
4
+ export const sessionTable = () => `classifySessionStore.${process.env.DOMAIN}`;
@@ -1,8 +1 @@
1
- export declare const EndPointsLogging: {
2
- create: boolean;
3
- connect: boolean;
4
- exceptions: boolean;
5
- calls: boolean;
6
- requests: boolean;
7
- data: boolean;
8
- };
1
+ export { EndPointsLogging } from "aws-classify-common";
@@ -1,8 +1 @@
1
- export const EndPointsLogging = {
2
- create: true,
3
- connect: true,
4
- exceptions: true,
5
- calls: true,
6
- requests: false,
7
- data: false,
8
- };
1
+ export { EndPointsLogging } from "aws-classify-common";
@@ -1,6 +1 @@
1
- export interface LambdaRequest {
2
- interfaceName: string;
3
- methodName: string;
4
- args: any;
5
- sessionId: string;
6
- }
1
+ export type { LambdaRequest } from "aws-classify-common";
@@ -1,6 +1 @@
1
- export interface LambdaResponse {
2
- data: any;
3
- cargo: any;
4
- exception: string | undefined;
5
- sessionId: string;
6
- }
1
+ export type { LambdaResponse } from "aws-classify-common";
@@ -1,4 +1 @@
1
- export type { LambdaRequest } from './LambdaRequest';
2
- export type { LambdaResponse } from './LambdaResponse';
3
- export { EndPointsLogging } from './EndPointsLogging';
4
- export declare const reqBody: () => never;
1
+ export * from "aws-classify-common";
@@ -1,2 +1,2 @@
1
- export { EndPointsLogging } from './EndPointsLogging';
2
- export const reqBody = () => { throw new Error('Request class not registered'); };
1
+ // Preserve the existing package subpath while sharing the common definitions.
2
+ export * from "aws-classify-common";
@@ -1,7 +1,9 @@
1
+ import { HttpEvent } from './Authentication';
1
2
  import { APIGatewayProxyEvent, Context } from 'aws-lambda';
2
3
  import { ClassifyServerless } from "./index";
3
4
  import { APIGatewayProxyStructuredResultV2 } from "aws-lambda/trigger/api-gateway-proxy";
4
5
  export declare const classifyServerless: ClassifyServerless;
5
- export declare const responseHandler: (event: APIGatewayProxyEvent, context: Context) => Promise<APIGatewayProxyStructuredResultV2>;
6
+ export declare const responseHandler: (event: HttpEvent, context: Context) => Promise<APIGatewayProxyStructuredResultV2>;
7
+ export declare const publicResponseHandler: (event: HttpEvent, context: Context) => Promise<APIGatewayProxyStructuredResultV2>;
6
8
  export declare const webSocketConnect: (event: APIGatewayProxyEvent, _context: Context) => Promise<APIGatewayProxyStructuredResultV2>;
7
- export declare const webSocketDisconnect: (_event: APIGatewayProxyEvent, _context: Context) => Promise<APIGatewayProxyStructuredResultV2>;
9
+ export declare const webSocketDisconnect: (event: APIGatewayProxyEvent, _context: Context) => Promise<APIGatewayProxyStructuredResultV2>;
@@ -7,23 +7,26 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
7
7
  step((generator = generator.apply(thisArg, _arguments || [])).next());
8
8
  });
9
9
  };
10
+ import { attachAuthenticatedSocket, detachAuthenticatedSocket } from './AuthenticatedSessions';
10
11
  import { ClassifyServerless } from "./index";
11
12
  import { serialize } from "js-freeze-dry";
12
13
  import { getSessionData, saveSessionData } from "./ClassifyServerless";
13
14
  export const classifyServerless = new ClassifyServerless();
14
- export const responseHandler = (event, context) => __awaiter(void 0, void 0, void 0, function* () {
15
+ const httpHandler = (event, context, entry) => __awaiter(void 0, void 0, void 0, function* () {
15
16
  try {
16
17
  //console.log(`dispatching`);
17
18
  return {
18
19
  statusCode: 200,
19
- body: (yield classifyServerless.dispatch(event, context)) || "",
20
+ headers: { 'Cache-Control': 'no-store' },
21
+ body: (yield classifyServerless.dispatch(event, context, {}, entry)) || "",
20
22
  };
21
23
  }
22
24
  catch (err) {
23
25
  //console.log(`request.body = ${event.body}`);
24
- console.log(`error = ${err.message} ${err.stack}`);
26
+ console.log('Dispatch failed');
25
27
  return {
26
28
  statusCode: 200,
29
+ headers: { 'Cache-Control': 'no-store' },
27
30
  body: serialize({
28
31
  data: undefined,
29
32
  exception: `Internal Server Error (${err}) - see log at ${new Date()}`,
@@ -33,11 +36,22 @@ export const responseHandler = (event, context) => __awaiter(void 0, void 0, voi
33
36
  };
34
37
  }
35
38
  });
39
+ export const responseHandler = (event, context) => httpHandler(event, context, 'protected');
40
+ export const publicResponseHandler = (event, context) => httpHandler(event, context, 'public');
36
41
  export const webSocketConnect = (event, _context) => __awaiter(void 0, void 0, void 0, function* () {
37
42
  //console.log('connecting');
38
- const sessionId = event.headers['Sec-WebSocket-Protocol'] || "";
43
+ const sessionId = event.headers['Sec-WebSocket-Protocol'] || event.headers['sec-websocket-protocol'] || "";
39
44
  const connectId = event.requestContext.connectionId;
40
45
  //console.log(JSON.stringify(event));
46
+ if (classifyServerless.authenticationEnabled) {
47
+ try {
48
+ yield attachAuthenticatedSocket(sessionId, connectId || '');
49
+ return { statusCode: 200, headers: { 'Sec-WebSocket-Protocol': sessionId }, body: '' };
50
+ }
51
+ catch (_a) {
52
+ return { statusCode: 403, body: 'Connection refused' };
53
+ }
54
+ }
41
55
  const result = yield getSessionData(sessionId); // Make sure session id passed in is valid
42
56
  if (result) {
43
57
  // Save connection id
@@ -54,14 +68,16 @@ export const webSocketConnect = (event, _context) => __awaiter(void 0, void 0, v
54
68
  };
55
69
  }
56
70
  else {
57
- console.log(`invalid sessionId - webSocketConnect sessionId=${sessionId} connectId=${connectId}`);
71
+ console.log('Invalid socket session');
58
72
  return {
59
73
  statusCode: 500,
60
74
  body: 'Opps'
61
75
  };
62
76
  }
63
77
  });
64
- export const webSocketDisconnect = (_event, _context) => __awaiter(void 0, void 0, void 0, function* () {
78
+ export const webSocketDisconnect = (event, _context) => __awaiter(void 0, void 0, void 0, function* () {
79
+ if (classifyServerless.authenticationEnabled && event.requestContext.connectionId)
80
+ yield detachAuthenticatedSocket(event.requestContext.connectionId);
65
81
  //console.log('Disconnect ' + JSON.stringify(event));
66
82
  //console.log(`webSocketDisconnect sessionId=${sessionId} connectId=${connectId}`);
67
83
  return {
@@ -1,3 +1,5 @@
1
1
  export { ClassifyServerless } from './ClassifyServerless';
2
2
  export { ClassifyResponse } from './ClassifyResponse';
3
- export { classifyServerless, webSocketConnect, webSocketDisconnect, responseHandler } from './dispatch';
3
+ export { classifyServerless, webSocketConnect, webSocketDisconnect, responseHandler, publicResponseHandler } from './dispatch';
4
+ export type { ServerAuthenticationOptions, HttpEvent } from './Authentication';
5
+ export type { RequestContext, AuthenticatedIdentity } from 'aws-classify-common';
package/lib/esm/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { ClassifyServerless } from './ClassifyServerless';
2
2
  export { ClassifyResponse } from './ClassifyResponse';
3
- export { classifyServerless, webSocketConnect, webSocketDisconnect, responseHandler } from './dispatch';
3
+ export { classifyServerless, webSocketConnect, webSocketDisconnect, responseHandler, publicResponseHandler } from './dispatch';
package/package.json CHANGED
@@ -1,36 +1,43 @@
1
1
  {
2
2
  "name": "aws-classify-server",
3
- "version": "0.1.8",
3
+ "version": "0.2.0",
4
4
  "main": "./lib/cjs/index.js",
5
5
  "module": "./lib/esm/index.js",
6
6
  "types": "./lib/esm/index.d.ts",
7
7
  "files": [
8
- "/lib", "yml"
8
+ "/lib",
9
+ "yml"
9
10
  ],
10
11
  "description": "Typescript Interface to Lambda and Websockets",
11
12
  "scripts": {
12
- "prepare": "cd tests && cd client && npm run test && cd .. && cd .. && npm run build",
13
+ "prepare": "npm run build",
13
14
  "build": "npm run build:esm && npm run build:cjs",
14
15
  "build:esm": "tsc",
15
16
  "build:cjs": "tsc --module commonjs --outDir lib/cjs"
16
17
  },
17
- "repository": "https://github.com/selsamman/aws-classify",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/selsamman/aws-classify.git",
21
+ "directory": "aws-classify-server"
22
+ },
18
23
  "author": "Sam Elsamman",
19
24
  "license": "MIT",
20
25
  "devDependencies": {
21
26
  "@types/aws-lambda": "^8.10.103",
22
27
  "@types/node": "^18.8.3",
23
- "@typescript-eslint/eslint-plugin": "^5.36.2",
24
- "@typescript-eslint/parser": "^5.36.2",
25
- "aws-lambda": "^1.0.7",
26
- "eslint": "8.22.0",
27
- "typescript": "^4.8.2"
28
+ "@typescript-eslint/eslint-plugin": "^8.71.0",
29
+ "@typescript-eslint/parser": "^8.71.0",
30
+ "eslint": "^8.57.1",
31
+ "typescript": "^4.9.5"
28
32
  },
29
33
  "dependencies": {
30
- "@aws-sdk/client-apigatewaymanagementapi": "^3.180.0",
31
- "@aws-sdk/client-dynamodb": "^3.168.0",
32
- "@aws-sdk/lib-dynamodb": "^3.168.0",
33
- "aws-classify-common": "0.1.0",
34
+ "@aws-sdk/client-apigatewaymanagementapi": "^3.1146.0",
35
+ "@aws-sdk/client-dynamodb": "^3.1146.0",
36
+ "@aws-sdk/lib-dynamodb": "^3.1146.0",
37
+ "aws-classify-common": "0.2.0",
34
38
  "js-freeze-dry": "^1.1.4"
39
+ },
40
+ "engines": {
41
+ "node": ">=20.0.0"
35
42
  }
36
43
  }
@@ -0,0 +1,18 @@
1
+ // Serverless v3/v4 file resolver. Loaded only by the authenticated functions include.
2
+ const {validatePublicSuffix} = require('aws-classify-common');
3
+ module.exports = async ({resolveVariable}) => {
4
+ const config = await resolveVariable('self:custom.awsClassify, null');
5
+ if (!config || !config.authorizer || typeof config.authorizer.name !== 'string' || !config.authorizer.name.trim())
6
+ throw new Error('awsClassify authentication requires an authorizer object with a nonempty name');
7
+ const definitions = await resolveVariable('self:provider.httpApi.authorizers, null');
8
+ if (!definitions || !Object.prototype.hasOwnProperty.call(definitions, config.authorizer.name))
9
+ throw new Error('awsClassify authorizer must be defined in provider.httpApi.authorizers');
10
+ const scopes = config.authorizer.scopes;
11
+ if (scopes !== undefined && (!Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string' && scope.length && !/\s/.test(scope))))
12
+ throw new Error('awsClassify authorizer scopes must be an array of nonempty scope names');
13
+ const publicSuffix = validatePublicSuffix(config.publicSuffix);
14
+ const logging = await resolveVariable('self:provider.logs.websocket, null');
15
+ if (logging && (logging === true || logging.fullExecutionData !== false))
16
+ throw new Error('Authenticated WebSocket logging requires fullExecutionData: false to protect connection credentials');
17
+ return {authorizer: config.authorizer, publicSuffix};
18
+ };
@@ -0,0 +1,34 @@
1
+ responseHandler:
2
+ environment:
3
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
4
+ AWS_CLASSIFY_PUBLIC_SUFFIX: ${file(${self:custom.yml}/authentication-config.js):publicSuffix}
5
+ handler: ${self:custom.directories.responseHandlers}.responseHandler
6
+ events:
7
+ - httpApi:
8
+ path: /api/dispatch
9
+ method: ANY
10
+ authorizer: ${file(${self:custom.yml}/authentication-config.js):authorizer}
11
+ connectHandler:
12
+ environment:
13
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
14
+ AWS_CLASSIFY_PUBLIC_SUFFIX: ${file(${self:custom.yml}/authentication-config.js):publicSuffix}
15
+ handler: ${self:custom.directories.responseHandlers}.webSocketConnect
16
+ events:
17
+ - websocket: $connect
18
+ disconnectHandler:
19
+ environment:
20
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
21
+ AWS_CLASSIFY_PUBLIC_SUFFIX: ${file(${self:custom.yml}/authentication-config.js):publicSuffix}
22
+ handler: ${self:custom.directories.responseHandlers}.webSocketDisconnect
23
+ events:
24
+ - websocket: $disconnect
25
+ publicResponseHandler:
26
+ name: ${self:service}-${sls:stage}-public
27
+ environment:
28
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
29
+ AWS_CLASSIFY_PUBLIC_SUFFIX: ${file(${self:custom.yml}/authentication-config.js):publicSuffix}
30
+ handler: ${self:custom.directories.responseHandlers}.publicResponseHandler
31
+ events:
32
+ - httpApi:
33
+ path: /api/dispatch/public
34
+ method: ANY
@@ -5,4 +5,5 @@ Action:
5
5
  - dynamodb:GetItem
6
6
  - dynamodb:PutItem
7
7
  - dynamodb:UpdateItem
8
+ - dynamodb:DeleteItem
8
9
  Resource: "*"
@@ -50,6 +50,11 @@ Resources:
50
50
  # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html
51
51
  OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
52
52
  ViewerProtocolPolicy: redirect-to-https
53
+ CustomErrorResponses:
54
+ - ErrorCachingMinTTL: 1
55
+ ErrorCode: 403
56
+ ResponseCode: 200
57
+ ResponsePagePath: "/"
53
58
  S3Bucket:
54
59
  Type: AWS::S3::Bucket
55
60
  Properties:
@@ -0,0 +1,69 @@
1
+ Resources:
2
+ CloudFrontOriginAccessIdentity:
3
+ Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
4
+ Properties:
5
+ CloudFrontOriginAccessIdentityConfig:
6
+ Comment: !Join ["-", ["access-identity", !GetAtt S3Bucket.DomainName]]
7
+ CloudFrontDistribution:
8
+ Type: AWS::CloudFront::Distribution
9
+ DependsOn:
10
+ - S3Bucket
11
+ Properties:
12
+ DistributionConfig:
13
+ PriceClass: PriceClass_All
14
+ ViewerCertificate:
15
+ CloudFrontDefaultCertificate: true
16
+ Enabled: true
17
+ HttpVersion: http2
18
+ DefaultRootObject: index.html
19
+ Origins:
20
+ - Id: api
21
+ DomainName: !Join [".", [!Ref HttpApi, "execute-api", !Ref AWS::Region, "amazonaws.com"]]
22
+ CustomOriginConfig:
23
+ OriginProtocolPolicy: https-only
24
+ - Id: static
25
+ DomainName: !GetAtt S3Bucket.RegionalDomainName
26
+ S3OriginConfig:
27
+ OriginAccessIdentity: !Join ["", ["origin-access-identity/cloudfront/", !Ref CloudFrontOriginAccessIdentity]]
28
+ CacheBehaviors:
29
+ - PathPattern: api/*
30
+ TargetOriginId: api
31
+ # Managed-CachingDisabled and AllViewerExceptHostHeader preserve Authorization.
32
+ CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
33
+ OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac
34
+ ViewerProtocolPolicy: redirect-to-https
35
+ AllowedMethods: [GET, HEAD, OPTIONS, PATCH, POST, PUT, DELETE]
36
+ DefaultCacheBehavior:
37
+ TargetOriginId: static
38
+ # 'Managed-CachingOptimized'
39
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html
40
+ CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6
41
+ # 'Managed-CORS-S3Origin'
42
+ # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html
43
+ OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
44
+ ViewerProtocolPolicy: redirect-to-https
45
+ CustomErrorResponses:
46
+ - ErrorCachingMinTTL: 1
47
+ ErrorCode: 403
48
+ S3Bucket:
49
+ Type: AWS::S3::Bucket
50
+ Properties:
51
+ BucketName: ${self:custom.bucketName}
52
+ S3BucketPolicy:
53
+ Type: AWS::S3::BucketPolicy
54
+ Properties:
55
+ Bucket: !Ref S3Bucket
56
+ PolicyDocument:
57
+ Statement:
58
+ - Effect: Allow
59
+ Action: "s3:GetObject"
60
+ Resource: !Join ["", [!GetAtt S3Bucket.Arn, "/*"]]
61
+ Principal:
62
+ CanonicalUser: !GetAtt CloudFrontOriginAccessIdentity.S3CanonicalUserId
63
+ Outputs:
64
+ CloudFrontId:
65
+ Description: CloudFront Id
66
+ Value: !Ref CloudFrontDistribution
67
+ WebsiteUrl:
68
+ Description: Cloudfront URL
69
+ Value: !GetAtt CloudFrontDistribution.DomainName
@@ -46,6 +46,11 @@ Resources:
46
46
  # https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-origin-request-policies.html
47
47
  OriginRequestPolicyId: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
48
48
  ViewerProtocolPolicy: redirect-to-https
49
+ CustomErrorResponses:
50
+ - ErrorCachingMinTTL: 1
51
+ ErrorCode: 403
52
+ ResponseCode: 200
53
+ ResponsePagePath: "/"
49
54
  S3Bucket:
50
55
  Type: AWS::S3::Bucket
51
56
  Properties: