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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ check:
clean:
rm -rf dist lib

.PHONY: clean-all
clean-all: clean
rm -rf node_modules

.PHONY: bun-build
bun-build:
bun run build
Expand Down
81 changes: 52 additions & 29 deletions build.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,57 @@
const entrypoints = [
"src/handlers/check-auth.ts",
"src/handlers/generate-secret.ts",
"src/handlers/http-headers.ts",
"src/handlers/parse-auth.ts",
"src/handlers/refresh-auth.ts",
"src/handlers/sign-out.ts",
]
const [handlerResult, lambdaConfigResult] = await Promise.all([
Bun.build({
entrypoints: [
"src/handlers/check-auth.ts",
"src/handlers/generate-secret.ts",
"src/handlers/http-headers.ts",
"src/handlers/parse-auth.ts",
"src/handlers/refresh-auth.ts",
"src/handlers/sign-out.ts",
],
outdir: "dist",
target: "node",
format: "cjs",
minify: true,
naming: {
entry: "[dir]/[name]/index.js",
},
loader: {
".html": "text",
},
}),
// Lambda-config custom resource handler (not Lambda@Edge, no size limit)
Bun.build({
entrypoints: ["src/lambda-config/handler.ts"],
outdir: "dist/lambda-config-handler",
target: "node",
format: "cjs",
minify: true,
naming: {
entry: "index.js",
},
external: ["@aws-sdk/*"],
}),
])

const result = await Bun.build({
entrypoints,
outdir: "dist",
target: "node",
format: "cjs",
minify: true,
naming: {
entry: "[dir]/[name]/index.js",
},
loader: {
".html": "text",
},
})

