aws-classify-server 0.1.9 → 0.2.2

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 -336
  2. package/lib/cjs/AuthenticatedSessions.d.ts +6 -0
  3. package/lib/cjs/AuthenticatedSessions.js +93 -0
  4. package/lib/cjs/Authentication.d.ts +10 -0
  5. package/lib/cjs/Authentication.js +37 -0
  6. package/lib/cjs/ClassDef.d.ts +3 -1
  7. package/lib/cjs/ClassifyServerless.d.ts +17 -4
  8. package/lib/cjs/ClassifyServerless.js +90 -30
  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 +28 -8
  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 +10 -0
  24. package/lib/esm/Authentication.js +32 -0
  25. package/lib/esm/ClassDef.d.ts +3 -1
  26. package/lib/esm/ClassifyServerless.d.ts +17 -4
  27. package/lib/esm/ClassifyServerless.js +84 -24
  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 +26 -7
  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 +16 -0
  42. package/yml/functions-authenticated.yml +30 -0
  43. package/yml/provider-iam.yml +4 -1
  44. package/yml/resources-custom-website.yml +4 -1
  45. package/yml/resources-website-authenticated.yml +72 -0
  46. package/yml/resources-website.yml +4 -1
@@ -1,6 +1,8 @@
1
+ import type { RequestContext } from "aws-classify-common";
1
2
  import { ClassifyResponse } from "./ClassifyResponse";
2
3
  export interface ClassDef<ServerClass extends ClassifyResponse, ClientClass> {
3
4
  serverClass: new () => ServerClass;
4
5
  clientClass: new () => ClientClass;
5
- authorizer: ((endPoint: ServerClass, method: string, args: IArguments) => Promise<boolean>) | undefined;
6
+ publicMethods: ReadonlySet<string>;
7
+ authorizer: ((endPoint: ServerClass, method: string, args: IArguments, context?: RequestContext) => Promise<boolean>) | undefined;
6
8
  }
@@ -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,11 @@ 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>, options?: {
23
+ publicMethods?: readonly string[];
24
+ }): void;
25
+ dispatch(ev: HttpEvent, context: Context, classes?: {}, entry?: 'protected' | 'public'): Promise<string>;
26
+ private dispatchRequest;
14
27
  registerRequest<T>(requestClass: new () => T, classes?: any): new () => T;
15
28
  getSessionId<T>(responseObj: T): any;
16
29
  getUserId<T>(responseObj: T): any;
@@ -24,7 +37,7 @@ export declare class ClassifyServerless {
24
37
  deleteSessions(): Promise<void>;
25
38
  }
26
39
  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>;
40
+ export declare function saveSessionData(sessionId: string, interfaceName?: string, sessionData?: string, connectionId?: string, userId?: string, expirationMinutes?: number, authOwner?: string): Promise<string>;
28
41
  export declare const EndPointsLogging: {
29
42
  create: boolean;
30
43
  connect: boolean;
@@ -8,19 +8,39 @@ 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 { isPublicEndpoint } 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({});
30
+ }
31
+ configureAuthentication(options) {
32
+ var _a;
33
+ const seconds = (_a = options.connectionCredentialSeconds) !== null && _a !== void 0 ? _a : 60;
34
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > 300)
35
+ throw new Error('connectionCredentialSeconds must be 1–300');
36
+ this.authentication = Object.freeze(Object.assign(Object.assign({}, options), { connectionCredentialSeconds: seconds }));
37
+ }
38
+ get authenticationEnabled() { return !!this.authentication; }
39
+ getRequestContext(response) {
40
+ const current = this.contexts.getStore();
41
+ if (!current || !this.activeContexts.has(current))
42
+ return undefined;
43
+ return response ? (this.responseContexts.get(response) === current ? current : undefined) : current;
24
44
  }
