Skip to content

feat: add public key client validation #1088

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
93089e7
chore: add public key client validation
tiwarishubham635 May 12, 2025
b47617f
chore: run prettier
tiwarishubham635 May 12, 2025
d3a1ae5
chore: add signed headers
tiwarishubham635 May 12, 2025
c9f2659
Merge branch 'main' into node-pkcv
tiwarishubham635 May 12, 2025
dcc01dc
chore: fixed trailing new line characters
tiwarishubham635 May 15, 2025
e86b34d
Merge branch 'main' into node-pkcv
tiwarishubham635 May 16, 2025
220fdd8
chore: json.stringify requestbody before hashing
tiwarishubham635 May 16, 2025
55f9ba1
docs: add inline documentation
tiwarishubham635 May 16, 2025
9e4d6cc
chore: handle multiple content type of request body
tiwarishubham635 May 16, 2025
54c8067
chore: add namespace for validation token
tiwarishubham635 May 16, 2025
94ab31e
chore: export ValidationToken from twilio package
tiwarishubham635 May 16, 2025
6cab7e4
chore: added test cases
tiwarishubham635 May 19, 2025
e2ec2c5
chore: add localeCompare for ASCII sort
tiwarishubham635 May 19, 2025
b32ebea
chore: add custom compare for ASCII sort
tiwarishubham635 May 19, 2025
cc6dd44
chore: use specific datatype for queryParams and headerParams
tiwarishubham635 May 23, 2025
17f2f7d
chore: use template literals for concatenation
tiwarishubham635 May 23, 2025
c67ef1b
chore: add inline docs for validationClient and validation interceptor
tiwarishubham635 May 23, 2025
ac06376
chore: add exception handling for validation interceptor
tiwarishubham635 May 23, 2025
c27754e
docs: inline documentation for ValidationToken.ts
tiwarishubham635 May 23, 2025
b013de6
chore: add exception handling for validation token
tiwarishubham635 May 26, 2025
8406717
chore: add cluster test for pkcv
tiwarishubham635 May 26, 2025
fac1ec5
chore: run prettier
tiwarishubham635 May 26, 2025
830a337
chore: remove cluster test for pkcv
tiwarishubham635 May 26, 2025
225a5ea
chore: make ValidationToken attributes private
tiwarishubham635 May 26, 2025
82fb466
chore: run prettier
tiwarishubham635 May 26, 2025
0ca22a3
chore: block request on validation error
tiwarishubham635 May 27, 2025
5db3fef
chore: convert ASCIICompare to inline
tiwarishubham635 May 27, 2025
f8a5089
chore: run prettier
tiwarishubham635 May 27, 2025
83e4979
chore: add test for validation interceptor
tiwarishubham635 May 27, 2025
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
59 changes: 59 additions & 0 deletions examples/pkcv.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use strict";
var Twilio = require("../lib");
const crypto = require("crypto");

var accountSid = process.env.TWILIO_ACCOUNT_SID;
var token = process.env.TWILIO_AUTH_TOKEN;

// Uncomment the following line to specify a custom CA bundle for HTTPS requests:
// process.env.TWILIO_CA_BUNDLE = '/path/to/cert.pem';
// You can also set this as a regular environment variable outside of the code

// Generate public and private key pair
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});

// Create a default rest client
var client = new Twilio(accountSid, token);

// Submit the public key using the default client
client.accounts.v1.credentials.publicKey
.create({
friendlyName: "Public Key",
publicKey: publicKey,
})
.then((key) => {
// Create a new signing key using the default client
client.newSigningKeys.create().then((signingKey) => {
// Switch to the Validation Client to validate API calls
const validationClient = new Twilio(signingKey.sid, signingKey.secret, {
accountSid: accountSid,
validationClient: {
accountSid: accountSid,
credentialSid: key.sid,
signingKey: signingKey.sid,
privateKey: privateKey,
algorithm: "PS256", // Validation client supports RS256 or PS256 algorithm. Default is RS256.
},
});
validationClient.setAccountSid(accountSid);

validationClient.messages
.list({
from: process.env.TWILIO_PHONE_NUMBER,
limit: 10,
})
.then((messages) => {
console.log(messages);
})
.catch((err) => {
console.log("Error making API request: ", err);
});
});
})
.catch((err) => {
console.log("Error creating public key: ", err);
});
336 changes: 336 additions & 0 deletions spec/unit/jwt/validation/ValidationToken.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
import twilio from "../../../../src";
import jwt from "jsonwebtoken";
import crypto from "crypto";

