@vercel/sandbox 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +16 -0
- package/dist/api-client/api-client.d.ts +7 -0
- package/dist/api-client/api-client.js +31 -2
- package/dist/api-client/validators.d.ts +13 -0
- package/dist/api-client/validators.js +1 -0
- package/dist/command.d.ts +26 -7
- package/dist/command.js +26 -5
- package/dist/sandbox.d.ts +5 -2
- package/dist/sandbox.js +6 -2
- package/dist/utils/get-credentials.d.ts +22 -1
- package/dist/utils/get-credentials.js +11 -4
- package/dist/utils/jwt-expiry.d.ts +42 -0
- package/dist/utils/jwt-expiry.js +105 -0
- package/dist/utils/normalizePath.d.ts +17 -0
- package/dist/utils/normalizePath.js +31 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/api-client/api-client.ts +39 -3
- package/src/api-client/validators.ts +1 -0
- package/src/command.ts +29 -7
- package/src/sandbox.test.ts +42 -1
- package/src/sandbox.ts +8 -3
- package/src/utils/get-credentials.ts +15 -10
- package/src/utils/jwt-expiry.test.ts +125 -0
- package/src/utils/jwt-expiry.ts +105 -0
- package/src/utils/normalizePath.test.ts +114 -0
- package/src/utils/normalizePath.ts +33 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize a path and make it relative to `params.extractDir` for inclusion
|
|
3
|
+
* in our tar archives.
|
|
4
|
+
*
|
|
5
|
+
* Relative paths are first resolved to `params.cwd`.
|
|
6
|
+
* Absolute paths are normalized and resolved relative to `params.extractDir`.
|
|
7
|
+
*
|
|
8
|
+
* In addition, paths are normalized so consecutive slashes are removed and
|
|
9
|
+
* stuff like `../..` is resolved appropriately.
|
|
10
|
+
*
|
|
11
|
+
* This function always returns a path relative to `params.extractDir`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function normalizePath(params: {
|
|
14
|
+
filePath: string;
|
|
15
|
+
cwd: string;
|
|
16
|
+
extractDir: string;
|
|
17
|
+
}): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.normalizePath = normalizePath;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a path and make it relative to `params.extractDir` for inclusion
|
|
10
|
+
* in our tar archives.
|
|
11
|
+
*
|
|
12
|
+
* Relative paths are first resolved to `params.cwd`.
|
|
13
|
+
* Absolute paths are normalized and resolved relative to `params.extractDir`.
|
|
14
|
+
*
|
|
15
|
+
* In addition, paths are normalized so consecutive slashes are removed and
|
|
16
|
+
* stuff like `../..` is resolved appropriately.
|
|
17
|
+
*
|
|
18
|
+
* This function always returns a path relative to `params.extractDir`.
|
|
19
|
+
*/
|
|
20
|
+
function normalizePath(params) {
|
|
21
|
+
if (!path_1.default.posix.isAbsolute(params.cwd)) {
|
|
22
|
+
throw new Error("cwd dir must be absolute");
|
|
23
|
+
}
|
|
24
|
+
if (!path_1.default.posix.isAbsolute(params.extractDir)) {
|
|
25
|
+
throw new Error("extractDir must be absolute");
|
|
26
|
+
}
|
|
27
|
+
const basePath = path_1.default.posix.isAbsolute(params.filePath)
|
|
28
|
+
? path_1.default.posix.normalize(params.filePath)
|
|
29
|
+
: path_1.default.posix.join(params.cwd, params.filePath);
|
|
30
|
+
return path_1.default.posix.relative(params.extractDir, basePath);
|
|
31
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.0.
|
|
1
|
+
export declare const VERSION = "0.0.18";
|
package/dist/version.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/sandbox",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"author": "",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@vercel/oidc": "^2.0.
|
|
12
|
+
"@vercel/oidc": "^2.0.2",
|
|
13
13
|
"async-retry": "1.3.3",
|
|
14
14
|
"jsonlines": "0.1.1",
|
|
15
15
|
"ms": "2.1.3",
|
|
@@ -20,9 +20,12 @@ import { z } from "zod";
|
|
|
20
20
|
import jsonlines from "jsonlines";
|
|
21
21
|
import os from "os";
|
|
22
22
|
import { Readable } from "stream";
|
|
23
|
+
import { normalizePath } from "../utils/normalizePath";
|
|
24
|
+
import { JwtExpiry } from "../utils/jwt-expiry";
|
|
23
25
|
|
|
24
26
|
export class APIClient extends BaseClient {
|
|
25
27
|
private teamId: string;
|
|
28
|
+
private tokenExpiry: JwtExpiry | null;
|
|
26
29
|
|
|
27
30
|
constructor(params: { host?: string; teamId: string; token: string }) {
|
|
28
31
|
super({
|
|
@@ -32,9 +35,29 @@ export class APIClient extends BaseClient {
|
|
|
32
35
|
});
|
|
33
36
|
|
|
34
37
|
this.teamId = params.teamId;
|
|
38
|
+
this.tokenExpiry = JwtExpiry.fromToken(params.token);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async ensureValidToken(): Promise<void> {
|
|
42
|
+
if (!this.tokenExpiry) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const newExpiry = await this.tokenExpiry.tryRefresh();
|
|
47
|
+
if (!newExpiry) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.tokenExpiry = newExpiry;
|
|
52
|
+
this.token = this.tokenExpiry.token;
|
|
53
|
+
if (this.tokenExpiry.payload) {
|
|
54
|
+
this.teamId = this.tokenExpiry.payload?.owner_id;
|
|
55
|
+
}
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
protected async request(path: string, params?: RequestParams) {
|
|
59
|
+
await this.ensureValidToken();
|
|
60
|
+
|
|
38
61
|
return super.request(path, {
|
|
39
62
|
...params,
|
|
40
63
|
query: { teamId: this.teamId, ...params?.query },
|
|
@@ -150,13 +173,16 @@ export class APIClient extends BaseClient {
|
|
|
150
173
|
);
|
|
151
174
|
}
|
|
152
175
|
|
|
153
|
-
getFileWriter(params: { sandboxId: string }) {
|
|
176
|
+
getFileWriter(params: { sandboxId: string; extractDir: string }) {
|
|
154
177
|
const writer = new FileWriter();
|
|
155
178
|
return {
|
|
156
179
|
response: (async () => {
|
|
157
180
|
return this.request(`/v1/sandboxes/${params.sandboxId}/fs/write`, {
|
|
158
181
|
method: "POST",
|
|
159
|
-
headers: {
|
|
182
|
+
headers: {
|
|
183
|
+
"content-type": "application/gzip",
|
|
184
|
+
"x-cwd": params.extractDir,
|
|
185
|
+
},
|
|
160
186
|
body: await consumeReadable(writer.readable),
|
|
161
187
|
});
|
|
162
188
|
})(),
|
|
@@ -166,14 +192,24 @@ export class APIClient extends BaseClient {
|
|
|
166
192
|
|
|
167
193
|
async writeFiles(params: {
|
|
168
194
|
sandboxId: string;
|
|
195
|
+
cwd: string;
|
|
169
196
|
files: { path: string; content: Buffer }[];
|
|
197
|
+
extractDir: string;
|
|
170
198
|
}) {
|
|
171
199
|
const { writer, response } = this.getFileWriter({
|
|
172
200
|
sandboxId: params.sandboxId,
|
|
201
|
+
extractDir: params.extractDir,
|
|
173
202
|
});
|
|
174
203
|
|
|
175
204
|
for (const file of params.files) {
|
|
176
|
-
await writer.addFile({
|
|
205
|
+
await writer.addFile({
|
|
206
|
+
name: normalizePath({
|
|
207
|
+
filePath: file.path,
|
|
208
|
+
extractDir: params.extractDir,
|
|
209
|
+
cwd: params.cwd,
|
|
210
|
+
}),
|
|
211
|
+
content: file.content,
|
|
212
|
+
});
|
|
177
213
|
}
|
|
178
214
|
|
|
179
215
|
writer.end();
|
package/src/command.ts
CHANGED
|
@@ -4,8 +4,11 @@ import { Signal, resolveSignal } from "./utils/resolveSignal";
|
|
|
4
4
|
/**
|
|
5
5
|
* A command executed in a Sandbox.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* For detached commands, you can {@link wait} to get a {@link CommandFinished} instance
|
|
8
|
+
* with the populated exit code. For non-detached commands, {@link Sandbox.runCommand}
|
|
9
|
+
* automatically waits and returns a {@link CommandFinished} instance.
|
|
10
|
+
*
|
|
11
|
+
* You can iterate over command output with {@link logs}.
|
|
9
12
|
*
|
|
10
13
|
* @see {@link Sandbox.runCommand} to start a command.
|
|
11
14
|
*
|
|
@@ -94,9 +97,14 @@ export class Command {
|
|
|
94
97
|
/**
|
|
95
98
|
* Wait for a command to exit and populate its exit code.
|
|
96
99
|
*
|
|
100
|
+
* This method is useful for detached commands where you need to wait
|
|
101
|
+
* for completion. For non-detached commands, {@link Sandbox.runCommand}
|
|
102
|
+
* automatically waits and returns a {@link CommandFinished} instance.
|
|
103
|
+
*
|
|
97
104
|
* ```
|
|
98
|
-
* await
|
|
99
|
-
*
|
|
105
|
+
* const detachedCmd = await sandbox.runCommand({ cmd: 'sleep', args: ['5'], detached: true });
|
|
106
|
+
* const result = await detachedCmd.wait();
|
|
107
|
+
* if (result.exitCode !== 0) {
|
|
100
108
|
* console.error("Something went wrong...")
|
|
101
109
|
* }
|
|
102
110
|
* ```
|
|
@@ -180,14 +188,16 @@ export class Command {
|
|
|
180
188
|
/**
|
|
181
189
|
* A command that has finished executing.
|
|
182
190
|
*
|
|
183
|
-
*
|
|
191
|
+
* The exit code is immediately available and populated upon creation.
|
|
192
|
+
* Unlike {@link Command}, you don't need to call wait() - the command
|
|
193
|
+
* has already completed execution.
|
|
184
194
|
*
|
|
185
195
|
* @hideconstructor
|
|
186
196
|
*/
|
|
187
197
|
export class CommandFinished extends Command {
|
|
188
198
|
/**
|
|
189
|
-
* The exit code of the command
|
|
190
|
-
*
|
|
199
|
+
* The exit code of the command. This is always populated for
|
|
200
|
+
* CommandFinished instances.
|
|
191
201
|
*/
|
|
192
202
|
public exitCode: number;
|
|
193
203
|
|
|
@@ -207,4 +217,16 @@ export class CommandFinished extends Command {
|
|
|
207
217
|
super({ ...params });
|
|
208
218
|
this.exitCode = params.exitCode;
|
|
209
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The wait method is not needed for CommandFinished instances since
|
|
223
|
+
* the command has already completed and exitCode is populated.
|
|
224
|
+
*
|
|
225
|
+
* @deprecated This method is redundant for CommandFinished instances.
|
|
226
|
+
* The exitCode is already available.
|
|
227
|
+
* @returns This CommandFinished instance.
|
|
228
|
+
*/
|
|
229
|
+
async wait(): Promise<CommandFinished> {
|
|
230
|
+
return this;
|
|
231
|
+
}
|
|
210
232
|
}
|
package/src/sandbox.test.ts
CHANGED
|
@@ -2,10 +2,11 @@ import { it, beforeEach, afterEach, expect } from "vitest";
|
|
|
2
2
|
import { consumeReadable } from "./utils/consume-readable";
|
|
3
3
|
import { Sandbox } from "./sandbox";
|
|
4
4
|
|
|
5
|
+
const PORTS = [3000, 4000];
|
|
5
6
|
let sandbox: Sandbox;
|
|
6
7
|
|
|
7
8
|
beforeEach(async () => {
|
|
8
|
-
sandbox = await Sandbox.create();
|
|
9
|
+
sandbox = await Sandbox.create({ ports: PORTS });
|
|
9
10
|
});
|
|
10
11
|
|
|
11
12
|
afterEach(async () => {
|
|
@@ -23,3 +24,43 @@ it("allows to write files and then read them", async () => {
|
|
|
23
24
|
expect((await consumeReadable(content1!)).toString()).toBe("Hello 1");
|
|
24
25
|
expect((await consumeReadable(content2!)).toString()).toBe("Hello 2");
|
|
25
26
|
});
|
|
27
|
+
|
|
28
|
+
it("verifies port forwarding works correctly", async () => {
|
|
29
|
+
const serverScript = `
|
|
30
|
+
const http = require('http');
|
|
31
|
+
const ports = process.argv.slice(2);
|
|
32
|
+
|
|
33
|
+
for (const port of ports) {
|
|
34
|
+
const server = http.createServer((req, res) => {
|
|
35
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
36
|
+
res.end(\`hello port \${port}\`);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
server.listen(port, () => {
|
|
40
|
+
console.log(\`Server running on port \${port}\`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
await sandbox.writeFiles([
|
|
46
|
+
{ path: "server.js", content: Buffer.from(serverScript) },
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
const server = await sandbox.runCommand({
|
|
50
|
+
cmd: "node",
|
|
51
|
+
args: ["server.js", ...PORTS.map(String)],
|
|
52
|
+
detached: true,
|
|
53
|
+
stdout: process.stdout,
|
|
54
|
+
stderr: process.stderr,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
58
|
+
|
|
59
|
+
for (const port of PORTS) {
|
|
60
|
+
const response = await fetch(sandbox.domain(port));
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
expect(text).toBe(`hello port ${port}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await server.kill();
|
|
66
|
+
});
|
package/src/sandbox.ts
CHANGED
|
@@ -32,7 +32,8 @@ export interface CreateSandboxParams {
|
|
|
32
32
|
}
|
|
33
33
|
| { type: "tarball"; url: string };
|
|
34
34
|
/**
|
|
35
|
-
* Array of port numbers to expose from the sandbox.
|
|
35
|
+
* Array of port numbers to expose from the sandbox. Sandboxes can
|
|
36
|
+
* expose up to 4 ports.
|
|
36
37
|
*/
|
|
37
38
|
ports?: number[];
|
|
38
39
|
/**
|
|
@@ -141,7 +142,7 @@ export class Sandbox {
|
|
|
141
142
|
static async create(
|
|
142
143
|
params?: CreateSandboxParams | (CreateSandboxParams & Credentials),
|
|
143
144
|
): Promise<Sandbox> {
|
|
144
|
-
const credentials = getCredentials(params);
|
|
145
|
+
const credentials = await getCredentials(params);
|
|
145
146
|
const client = new APIClient({
|
|
146
147
|
teamId: credentials.teamId,
|
|
147
148
|
token: credentials.token,
|
|
@@ -172,7 +173,7 @@ export class Sandbox {
|
|
|
172
173
|
static async get(
|
|
173
174
|
params: GetSandboxParams | (GetSandboxParams & Credentials),
|
|
174
175
|
): Promise<Sandbox> {
|
|
175
|
-
const credentials = getCredentials(params);
|
|
176
|
+
const credentials = await getCredentials(params);
|
|
176
177
|
const client = new APIClient({
|
|
177
178
|
teamId: credentials.teamId,
|
|
178
179
|
token: credentials.token,
|
|
@@ -331,6 +332,8 @@ export class Sandbox {
|
|
|
331
332
|
|
|
332
333
|
/**
|
|
333
334
|
* Write files to the filesystem of this sandbox.
|
|
335
|
+
* Defaults to writing to /vercel/sandbox unless an absolute path is specified.
|
|
336
|
+
* Writes files using the `vercel-sandbox` user.
|
|
334
337
|
*
|
|
335
338
|
* @param files - Array of files with path and stream/buffer contents
|
|
336
339
|
* @returns A promise that resolves when the files are written
|
|
@@ -338,6 +341,8 @@ export class Sandbox {
|
|
|
338
341
|
async writeFiles(files: { path: string; content: Buffer }[]) {
|
|
339
342
|
return this.client.writeFiles({
|
|
340
343
|
sandboxId: this.sandbox.id,
|
|
344
|
+
cwd: this.sandbox.cwd,
|
|
345
|
+
extractDir: "/",
|
|
341
346
|
files: files,
|
|
342
347
|
});
|
|
343
348
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getVercelOidcToken } from "@vercel/oidc";
|
|
2
2
|
import { decodeBase64Url } from "./decode-base64-url";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
|
|
@@ -28,13 +28,13 @@ export interface Credentials {
|
|
|
28
28
|
* If both methods are used, the object properties take precedence over the
|
|
29
29
|
* environment variable. If neither method is used, an error is thrown.
|
|
30
30
|
*/
|
|
31
|
-
export function getCredentials
|
|
31
|
+
export async function getCredentials(params?: unknown): Promise<Credentials> {
|
|
32
32
|
const credentials = getCredentialsFromParams(params ?? {});
|
|
33
33
|
if (credentials) {
|
|
34
34
|
return credentials;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
const oidcToken =
|
|
37
|
+
const oidcToken = await getVercelOidcToken();
|
|
38
38
|
if (oidcToken) {
|
|
39
39
|
return getCredentialsFromOIDCToken(oidcToken);
|
|
40
40
|
}
|
|
@@ -50,9 +50,12 @@ export function getCredentials<T>(params?: T | Credentials): Credentials {
|
|
|
50
50
|
* required fields (`token`, `teamId`, and `projectId`) must be present
|
|
51
51
|
* or none of them, otherwise an error is thrown.
|
|
52
52
|
*/
|
|
53
|
-
function getCredentialsFromParams
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
function getCredentialsFromParams(params: unknown): Credentials | null {
|
|
54
|
+
// Type guard: params must be an object
|
|
55
|
+
if (!params || typeof params !== "object") {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
56
59
|
const missing = [
|
|
57
60
|
"token" in params && typeof params.token === "string" ? null : "token",
|
|
58
61
|
"teamId" in params && typeof params.teamId === "string" ? null : "teamId",
|
|
@@ -63,9 +66,9 @@ function getCredentialsFromParams<Params extends Record<string, unknown>>(
|
|
|
63
66
|
|
|
64
67
|
if (missing.length === 0) {
|
|
65
68
|
return {
|
|
66
|
-
token: params
|
|
67
|
-
projectId: params
|
|
68
|
-
teamId: params
|
|
69
|
+
token: (params as any).token,
|
|
70
|
+
projectId: (params as any).projectId,
|
|
71
|
+
teamId: (params as any).teamId,
|
|
69
72
|
};
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -84,7 +87,9 @@ function getCredentialsFromParams<Params extends Record<string, unknown>>(
|
|
|
84
87
|
* Schema to validate the payload of the Vercel OIDC token where we expect
|
|
85
88
|
* to find the `teamId` and `projectId`.
|
|
86
89
|
*/
|
|
87
|
-
const schema = z.object({
|
|
90
|
+
export const schema = z.object({
|
|
91
|
+
exp: z.number().optional().describe("Expiry timestamp (seconds since epoch)"),
|
|
92
|
+
iat: z.number().optional().describe("Issued at timestamp"),
|
|
88
93
|
owner_id: z.string(),
|
|
89
94
|
project_id: z.string(),
|
|
90
95
|
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { assert, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { JwtExpiry } from "./jwt-expiry";
|
|
3
|
+
import type { getVercelOidcToken } from "@vercel/oidc";
|
|
4
|
+
|
|
5
|
+
const { getVercelOidcTokenMock } = vi.hoisted(() => {
|
|
6
|
+
return {
|
|
7
|
+
getVercelOidcTokenMock: vi.fn<typeof getVercelOidcToken>(),
|
|
8
|
+
};
|
|
9
|
+
});
|
|
10
|
+
vi.mock("@vercel/oidc", () => ({
|
|
11
|
+
getVercelOidcToken: getVercelOidcTokenMock,
|
|
12
|
+
}));
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
getVercelOidcTokenMock.mockReset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("JwtExpiry", () => {
|
|
18
|
+
test("refreshes a token", async () => {
|
|
19
|
+
const token = createMockJWT({
|
|
20
|
+
owner_id: "team1",
|
|
21
|
+
project_id: "proj1",
|
|
22
|
+
});
|
|
23
|
+
getVercelOidcTokenMock.mockImplementationOnce(async () => "hello world");
|
|
24
|
+
const expiry = await JwtExpiry.fromToken(token)?.refresh();
|
|
25
|
+
expect(expiry).toBeInstanceOf(JwtExpiry);
|
|
26
|
+
expect(expiry?.token).toEqual("hello world");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("isValid returns true for tokens without expiry", () => {
|
|
30
|
+
// Mock token without exp field (like OIDC tokens without exp)
|
|
31
|
+
const tokenWithoutExp = createMockJWT({
|
|
32
|
+
owner_id: "team1",
|
|
33
|
+
project_id: "proj1",
|
|
34
|
+
});
|
|
35
|
+
const expiry = JwtExpiry.fromToken(tokenWithoutExp);
|
|
36
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
37
|
+
expect(expiry.isValid()).toBe(false); // No exp field means malformed JWT
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("isValid returns true for unexpired tokens", () => {
|
|
41
|
+
const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
|
42
|
+
const tokenValid = createMockJWT({
|
|
43
|
+
owner_id: "team1",
|
|
44
|
+
project_id: "proj1",
|
|
45
|
+
exp: futureTime,
|
|
46
|
+
});
|
|
47
|
+
const expiry = JwtExpiry.fromToken(tokenValid);
|
|
48
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
49
|
+
expect(expiry.isValid()).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("isValid returns false for expired tokens", () => {
|
|
53
|
+
const pastTime = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago
|
|
54
|
+
const tokenExpired = createMockJWT({
|
|
55
|
+
owner_id: "team1",
|
|
56
|
+
project_id: "proj1",
|
|
57
|
+
exp: pastTime,
|
|
58
|
+
});
|
|
59
|
+
const expiry = JwtExpiry.fromToken(tokenExpired);
|
|
60
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
61
|
+
expect(expiry.isValid()).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("isValid returns false for tokens expiring within buffer time", () => {
|
|
65
|
+
const soonTime = Math.floor(Date.now() / 1000) + 120; // 2 minutes from now
|
|
66
|
+
const tokenExpiringSoon = createMockJWT({
|
|
67
|
+
owner_id: "team1",
|
|
68
|
+
project_id: "proj1",
|
|
69
|
+
exp: soonTime,
|
|
70
|
+
});
|
|
71
|
+
const expiry = JwtExpiry.fromToken(tokenExpiringSoon);
|
|
72
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
73
|
+
expect(expiry.isValid(5)).toBe(false); // 5 minute buffer
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("isValid returns false for malformed JWT tokens", () => {
|
|
77
|
+
const expiry = JwtExpiry.fromToken("header.invalid-payload.signature");
|
|
78
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
79
|
+
expect(expiry.isValid()).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("getExpiryDate returns correct expiry date", () => {
|
|
83
|
+
const expTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
|
|
84
|
+
const token = createMockJWT({
|
|
85
|
+
owner_id: "team1",
|
|
86
|
+
project_id: "proj1",
|
|
87
|
+
exp: expTime,
|
|
88
|
+
});
|
|
89
|
+
const expiry = JwtExpiry.fromToken(token);
|
|
90
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
91
|
+
expect(expiry.getExpiryDate()).toEqual(new Date(expTime * 1000));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("getExpiryDate returns null for tokens without expiry", () => {
|
|
95
|
+
const token = createMockJWT({ owner_id: "team1", project_id: "proj1" });
|
|
96
|
+
const expiry = JwtExpiry.fromToken(token);
|
|
97
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
98
|
+
expect(expiry.getExpiryDate()).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("getExpiryDate returns null for malformed tokens", () => {
|
|
102
|
+
const token = "hello.world.hey";
|
|
103
|
+
const expiry = JwtExpiry.fromToken(token);
|
|
104
|
+
assert(expiry, "Expiry should not be null for valid JWT");
|
|
105
|
+
expect(expiry.getExpiryDate()).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns null for non-JWT style tokens", () => {
|
|
109
|
+
expect(JwtExpiry.fromToken("personal-access-token")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Helper function to create mock JWT tokens for testing
|
|
114
|
+
function createMockJWT(payload: any): string {
|
|
115
|
+
const header = { typ: "JWT", alg: "HS256" };
|
|
116
|
+
const encodedHeader = Buffer.from(JSON.stringify(header)).toString(
|
|
117
|
+
"base64url",
|
|
118
|
+
);
|
|
119
|
+
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
|
|
120
|
+
"base64url",
|
|
121
|
+
);
|
|
122
|
+
const signature = "mock-signature";
|
|
123
|
+
|
|
124
|
+
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { decodeBase64Url } from "./decode-base64-url";
|
|
3
|
+
import { schema } from "./get-credentials";
|
|
4
|
+
import { getVercelOidcToken } from "@vercel/oidc";
|
|
5
|
+
import ms from "ms";
|
|
6
|
+
|
|
7
|
+
/** Time buffer before token expiry to consider it invalid (in milliseconds) */
|
|
8
|
+
const BUFFER_MS = ms("5m");
|
|
9
|
+
|
|
10
|
+
export class OidcRefreshError extends Error {
|
|
11
|
+
name = "OidcRefreshError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Expiry implementation for JWT tokens (OIDC tokens).
|
|
16
|
+
* Parses the JWT once and provides fast expiry validation.
|
|
17
|
+
*/
|
|
18
|
+
export class JwtExpiry {
|
|
19
|
+
private expiryTime: number | null; // Unix timestamp in seconds
|
|
20
|
+
readonly payload?: Readonly<z.infer<typeof schema>>;
|
|
21
|
+
|
|
22
|
+
static fromToken(token: string): JwtExpiry | null {
|
|
23
|
+
if (!isJwtFormat(token)) {
|
|
24
|
+
return null;
|
|
25
|
+
} else {
|
|
26
|
+
return new JwtExpiry(token);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates a new JWT expiry checker.
|
|
32
|
+
*
|
|
33
|
+
* @param token - The JWT token to parse
|
|
34
|
+
*/
|
|
35
|
+
constructor(readonly token: string) {
|
|
36
|
+
try {
|
|
37
|
+
const tokenContents = token.split(".")[1];
|
|
38
|
+
this.payload = schema.parse(decodeBase64Url(tokenContents));
|
|
39
|
+
this.expiryTime = this.payload.exp || null;
|
|
40
|
+
} catch {
|
|
41
|
+
// Malformed token - treat as expired to trigger refresh
|
|
42
|
+
this.expiryTime = 0;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Checks if the JWT token is valid (not expired).
|
|
48
|
+
* @returns true if token is valid, false if expired or expiring soon
|
|
49
|
+
*/
|
|
50
|
+
isValid(): boolean {
|
|
51
|
+
if (this.expiryTime === null) {
|
|
52
|
+
return false; // No expiry means malformed JWT
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const now = Math.floor(Date.now() / 1000);
|
|
56
|
+
const buffer = BUFFER_MS / 1000;
|
|
57
|
+
return now + buffer < this.expiryTime;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Gets the expiry date of the JWT token.
|
|
62
|
+
*
|
|
63
|
+
* @returns Date object representing when the token expires, or null if no expiry
|
|
64
|
+
*/
|
|
65
|
+
getExpiryDate(): Date | null {
|
|
66
|
+
return this.expiryTime ? new Date(this.expiryTime * 1000) : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Refreshes the JWT token by fetching a new OIDC token.
|
|
71
|
+
*
|
|
72
|
+
* @returns Promise resolving to a new JwtExpiry instance with fresh token
|
|
73
|
+
*/
|
|
74
|
+
async refresh(): Promise<JwtExpiry> {
|
|
75
|
+
try {
|
|
76
|
+
const freshToken = await getVercelOidcToken();
|
|
77
|
+
return new JwtExpiry(freshToken);
|
|
78
|
+
} catch (cause) {
|
|
79
|
+
throw new OidcRefreshError("Failed to refresh OIDC token", {
|
|
80
|
+
cause,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Refreshes the JWT token if it's expired or expiring soon.
|
|
87
|
+
*/
|
|
88
|
+
async tryRefresh(): Promise<JwtExpiry | null> {
|
|
89
|
+
if (this.isValid()) {
|
|
90
|
+
return null; // Still valid, no need to refresh
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return this.refresh();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Checks if a token follows JWT format (has 3 parts separated by dots).
|
|
99
|
+
*
|
|
100
|
+
* @param token - The token to check
|
|
101
|
+
* @returns true if token appears to be a JWT, false otherwise
|
|
102
|
+
*/
|
|
103
|
+
function isJwtFormat(token: string): boolean {
|
|
104
|
+
return token.split(".").length === 3;
|
|
105
|
+
}
|