Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

This is a Lambda@Edge function for authorizing access to private room resources in educandu. It serves as an authentication gateway that verifies user sessions before allowing access to protected media files stored in CloudFront/CDN.

The package has dual purposes:
1. Deployed as a Lambda@Edge function on AWS CloudFront
2. Published as an npm package (`@educandu/rooms-auth-lambda`) for integration testing

## Development Commands

### Common Tasks
- **Lint**: `gulp lint`
- **Fix linting issues**: `gulp fix`
- **Run tests**: `gulp test`
- **Run tests in watch mode**: `gulp testWatch`
- **Build**: `gulp build` (outputs bundled `dist/index.js` and `pack/lambda.zip`)

### Development Server
- **Start dev server**: `gulp serve` (runs on port 10000)
- **Watch mode**: `gulp watch` (rebuilds and restarts on changes)

The dev server requires these environment variables:
```sh
export SESSION_COOKIE_NAME="SESSION_ID"
export WEBSITE_BASE_URL="http://localhost:3000"
export X_ROOMS_AUTH_SECRET="<secret>"
```

### Running Single Tests
Tests use Vitest. Run a single test file:
```sh
npx vitest run src/lambda/index.spec.js
```

Run tests matching a pattern:
```sh
npx vitest run -t "pattern"
```

## Architecture

### Core Authentication Flow

The Lambda@Edge handler ([src/lambda/index.js](src/lambda/index.js)) intercepts CloudFront requests and:

1. **Request Analysis**: Parses the request URI to determine resource type (via [request-helper.js](src/lambda/request-helper.js)):
- `/room-media/{roomId}/*` - Room-specific media files
- `/document-input-media/{roomId}/{documentInputId}/*` - Document input media files
- `/media-trash/*` - Deleted files media in trash

2. **Cookie Extraction**: Extracts session cookie from request headers

3. **Authorization**: Makes API call to educandu website to verify access:
- Sends session cookie and `x-rooms-auth-secret` header
- Checks response status (200/401/403/500)

4. **Response**:
- 200: Returns original request (allows CloudFront to serve file)
- 401: Redirects to login page with return URL
- 403: Returns forbidden response
- Other: Returns 500 error

### Key Components

- **[src/lambda/index.js](src/lambda/index.js)**: Main Lambda@Edge handler
- **[src/lambda/website-api-client.js](src/lambda/website-api-client.js)**: Makes authorization API calls to educandu website
- **[src/lambda/request-helper.js](src/lambda/request-helper.js)**: Parses CloudFront requests to extract resource type and IDs
- **[src/lambda/response-helper.js](src/lambda/response-helper.js)**: Generates CloudFront-compatible response objects
- **[src/lambda/urls.js](src/lambda/urls.js)**: Constructs API endpoint URLs
- **[src/lambda/config.js](src/lambda/config.js)**: Environment variable configuration
- **[src/dev-server/](src/dev-server/)**: Express-based local development server that simulates Lambda@Edge

### Build System

Uses Gulp with `@educandu/dev-tools`:
- **esbuild**: Bundles lambda code into single CommonJS file for AWS deployment (Node 16 target)
- **Bundling**: Entry point is `src/lambda/index.js`, outputs to `dist/index.js` (CommonJS format)
- **Packaging**: Creates `pack/lambda.zip` containing the bundled file for AWS deployment

### Environment Variables

Required variables (configured in [src/lambda/config.js](src/lambda/config.js)):
- `SESSION_COOKIE_NAME`: Name of the session cookie to extract
- `WEBSITE_BASE_URL`: Base URL of the educandu website API
- `X_ROOMS_AUTH_SECRET`: Shared secret for authenticating Lambda to website API
- `DISABLE_LOGGING`: Optional, set to "true" to disable logging, used in tests

**Important**: Lambda@Edge doesn't support environment variables natively. Values must be injected into the bundled code after deployment by prepending `process.env` assignments at the top of `dist/index.js`.

### Testing

- Test files use `.spec.js` suffix
- Tests use Vitest with Sinon for mocking
- Coverage excludes dev-server and website-api-client
- Test setup in [src/test-setup.js](src/test-setup.js)

### AWS Lambda@Edge Constraints

- Only GET and HEAD requests are allowed (enforced in CloudFront config)
- Uses CommonJS format (not ESM) for AWS compatibility
- Target is Node 16 (Lambda@Edge restriction)
- Uses `phin` library as polyfill for `fetch` (until Lambda@Edge supports Node 18+)

## Code Style