process.noDeprecation = true;

describe("ValidationToken", function () {
const accountSid = "ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const credentialSid = "CRaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const signingKey = "SKb5aed9ca12bf5890f37930e63cad6d38";
const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});

function getToken(alg) {
return new twilio.jwt.ValidationToken({
accountSid: accountSid,
credentialSid: credentialSid,
signingKey: signingKey,
privateKey: privateKey,
algorithm: alg,
});
}

describe("constructor", function () {
it("should require accountSid", function () {
expect(() => new twilio.jwt.ValidationToken({})).toThrow(
new Error("accountSid is required")
);
});
it("should require credentialSid", function () {
expect(
() =>
new twilio.jwt.ValidationToken({
accountSid: accountSid,
})
).toThrow(new Error("credentialSid is required"));
});
it("should require signingKey", function () {
expect(
() =>
new twilio.jwt.ValidationToken({
accountSid: accountSid,
credentialSid: credentialSid,
})
).toThrow(new Error("signingKey is required"));
});
it("should require privateKey", function () {
expect(
() =>
new twilio.jwt.ValidationToken({
accountSid: accountSid,
credentialSid: credentialSid,
signingKey: signingKey,
})
).toThrow(new Error("privateKey is required"));
});

describe("setters", function () {
const token = getToken();
it("should set accountSid correctly", function () {
expect(token.accountSid).toEqual(accountSid);
});
it("should set credentialSid correctly", function () {
expect(token.credentialSid).toEqual(credentialSid);
});
it("should set signingKey correctly", function () {
expect(token.signingKey).toEqual(signingKey);
});
it("should set privateKey correctly", function () {
expect(token.privateKey).toEqual(privateKey);
});
it("should set default algorithm to RS256", function () {
expect(token.algorithm).toEqual("RS256");
});
it("should set default ttl to 300", function () {
expect(token.ttl).toEqual(300);
});
});
it("should set algorithm correctly", function () {
const token = getToken("PS256");
expect(token.algorithm).toEqual("PS256");
});
it("should not accept unsupported algorithm", function () {
expect(() => getToken("HS256")).toThrow(
new Error("Algorithm not supported. Allowed values are RS256, PS256")
);
});
});

describe("RequestCanonicalizer", function () {
const token = getToken();
describe("should validate request", function () {
it("should require url", () => {
expect(() => token.getRequestCanonicalizer({})).toThrow(
new Error("Url is required")
);
});

it("should require method", () => {
expect(() =>
token.getRequestCanonicalizer({
url: "https://example.com",
})
).toThrow(new Error("Method is required"));
});
});

it("should set signedHeaders correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path",
method: "POST",
headers: {
Authorization: "Basic ABC",
},
});
expect(canonicalRequest.headers).toEqual({
host: "example.com",
authorization: "Basic ABC",
});
});

describe("should convert to sha256 hex correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com",
method: "POST",
});

it("sha256Hex hashes a string correctly", () => {
const input = "hello world";
// Precomputed SHA-256 hex of 'hello world'
const preComputedHash =
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
expect(canonicalRequest.sha256Hex(input)).toEqual(preComputedHash);
});

it("sha256Hex returns a hex string of length 64", () => {
expect(canonicalRequest.sha256Hex("test")).toMatch(/^[a-f0-9]{64}$/); // Hex string, 64 characters
});
});

describe("canonicalize function", function () {
it("should canonicalize method correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com",
method: "post",
});
expect(canonicalRequest.getCanonicalizedMethod()).toEqual("POST");
});

describe("should canonicalize path correctly", function () {
it("should set empty path string", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com",
method: "POST",
});
expect(canonicalRequest.uri).toEqual("/");
});

it("should set path correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path",
method: "POST",
});
expect(canonicalRequest.uri).toEqual("/path");
});

it("should set relative path correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com//foobar/../barfoo",
method: "POST",
});

