@vercel/sandbox 0.0.17 → 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 +6 -0
- package/dist/api-client/api-client.d.ts +2 -0
- package/dist/api-client/api-client.js +17 -0
- package/dist/sandbox.js +2 -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/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/src/api-client/api-client.ts +22 -0
- package/src/sandbox.ts +2 -2
- 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/version.ts +1 -1
package/.turbo/turbo-build.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -4,11 +4,13 @@ import { FileWriter } from "./file-writer";
|
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
export declare class APIClient extends BaseClient {
|
|
6
6
|
private teamId;
|
|
7
|
+
private tokenExpiry;
|
|
7
8
|
constructor(params: {
|
|
8
9
|
host?: string;
|
|
9
10
|
teamId: string;
|
|
10
11
|
token: string;
|
|
11
12
|
});
|
|
13
|
+
private ensureValidToken;
|
|
12
14
|
protected request(path: string, params?: RequestParams): Promise<Response>;
|
|
13
15
|
getSandbox(params: {
|
|
14
16
|
sandboxId: string;
|
|
@@ -14,6 +14,7 @@ const jsonlines_1 = __importDefault(require("jsonlines"));
|
|
|
14
14
|
const os_1 = __importDefault(require("os"));
|
|
15
15
|
const stream_1 = require("stream");
|
|
16
16
|
const normalizePath_1 = require("../utils/normalizePath");
|
|
17
|
+
const jwt_expiry_1 = require("../utils/jwt-expiry");
|
|
17
18
|
class APIClient extends base_client_1.BaseClient {
|
|
18
19
|
constructor(params) {
|
|
19
20
|
super({
|
|
@@ -22,8 +23,24 @@ class APIClient extends base_client_1.BaseClient {
|
|
|
22
23
|
debug: false,
|
|
23
24
|
});
|
|
24
25
|
this.teamId = params.teamId;
|
|
26
|
+
this.tokenExpiry = jwt_expiry_1.JwtExpiry.fromToken(params.token);
|
|
27
|
+
}
|
|
28
|
+
async ensureValidToken() {
|
|
29
|
+
if (!this.tokenExpiry) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const newExpiry = await this.tokenExpiry.tryRefresh();
|
|
33
|
+
if (!newExpiry) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.tokenExpiry = newExpiry;
|
|
37
|
+
this.token = this.tokenExpiry.token;
|
|
38
|
+
if (this.tokenExpiry.payload) {
|
|
39
|
+
this.teamId = this.tokenExpiry.payload?.owner_id;
|
|
40
|
+
}
|
|
25
41
|
}
|
|
26
42
|
async request(path, params) {
|
|
43
|
+
await this.ensureValidToken();
|
|
27
44
|
return super.request(path, {
|
|
28
45
|
...params,
|
|
29
46
|
query: { teamId: this.teamId, ...params?.query },
|
package/dist/sandbox.js
CHANGED
|
@@ -30,7 +30,7 @@ class Sandbox {
|
|
|
30
30
|
* @returns A promise resolving to the created {@link Sandbox}.
|
|
31
31
|
*/
|
|
32
32
|
static async create(params) {
|
|
33
|
-
const credentials = (0, get_credentials_1.getCredentials)(params);
|
|
33
|
+
const credentials = await (0, get_credentials_1.getCredentials)(params);
|
|
34
34
|
const client = new api_client_1.APIClient({
|
|
35
35
|
teamId: credentials.teamId,
|
|
36
36
|
token: credentials.token,
|
|
@@ -56,7 +56,7 @@ class Sandbox {
|
|
|
56
56
|
* @returns A promise resolving to the {@link Sandbox}.
|
|
57
57
|
*/
|
|
58
58
|
static async get(params) {
|
|
59
|
-
const credentials = (0, get_credentials_1.getCredentials)(params);
|
|
59
|
+
const credentials = await (0, get_credentials_1.getCredentials)(params);
|
|
60
60
|
const client = new api_client_1.APIClient({
|
|
61
61
|
teamId: credentials.teamId,
|
|
62
62
|
token: credentials.token,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
1
2
|
export interface Credentials {
|
|
2
3
|
/**
|
|
3
4
|
* Authentication token for the Vercel API. It could be an OIDC token
|
|
@@ -23,4 +24,24 @@ export interface Credentials {
|
|
|
23
24
|
* If both methods are used, the object properties take precedence over the
|
|
24
25
|
* environment variable. If neither method is used, an error is thrown.
|
|
25
26
|
*/
|
|
26
|
-
export declare function getCredentials
|
|
27
|
+
export declare function getCredentials(params?: unknown): Promise<Credentials>;
|
|
28
|
+
/**
|
|
29
|
+
* Schema to validate the payload of the Vercel OIDC token where we expect
|
|
30
|
+
* to find the `teamId` and `projectId`.
|
|
31
|
+
*/
|
|
32
|
+
export declare const schema: z.ZodObject<{
|
|
33
|
+
exp: z.ZodOptional<z.ZodNumber>;
|
|
34
|
+
iat: z.ZodOptional<z.ZodNumber>;
|
|
35
|
+
owner_id: z.ZodString;
|
|
36
|
+
project_id: z.ZodString;
|
|
37
|
+
}, "strip", z.ZodTypeAny, {
|
|
38
|
+
owner_id: string;
|
|
39
|
+
project_id: string;
|
|
40
|
+
exp?: number | undefined;
|
|
41
|
+
iat?: number | undefined;
|
|
42
|
+
}, {
|
|
43
|
+
owner_id: string;
|
|
44
|
+
project_id: string;
|
|
45
|
+
exp?: number | undefined;
|
|
46
|
+
iat?: number | undefined;
|
|
47
|
+
}>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.schema = void 0;
|
|
3
4
|
exports.getCredentials = getCredentials;
|
|
4
5
|
const oidc_1 = require("@vercel/oidc");
|
|
5
6
|
const decode_base64_url_1 = require("./decode-base64-url");
|
|
@@ -14,12 +15,12 @@ const zod_1 = require("zod");
|
|
|
14
15
|
* If both methods are used, the object properties take precedence over the
|
|
15
16
|
* environment variable. If neither method is used, an error is thrown.
|
|
16
17
|
*/
|
|
17
|
-
function getCredentials(params) {
|
|
18
|
+
async function getCredentials(params) {
|
|
18
19
|
const credentials = getCredentialsFromParams(params ?? {});
|
|
19
20
|
if (credentials) {
|
|
20
21
|
return credentials;
|
|
21
22
|
}
|
|
22
|
-
const oidcToken = (0, oidc_1.
|
|
23
|
+
const oidcToken = await (0, oidc_1.getVercelOidcToken)();
|
|
23
24
|
if (oidcToken) {
|
|
24
25
|
return getCredentialsFromOIDCToken(oidcToken);
|
|
25
26
|
}
|
|
@@ -32,6 +33,10 @@ function getCredentials(params) {
|
|
|
32
33
|
* or none of them, otherwise an error is thrown.
|
|
33
34
|
*/
|
|
34
35
|
function getCredentialsFromParams(params) {
|
|
36
|
+
// Type guard: params must be an object
|
|
37
|
+
if (!params || typeof params !== "object") {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
35
40
|
const missing = [
|
|
36
41
|
"token" in params && typeof params.token === "string" ? null : "token",
|
|
37
42
|
"teamId" in params && typeof params.teamId === "string" ? null : "teamId",
|
|
@@ -57,7 +62,9 @@ function getCredentialsFromParams(params) {
|
|
|
57
62
|
* Schema to validate the payload of the Vercel OIDC token where we expect
|
|
58
63
|
* to find the `teamId` and `projectId`.
|
|
59
64
|
*/
|
|
60
|
-
|
|
65
|
+
exports.schema = zod_1.z.object({
|
|
66
|
+
exp: zod_1.z.number().optional().describe("Expiry timestamp (seconds since epoch)"),
|
|
67
|
+
iat: zod_1.z.number().optional().describe("Issued at timestamp"),
|
|
61
68
|
owner_id: zod_1.z.string(),
|
|
62
69
|
project_id: zod_1.z.string(),
|
|
63
70
|
});
|
|
@@ -71,7 +78,7 @@ const schema = zod_1.z.object({
|
|
|
71
78
|
*/
|
|
72
79
|
function getCredentialsFromOIDCToken(token) {
|
|
73
80
|
try {
|
|
74
|
-
const payload = schema.parse((0, decode_base64_url_1.decodeBase64Url)(token.split(".")[1]));
|
|
81
|
+
const payload = exports.schema.parse((0, decode_base64_url_1.decodeBase64Url)(token.split(".")[1]));
|
|
75
82
|
return {
|
|
76
83
|
token,
|
|
77
84
|
projectId: payload.project_id,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { schema } from "./get-credentials";
|
|
3
|
+
export declare class OidcRefreshError extends Error {
|
|
4
|
+
name: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Expiry implementation for JWT tokens (OIDC tokens).
|
|
8
|
+
* Parses the JWT once and provides fast expiry validation.
|
|
9
|
+
*/
|
|
10
|
+
export declare class JwtExpiry {
|
|
11
|
+
readonly token: string;
|
|
12
|
+
private expiryTime;
|
|
13
|
+
readonly payload?: Readonly<z.infer<typeof schema>>;
|
|
14
|
+
static fromToken(token: string): JwtExpiry | null;
|
|
15
|
+
/**
|
|
16
|
+
* Creates a new JWT expiry checker.
|
|
17
|
+
*
|
|
18
|
+
* @param token - The JWT token to parse
|
|
19
|
+
*/
|
|
20
|
+
constructor(token: string);
|
|
21
|
+
/**
|
|
22
|
+
* Checks if the JWT token is valid (not expired).
|
|
23
|
+
* @returns true if token is valid, false if expired or expiring soon
|
|
24
|
+
*/
|
|
25
|
+
isValid(): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Gets the expiry date of the JWT token.
|
|
28
|
+
*
|
|
29
|
+
* @returns Date object representing when the token expires, or null if no expiry
|
|
30
|
+
*/
|
|
31
|
+
getExpiryDate(): Date | null;
|
|
32
|
+
/**
|
|
33
|
+
* Refreshes the JWT token by fetching a new OIDC token.
|
|
34
|
+
*
|
|
35
|
+
* @returns Promise resolving to a new JwtExpiry instance with fresh token
|
|
36
|
+
*/
|
|
37
|
+
refresh(): Promise<JwtExpiry>;
|
|
38
|
+
/**
|
|
39
|
+
* Refreshes the JWT token if it's expired or expiring soon.
|
|
40
|
+
*/
|
|
41
|
+
tryRefresh(): Promise<JwtExpiry | null>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
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.JwtExpiry = exports.OidcRefreshError = void 0;
|
|
7
|
+
const decode_base64_url_1 = require("./decode-base64-url");
|
|
8
|
+
const get_credentials_1 = require("./get-credentials");
|
|
9
|
+
const oidc_1 = require("@vercel/oidc");
|
|
10
|
+
const ms_1 = __importDefault(require("ms"));
|
|
11
|
+
/** Time buffer before token expiry to consider it invalid (in milliseconds) */
|
|
12
|
+
const BUFFER_MS = (0, ms_1.default)("5m");
|
|
13
|
+
class OidcRefreshError extends Error {
|
|
14
|
+
constructor() {
|
|
15
|
+
super(...arguments);
|
|
16
|
+
this.name = "OidcRefreshError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
exports.OidcRefreshError = OidcRefreshError;
|
|
20
|
+
/**
|
|
21
|
+
* Expiry implementation for JWT tokens (OIDC tokens).
|
|
22
|
+
* Parses the JWT once and provides fast expiry validation.
|
|
23
|
+
*/
|
|
24
|
+
class JwtExpiry {
|
|
25
|
+
static fromToken(token) {
|
|
26
|
+
if (!isJwtFormat(token)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
return new JwtExpiry(token);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Creates a new JWT expiry checker.
|
|
35
|
+
*
|
|
36
|
+
* @param token - The JWT token to parse
|
|
37
|
+
*/
|
|
38
|
+
constructor(token) {
|
|
39
|
+
this.token = token;
|
|
40
|
+
try {
|
|
41
|
+
const tokenContents = token.split(".")[1];
|
|
42
|
+
this.payload = get_credentials_1.schema.parse((0, decode_base64_url_1.decodeBase64Url)(tokenContents));
|
|
43
|
+
this.expiryTime = this.payload.exp || null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Malformed token - treat as expired to trigger refresh
|
|
47
|
+
this.expiryTime = 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Checks if the JWT token is valid (not expired).
|
|
52
|
+
* @returns true if token is valid, false if expired or expiring soon
|
|
53
|
+
*/
|
|
54
|
+
isValid() {
|
|
55
|
+
if (this.expiryTime === null) {
|
|
56
|
+
return false; // No expiry means malformed JWT
|
|
57
|
+
}
|
|
58
|
+
const now = Math.floor(Date.now() / 1000);
|
|
59
|
+
const buffer = BUFFER_MS / 1000;
|
|
60
|
+
return now + buffer < this.expiryTime;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Gets the expiry date of the JWT token.
|
|
64
|
+
*
|
|
65
|
+
* @returns Date object representing when the token expires, or null if no expiry
|
|
66
|
+
*/
|
|
67
|
+
getExpiryDate() {
|
|
68
|
+
return this.expiryTime ? new Date(this.expiryTime * 1000) : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Refreshes the JWT token by fetching a new OIDC token.
|
|
72
|
+
*
|
|
73
|
+
* @returns Promise resolving to a new JwtExpiry instance with fresh token
|
|
74
|
+
*/
|
|
75
|
+
async refresh() {
|
|
76
|
+
try {
|
|
77
|
+
const freshToken = await (0, oidc_1.getVercelOidcToken)();
|
|
78
|
+
return new JwtExpiry(freshToken);
|
|
79
|
+
}
|
|
80
|
+
catch (cause) {
|
|
81
|
+
throw new OidcRefreshError("Failed to refresh OIDC token", {
|
|
82
|
+
cause,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Refreshes the JWT token if it's expired or expiring soon.
|
|
88
|
+
*/
|
|
89
|
+
async tryRefresh() {
|
|
90
|
+
if (this.isValid()) {
|
|
91
|
+
return null; // Still valid, no need to refresh
|
|
92
|
+
}
|
|
93
|
+
return this.refresh();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
exports.JwtExpiry = JwtExpiry;
|
|
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) {
|
|
104
|
+
return token.split(".").length === 3;
|
|
105
|
+
}
|
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",
|
|
@@ -21,9 +21,11 @@ import jsonlines from "jsonlines";
|
|
|
21
21
|
import os from "os";
|
|
22
22
|
import { Readable } from "stream";
|
|
23
23
|
import { normalizePath } from "../utils/normalizePath";
|
|
24
|
+
import { JwtExpiry } from "../utils/jwt-expiry";
|
|
24
25
|
|
|
25
26
|
export class APIClient extends BaseClient {
|
|
26
27
|
private teamId: string;
|
|
28
|
+
private tokenExpiry: JwtExpiry | null;
|
|
27
29
|
|
|
28
30
|
constructor(params: { host?: string; teamId: string; token: string }) {
|
|
29
31
|
super({
|
|
@@ -33,9 +35,29 @@ export class APIClient extends BaseClient {
|
|
|
33
35
|
});
|
|
34
36
|
|
|
35
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
|
+
}
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
protected async request(path: string, params?: RequestParams) {
|
|
59
|
+
await this.ensureValidToken();
|
|
60
|
+
|
|
39
61
|
return super.request(path, {
|
|
40
62
|
...params,
|
|
41
63
|
query: { teamId: this.teamId, ...params?.query },
|
package/src/sandbox.ts
CHANGED
|
@@ -142,7 +142,7 @@ export class Sandbox {
|
|
|
142
142
|
static async create(
|
|
143
143
|
params?: CreateSandboxParams | (CreateSandboxParams & Credentials),
|
|
144
144
|
): Promise<Sandbox> {
|
|
145
|
-
const credentials = getCredentials(params);
|
|
145
|
+
const credentials = await getCredentials(params);
|
|
146
146
|
const client = new APIClient({
|
|
147
147
|
teamId: credentials.teamId,
|
|
148
148
|
token: credentials.token,
|
|
@@ -173,7 +173,7 @@ export class Sandbox {
|
|
|
173
173
|
static async get(
|
|
174
174
|
params: GetSandboxParams | (GetSandboxParams & Credentials),
|
|
175
175
|
): Promise<Sandbox> {
|
|
176
|
-
const credentials = getCredentials(params);
|
|
176
|
+
const credentials = await getCredentials(params);
|
|
177
177
|
const client = new APIClient({
|
|
178
178
|
teamId: credentials.teamId,
|
|
179
179
|
token: credentials.token,
|
|
@@ -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
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Autogenerated by inject-version.ts
|
|
2
|
-
export const VERSION = "0.0.
|
|
2
|
+
export const VERSION = "0.0.18";
|