diff --git a/examples/apigateway-auth/Pulumi.yaml b/examples/apigateway-auth/Pulumi.yaml new file mode 100644 index 0000000..70ea14b --- /dev/null +++ b/examples/apigateway-auth/Pulumi.yaml @@ -0,0 +1,3 @@ +name: apigateway-auth +runtime: nodejs +description: An API Gateway with a custom lambda authorizer diff --git a/examples/apigateway-auth/auth-lambda.ts b/examples/apigateway-auth/auth-lambda.ts new file mode 100644 index 0000000..91857c8 --- /dev/null +++ b/examples/apigateway-auth/auth-lambda.ts @@ -0,0 +1,67 @@ +import * as awslambda from "aws-lambda"; + +type AuthorizerLambda = (event: awslambda.APIGatewayAuthorizerEvent) => Promise + +export function authorizerLambda(): AuthorizerLambda { + return async (event: awslambda.APIGatewayAuthorizerEvent) => { + try { + return await authenticate(event); + } + catch (err) { + console.log(err); + // Tells API Gateway to return a 401 Unauthorized response + throw new Error("Unauthorized"); + } + } +} + +// Extract and return the Bearer Token from the Lambda event parameters +function getToken(event: awslambda.APIGatewayAuthorizerEvent): string | undefined { + if (!event.type || event.type !== "TOKEN") { + throw new Error('Expected "event.type" parameter to have value "TOKEN"'); + } + + const tokenString = event.authorizationToken; + if (!tokenString) { + return undefined; + } + + const match = tokenString.match(/^Bearer (.*)$/); + if (!match) { + // Invalid Authorization token - does not match "Bearer .*" + return undefined; + } + return match[1]; +} + +// Check the Token is valid +async function authenticate(event: awslambda.APIGatewayAuthorizerEvent): Promise { + console.log(event); + const token = getToken(event); + + // Dummy check for token, in a real-world scenario, you would verify the token + const effect = token ? "Allow" : "Deny"; + + const methodArn = getMethodArn(event); + console.log(`Method ARN: ${methodArn}`); + return { + principalId: "me", + policyDocument: { + Version: "2012-10-17", + Statement: [{ + Action: "execute-api:Invoke", + Effect: effect, + Resource: methodArn, + }], + }, + }; +} + +function getMethodArn(event: awslambda.APIGatewayAuthorizerEvent): string { + if (!event.methodArn) { + throw new Error('Expected "event.methodArn" parameter to be set'); + } + + const arnPartials = event.methodArn.split("/"); + return arnPartials.slice(0, 2).join("/") + "/*"; +} diff --git a/examples/apigateway-auth/index.ts b/examples/apigateway-auth/index.ts new file mode 100644 index 0000000..e71caac --- /dev/null +++ b/examples/apigateway-auth/index.ts @@ -0,0 +1,38 @@ +import * as aws from "@pulumi/aws"; +import { authorizerLambda } from "./auth-lambda"; +import * as apigateway from "@pulumi/aws-apigateway"; + +const f = new aws.lambda.CallbackFunction("f", { + callback: async (ev, ctx) => { + console.log(JSON.stringify(ev)); + return { + statusCode: 200, + body: "Hello, World!", + }; + }, + }); + +const authorizer = { + authType: "custom", + authorizerName: "jwt-rsa-custom-authorizer", + parameterName: "Authorization", + identityValidationExpression: "^Bearer [-0-9a-zA-Z\._]*$", + type: "token", + parameterLocation: "header", + authorizerResultTtlInSeconds: 300, + handler: new aws.lambda.CallbackFunction("authorizer", { + callback: authorizerLambda(), + }), +} + +const api = new apigateway.RestAPI("my-api", { + routes: [{ + path: "/{proxy+}", + method: "ANY", + eventHandler: f, + authorizers: [authorizer] + }], + binaryMediaTypes: ["application/json"], +}); + +export const url = api.url; diff --git a/examples/apigateway-auth/package.json b/examples/apigateway-auth/package.json new file mode 100644 index 0000000..6b7b863 --- /dev/null +++ b/examples/apigateway-auth/package.json @@ -0,0 +1,14 @@ +{ + "name": "apigateway-auth", + "main": "index.ts", + "devDependencies": { + "@types/node": "^18", + "typescript": "^5.0.0", + "@types/aws-lambda": "^8.10.0" + }, + "dependencies": { + "@pulumi/aws": "^6.0.0", + "@pulumi/pulumi": "^3.113.0", + "@pulumi/aws-apigateway": "^2.5.0" + } +} diff --git a/examples/apigateway-auth/tsconfig.json b/examples/apigateway-auth/tsconfig.json new file mode 100644 index 0000000..f960d51 --- /dev/null +++ b/examples/apigateway-auth/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "strict": true, + "outDir": "bin", + "target": "es2020", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "experimentalDecorators": true, + "pretty": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "files": [ + "index.ts" + ] +} diff --git a/examples/examples_nodejs_test.go b/examples/examples_nodejs_test.go index 86a5ee7..c21e6eb 100644 --- a/examples/examples_nodejs_test.go +++ b/examples/examples_nodejs_test.go @@ -6,6 +6,7 @@ import ( "path" "path/filepath" "testing" + "time" "github.com/pulumi/providertest/pulumitest" "github.com/pulumi/pulumi/pkg/v3/testing/integration" @@ -73,7 +74,7 @@ func TestTagging(t *testing.T) { ExtraRuntimeValidation: func(t *testing.T, stackInfo integration.RuntimeValidationStackInfo) { expectedTags := map[string]interface{}{ "environment": "development", - "test": "test-tag", + "test": "test-tag", } assert.Equal(t, expectedTags, stackInfo.Outputs["apiTags"]) assert.Equal(t, expectedTags, stackInfo.Outputs["stageTags"]) @@ -83,6 +84,28 @@ func TestTagging(t *testing.T) { integration.ProgramTest(t, &test) } +func TestAuth(t *testing.T) { + test := getJSBaseOptions(t). + With(integration.ProgramTestOptions{ + Dir: filepath.Join(getCwd(t), "apigateway-auth"), + ExtraRuntimeValidation: func(t *testing.T, stackInfo integration.RuntimeValidationStackInfo) { + url := stackInfo.Outputs["url"].(string) + "test" + + validAuthHeaders := map[string]string{"Authorization": "Bearer DUMMY_TOKEN"} + + // Make a request to the API Gateway endpoint with an auth token to verify it's working + integration.AssertHTTPResultWithRetry(t, url, validAuthHeaders, 60*time.Second, func(body string) bool { + return assert.Equal(t, "Hello, World!", body, "Body should equal 'Hello, World!', got %s", body) + }) + + // Make a request to the API Gateway endpoint without an auth token and expect a 401 to verify the authorizer is working + retryGETRequestUntil(t, url, nil, 401, 60*time.Second) + }, + }) + + integration.ProgramTest(t, &test) +} + func getJSBaseOptions(t *testing.T) integration.ProgramTestOptions { base := getBaseOptions(t) baseJS := base.With(integration.ProgramTestOptions{ diff --git a/examples/examples_test.go b/examples/examples_test.go index a23a7b5..7e4af58 100644 --- a/examples/examples_test.go +++ b/examples/examples_test.go @@ -3,10 +3,16 @@ package examples import ( + "context" + "net/http" "os" + "strings" "testing" + "time" "github.com/pulumi/pulumi/pkg/v3/testing/integration" + "github.com/pulumi/pulumi/sdk/v3/go/common/util/retry" + "github.com/stretchr/testify/assert" ) func getRegion(t *testing.T) string { @@ -46,3 +52,40 @@ func skipIfShort(t *testing.T) { t.Skip("skipping long-running test in short mode") } } + +func retryGETRequestUntil(t *testing.T, url string, headers map[string]string, expectedStatusCode int, timeout time.Duration) { + _, finalStatusCode, err := retry.UntilTimeout(context.TODO(), retry.Acceptor{ + Accept: func(try int, delay time.Duration) (bool, interface{}, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, nil, err + } + + for k, v := range headers { + // Host header cannot be set via req.Header.Set(), and must be set + // directly. + if strings.ToLower(k) == "host" { + req.Host = v + continue + } + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: time.Second * 10} + resp, err := client.Do(req) + assert.NoError(t, err, "error reading response: %v", err) + if resp.Body != nil { + defer resp.Body.Close() + } + + if err != nil { + t.Logf("Http Error: %v\n", err) + return false, nil, nil + } + + return resp.StatusCode == expectedStatusCode, resp.StatusCode, nil + }, + }, timeout) + assert.NoError(t, err, "error retrying request: %v", err) + assert.Equal(t, expectedStatusCode, finalStatusCode, "expected status code %d, got %d", expectedStatusCode, finalStatusCode) +}