25
45
  setExpirationMinutes(minutes) {
26
46
  this.expirationMinutes = minutes;
@@ -31,7 +51,7 @@ export class ClassifyServerless {
31
51
  setLogLevel(logLevel) {
32
52
  this.logLevel = logLevel;
33
53
  }
34
- registerResponse(responseClass, authorizer) {
54
+ registerResponse(responseClass, authorizer, options) {
35
55
  // Find ultimate base class
36
56
  let clientClass = responseClass;
37
57
  while (Object.getPrototypeOf(clientClass).prototype)
@@ -42,17 +62,47 @@ export class ClassifyServerless {
42
62
  const interfaceName = clientClass['interfaceName'];
43
63
  if (!interfaceName)
44
64
  throw ('ClassifyServerless.registerResponse: Request must have interfaceName as a static property');
45
- this.classDefs.set(interfaceName, { serverClass: responseClass, clientClass, authorizer });
65
+ const methods = Object.getOwnPropertyNames(clientClass.prototype).filter(method => method !== 'constructor' && typeof clientClass.prototype[method] === 'function');
66
+ const decorated = methods.filter(method => isPublicEndpoint(clientClass.prototype[method]));
67
+ if ((options === null || options === void 0 ? void 0 : options.publicMethods) !== undefined && decorated.length)
68
+ throw new Error('Use either @Public() or publicMethods, not both');
69
+ const publicMethods = (options === null || options === void 0 ? void 0 : options.publicMethods) === undefined ? decorated : [...options.publicMethods];
70
+ if (!publicMethods.every(method => typeof method === 'string' && methods.includes(method)) || new Set(publicMethods).size !== publicMethods.length)
71
+ throw new Error('publicMethods must be unique request-class method names');
72
+ this.classDefs.set(interfaceName, { serverClass: responseClass, clientClass, authorizer, publicMethods: new Set(publicMethods) });
46
73
  responseClass.__interfaceName__ = interfaceName;
47
74
  }
48
- dispatch(ev, context, classes = {}) {
75
+ dispatch(ev, context, classes = {}, entry = 'protected') {
76
+ return __awaiter(this, void 0, void 0, function* () {
77
+ if (entry === 'public' && !this.authentication)
78
+ throw new Error('Public dispatch requires authentication configuration');
79
+ const trusted = requestContext(ev, this.authentication ? entry : 'legacy', this.authentication);
80
+ this.activeContexts.add(trusted);
81
+ try {
82
+ return yield this.contexts.run(trusted, () => this.dispatchRequest(ev, context, classes, trusted));
83
+ }
84
+ finally {
85
+ this.activeContexts.delete(trusted);
86
+ }
87
+ });
88
+ }
89
+ dispatchRequest(ev, context, classes, trusted) {
49
90
  return __awaiter(this, void 0, void 0, function* () {
50
91
  const request = deserialize(ev.body, classes);
92
+ if (!request || typeof request.interfaceName !== 'string' || typeof request.methodName !== 'string' || !Array.isArray(request.args) || typeof request.sessionId !== 'string')
93
+ throw new Error('Malformed dispatch request');
51
94
  const logName = `${request.interfaceName}.${request.methodName}`;
52
95
  let sessionId = request.sessionId; // May be empty
53
96
  // 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
97
+ // Legacy mode passes the session id; authenticated mode issues a separate credential.
55
98
  if (logName === '$WebSocket.$authorize') {
99
+ if (this.authentication) {
100
+ if (trusted.dispatch !== 'protected')
101
+ throw new Error('Socket authorization requires protected dispatch');
102
+ const session = yield ownedSession(sessionId, sessionOwner(trusted), this.expirationMinutes);
103
+ const data = yield issueConnectionCredential(session, this.authentication.connectionCredentialSeconds, process.env.IS_OFFLINE ? 'ws://localhost:3001' : `wss://${process.env.APIG_ENDPOINT}`);
104
+ return serialize({ data, sessionId: session.sessionId, exception: undefined, cargo: undefined }, classes);
105
+ }
56
106
  const socketResult = yield getSessionData(sessionId, "");
57
107
  if (!socketResult) {
58
108
  sessionId = context.awsRequestId;
@@ -73,15 +123,19 @@ export class ClassifyServerless {
73
123
  const classDef = this.classDefs.get(request.interfaceName);
74
124
  if (!classDef)
75
125
  throw new Error(`No Response defined for ${request.interfaceName}`);
76
- if (!classDef.clientClass.prototype[request.methodName])
126
+ if (request.methodName === 'constructor' || !Object.prototype.hasOwnProperty.call(classDef.clientClass.prototype, request.methodName) || typeof classDef.clientClass.prototype[request.methodName] !== 'function')
77
127
  throw new Error(`${request.methodName} not found in ${request.interfaceName} client request class`);
128
+ if (this.authentication && classDef.publicMethods.has(request.methodName) !== (trusted.dispatch === 'public'))
129
+ throw new Error('Member is not permitted on this dispatch endpoint');
78
130
  // If authorizer passed in make sure request is authorized
79
- if (!classDef.authorizer || (yield classDef.authorizer(request.interfaceName, request.methodName, request.args))) {
131
+ if (!classDef.authorizer || (yield (this.authentication ? classDef.authorizer(request.interfaceName, request.methodName, request.args, trusted) : classDef.authorizer(request.interfaceName, request.methodName, request.args)))) {
80
132
  // Instantiate the class
81
133
  const obj = new classDef.serverClass();
82
134
  // Retrieve session data from DynamoDB base on sessionId in request
83
135
  let orignalSessionData = "";
84
- const result = yield getSessionData(sessionId, request.interfaceName);
136
+ const result = this.authentication ? yield ownedSession(sessionId, sessionOwner(trusted), this.expirationMinutes) : yield getSessionData(sessionId, request.interfaceName);
137
+ if (this.authentication)
138
+ sessionId = result.sessionId;
85
139
  if (!result)
86
140
  sessionId = context.awsRequestId;
87
141
  else {
@@ -91,6 +145,7 @@ export class ClassifyServerless {
91
145
  Object.assign(obj, orignalSessionData);
92
146
  }
93
147
  }
148
+ this.responseContexts.set(obj, trusted);
94
149
  obj.__sessionId__ = sessionId;
95
150
  obj.__connectionId__ = result === null || result === void 0 ? void 0 : result.connectionId;
96
151
  // Call the class method and catch any exceptions it might throw to pass back
@@ -111,7 +166,7 @@ export class ClassifyServerless {
111
166
  }
112
167
  const updatedSessionData = serialize(obj, classes);
113
168
  if (!result || updatedSessionData !== orignalSessionData || (Date.now() > (result.updated + updateDebounceInterval)))
114
- sessionId = yield saveSessionData(sessionId, request.interfaceName, updatedSessionData, undefined, obj.__userId__, this.expirationMinutes);
169
+ sessionId = yield saveSessionData(sessionId, request.interfaceName, updatedSessionData, undefined, obj.__userId__, this.expirationMinutes, this.authentication ? sessionOwner(trusted) : undefined);
115
170
  // Formulate response
116
171
  const lambdaResponse = {
117
172
  data: fnResponse,
@@ -146,7 +201,13 @@ export class ClassifyServerless {
146
201
  try {
147
202
  // Build request
148
203
  const sessionId = this['__sessionId__'];
149
- const connectionId = this['__connectionId__'];
204
+ let connectionId = this['__connectionId__'];
205
+ if (expressServer.authentication && sessionId) {
206
+ const live = yield getSessionData(sessionId);
207
+ if (!(live === null || live === void 0 ? void 0 : live.authOwner) || live.authOwner === 'public' || !(live.expires > Math.floor(Date.now() / 1000)))
208
+ throw new Error('Invalid authenticated notification session');
209
+ connectionId = live.connectionId;
210
+ }
150
211
  if (!sessionId)
151
212
  throw new Error(`You must instantiate ${interfaceName} using the ClassifyServerless.createRequest`);
152
213
  if (!connectionId)
@@ -213,6 +274,8 @@ export class ClassifyServerless {
213
274
  const result = yield getSessionData(sessionId);
214
275
  if (!result)
215
276
  throw new Error(`ClassifyResponse.createRequestForSession: invalid session id`);
277
+ if (this.authentication && (!result.authOwner || result.authOwner === 'public' || !(result.expires > Math.floor(Date.now() / 1000))))
278
+ throw new Error('Invalid authenticated notification session');
216
279
  if (!result.connectionId)
217
280
  throw new Error(`WebSocket handshake not established for session`);
218
281
  // Create request
@@ -230,6 +293,8 @@ export class ClassifyServerless {
230
293
  const result = yield getSessionData(sessionId, interfaceName);
231
294
  if (!result)
232
295
  throw new Error(`ClassifyResponse.createResponse: invalid session id for interface ${interfaceName}`);
296
+ if (this.authentication && (!result.authOwner || result.authOwner === 'public' || !(result.expires > Math.floor(Date.now() / 1000))))
297
+ throw new Error('Invalid authenticated notification session');
233
298
  const sessionData = result[`interface_${interfaceName}`];
234
299
  // Instantiate the class
235
300
  const obj = (new responseClass());
@@ -246,7 +311,7 @@ export class ClassifyServerless {
246
311
  if (updatedSessionData !== sessionData || (Date.now() > (result.updated + updateDebounceInterval))) {
247
312
  if (this.logLevel.calls)
248
313
  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);
314
+ yield saveSessionData(sessionId, interfaceName, updatedSessionData, undefined, undefined, this.expirationMinutes, this.authentication ? result.authOwner : undefined);
250
315
  }
251
316
  return ret;
252
317
  });
@@ -263,7 +328,7 @@ export class ClassifyServerless {
263
328
  },
264
329
  ProjectionExpression: `sessionId`
265
330
  });
266
- return data.Items ? data.Items.map(i => i.sessionId) : [];
331
+ return data.Items ? data.Items.filter(i => !this.authentication || !i.sessionId.startsWith('connection#')).map(i => i.sessionId) : [];
267
332
  }
268
333
  return [];
269
334
  });
@@ -274,7 +339,7 @@ export class ClassifyServerless {
274
339
  TableName: `classifySessionStore.${process.env.DOMAIN}`,
275
340
  ProjectionExpression: `sessionId`
276
341
  });
277
- return data.Items ? data.Items.map(i => i.sessionId) : [];
342
+ return data.Items ? data.Items.filter(i => !this.authentication || !i.sessionId.startsWith('connection#')).map(i => i.sessionId) : [];
278
343
  });
279
344
  }
280
345
  deleteSessionsForUserId(userId) {
@@ -322,14 +387,14 @@ export function getSessionData(sessionId, interfaceName = "") {
322
387
  TableName: `classifySessionStore.${process.env.DOMAIN}`,
323
388
  Key: { sessionId },
324
389
  ConsistentRead: true,
325
- ProjectionExpression: `interface_${interfaceName}, connectionId, userId, updated, expires`
390
+ ProjectionExpression: `interface_${interfaceName}, connectionId, userId, updated, expires, authOwner`
326
391
  });
327
392
  return data.Item;
328
393
  }
329
394
  return undefined;
330
395
  });
331
396
  }
332
- export function saveSessionData(sessionId, interfaceName, sessionData, connectionId, userId, expirationMinutes) {
397
+ export function saveSessionData(sessionId, interfaceName, sessionData, connectionId, userId, expirationMinutes, authOwner) {
333
398
  return __awaiter(this, void 0, void 0, function* () {
334
399
  const updateExpressionComponents = ['updated = :time'];
335
400
  const expressionAttributeValues = { ':time': new Date().getTime() };
@@ -352,12 +417,7 @@ export function saveSessionData(sessionId, interfaceName, sessionData, connectio
352
417
  expressionAttributeValues[':userId'] = userId;
353
418
  }
354
419
  // 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
- });
420
+ 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
421
  return sessionId;
362
422
  });
363
423
  }
@@ -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,37 +7,54 @@ 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
+ // Do not reflect parse, database, or framework errors to an untrusted caller.
27
+ // Operators still receive the original error in Lambda's logs.
28
+ console.error('Dispatch failed', err);
25
29
  return {
26
30
  statusCode: 200,
31
+ headers: { 'Cache-Control': 'no-store' },
27
32
  body: serialize({
28
33
  data: undefined,
29
- exception: `Internal Server Error (${err}) - see log at ${new Date()}`,
34
+ // Keep the existing serialized-error / HTTP-200 contract for clients.
35
+ exception: 'Internal Server Error',
30
36
  cargo: undefined,
31
37
  sessionId: undefined
32
38
  })
33
39
  };
34
40
  }
35
41
  });
