seahaven-door-unlock-api/lambda/authorizer/authorizer-handler.ts

63 lines
1.9 KiB
TypeScript
Raw Normal View History

Gateway token authorizer + finish CI/CD migration (INFRA-99, INFRA-2) (#32) * feat: add gateway token authorizer to door-unlock API (INFRA-99) All three routes (GET /unlock, /lockdown, /lockdown/status) were AuthorizationType NONE — auth relied solely on each handler checking the ?token= query param. Add a REQUEST-type HTTP API Lambda authorizer (door-unlock-api-authorizer) that validates the SAME ?token= value the Yealink XML Browser keys already send, against the existing /seahaven/door-unlock/auth-token SSM SecureString, and attach it to all three routes. Transparent to the phones: identity source is $request.querystring.token (exactly what the type-17 XML Browser keys send via GET), simple response {isAuthorized}, fail-closed, 5-min results cache. Token is cached in module scope so warm invocations skip SSM. GET is kept (not switched to POST): the Yealink type-17 XML Browser keys are GET-only and render the returned Yealink XML — they cannot issue a POST body or custom headers. POST is therefore deferred to avoid bricking the door keys. Handlers retain their own token check as defense-in-depth. Purely additive change set; no existing Lambda or integration is modified. * chore: complete CI/CD migration to GitHub Actions (INFRA-2) GitHub Actions (ci.yaml + deploy.yaml via the Sea Haven reusable workflows) is the proven deploy path. Remove the now-orphaned buildspec.yml and update the README CI/CD and architecture sections. The legacy CodePipeline was already deleted (2026-06-05); the leftover CodeBuild project seahaven-door-unlock-api-build and its IAM role seahaven-door-unlock-api-codebuild have now also been decommissioned.
2026-06-08 18:03:30 -04:00
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<string> {
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<string, string>;
}): 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) };
}