Skip to content

Commit 1f56d8e

Browse files
francisfuzzgr2m
andauthored
feat: support caching of verification kys (#81)
## Summary Closes #9 Previously, clients who fetched verification keys from the server had no way to cache those keys and reuse them for other requests. This PR proposes a change using the GitHub API's [conditional requests](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api?apiVersion=2022-11-28#use-conditional-requests-if-appropriate) feature: clients can optionally specify a cache for their keys and only fetch new ones if the cache is outdated. BREAKING CHANGE: `verifyRequestByKeyId() now returns an object with a `isValid` property and a`cache` property. Before ```js const isValid = await verifyRequestByKeyId(); ``` After ```js const { isValid, cache } = await verifyRequestByKeyId(); ``` BREAKING CHANGE: `fetchVerificationKeys()` now returns an object with a `keys` property and a`cache` property. Before ```js const keys = await fetchVerificationKeys(); ``` After ```js const { keys, cache } = await fetchVerificationKeys(); ``` --------- Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
1 parent 38e210f commit 1f56d8e

9 files changed

+4448
-4242
lines changed

README.md

+55-21
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,16 @@ We consider this SDK alpha software in terms of API stability, but we adhere to
2323
```js
2424
import { verifyRequestByKeyId } from "@copilot-extensions/preview-sdk";
2525

26-
const payloadIsVerified = await verifyRequestByKeyId(
26+
const { isValid, cache } = await verifyRequestByKeyId(
2727
request.body,
2828
signature,
2929
keyId,
3030
{
3131
token: process.env.GITHUB_TOKEN,
3232
},
3333
);
34-
// true or false
34+
// isValid: true or false
35+
// cache: { id, keys }
3536
```
3637

3738
### Build a response
@@ -76,54 +77,82 @@ try {
7677

7778
Verify the request payload using the provided signature and key ID. The method will request the public key from GitHub's API for the given keyId and then verify the payload.
7879

79-
The `options` argument is optional. It can contain a `token` to authenticate the request to GitHub's API, or a custom `request` instance to use for the request.
80+
The `requestOptions` argument is optional. It can contain:
81+
82+
- a `token` to authenticate the request to GitHub's API
83+
- a custom [octokit `request`](https://github.com/octokit/request.js) instance to use for the request
84+
- a `cache` to use cached keys
8085

8186
```js
8287
import { verifyRequestByKeyId } from "@copilot-extensions/preview-sdk";
8388

84-
const payloadIsVerified = await verifyRequestByKeyId(
89+
const { isValid, cache } = await verifyRequestByKeyId(
8590
request.body,
8691
signature,
87-
key,
92+
keyId,
8893
);
8994

9095
// with token
91-
await verifyRequestByKeyId(request.body, signature, key, { token: "ghp_1234" });
96+
const { isValid, cache } = await verifyRequestByKeyId(
97+
request.body,
98+
signature,
99+
keyId,
100+
{ token: "ghp_1234" },
101+
);
92102

93103
// with custom octokit request instance
94-
await verifyRequestByKeyId(request.body, signature, key, { request });
104+
const { isValid, cache } = await verifyRequestByKeyId(
105+
request.body,
106+
signature,
107+
keyId,
108+
{ request },
109+
);
110+
111+
// with cache
112+
const previousCache = {
113+
id: "etag_value",
114+
keys: [{ key_identifier: "key1", key: "public_key1", is_current: true }],
115+
};
116+
const { isValid, cache } = await verifyRequestByKeyId(
117+
request.body,
118+
signature,
119+
keyId,
120+
{ cache: previousCache },
121+
);
95122
```
96123

97124
#### `async fetchVerificationKeys(options)`
98125

99-
Fetches public keys for verifying copilot extension requests [from GitHub's API](https://api.github.com/meta/public_keys/copilot_api)
100-
and returns them as an array. The request can be made without authentication, with a token, or with a custom [octokit request](https://github.com/octokit/request.js) instance.
126+
Fetches public keys for verifying copilot extension requests [from GitHub's API](https://api.github.com/meta/public_keys/copilot_api) and returns them as an array. The request can be made without authentication, with a token, with a custom [octokit request](https://github.com/octokit/request.js) instance, or with a cache.
101127

102128
```js
103129
import { fetchVerificationKeys } from "@copilot-extensions/preview-sdk";
104130

105131
// fetch without authentication
106-
const [current] = await fetchVerificationKeys();
132+
const { id, keys } = await fetchVerificationKeys();
107133

108134
// with token
109-
const [current] = await fetchVerificationKeys({ token: "ghp_1234" });
135+
const { id, keys } = await fetchVerificationKeys({ token: "ghp_1234" });
110136

111137
// with custom octokit request instance
112-
const [current] = await fetchVerificationKeys({ request });)
138+
const { id, keys } = await fetchVerificationKeys({ request });
139+
140+
// with cache
141+
const cache = {
142+
id: "etag_value",
143+
keys: [{ key_identifier: "key1", key: "public_key1" }],
144+
};
145+
const { id, keys } = await fetchVerificationKeys({ cache });
113146
```
114147

115-
#### `async verifyRequestPayload(rawBody, signature, keyId)`
148+
#### `async verifyRequest(rawBody, signature, keyId)`
116149

117150
Verify the request payload using the provided signature and key. Note that the raw body as received by GitHub must be passed, before any parsing.
118151

119152
```js
120153
import { verify } from "@copilot-extensions/preview-sdk";
121154

122-
const payloadIsVerified = await verifyRequestPayload(
123-
request.body,
124-
signature,
125-
key,
126-
);
155+
const payloadIsVerified = await verifyRequest(request.body, signature, key);
127156
// true or false
128157
```
129158

@@ -274,17 +303,22 @@ Convenience method to verify and parse a request in one go. It calls [`verifyReq
274303
```js
275304
import { verifyAndParseRequest } from "@copilot-extensions/preview-sdk";
276305

277-
const { isValidRequest, payload } = await verifyAndParseRequest(
278-
request,
306+
const { isValidRequest, payload, cache } = await verifyAndParseRequest(
307+
request.body,
279308
signature,
280-
key,
309+
keyId,
310+
{
311+
token: process.env.GITHUB_TOKEN,
312+
},
281313
);
282314

283315
if (!isValidRequest) {
284316
throw new Error("Request could not be verified");
285317
}
286318

319+
// `isValidRequest` is a boolean.
287320
// `payload` has type support.
321+
// `cache` contains the id and keys used for verification.
288322
```
289323
290324
#### `getUserMessage()`

index.d.ts

+15-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,14 @@ import { request } from "@octokit/request";
33
// verification types
44

55
type RequestInterface = typeof request;
6+
export type VerificationKeysCache = {
7+
id: string;
8+
keys: VerificationPublicKey[];
9+
};
610
type RequestOptions = {
711
request?: RequestInterface;
812
token?: string;
13+
cache?: VerificationKeysCache;
914
};
1015
export type VerificationPublicKey = {
1116
key_identifier: string;
@@ -18,7 +23,7 @@ interface VerifyRequestInterface {
1823
}
1924

2025
interface FetchVerificationKeysInterface {
21-
(requestOptions?: RequestOptions): Promise<VerificationPublicKey[]>;
26+
(requestOptions?: RequestOptions): Promise<VerificationKeysCache>;
2227
}
2328

2429
interface VerifyRequestByKeyIdInterface {
@@ -27,7 +32,10 @@ interface VerifyRequestByKeyIdInterface {
2732
signature: string,
2833
keyId: string,
2934
requestOptions?: RequestOptions,
30-
): Promise<boolean>;
35+
): Promise<{
36+
isValid: boolean;
37+
cache: VerificationKeysCache;
38+
}>;
3139
}
3240

3341
// response types
@@ -188,7 +196,11 @@ export interface VerifyAndParseRequestInterface {
188196
signature: string,
189197
keyID: string,
190198
requestOptions?: RequestOptions,
191-
): Promise<{ isValidRequest: boolean; payload: CopilotRequestPayload }>;
199+
): Promise<{
200+
isValidRequest: boolean;
201+
payload: CopilotRequestPayload;
202+
cache: VerificationKeysCache;
203+
}>;
192204
}
193205

194206
export interface GetUserMessageInterface {

index.test-d.ts

+19-5
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
prompt,
2323
PromptResult,
2424
getFunctionCalls,
25+
VerificationKeysCache,
2526
} from "./index.js";
2627

2728
const token = "";
@@ -32,7 +33,10 @@ export async function verifyRequestByKeyIdTest(
3233
keyId: string,
3334
) {
3435
const result = await verifyRequestByKeyId(rawBody, signature, keyId);
35-
expectType<boolean>(result);
36+
expectType<{
37+
isValid: boolean;
38+
cache: { id: string; keys: VerificationPublicKey[] };
39+
}>(result);
3640

3741
// @ts-expect-error - first 3 arguments are required
3842
verifyRequestByKeyId(rawBody, signature);
@@ -51,6 +55,11 @@ export async function verifyRequestByKeyIdTest(
5155

5256
// accepts a request argument
5357
await verifyRequestByKeyId(rawBody, signature, keyId, { request });
58+
59+
// accepts a cache argument
60+
await verifyRequestByKeyId(rawBody, signature, keyId, {
61+
cache: { id: "test", keys: [] },
62+
});
5463
}
5564

5665
export async function verifyRequestTest(
@@ -76,13 +85,16 @@ export async function verifyRequestTest(
7685

7786
export async function fetchVerificationKeysTest() {
7887
const result = await fetchVerificationKeys();
79-
expectType<VerificationPublicKey[]>(result);
88+
expectType<{ id: string; keys: VerificationPublicKey[] }>(result);
8089

8190
// accepts a token argument
8291
await fetchVerificationKeys({ token });
8392

8493
// accepts a request argument
8594
await fetchVerificationKeys({ request });
95+
96+
// accepts a cache argument
97+
await fetchVerificationKeys({ cache: { id: "test", keys: [] } });
8698
}
8799

88100
export function createAckEventTest() {
@@ -181,9 +193,11 @@ export async function verifyAndParseRequestTest(
181193
keyId: string,
182194
) {
183195
const result = await verifyAndParseRequest(rawBody, signature, keyId);
184-
expectType<{ isValidRequest: boolean; payload: CopilotRequestPayload }>(
185-
result,
186-
);
196+
expectType<{
197+
isValidRequest: boolean;
198+
payload: CopilotRequestPayload;
199+
cache: { id: string; keys: VerificationPublicKey[] };
200+
}>(result);
187201
}
188202

189203
export function getUserMessageTest(payload: CopilotRequestPayload) {

lib/parse.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,17 @@ export function transformPayloadForOpenAICompatibility(payload) {
2222

2323
/** @type {import('..').VerifyAndParseRequestInterface} */
2424
export async function verifyAndParseRequest(body, signature, keyID, options) {
25-
const isValidRequest = await verifyRequestByKeyId(
25+
const { isValid, cache } = await verifyRequestByKeyId(
2626
body,
2727
signature,
2828
keyID,
2929
options
3030
);
3131

3232
return {
33-
isValidRequest,
33+
isValidRequest: isValid,
3434
payload: parseRequestBody(body),
35+
cache,
3536
};
3637
}
3738

lib/verification.js

+28-12
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createVerify } from "node:crypto";
44

55
import { request as defaultRequest } from "@octokit/request";
66

7-
/** @type {import('..').VerifyRequestByKeyIdInterface} */
7+
/** @type {import('..').VerifyRequestInterface} */
88
export async function verifyRequest(rawBody, signature, key) {
99
// verify arguments
1010
assertValidString(rawBody, "Invalid payload");
@@ -23,51 +23,67 @@ export async function verifyRequest(rawBody, signature, key) {
2323

2424
/** @type {import('..').FetchVerificationKeysInterface} */
2525
export async function fetchVerificationKeys(
26-
{ token = "", request = defaultRequest } = { request: defaultRequest }
26+
{ token = "", request = defaultRequest, cache = { id: "", keys: [] } } = {
27+
request: defaultRequest,
28+
},
2729
) {
28-
const { data } = await request("GET /meta/public_keys/copilot_api", {
29-
headers: token
30+
try {
31+
const headers = token
3032
? {
3133
Authorization: `token ${token}`,
3234
}
33-
: {},
34-
});
35+
: {};
36+
37+
if (cache.id) headers["if-none-match"] = cache.id;
3538

36-
return data.public_keys;
39+
const response = await request("GET /meta/public_keys/copilot_api", {
40+
headers,
41+
});
42+
43+
const cacheId = response.headers.etag || "";
44+
return { id: cacheId, keys: response.data.public_keys };
45+
} catch (error) {
46+
if (error.status === 304) {
47+
return cache;
48+
}
49+
50+
throw error;
51+
}
3752
}
3853

3954
/** @type {import('..').VerifyRequestByKeyIdInterface} */
4055
export async function verifyRequestByKeyId(
4156
rawBody,
4257
signature,
4358
keyId,
44-
requestOptions
59+
requestOptions,
4560
) {
4661
// verify arguments
4762
assertValidString(rawBody, "Invalid payload");
4863
assertValidString(signature, "Invalid signature");
4964
assertValidString(keyId, "Invalid keyId");
5065

5166
// receive valid public keys from GitHub
52-
const keys = await fetchVerificationKeys(requestOptions);
67+
const { id, keys } = await fetchVerificationKeys(requestOptions);
5368

5469
// verify provided key Id
5570
const publicKey = keys.find((key) => key.key_identifier === keyId);
5671

5772
if (!publicKey) {
5873
const keyNotFoundError = Object.assign(
5974
new Error(
60-
"[@copilot-extensions/preview-sdk] No public key found matching key identifier"
75+
"[@copilot-extensions/preview-sdk] No public key found matching key identifier",
6176
),
6277
{
6378
keyId,
6479
keys,
65-
}
80+
},
6681
);
6782
throw keyNotFoundError;
6883
}
6984

70-
return verifyRequest(rawBody, signature, publicKey.key);
85+
const isValid = await verifyRequest(rawBody, signature, publicKey.key);
86+
return { isValid, cache: { id: id, keys } };
7187
}
7288

7389
function assertValidString(value, message) {

0 commit comments

Comments
 (0)