diff --git a/README.md b/README.md index 072240b..aaf2a94 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ AWS Lambda middleware that allows Yealink desk phones to unlock the front door and manage lockdown profiles via LenelS2 Elements. ``` -Yealink T54W/T58W → HTTPS GET → API Gateway → Lambda → LenelS2 Elements API +Yealink T54W/T58W → HTTPS GET (?token=) → API Gateway (token authorizer) → Lambda → LenelS2 Elements API ``` ## Architecture - **API Gateway (HTTP API)** — `GET /unlock`, `GET /lockdown`, `GET /lockdown/status` with throttling (5 burst / 2 sustained req/sec) +- **Token authorizer Lambda** — a REQUEST-type Lambda authorizer validates the `?token=` query-string value (the same shared secret the phones already send) against the `/seahaven/door-unlock/auth-token` SSM parameter, so unauthenticated callers are rejected at the gateway (401/403) before any handler runs. Identity source is `$request.querystring.token`; results are cached 5 minutes. Fail-closed. The handlers also re-validate the token as defense-in-depth. - **Unlock Lambda** — validates a shared auth token, calls the Elements `TemporaryUnlock` command - **Lockdown Lambda** — toggles lockdown profiles (start/stop) and checks status, returns Yealink XML TextScreen responses - **Lockdown Poller Lambda** — VPC-connected, polls Elements API every 15 seconds for lockdown status (runs 4x per 1-minute EventBridge schedule) @@ -46,15 +47,12 @@ Pressing the line key toggles the lockdown on/off and displays the current statu ## CI/CD -Pushes to `main` trigger an AWS CodePipeline (V2) that runs `cdk deploy` via CodeBuild. +GitHub Actions, using the Sea Haven reusable workflows: -| Resource | Name | -|----------|------| -| Pipeline | `seahaven-door-unlock-api-pipeline` | -| CodeBuild project | `seahaven-door-unlock-api-build` | -| Artifact bucket | `seahaven-door-unlock-api-pipeline-artifacts` | +- **`.github/workflows/ci.yaml`** — on pull requests to `main`, runs the `ci-typescript-cdk` reusable workflow (build, lint, synth). +- **`.github/workflows/deploy.yaml`** — on push to `main`, runs the `cd-cdk` reusable workflow which assumes the `githubdeploy-seahaven-door-unlock-api` OIDC role (`AWS_DEPLOY_ROLE_ARN` repo secret) and runs `cdk deploy`. -The CodeBuild role assumes CDK bootstrap roles for deployment — no separate CloudFormation stage. +The legacy CodePipeline/CodeBuild deploy path has been fully decommissioned. ## Manual Deployment diff --git a/buildspec.yml b/buildspec.yml deleted file mode 100644 index cd2f3aa..0000000 --- a/buildspec.yml +++ /dev/null @@ -1,11 +0,0 @@ -version: 0.2 - -phases: - install: - runtime-versions: - nodejs: 22 - commands: - - npm ci - build: - commands: - - npx cdk deploy --require-approval never diff --git a/lambda/authorizer/authorizer-handler.ts b/lambda/authorizer/authorizer-handler.ts new file mode 100644 index 0000000..2e8f334 --- /dev/null +++ b/lambda/authorizer/authorizer-handler.ts @@ -0,0 +1,62 @@ +import { + SSMClient, + GetParameterCommand, +} from "@aws-sdk/client-ssm"; +import { timingSafeEqual } from "node:crypto"; + +const ssm = new SSMClient({}); + +function tokensMatch(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + // timingSafeEqual throws on unequal-length buffers; check length first. + return a.length === b.length && timingSafeEqual(a, b); +} + +let cachedAuthToken: string | undefined; + +async function getAuthToken(): Promise { + if (cachedAuthToken) return cachedAuthToken; + const res = await ssm.send( + new GetParameterCommand({ + Name: process.env.AUTH_TOKEN_PARAM!, + WithDecryption: true, + }) + ); + cachedAuthToken = res.Parameter!.Value!; + return cachedAuthToken; +} + +/** + * API Gateway HTTP API (payload v2.0) REQUEST Lambda authorizer. + * + * Validates the SAME `?token=` query-string parameter the Yealink XML Browser + * keys already send (type-17 keys issue a plain GET and cannot send headers or + * a POST body), so this authorizer is transparent to the phones. An + * unauthenticated or wrong-token request is now rejected at the gateway with + * 401/403 before any handler Lambda is invoked. + * + * Returns the simple-response shape ({ isAuthorized }) which the routes are + * configured for (enableSimpleResponses: true). + */ +export async function handler(event: { + queryStringParameters?: Record; +}): Promise<{ isAuthorized: boolean }> { + const token = event.queryStringParameters?.token; + if (!token) { + return { isAuthorized: false }; + } + + let expected: string; + try { + expected = await getAuthToken(); + } catch (err) { + console.error( + JSON.stringify({ action: "authorize", status: "error", reason: "ssm_failure", error: String(err) }) + ); + // Fail closed: deny if the token cannot be loaded. + return { isAuthorized: false }; + } + + return { isAuthorized: tokensMatch(token, expected) }; +} diff --git a/lib/door-unlock-stack.ts b/lib/door-unlock-stack.ts index 6d20d1b..3278165 100644 --- a/lib/door-unlock-stack.ts +++ b/lib/door-unlock-stack.ts @@ -2,6 +2,7 @@ import * as cdk from "aws-cdk-lib"; import * as lambda from "aws-cdk-lib/aws-lambda"; import * as apigwv2 from "aws-cdk-lib/aws-apigatewayv2"; import * as integrations from "aws-cdk-lib/aws-apigatewayv2-integrations"; +import * as authorizers from "aws-cdk-lib/aws-apigatewayv2-authorizers"; import * as ssm from "aws-cdk-lib/aws-ssm"; import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; import * as ec2 from "aws-cdk-lib/aws-ec2"; @@ -100,6 +101,57 @@ export class DoorUnlockStack extends cdk.Stack { elementsApiKeyParam.grantRead(lockdownHandler); authTokenParam.grantRead(lockdownHandler); + // Gateway authorizer (INFRA-99): validates the same `?token=` query-string + // value the Yealink XML Browser keys already send, so it is transparent to + // the phones while rejecting unauthenticated callers at the gateway. + const authorizerHandler = new lambda.Function(this, "AuthorizerHandler", { + functionName: "door-unlock-api-authorizer", + runtime: lambda.Runtime.NODEJS_22_X, + architecture: lambda.Architecture.ARM_64, + handler: "authorizer-handler.handler", + code: lambda.Code.fromAsset(path.join(__dirname, "../lambda/authorizer"), { + bundling: { + image: lambda.Runtime.NODEJS_22_X.bundlingImage, + local: { + tryBundle(outputDir: string) { + const { execSync } = require("child_process"); + execSync( + `esbuild ${path.join(__dirname, "../lambda/authorizer/authorizer-handler.ts")} --bundle --platform=node --target=node22 --outfile=${path.join(outputDir, "authorizer-handler.js")} --external:@aws-sdk/*` + ); + return true; + }, + }, + }, + }), + environment: { + AUTH_TOKEN_PARAM: authTokenParam.parameterName, + }, + // APIGW HTTP API authorizers have a hard 10s limit; keep margin so the + // gateway returns its own 500 rather than racing the Lambda timeout. The + // token is cached in module scope, so warm invocations never hit SSM. + timeout: cdk.Duration.seconds(8), + memorySize: 128, + logRetention: logs.RetentionDays.TWO_MONTHS, + }); + + authTokenParam.grantRead(authorizerHandler); + + const tokenAuthorizer = new authorizers.HttpLambdaAuthorizer( + "DoorUnlockTokenAuthorizer", + authorizerHandler, + { + authorizerName: "door-unlock-api-token-authorizer", + // The Yealink phones send the token in the query string; scope the + // identity source there. A request with no `token` query param is + // rejected by the gateway before the authorizer Lambda is invoked. + identitySource: ["$request.querystring.token"], + responseTypes: [authorizers.HttpLambdaResponseType.SIMPLE], + // Authorizer result caching keyed on the identity source (the token). + // 5 min keeps repeated phone presses fast without a long stale window. + resultsCacheTtl: cdk.Duration.minutes(5), + } + ); + const vpc = ec2.Vpc.fromLookup(this, "SeaHavenVpc", { vpcId: "vpc-0d3d4b67bd0cf8a68", }); @@ -212,6 +264,7 @@ export class DoorUnlockStack extends cdk.Stack { "UnlockIntegration", unlockHandler ), + authorizer: tokenAuthorizer, }); const lockdownIntegration = new integrations.HttpLambdaIntegration( @@ -223,12 +276,14 @@ export class DoorUnlockStack extends cdk.Stack { path: "/lockdown", methods: [apigwv2.HttpMethod.GET], integration: lockdownIntegration, + authorizer: tokenAuthorizer, }); httpApi.addRoutes({ path: "/lockdown/status", methods: [apigwv2.HttpMethod.GET], integration: lockdownIntegration, + authorizer: tokenAuthorizer, }); const hostedZone = route53.HostedZone.fromHostedZoneAttributes(