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
2 changes: 2 additions & 0 deletions aws-ts-esc-external-adapter-lambda/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/bin/
/node_modules/
3 changes: 3 additions & 0 deletions aws-ts-esc-external-adapter-lambda/Pulumi.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
name: aws-ts-esc-external-adapter-lambda
runtime: nodejs
description: External secrets adapter for Pulumi ESC using AWS Lambda with JWT authentication
133 changes: 133 additions & 0 deletions aws-ts-esc-external-adapter-lambda/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
[![Deploy this example with Pulumi](https://get.pulumi.com/new/button.svg)](https://app.pulumi.com/new?template=https://github.com/pulumi/examples/blob/master/aws-ts-esc-external-adapter-lambda/README.md#gh-light-mode-only)
[![Deploy this example with Pulumi](https://get.pulumi.com/new/button-light.svg)](https://app.pulumi.com/new?template=https://github.com/pulumi/examples/blob/master/aws-ts-esc-external-adapter-lambda/README.md#gh-dark-mode-only)

# External secrets adapter for Pulumi ESC on AWS Lambda

A reference implementation showing how to build a secure external secrets adapter for Pulumi ESC.
This example validates JWT authentication and request integrity, making it easy to integrate custom or proprietary secret sources with ESC.

For complete documentation on ESC Connect, see the [external provider documentation](/docs/esc/integrations/dynamic-secrets/external/).

## Deploying the adapter

1. Install dependencies:

```bash
npm install
```

1. Create a new Pulumi stack:

```bash
pulumi stack init dev
```

1. Configure your AWS region:

```bash
pulumi config set aws:region us-west-2
```

1. Deploy:

```bash
pulumi up
```

1. Copy the adapter URL from the output:

```bash
export ADAPTER_URL=$(pulumi stack output adapterUrl)
```

## Using with Pulumi ESC

Create a Pulumi ESC environment:

```yaml
values:
demo:
fn::open::external:
url: https://YOUR-API-ID.execute-api.us-west-2.amazonaws.com/stage/
request:
message: "Hello from ESC!"
```

Open the environment:

```bash
esc open <your-org>/external-demo
```

Expected output:

```json
{
"demo": {
"response": {
"message": "External secrets adapter responding successfully!",
"requestEcho": {
"message": "Hello from ESC!"
},
"timestamp": "2025-11-26T12:00:00.000Z"
}
}
}
```

## Building your own adapter

The `ESCRequestValidator` class in `index.ts` handles request integrity validation. To integrate your own secret source:

1. Copy the `ESCRequestValidator` class into your adapter
1. Replace the `TODO` comment in the Lambda handler with your secret fetching logic:

```typescript
const { claims, requestBody } = await validator.validateRequest(event);

// Use claims to further authorize the request
if (claims.org !== "YOUR-PULUMI-ORG") {
return { statusCode: 401 };
}

// Fetch from your secret source
const secret = await fetchFromYourSecretStore(requestBody.secretName);

return {
statusCode: 200,
body: JSON.stringify(secret),
};
```

See the [external provider documentation](/docs/esc/integrations/dynamic-secrets/external/) for complete implementation guidance and examples in other languages.

## Monitoring

View Lambda logs:

```bash
pulumi logs --follow
```

Or use the AWS CLI:

```bash
aws logs tail /aws/lambda/$(pulumi stack output functionName) --follow
```

The handler logs JWT claims to CloudWatch for debugging.

## Clean up

```bash
pulumi destroy
pulumi stack rm dev
```

## Additional resources

- [Blog: Introducing ESC Connect](https://www.pulumi.com/blog/esc-connect/)
- [External Provider Documentation](https://www.pulumi.com/docs/esc/integrations/dynamic-secrets/external/)
- [External Rotator Documentation](https://www.pulumi.com/docs/esc/integrations/rotated-secrets/external/)
- [Pulumi ESC Documentation](https://www.pulumi.com/docs/esc/)
- [Pulumi Community Slack](https://slack.pulumi.com/)
179 changes: 179 additions & 0 deletions aws-ts-esc-external-adapter-lambda/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// Copyright 2016-2025, Pulumi Corporation. All rights reserved.

import * as aws from "@pulumi/aws";
import * as apigateway from "@pulumi/aws-apigateway";
import * as pulumi from "@pulumi/pulumi";

import * as crypto from "crypto";
import * as jwt from "jsonwebtoken";
import * as jwksClient from "jwks-rsa";

interface APIGatewayProxyEvent {
headers: { [key: string]: string | undefined };
requestContext: {
domainName: string;
path: string;
};
body: string | null;
}

interface APIGatewayProxyResult {
statusCode: number;
headers?: { [key: string]: string };
body: string;
}

/**
* Reusable helper for validating ESC external provider requests.
* Copy-paste this into your own adapters to get secure JWT validation.
*/
class ESCRequestValidator {
private client: jwksClient.JwksClient;

constructor(jwksUrl: string = "https://api.pulumi.com/oidc/.well-known/jwks") {
this.client = jwksClient({
jwksUri: jwksUrl,
cache: true,
cacheMaxAge: 600000, // 10 minutes
});
}

/**
* Validates an ESC external provider request.
* Returns the validated JWT claims and request body on success.
* Throws an error with a user-friendly message on validation failure.
*/
async validateRequest(event: APIGatewayProxyEvent): Promise<{
claims: jwt.JwtPayload;
requestBody: any;
}> {
// Extract Authorization header
const authHeader = event.headers.Authorization || event.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
throw new Error("Missing or invalid Authorization header");
}

const token = authHeader.substring(7);
const requestBody = event.body || "{}";

// Verify JWT signature and claims
const decoded = await this.verifyJWT(token);

// Verify audience matches adapter URL
const requestUrl = `https://${event.headers.Host || event.requestContext.domainName}${event.requestContext.path}`;
if (decoded.aud !== requestUrl) {
throw new Error(`Audience mismatch: expected ${requestUrl}, got ${decoded.aud}`);
}

// Verify body hash
const bodyHash = decoded.body_hash as string;
if (!bodyHash) {
throw new Error("Missing body_hash claim in JWT");
}

if (!this.verifyBodyHash(requestBody, bodyHash)) {
throw new Error("Body hash verification failed");
}

return {
claims: decoded,
requestBody: JSON.parse(requestBody),
};
}

private async verifyJWT(token: string): Promise<jwt.JwtPayload> {
return new Promise((resolve, reject) => {
jwt.verify(
token,
(header, callback) => {
this.client.getSigningKey(header.kid, (err, key) => {
if (err) {
callback(err);
return;
}
callback(null, key?.getPublicKey());
});
},
{
algorithms: ["RS256"],
complete: false,
},
(err, decoded) => {
if (err) { reject(err); }
else { resolve(decoded as jwt.JwtPayload); }
},
);
});
}

private verifyBodyHash(body: string, expectedHash: string): boolean {
const hash = crypto.createHash("sha256").update(body).digest("base64");
const actualHash = `sha256-${hash}`;
return actualHash === expectedHash;
}
}

const adapterFunction = new aws.lambda.CallbackFunction("escExternalAdapter", {
callback: async (event: APIGatewayProxyEvent): Promise<APIGatewayProxyResult> => {
try {
// Initialize the validator (Lambda will cache across invocations)
const validator = new ESCRequestValidator();

// Validate the request using the reusable helper
const { claims, requestBody } = await validator.validateRequest(event);

// Log JWT claims for debugging. You can use these claims for authorization decisions.
console.log("JWT validation successful");
console.log("Organization:", claims.org);
console.log("Environment:", claims.current_env);
console.log("Trigger User:", claims.trigger_user);
console.log("Issued At:", new Date((claims.iat || 0) * 1000).toISOString());

// TODO: Replace this with your secret fetching logic
// For example:
// const secret = await fetchFromYourSecretStore(requestBody.secretName);
// return { statusCode: 200, body: JSON.stringify(secret) };

return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "External secrets adapter responding successfully!",
requestEcho: requestBody,
timestamp: new Date().toISOString(),
}),
};
} catch (error: any) {
console.error("Error processing request:", error);

// Return appropriate status code based on error type
const statusCode = error.message.includes("Authorization") ? 401
: error.message.includes("hash") || error.message.includes("Audience") ? 400
: 500;

return {
statusCode,
body: JSON.stringify({
error: error.message || "Internal server error",
}),
};
}
},
policies: [aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole],
});

const api = new apigateway.RestAPI("escExternalAdapterApi", {
routes: [{
path: "/",
method: "POST",
eventHandler: adapterFunction,
}],
// Don't treat JSON as binary data
binaryMediaTypes: [],
});

export const adapterUrl = api.url;
export const functionName = adapterFunction.name;
export const functionArn = adapterFunction.arn;
17 changes: 17 additions & 0 deletions aws-ts-esc-external-adapter-lambda/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "aws-ts-lambda-external-secrets",
"version": "1.0.0",
"description": "External secrets adapter for Pulumi ESC using AWS Lambda",
"main": "index.ts",
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.0.0"
},
"dependencies": {
"@pulumi/pulumi": "^3.100.0",
"@pulumi/aws": "^6.0.0",
"@pulumi/aws-apigateway": "^2.0.0",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.0.0"
}
}
18 changes: 18 additions & 0 deletions aws-ts-esc-external-adapter-lambda/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2016",
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts"
]
}