expect(canonicalRequest.getCanonicalizedPath()).toEqual("/barfoo");
});

it("should encode url correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com//foo bar+/baz*qux/abc%7Edef",
method: "POST",
});

expect(canonicalRequest.getCanonicalizedPath()).toEqual(
"/foo%20bar%2B/baz%2Aqux/abc~def"
);
});
});

describe("should canonicalize query params correctly", function () {
it("should set path without query params", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path?foo=bar",
method: "POST",
});
expect(canonicalRequest.uri).toEqual("/path");
});

it("should sort and encode query params correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path",
method: "POST",
params: {
To: "+1XXXXXXXXX",
To9: "+2XXXXXXXXX",
PageSize: 20,
},
});
expect(canonicalRequest.getCanonicalizedQueryParams()).toEqual(
"PageSize=20&To9=%2B2XXXXXXXXX&To=%2B1XXXXXXXXX"
);
});
});

describe("should canonicalize headers correctly", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path",
method: "POST",
headers: {
Authorization: " Basic ABC ",
},
});

it("should trim headers", () => {
expect(canonicalRequest.getCanonicalizedHeaders()).toEqual(
"authorization:Basic ABC\nhost:example.com\n"
);
});

it("should sort hashed headers", () => {
expect(canonicalRequest.getCanonicalizedHashedHeaders()).toEqual(
"authorization;host"
);
});
});

describe("should canonicalize request body correctly", function () {
it("should skip empty request body", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path",
method: "POST",
});
expect(canonicalRequest.getCanonicalizedRequestBody()).toEqual("");
});

it("should handle form params", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path",
method: "POST",
data: "To=%2BXXXXXXXXX&From=%2BXXXXXXXXY&Body=Hello%20from%20Twilio%21",
});
const preComputedHash =
"911a4c01ce5ae65070c3bd55da064b703a30c76297c10cb53f2d17ce3e1affae";
expect(canonicalRequest.getCanonicalizedRequestBody()).toEqual(
preComputedHash
);
});

it("should handle body params", function () {
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://example.com/path",
method: "POST",
data: {
language: "es",
types: {
"twilio/text": {
body: "Hello World",
},
},
},
});
const preComputedHash =
"c4f9cb487a73a84e358d4d2ccf3a7091f99a29e3dcf9821a1f82551eff12a2f1";
expect(canonicalRequest.getCanonicalizedRequestBody()).toEqual(
preComputedHash
);
});
});

it("should create combined canonicalized request", function () {
// example taken from documentation - https://www.twilio.com/docs/iam/pkcv/quickstart#hashing-example
const canonicalRequest = token.getRequestCanonicalizer({
url: "https://api.twilio.com//2010-04-01/Accounts/AC00000000000000000000000000000000",
method: "POST",
headers: {
Authorization:
"Basic QUMwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDpmb29iYXI=",
},
data: "FriendlyName=my new friendly name",
});
expect(canonicalRequest.getCanonicalizedRequestString()).toEqual(
"POST\n" +
"/2010-04-01/Accounts/AC00000000000000000000000000000000\n" +
"\n" +
"authorization:Basic QUMwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDpmb29iYXI=\n" +
"host:api.twilio.com\n" +
"\n" +
"authorization;host\n" +
"b8e20591615abc52293f088c87be6df8e9b7b40c3da573f134c9132add851e2d"
);
});
});
});

describe("fromHttpRequest", function () {
describe("should generate the correct JWT token", function () {
const token = getToken("PS256");
const request = {
url: "https://example.com/path",
method: "POST",
};
var decoded = jwt.decode(token.fromHttpRequest(request), {
complete: true,
});

it("should have the correct header", function () {
expect(decoded.header).toEqual({
cty: "twilio-pkrv;v=1",
typ: "JWT",
alg: "PS256",
kid: credentialSid,
});
});

it("should have the correct payload", function () {
expect(decoded.payload.iss).toEqual(signingKey);
expect(decoded.payload.sub).toEqual(accountSid);
expect(decoded.payload.hrh).toEqual("authorization;host");
expect(decoded.payload.rqh).toEqual(
token.getRequestCanonicalizer(request).create()
);
});
});
});
});
Loading
Loading