|
import crypto from "crypto"; |
|
|
|
/** |
|
* @typedef {Object} RequestContext |
|
* @property {string} apiId - The API Gateway ID |
|
* @property {string} accountId - The AWS account ID |
|
* @property {string} requestId - The unique request ID |
|
* @property {'EVENT_CONNECT'|'EVENT_SUBSCRIBE'|'EVENT_PUBLISH'} operation - The WebSocket operation |
|
* @property {string} channelNamespaceName - The channel namespace |
|
* @property {string} channel - The full channel path |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} Event |
|
* @property {string} authorizationToken - The authorization token |
|
* @property {RequestContext} requestContext - The request context |
|
* @property {Object.<string,string>} requestHeaders - The request headers |
|
*/ |
|
|
|
/** |
|
* @typedef {Object} Response |
|
* @property {boolean} isAuthorized - Whether the request is authorized |
|
* @property {Record<string,string>} [handlerContext] - Optional context to pass to the handler |
|
* @property {number} [ttlOverride] - Optional TTL override in seconds |
|
*/ |
|
|
|
/** |
|
* @param {Event} event |
|
* @returns {Promise<Response>} |
|
*/ |
|
export async function handler(event) { |
|
const [authMethod, authToken] = event.authorizationToken.split(" "); |
|
if (authMethod !== "Bearer" || !authToken) return { isAuthorized: false }; |
|
|
|
if (!process.env.TOKEN) { |
|
console.error("Missing TOKEN env var"); |
|
return { isAuthorized: false }; |
|
} |
|
|
|
// The secret can be provided as admin for non-jwt based auth |
|
if (authToken === process.env.TOKEN) return { isAuthorized: true }; |
|
|
|
const payload = verifyJWT(authToken, process.env.TOKEN); |
|
if (!payload) return { isAuthorized: false }; |
|
|
|
switch (event.requestContext.operation) { |
|
case "EVENT_CONNECT": |
|
return { isAuthorized: true }; |
|
|
|
case "EVENT_SUBSCRIBE": |
|
return { |
|
isAuthorized: |
|
payload.subscribe?.some((scope) => |
|
matchesScope(scope, event.requestContext.channel) |
|
) ?? false, |
|
}; |
|
|
|
case "EVENT_PUBLISH": |
|
return { |
|
isAuthorized: |
|
payload.publish?.some((scope) => |
|
matchesScope(scope, event.requestContext.channel) |
|
) ?? false, |
|
}; |
|
} |
|
} |
|
|
|
/** |
|
* @typedef {Object} TokenPayload |
|
* @property {string[]} [subscribe] - Array of channel patterns that can be subscribed to |
|
* @property {string[]} [publish] - Array of channel patterns that can be published to |
|
*/ |
|
|
|
/** |
|
* Verifies a JWT token using HMAC SHA-256 |
|
* @param {string} token - The JWT token to verify |
|
* @param {string} secret - The secret key |
|
* @returns {TokenPayload|null} The decoded payload or null if invalid |
|
*/ |
|
function verifyJWT(token, secret) { |
|
try { |
|
const [headerB64, payloadB64, signatureB64] = token.split("."); |
|
if (!headerB64 || !payloadB64 || !signatureB64) return null; |
|
|
|
// Verify signature |
|
const data = `${headerB64}.${payloadB64}`; |
|
const expectedSignature = crypto |
|
.createHmac("sha256", secret) |
|
.update(data) |
|
.digest("base64url"); |
|
|
|
if (signatureB64 !== expectedSignature) return null; |
|
|
|
// Decode payload |
|
const payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString()); |
|
|
|
// Check expiration |
|
if (payload.exp && Date.now() >= payload.exp * 1000) return null; |
|
|
|
return payload; |
|
} catch { |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Checks if a channel scope matches another channel scope, supporting wildcards |
|
* @param {string} scope - The permission scope to check (e.g. /default/test/*) |
|
* @param {string} channel - The channel to check against (e.g. /default/test/123) |
|
* @returns {boolean} Whether the scope matches the channel |
|
*/ |
|
function matchesScope(scope, channel) { |
|
const scopeParts = scope.split("/").filter(Boolean); |
|
const channelParts = channel.split("/").filter(Boolean); |
|
|
|
for (let i = 0; i < scopeParts.length; i++) { |
|
if (scopeParts[i] === "*") return true; |
|
if (channelParts[i] !== scopeParts[i]) return false; |
|
} |
|
|
|
return scopeParts.length === channelParts.length; |
|
} |