42
+ export const responseHandler = (event, context) => httpHandler(event, context, 'protected');
43
+ export const publicResponseHandler = (event, context) => httpHandler(event, context, 'public');
36
44
  export const webSocketConnect = (event, _context) => __awaiter(void 0, void 0, void 0, function* () {
37
45
  //console.log('connecting');
38
- const sessionId = event.headers['Sec-WebSocket-Protocol'] || "";
46
+ const sessionId = event.headers['Sec-WebSocket-Protocol'] || event.headers['sec-websocket-protocol'] || "";
39
47
  const connectId = event.requestContext.connectionId;
40
48
  //console.log(JSON.stringify(event));
49
+ if (classifyServerless.authenticationEnabled) {
50
+ try {
51
+ yield attachAuthenticatedSocket(sessionId, connectId || '');
52
+ return { statusCode: 200, headers: { 'Sec-WebSocket-Protocol': sessionId }, body: '' };
53
+ }
54
+ catch (_a) {
55
+ return { statusCode: 403, body: 'Connection refused' };
56
+ }
57
+ }
41
58
  const result = yield getSessionData(sessionId); // Make sure session id passed in is valid
42
59
  if (result) {
43
60
  // Save connection id
@@ -54,14 +71,16 @@ export const webSocketConnect = (event, _context) => __awaiter(void 0, void 0, v
54
71
  };
55
72
  }
56
73
  else {
57
- console.log(`invalid sessionId - webSocketConnect sessionId=${sessionId} connectId=${connectId}`);
74
+ console.log('Invalid socket session');
58
75
  return {
59
76
  statusCode: 500,
60
77
  body: 'Opps'
61
78
  };
62
79
  }
63
80
  });