if (!result.success) {
console.error("Build failed:")
for (const log of result.logs) {
console.error(log)
function assertBuildSuccess(
result: Awaited<ReturnType<typeof Bun.build>>,
label: string,
) {
if (!result.success) {
console.error(`${label} build failed:`)
for (const log of result.logs) {
console.error(log)
}
process.exit(1)
}
process.exit(1)
}

assertBuildSuccess(handlerResult, "Handler")
assertBuildSuccess(lambdaConfigResult, "Lambda config handler")

// Check bundle sizes (Lambda@Edge limit: 1MB for viewer request)
const MAX_SIZE = 1048576
for (const output of result.outputs) {
for (const output of handlerResult.outputs) {
if (output.kind !== "entry-point") continue
const size = output.size
if (size > MAX_SIZE) {
Expand All @@ -42,4 +62,7 @@ for (const output of result.outputs) {
}
}

console.log(`Built ${result.outputs.filter((o) => o.kind === "entry-point").length} bundles`)
const totalBuilt =
handlerResult.outputs.filter((o) => o.kind === "entry-point").length +
lambdaConfigResult.outputs.filter((o) => o.kind === "entry-point").length
console.log(`Built ${totalBuilt} bundles`)
187 changes: 179 additions & 8 deletions bun.lock

Large diffs are not rendered by default.

1 change: 0 additions & 1 deletion bunfig.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
[install]
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@liflig/cdk-lambda-config"]
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,16 @@
"provenance": true
},
"devDependencies": {
"@aws-sdk/client-lambda": "^3.1005.0",
"@biomejs/biome": "2.4.6",
"@commitlint/cli": "20.4.4",
"@commitlint/config-conventional": "20.4.4",
"@types/adm-zip": "^0.5.7",
"@types/aws-lambda": "8.10.161",
"@types/jest": "30.0.0",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "24.12.0",
"adm-zip": "^0.5.16",
"aws-cdk-lib": "2.243.0",
"axios": "1.13.6",
"constructs": "10.5.1",
Expand All @@ -55,10 +58,7 @@
"ts-jest": "29.4.6",
"typescript": "5.9.3"
},
"dependencies": {
"@henrist/cdk-cross-region-params": "^2.0.1",
"@liflig/cdk-lambda-config": "1.7.36"
},
"dependencies": {},
"peerDependencies": {
"aws-cdk-lib": "^2.0.0"
},
Expand Down
2 changes: 1 addition & 1 deletion src/cloudfront-auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { LambdaConfig } from "@liflig/cdk-lambda-config"
import * as cloudfront from "aws-cdk-lib/aws-cloudfront"
import {
type AddBehaviorOptions,
Expand All @@ -14,6 +13,7 @@ import { RetrieveClientSecret } from "./client-secret"
import { ClientUpdate } from "./client-update"
import { GenerateSecret } from "./generate-secret"
import type { StoredConfig } from "./handlers/util/config"
import { LambdaConfig } from "./lambda-config/lambda-config"
import type { AuthLambdas } from "./lambdas"

export interface CloudFrontAuthProps {
Expand Down
84 changes: 84 additions & 0 deletions src/cross-region-params.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { App, Stack } from "aws-cdk-lib"
import { Template } from "aws-cdk-lib/assertions"
import { CrossRegionParam } from "./cross-region-params"

function createParam(props: {
producerRegion: string
consumerRegion?: string
regions: string[]
nonce?: string
}) {
const app = new App()
const producerStack = new Stack(app, "ProducerStack", {
env: { account: "112233445566", region: props.producerRegion },
})

const param = new CrossRegionParam<string>(producerStack, "Param", {
parameterName: "/test/my-param",
resource: "my-resource-value",
resourceToReference: (r) => r,
referenceToResource: (_scope, _id, ref) => ref,
regions: props.regions,
nonce: props.nonce,
})

if (!props.consumerRegion) {
return { app, producerStack, param }
}

const consumerStack = new Stack(app, "ConsumerStack", {
env: { account: "112233445566", region: props.consumerRegion },
})
const resolved = param.get(consumerStack, "Import")

return { app, producerStack, consumerStack, param, resolved }
}

test("creates SSM parameters in each target region", () => {
const { producerStack } = createParam({
producerRegion: "us-east-1",
regions: ["eu-west-1", "ap-southeast-1"],
})

const template = Template.fromStack(producerStack)

// Should have two custom resources for the two target regions
template.resourceCountIs("Custom::AWS", 2)
})

test("get returns resource directly when same region", () => {
const { resolved } = createParam({
producerRegion: "us-east-1",
consumerRegion: "us-east-1",
regions: ["us-east-1"],
})

// Same region: returns the original string directly
expect(resolved).toBe("my-resource-value")
})

test("get resolves via SSM when cross-region", () => {
const { consumerStack } = createParam({
producerRegion: "us-east-1",
consumerRegion: "eu-west-1",
regions: ["eu-west-1"],
nonce: "test-nonce",
})

const template = Template.fromStack(consumerStack)

// Consumer stack should have a CfnParameter for the nonce
template.hasParameter("ImportNonce", {
Default: "test-nonce",
})
})

test("get throws for unregistered region", () => {
expect(() =>
createParam({
producerRegion: "us-east-1",
consumerRegion: "ap-southeast-1",
regions: ["eu-west-1"],
}),
).toThrow("Region ap-southeast-1 is not registered")
})
144 changes: 144 additions & 0 deletions src/cross-region-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { CfnParameter, Stack } from "aws-cdk-lib"
import * as ssm from "aws-cdk-lib/aws-ssm"
import * as cr from "aws-cdk-lib/custom-resources"
import { Construct } from "constructs"

export interface CrossRegionParamProps<T> {
/**
* Nonce to force the stack to re-check for updated values.
*
* @default Date.now().toString()
*/
nonce?: string
/**
* SSM Parameter name used to store the reference.
*/
parameterName: string
/**
* The resource to make available cross-region.
*/
resource: T
/**
* Convert the resource to a string representation for storage in SSM.
*/
resourceToReference(resource: T): string
/**
* Reconstruct the resource from its stored string representation.
*/
referenceToResource(scope: Construct, id: string, reference: string): T
/**
* Regions where this reference should be available.
*/
regions: string[]
}

/**
* Makes a CDK resource available in other AWS regions by storing a
* string reference in SSM Parameter Store.
*
* On the producer side (constructor), the resource's string representation
* is written to SSM in each target region via custom resources.
*
* On the consumer side (get), the reference is read back from SSM and
* used to reconstruct the resource. If producer and consumer are in the
* same region, the resource is returned directly without SSM.
*/
export class CrossRegionParam<T> extends Construct {
private readonly nonce: string
private readonly parameterName: string
private readonly resource: T
private readonly referenceToResource: CrossRegionParamProps<T>["referenceToResource"]
private readonly regions: string[]

constructor(scope: Construct, id: string, props: CrossRegionParamProps<T>) {
super(scope, id)
this.nonce = props.nonce ?? Date.now().toString()
this.parameterName = props.parameterName
this.resource = props.resource
this.referenceToResource = props.referenceToResource
this.regions = props.regions

const value = props.resourceToReference(props.resource)

for (const region of props.regions) {
this.putParameterInRegion(`Param${region}`, {
name: this.parameterName,
region,
value,
})
}
}

/**
* Retrieve the resource. Returns it directly when in the same region
* as the producer, otherwise resolves it from SSM Parameter Store.
*/
public get(scope: Construct, id: string): T {
const producerRegion = Stack.of(this).region
const consumerRegion = Stack.of(scope).region

if (producerRegion === consumerRegion) {
return this.resource
}

if (!this.regions.includes(consumerRegion)) {
throw new Error(
`Region ${consumerRegion} is not registered for parameter ${this.parameterName}`,
)
}

scope.node.addDependency(this)

new CfnParameter(scope, `${id}Nonce`, {
default: this.nonce,
})

const reference = ssm.StringParameter.valueForStringParameter(
scope,
this.parameterName,
)
return this.referenceToResource(scope, id, reference)
}

/**
* Write an SSM Parameter to a specific region using a custom resource.
*/
private putParameterInRegion(
id: string,
props: { name: string; region: string; value: string },
) {
const physicalResourceId = cr.PhysicalResourceId.of(props.name)

// "Resoure" is an intentional typo preserved for backward compatibility.
// Changing it would alter CloudFormation logical IDs and cause resource
// replacement in existing deployments.
const paramScope = new Construct(this, id)
new cr.AwsCustomResource(paramScope, "Resoure", {
onUpdate: {
service: "SSM",
action: "putParameter",
parameters: {
Name: props.name,
Value: props.value,
Type: "String",
Overwrite: true,
},
region: props.region,
physicalResourceId,
},
onDelete: {
service: "SSM",
action: "deleteParameter",
parameters: {
Name: props.name,
},
region: props.region,
physicalResourceId,
},
policy: cr.AwsCustomResourcePolicy.fromSdkCalls({
resources: cr.AwsCustomResourcePolicy.ANY_RESOURCE,
}),
installLatestAwsSdk: false,
})
}
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export * from "./cloudfront-auth"
export * from "./cross-region-params"
export * from "./lambda-config/lambda-config"
export * from "./lambdas"
Loading
Loading