- Uses ESLint with educandu dev-tools configuration
- ES modules (ESM) in source code
- CommonJS in bundled output for Lambda@Edge
2 changes: 1 addition & 1 deletion src/dev-server/middlewares.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export function lambdaMiddleware(cdnBaseUrl) {
const proxy = httpProxy.createProxyServer({ target: cdnBaseUrl });

return async (req, res, next) => {
if ((/^\/(room-media|document-input-media)\/.+$/).test(req.url)) {
if ((/^\/(room-media|document-input-media|media-trash)\/.+$/).test(req.url)) {
// eslint-disable-next-line no-console
console.log('Sending request to Lambda@Edge');
try {
Expand Down
1 change: 0 additions & 1 deletion src/lambda/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,3 @@ if (!X_ROOMS_AUTH_SECRET) {
export const CLOUDFRONT_PROTO = new URL(WEBSITE_BASE_URL).protocol;

export const DISABLE_LOGGING = process.env.DISABLE_LOGGING === true.toString();

2 changes: 1 addition & 1 deletion src/lambda/cookie-helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export function parseCookie(headers, cookieName) {
});
}

return parsedCookie[cookieName];
return parsedCookie[cookieName] ?? null;
}
33 changes: 17 additions & 16 deletions src/lambda/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { inspect } from 'node:util';
import { logger } from './logging.js';
import { parseCookie } from './cookie-helper.js';
import { SESSION_COOKIE_NAME } from './config.js';
import WebsiteApiClient from './website-api-client.js';
import { analyzeRequest, REQUEST_TYPE } from './request-helper.js';
import { forbiddenResponse, internalServerErrorResponse, loginRedirectResponse, notFoundResponse } from './response-helper.js';

const websiteApiClient = new WebsiteApiClient();
Expand All @@ -16,31 +15,33 @@ export async function handler(event, _context, callback) {
return callback(null, internalServerErrorResponse());
}

let roomId;
let documentInputId;
roomId = ((/^\/room-media\/([^/]+)\/.+$/).exec(request.uri) || [])[1];
if (roomId) {
documentInputId = null;
} else {
[roomId, documentInputId] = ((/^\/document-input-media\/([^/]+)\/([^/]+)\/.+$/).exec(request.uri) || []).slice(1, 3);
}
const { requestType, roomId, documentInputId, sessionCookie } = analyzeRequest(request);

if (!roomId) {
if (requestType === REQUEST_TYPE.unknown) {
// Request path does not match any of the required patterns -> 404
return callback(null, notFoundResponse());
}

const sessionCookie = parseCookie(request.headers, SESSION_COOKIE_NAME);
if (!sessionCookie) {
// If there is no cookie, we redirect to the login page, so the user can login and then be redirected back to the room resource
// If there is no cookie, we redirect to the login page, so the user can login and then be redirected back to the requested resource
return callback(null, loginRedirectResponse(request));
}

let verificationResponse;
try {
verificationResponse = documentInputId
? await websiteApiClient.callDocumentInputAccessAuthEndpoint(documentInputId, sessionCookie)
: await websiteApiClient.callRoomAccessAuthEndpoint(roomId, sessionCookie);
switch (requestType) {
case REQUEST_TYPE.roomMedia:
verificationResponse = await websiteApiClient.callRoomAccessAuthEndpoint(roomId, sessionCookie);
break;
case REQUEST_TYPE.documentInputMedia:
verificationResponse = await websiteApiClient.callDocumentInputAccessAuthEndpoint(documentInputId, sessionCookie);
break;
case REQUEST_TYPE.mediaTrash:
verificationResponse = await websiteApiClient.callMediaTrashAccessAuthEndpoint(sessionCookie);
break;
default:
throw new Error(`Unexpected request type: '${requestType}'`);
}
} catch (err) {
logger.error(inspect(err));
return callback(null, internalServerErrorResponse());
Expand Down
29 changes: 29 additions & 0 deletions src/lambda/request-helper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { parseCookie } from './cookie-helper.js';
import { SESSION_COOKIE_NAME } from './config.js';

export const REQUEST_TYPE = {
roomMedia: 'roomMedia',
documentInputMedia: 'documentInputMedia',
mediaTrash: 'mediaTrash',
unknown: 'unknown'
};

export function analyzeRequest(request) {
const sessionCookie = parseCookie(request.headers, SESSION_COOKIE_NAME);

const roomMediaRoomId = ((/^\/room-media\/([^/]+)\/.+$/).exec(request.uri) || [])[1];
if (roomMediaRoomId) {
return { requestType: REQUEST_TYPE.roomMedia, roomId: roomMediaRoomId, documentInputId: null, sessionCookie };
}

const [documentInputMediaRoomId, documentInputId] = ((/^\/document-input-media\/([^/]+)\/([^/]+)\/.+$/).exec(request.uri) || []).slice(1, 3);
if (documentInputMediaRoomId && documentInputId) {
return { requestType: REQUEST_TYPE.documentInputMedia, roomId: documentInputMediaRoomId, documentInputId, sessionCookie };
}

if ((/^\/media-trash\/.+$/).test(request.uri)) {
return { requestType: REQUEST_TYPE.mediaTrash, roomId: null, documentInputId: null, sessionCookie };
}

return { requestType: REQUEST_TYPE.unknown, roomId: null, documentInputId: null, sessionCookie };
}
105 changes: 105 additions & 0 deletions src/lambda/request-helper.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { analyzeRequest, REQUEST_TYPE } from './request-helper.js';

const SESSION_COOKIE_VALUE = 's%3AIMb28VLUKex1w166';

function createLambdaRequest({ uri = '/', cookie = `SESSION_ID=${SESSION_COOKIE_VALUE}` }) {
return {
uri,
headers: {
cookie: cookie ? [{ key: 'Cookie', value: cookie }] : null
}
};
}

describe('request-helper', () => {

describe('analyzeRequest', () => {

describe('when there is no cookie set in the request', () => {
let result;
beforeEach(() => {
result = analyzeRequest(createLambdaRequest({ cookie: null }));
});
it('should set the sessionCookie to null', () => {
expect(result.sessionCookie).toBeNull();
});
});

describe('when there is a session cookie set in the request', () => {
let result;
beforeEach(() => {
result = analyzeRequest(createLambdaRequest({}));
});
it('should set the sessionCookie to the correct value', () => {
expect(result.sessionCookie).toBe(SESSION_COOKIE_VALUE);
});
});

describe('when the uri is a room media URL', () => {
let result;
beforeEach(() => {
result = analyzeRequest(createLambdaRequest({ uri: '/room-media/abc/my-image.png' }));
});
it('should return requestType roomMedia', () => {
expect(result.requestType).toBe(REQUEST_TYPE.roomMedia);
});
it('should return the correct roomId', () => {
expect(result.roomId).toBe('abc');
});
it('should return documentInputId as null', () => {
expect(result.documentInputId).toBeNull();
});
});

describe('when the uri is a document input media URL', () => {
let result;
beforeEach(() => {
result = analyzeRequest(createLambdaRequest({ uri: '/document-input-media/xyz/abc/my-image.png' }));
});
it('should return requestType documentInputMedia', () => {
expect(result.requestType).toBe(REQUEST_TYPE.documentInputMedia);
});
it('should return the correct roomId', () => {
expect(result.roomId).toBe('xyz');
});
it('should return the correct documentInputId', () => {
expect(result.documentInputId).toBe('abc');
});
});

describe('when the uri is a media trash URL', () => {
let result;
beforeEach(() => {
result = analyzeRequest(createLambdaRequest({ uri: '/media-trash/my-image.png' }));
});
it('should return requestType mediaTrash', () => {
expect(result.requestType).toBe(REQUEST_TYPE.mediaTrash);
});
it('should return roomId as null', () => {
expect(result.roomId).toBeNull();
});
it('should return documentInputId as null', () => {
expect(result.documentInputId).toBeNull();
});
});

describe('when the uri does not match any known pattern', () => {
let result;
beforeEach(() => {
result = analyzeRequest(createLambdaRequest({ uri: '/chambers/123/some-file.png' }));
});
it('should return requestType unknown', () => {
expect(result.requestType).toBe(REQUEST_TYPE.unknown);
});
it('should return roomId as null', () => {
expect(result.roomId).toBeNull();
});
it('should return documentInputId as null', () => {
expect(result.documentInputId).toBeNull();
});
});

});

});
4 changes: 4 additions & 0 deletions src/lambda/urls.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,7 @@ export function getRoomAccessAuthorizationEndpointUrl(roomId) {
export function getDocumentInputAccessAuthorizationEndpointUrl(documentInputId) {
return `${WEBSITE_BASE_URL}/api/v1/doc-inputs/${encodeURIComponent(documentInputId)}/authorize-resources-access`;
}

export function getMediaTrashAccessAuthorizationEndpointUrl() {
return `${WEBSITE_BASE_URL}/api/v1/media-trash/items/authorize-resources-access`;
}
13 changes: 12 additions & 1 deletion src/lambda/website-api-client.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import phin from 'phin';
import { SESSION_COOKIE_NAME, X_ROOMS_AUTH_SECRET } from './config.js';
import { getDocumentInputAccessAuthorizationEndpointUrl, getRoomAccessAuthorizationEndpointUrl } from './urls.js';
import { getDocumentInputAccessAuthorizationEndpointUrl, getMediaTrashAccessAuthorizationEndpointUrl, getRoomAccessAuthorizationEndpointUrl } from './urls.js';

// Until Lambda@Edge supports node v18.x we have to polyfill native global `fetch`:
globalThis.fetch ??= async function fetch(url, options) {
Expand Down Expand Up @@ -30,4 +30,15 @@ export default class WebsiteApiClient {
}
});
}

callMediaTrashAccessAuthEndpoint(sessionCookie) {
return fetch(getMediaTrashAccessAuthorizationEndpointUrl(), {
method: 'GET',
headers: {
'Accept': 'application/json',
'Cookie': `${SESSION_COOKIE_NAME}=${sessionCookie}`,
'x-rooms-auth-secret': X_ROOMS_AUTH_SECRET
}
});
}
}
Loading