64
- export const webSocketDisconnect = (_event, _context) => __awaiter(void 0, void 0, void 0, function* () {
81
+ export const webSocketDisconnect = (event, _context) => __awaiter(void 0, void 0, void 0, function* () {
82
+ if (classifyServerless.authenticationEnabled && event.requestContext.connectionId)
83
+ yield detachAuthenticatedSocket(event.requestContext.connectionId);
65
84
  //console.log('Disconnect ' + JSON.stringify(event));
66
85
  //console.log(`webSocketDisconnect sessionId=${sessionId} connectId=${connectId}`);
67
86
  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.9",
3
+ "version": "0.2.2",
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.2",
34
38
  "js-freeze-dry": "^1.1.4"
39
+ },
40
+ "engines": {
41
+ "node": ">=20.0.0"
35
42
  }
36
43
  }
@@ -0,0 +1,16 @@
1
+ // Serverless v3/v4 file resolver. Loaded only by the authenticated functions include.
2
+ module.exports = async ({resolveVariable}) => {
3
+ const config = await resolveVariable('self:custom.awsClassify, null');
4
+ if (!config || !config.authorizer || typeof config.authorizer.name !== 'string' || !config.authorizer.name.trim())
5
+ throw new Error('awsClassify authentication requires an authorizer object with a nonempty name');
6
+ const definitions = await resolveVariable('self:provider.httpApi.authorizers, null');
7
+ if (!definitions || !Object.prototype.hasOwnProperty.call(definitions, config.authorizer.name))
8
+ throw new Error('awsClassify authorizer must be defined in provider.httpApi.authorizers');
9
+ const scopes = config.authorizer.scopes;
10
+ if (scopes !== undefined && (!Array.isArray(scopes) || !scopes.every(scope => typeof scope === 'string' && scope.length && !/\s/.test(scope))))
11
+ throw new Error('awsClassify authorizer scopes must be an array of nonempty scope names');
12
+ const logging = await resolveVariable('self:provider.logs.websocket, null');
13
+ if (logging && (logging === true || logging.fullExecutionData !== false))
14
+ throw new Error('Authenticated WebSocket logging requires fullExecutionData: false to protect connection credentials');
15
+ return {authorizer: config.authorizer};
16
+ };
@@ -0,0 +1,30 @@
1
+ responseHandler:
2
+ environment:
3
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
4
+ handler: ${self:custom.directories.responseHandlers}.responseHandler
5
+ events:
6
+ - httpApi:
7
+ path: /api/dispatch
8
+ method: ANY
9
+ authorizer: ${file(${self:custom.yml}/authentication-config.js):authorizer}
10
+ connectHandler:
11
+ environment:
12
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
13
+ handler: ${self:custom.directories.responseHandlers}.webSocketConnect
14
+ events:
15
+ - websocket: $connect
16
+ disconnectHandler:
17
+ environment:
18
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
19
+ handler: ${self:custom.directories.responseHandlers}.webSocketDisconnect
20
+ events:
21
+ - websocket: $disconnect
22
+ publicResponseHandler:
23
+ name: ${self:service}-${sls:stage}-public
24
+ environment:
25
+ AWS_CLASSIFY_AUTHENTICATION: 'true'
26
+ handler: ${self:custom.directories.responseHandlers}.publicResponseHandler
27
+ events:
28
+ - httpApi:
29
+ path: /api/dispatch/public
30
+ method: ANY
@@ -5,4 +5,7 @@ Action:
5
5
  - dynamodb:GetItem
6
6
  - dynamodb:PutItem
7
7
  - dynamodb:UpdateItem
8
- Resource: "*"
8
+ - dynamodb:DeleteItem
9
+ Resource:
10
+ - !GetAtt AWSClassifySessionTable.Arn
11
+ - !Join ["", [!GetAtt AWSClassifySessionTable.Arn, "/index/*"]]
@@ -84,9 +84,12 @@ Resources:
84
84
  - CloudFrontDistribution
85
85
  - DomainName
86
86
  Outputs:
87
+ ContentBucket:
88
+ Description: Content bucket
89
+ Value: !Ref S3Bucket
87
90
  CloudFrontId:
88
91
  Description: CloudFront Id
89
92
  Value: !Ref CloudFrontDistribution
90
93
  WebsiteUrl:
91
94
  Description: Cloudfront URL
92
- Value: !GetAtt CloudFrontDistribution.DomainName
95
+ Value: !GetAtt CloudFrontDistribution.DomainName