@vercel/sandbox 1.1.4 → 1.1.6
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-test.log +14 -8
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +12 -0
- package/__mocks__/picocolors.ts +13 -0
- package/dist/api-client/api-client.d.ts +2 -2
- package/dist/api-client/api-client.js +3 -1
- package/dist/api-client/api-client.js.map +1 -1
- package/dist/api-client/api-error.d.ts +4 -1
- package/dist/api-client/api-error.js +3 -1
- package/dist/api-client/api-error.js.map +1 -1
- package/dist/api-client/base-client.js +13 -0
- package/dist/api-client/base-client.js.map +1 -1
- package/dist/api-client/validators.d.ts +10 -10
- package/dist/api-client/with-retry.js +1 -1
- package/dist/api-client/with-retry.js.map +1 -1
- package/dist/auth/api.d.ts +6 -0
- package/dist/auth/api.js +28 -0
- package/dist/auth/api.js.map +1 -0
- package/dist/auth/error.d.ts +11 -0
- package/dist/auth/error.js +12 -0
- package/dist/auth/error.js.map +1 -0
- package/dist/auth/file.d.ts +22 -0
- package/dist/auth/file.js +66 -0
- package/dist/auth/file.js.map +1 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.js +27 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/linked-project.d.ts +10 -0
- package/dist/auth/linked-project.js +69 -0
- package/dist/auth/linked-project.js.map +1 -0
- package/dist/auth/oauth.d.ts +131 -0
- package/dist/auth/oauth.js +269 -0
- package/dist/auth/oauth.js.map +1 -0
- package/dist/auth/poll-for-token.d.ts +20 -0
- package/dist/auth/poll-for-token.js +66 -0
- package/dist/auth/poll-for-token.js.map +1 -0
- package/dist/auth/project.d.ts +40 -0
- package/dist/auth/project.js +80 -0
- package/dist/auth/project.js.map +1 -0
- package/dist/auth/zod.d.ts +5 -0
- package/dist/auth/zod.js +20 -0
- package/dist/auth/zod.js.map +1 -0
- package/dist/command.d.ts +7 -0
- package/dist/command.js +39 -7
- package/dist/command.js.map +1 -1
- package/dist/sandbox.js +1 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/utils/dev-credentials.d.ts +37 -0
- package/dist/utils/dev-credentials.js +191 -0
- package/dist/utils/dev-credentials.js.map +1 -0
- package/dist/utils/get-credentials.d.ts +16 -0
- package/dist/utils/get-credentials.js +66 -7
- package/dist/utils/get-credentials.js.map +1 -1
- package/dist/utils/log.d.ts +2 -0
- package/dist/utils/log.js +24 -0
- package/dist/utils/log.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +4 -1
- package/src/api-client/api-client.test.ts +176 -0
- package/src/api-client/api-client.ts +7 -1
- package/src/api-client/api-error.ts +6 -1
- package/src/api-client/base-client.ts +15 -0
- package/src/api-client/with-retry.ts +1 -1
- package/src/auth/api.ts +31 -0
- package/src/auth/error.ts +8 -0
- package/src/auth/file.ts +69 -0
- package/src/auth/index.ts +9 -0
- package/src/auth/infer-scope.test.ts +178 -0
- package/src/auth/linked-project.test.ts +86 -0
- package/src/auth/linked-project.ts +40 -0
- package/src/auth/oauth.ts +333 -0
- package/src/auth/poll-for-token.ts +89 -0
- package/src/auth/project.ts +92 -0
- package/src/auth/zod.ts +16 -0
- package/src/command.ts +50 -7
- package/src/sandbox.ts +1 -1
- package/src/utils/dev-credentials.test.ts +217 -0
- package/src/utils/dev-credentials.ts +189 -0
- package/src/utils/get-credentials.test.ts +20 -0
- package/src/utils/get-credentials.ts +72 -8
- package/src/utils/log.ts +20 -0
- package/src/version.ts +1 -1
- package/test-utils/mock-response.ts +12 -0
- package/vitest.config.ts +1 -0
package/src/command.ts
CHANGED
|
@@ -33,6 +33,14 @@ export class Command {
|
|
|
33
33
|
|
|
34
34
|
public exitCode: number | null;
|
|
35
35
|
|
|
36
|
+
private outputCache: { stdout: string; stderr: string; both: string } | null =
|
|
37
|
+
null;
|
|
38
|
+
private outputCachePromise: Promise<{
|
|
39
|
+
stdout: string;
|
|
40
|
+
stderr: string;
|
|
41
|
+
both: string;
|
|
42
|
+
}> | null = null;
|
|
43
|
+
|
|
36
44
|
/**
|
|
37
45
|
* ID of the command execution.
|
|
38
46
|
*/
|
|
@@ -134,6 +142,46 @@ export class Command {
|
|
|
134
142
|
});
|
|
135
143
|
}
|
|
136
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Get cached output, fetching logs only once and reusing for concurrent calls.
|
|
147
|
+
* This prevents race conditions when stdout() and stderr() are called in parallel.
|
|
148
|
+
*/
|
|
149
|
+
private async getCachedOutput(opts?: { signal?: AbortSignal }): Promise<{
|
|
150
|
+
stdout: string;
|
|
151
|
+
stderr: string;
|
|
152
|
+
both: string;
|
|
153
|
+
}> {
|
|
154
|
+
if (this.outputCache) {
|
|
155
|
+
return this.outputCache;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!this.outputCachePromise) {
|
|
159
|
+
this.outputCachePromise = (async () => {
|
|
160
|
+
try {
|
|
161
|
+
let stdout = "";
|
|
162
|
+
let stderr = "";
|
|
163
|
+
let both = "";
|
|
164
|
+
for await (const log of this.logs({ signal: opts?.signal })) {
|
|
165
|
+
both += log.data;
|
|
166
|
+
if (log.stream === "stdout") {
|
|
167
|
+
stdout += log.data;
|
|
168
|
+
} else {
|
|
169
|
+
stderr += log.data;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
this.outputCache = { stdout, stderr, both };
|
|
173
|
+
return this.outputCache;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
// Clear the promise so future calls can retry
|
|
176
|
+
this.outputCachePromise = null;
|
|
177
|
+
throw err;
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return this.outputCachePromise;
|
|
183
|
+
}
|
|
184
|
+
|
|
137
185
|
/**
|
|
138
186
|
* Get the output of `stdout`, `stderr`, or both as a string.
|
|
139
187
|
*
|
|
@@ -149,13 +197,8 @@ export class Command {
|
|
|
149
197
|
stream: "stdout" | "stderr" | "both" = "both",
|
|
150
198
|
opts?: { signal?: AbortSignal },
|
|
151
199
|
) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (stream === "both" || log.stream === stream) {
|
|
155
|
-
data += log.data;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return data;
|
|
200
|
+
const cached = await this.getCachedOutput(opts);
|
|
201
|
+
return cached[stream];
|
|
159
202
|
}
|
|
160
203
|
|
|
161
204
|
/**
|
package/src/sandbox.ts
CHANGED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { signInAndGetToken, generateCredentials } from "./dev-credentials";
|
|
2
|
+
import { describe, expect, test, vi, beforeEach, type Mock } from "vitest";
|
|
3
|
+
import { factory } from "factoree";
|
|
4
|
+
import { setTimeout } from "node:timers/promises";
|
|
5
|
+
import { DeviceAuthorizationRequest, OAuth } from "../auth";
|
|
6
|
+
|
|
7
|
+
vi.mock("picocolors");
|
|
8
|
+
|
|
9
|
+
vi.mock("../auth/index", () => ({
|
|
10
|
+
getAuth: vi.fn(),
|
|
11
|
+
inferScope: vi.fn(),
|
|
12
|
+
updateAuthConfig: vi.fn(),
|
|
13
|
+
OAuth: vi.fn(),
|
|
14
|
+
pollForToken: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import * as auth from "../auth/index";
|
|
18
|
+
|
|
19
|
+
describe("signInAndGetToken", () => {
|
|
20
|
+
test("times out after provided timeout", async () => {
|
|
21
|
+
const consoleError = vi.spyOn(console, "error").mockReturnValue();
|
|
22
|
+
const promise = signInAndGetToken(
|
|
23
|
+
{
|
|
24
|
+
getAuth: () => null,
|
|
25
|
+
OAuth: async () => {
|
|
26
|
+
return createOAuthFactory({
|
|
27
|
+
async deviceAuthorizationRequest() {
|
|
28
|
+
return createDeviceAuthorizationRequest({
|
|
29
|
+
device_code: "device_code",
|
|
30
|
+
user_code: "user_code",
|
|
31
|
+
verification_uri_complete: `https://example.vercel.sh/device_code?code=user_code`,
|
|
32
|
+
verification_uri: "https://example.vercel.sh/device_code",
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
pollForToken: async function* () {
|
|
38
|
+
await setTimeout(500);
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
`100 milliseconds`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
await expect(promise).rejects.toThrowError(
|
|
45
|
+
/Authentication flow timed out after 100 milliseconds./,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const printed = consoleError.mock.calls.map((x) => x.join(" ")).join("\n");
|
|
49
|
+
expect(printed).toMatchInlineSnapshot(`
|
|
50
|
+
"<yellow><dim>[vercel/sandbox]</dim> No VERCEL_OIDC_TOKEN environment variable found, initiating device authorization flow...
|
|
51
|
+
<dim>[vercel/sandbox]</dim> │ <bold>help:</bold> this flow only happens on development environment.
|
|
52
|
+
<dim>[vercel/sandbox]</dim> │ In production, make sure to set up a proper token, or set up Vercel OIDC [https://vercel.com/docs/oidc].</yellow>
|
|
53
|
+
<blue><dim>[vercel/sandbox]</dim> ╰▶ To authenticate, visit: https://example.vercel.sh/device_code?code=user_code
|
|
54
|
+
<dim>[vercel/sandbox]</dim> or visit <italic>https://example.vercel.sh/device_code</italic> and type <bold>user_code</bold>
|
|
55
|
+
<dim>[vercel/sandbox]</dim> Press <bold><return></bold> to open in your browser</blue>
|
|
56
|
+
<red><dim>[vercel/sandbox]</dim> <bold>error:</bold> Authentication failed: Authentication flow timed out after 100 milliseconds.
|
|
57
|
+
<dim>[vercel/sandbox]</dim> │ Make sure to provide a token to avoid prompting an interactive flow.
|
|
58
|
+
<dim>[vercel/sandbox]</dim> ╰▶ <bold>help:</bold> Link your project with <italic><dim>\`</dim>npx vercel link<dim>\`</dim></italic> to refresh OIDC token automatically.</red>"
|
|
59
|
+
`);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const createOAuthFactory = factory<Awaited<OAuth>>();
|
|
64
|
+
const createDeviceAuthorizationRequest = factory<DeviceAuthorizationRequest>();
|
|
65
|
+
|
|
66
|
+
describe("generateCredentials", () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.clearAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("triggers sign-in when auth exists but has no token", async () => {
|
|
72
|
+
// Auth object with refreshToken but no token - this was the bug
|
|
73
|
+
(auth.getAuth as Mock).mockReturnValue({
|
|
74
|
+
refreshToken: "refresh_xxx",
|
|
75
|
+
expiresAt: new Date(Date.now() + 100000),
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
(auth.OAuth as Mock).mockResolvedValue(
|
|
79
|
+
createOAuthFactory({
|
|
80
|
+
async deviceAuthorizationRequest() {
|
|
81
|
+
return createDeviceAuthorizationRequest({
|
|
82
|
+
device_code: "device_code",
|
|
83
|
+
user_code: "user_code",
|
|
84
|
+
verification_uri_complete: "https://vercel.com/device",
|
|
85
|
+
verification_uri: "https://vercel.com/device",
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
(auth.pollForToken as Mock).mockImplementation(async function* () {
|
|
92
|
+
// Simulate successful auth by updating getAuth to return a token
|
|
93
|
+
(auth.getAuth as Mock).mockReturnValue({ token: "new_token" });
|
|
94
|
+
yield { _tag: "Response" as const };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
(auth.inferScope as Mock).mockResolvedValue({
|
|
98
|
+
teamId: "team_xxx",
|
|
99
|
+
projectId: "prj_xxx",
|
|
100
|
+
created: false,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const result = await generateCredentials({});
|
|
104
|
+
|
|
105
|
+
expect(auth.pollForToken).toHaveBeenCalled();
|
|
106
|
+
expect(result).toEqual({
|
|
107
|
+
token: "new_token",
|
|
108
|
+
teamId: "team_xxx",
|
|
109
|
+
projectId: "prj_xxx",
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("triggers sign-in when auth is null", async () => {
|
|
114
|
+
(auth.getAuth as Mock).mockReturnValue(null);
|
|
115
|
+
|
|
116
|
+
(auth.OAuth as Mock).mockResolvedValue(
|
|
117
|
+
createOAuthFactory({
|
|
118
|
+
async deviceAuthorizationRequest() {
|
|
119
|
+
return createDeviceAuthorizationRequest({
|
|
120
|
+
device_code: "device_code",
|
|
121
|
+
user_code: "user_code",
|
|
122
|
+
verification_uri_complete: "https://vercel.com/device",
|
|
123
|
+
verification_uri: "https://vercel.com/device",
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
(auth.pollForToken as Mock).mockImplementation(async function* () {
|
|
130
|
+
(auth.getAuth as Mock).mockReturnValue({ token: "new_token" });
|
|
131
|
+
yield { _tag: "Response" as const };
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
(auth.inferScope as Mock).mockResolvedValue({
|
|
135
|
+
teamId: "team_xxx",
|
|
136
|
+
projectId: "prj_xxx",
|
|
137
|
+
created: false,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
await generateCredentials({});
|
|
141
|
+
|
|
142
|
+
expect(auth.pollForToken).toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("skips sign-in when auth has valid token", async () => {
|
|
146
|
+
(auth.getAuth as Mock).mockReturnValue({ token: "valid_token" });
|
|
147
|
+
|
|
148
|
+
(auth.inferScope as Mock).mockResolvedValue({
|
|
149
|
+
teamId: "team_xxx",
|
|
150
|
+
projectId: "prj_xxx",
|
|
151
|
+
created: false,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const result = await generateCredentials({});
|
|
155
|
+
|
|
156
|
+
expect(auth.pollForToken).not.toHaveBeenCalled();
|
|
157
|
+
expect(auth.OAuth).not.toHaveBeenCalled();
|
|
158
|
+
expect(result).toEqual({
|
|
159
|
+
token: "valid_token",
|
|
160
|
+
teamId: "team_xxx",
|
|
161
|
+
projectId: "prj_xxx",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("calls inferScope only once when deriving both teamId and projectId", async () => {
|
|
166
|
+
(auth.getAuth as Mock).mockReturnValue({ token: "valid_token" });
|
|
167
|
+
|
|
168
|
+
(auth.inferScope as Mock).mockResolvedValue({
|
|
169
|
+
teamId: "team_xxx",
|
|
170
|
+
projectId: "prj_xxx",
|
|
171
|
+
created: false,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await generateCredentials({});
|
|
175
|
+
|
|
176
|
+
expect(auth.inferScope).toHaveBeenCalledTimes(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("does not call inferScope when both teamId and projectId are provided", async () => {
|
|
180
|
+
(auth.getAuth as Mock).mockReturnValue({ token: "valid_token" });
|
|
181
|
+
|
|
182
|
+
const result = await generateCredentials({
|
|
183
|
+
teamId: "provided_team",
|
|
184
|
+
projectId: "provided_project",
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
expect(auth.inferScope).not.toHaveBeenCalled();
|
|
188
|
+
expect(result).toEqual({
|
|
189
|
+
token: "valid_token",
|
|
190
|
+
teamId: "provided_team",
|
|
191
|
+
projectId: "provided_project",
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("calls inferScope with provided teamId when only teamId is given", async () => {
|
|
196
|
+
(auth.getAuth as Mock).mockReturnValue({ token: "valid_token" });
|
|
197
|
+
|
|
198
|
+
(auth.inferScope as Mock).mockResolvedValue({
|
|
199
|
+
teamId: "provided_team",
|
|
200
|
+
projectId: "inferred_project",
|
|
201
|
+
created: false,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const result = await generateCredentials({ teamId: "provided_team" });
|
|
205
|
+
|
|
206
|
+
expect(auth.inferScope).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(auth.inferScope).toHaveBeenCalledWith({
|
|
208
|
+
teamId: "provided_team",
|
|
209
|
+
token: "valid_token",
|
|
210
|
+
});
|
|
211
|
+
expect(result).toEqual({
|
|
212
|
+
token: "valid_token",
|
|
213
|
+
teamId: "provided_team",
|
|
214
|
+
projectId: "inferred_project",
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import pico from "picocolors";
|
|
2
|
+
import type { Credentials } from "./get-credentials";
|
|
3
|
+
import ms from "ms";
|
|
4
|
+
import * as Log from "./log";
|
|
5
|
+
|
|
6
|
+
async function importAuth() {
|
|
7
|
+
const auth = await import("../auth/index");
|
|
8
|
+
return auth;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function shouldPromptForCredentials(): boolean {
|
|
12
|
+
return (
|
|
13
|
+
process.env.NODE_ENV !== "production" &&
|
|
14
|
+
!["1", "true"].includes(process.env.CI || "") &&
|
|
15
|
+
process.stdout.isTTY &&
|
|
16
|
+
process.stdin.isTTY
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns cached credentials for the given team/project combination.
|
|
22
|
+
*
|
|
23
|
+
* @remarks
|
|
24
|
+
* The cache is keyed by `teamId` and `projectId`. A new credential generation
|
|
25
|
+
* is triggered only when these values change or when a previous attempt failed.
|
|
26
|
+
*
|
|
27
|
+
* **Important:** Successfully resolved credentials are cached indefinitely and
|
|
28
|
+
* will not be refreshed even if the token expires. Cache invalidation only occurs
|
|
29
|
+
* on rejection (error). This is intentional for development use cases where
|
|
30
|
+
* short-lived sessions don't require proactive token refresh.
|
|
31
|
+
*/
|
|
32
|
+
export const cachedGenerateCredentials = (() => {
|
|
33
|
+
let cache:
|
|
34
|
+
| [{ teamId?: string; projectId?: string }, Promise<Credentials>]
|
|
35
|
+
| null = null;
|
|
36
|
+
return async (opts: { projectId?: string; teamId?: string }) => {
|
|
37
|
+
if (
|
|
38
|
+
!cache ||
|
|
39
|
+
cache[0].teamId !== opts.teamId ||
|
|
40
|
+
cache[0].projectId !== opts.projectId
|
|
41
|
+
) {
|
|
42
|
+
const promise = generateCredentials(opts).catch((err) => {
|
|
43
|
+
cache = null;
|
|
44
|
+
throw err;
|
|
45
|
+
});
|
|
46
|
+
cache = [opts, promise];
|
|
47
|
+
}
|
|
48
|
+
const v = await cache[1];
|
|
49
|
+
Log.write(
|
|
50
|
+
"warn",
|
|
51
|
+
`using inferred credentials team=${v.teamId} project=${v.projectId}`,
|
|
52
|
+
);
|
|
53
|
+
return v;
|
|
54
|
+
};
|
|
55
|
+
})();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Generates credentials by authenticating and inferring scope.
|
|
59
|
+
*
|
|
60
|
+
* @internal This is exported for testing purposes. Consider using
|
|
61
|
+
* {@link cachedGenerateCredentials} instead, which caches the result
|
|
62
|
+
* to avoid redundant authentication flows.
|
|
63
|
+
*/
|
|
64
|
+
export async function generateCredentials(opts: {
|
|
65
|
+
teamId?: string;
|
|
66
|
+
projectId?: string;
|
|
67
|
+
}): Promise<Credentials> {
|
|
68
|
+
const { OAuth, pollForToken, getAuth, updateAuthConfig, inferScope } =
|
|
69
|
+
await importAuth();
|
|
70
|
+
let auth = getAuth();
|
|
71
|
+
if (!auth?.token) {
|
|
72
|
+
const timeout: ms.StringValue = process.env.VERCEL_URL
|
|
73
|
+
? /* when deployed to vercel we don't want to have a long timeout */ "1 minute"
|
|
74
|
+
: "5 minutes";
|
|
75
|
+
auth = await signInAndGetToken({ OAuth, pollForToken, getAuth }, timeout);
|
|
76
|
+
}
|
|
77
|
+
if (
|
|
78
|
+
auth?.refreshToken &&
|
|
79
|
+
auth.expiresAt &&
|
|
80
|
+
auth.expiresAt.getTime() <= Date.now()
|
|
81
|
+
) {
|
|
82
|
+
const oauth = await OAuth();
|
|
83
|
+
const newToken = await oauth.refreshToken(auth.refreshToken);
|
|
84
|
+
auth = {
|
|
85
|
+
expiresAt: new Date(Date.now() + newToken.expires_in * 1000),
|
|
86
|
+
token: newToken.access_token,
|
|
87
|
+
refreshToken: newToken.refresh_token || auth.refreshToken,
|
|
88
|
+
};
|
|
89
|
+
updateAuthConfig(auth);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!auth?.token) {
|
|
93
|
+
throw new Error("Failed to retrieve authentication token.");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (opts.teamId && opts.projectId) {
|
|
97
|
+
return {
|
|
98
|
+
token: auth.token,
|
|
99
|
+
teamId: opts.teamId,
|
|
100
|
+
projectId: opts.projectId,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const scope = await inferScope({ teamId: opts.teamId, token: auth.token });
|
|
105
|
+
|
|
106
|
+
if (scope.created) {
|
|
107
|
+
Log.write(
|
|
108
|
+
"info",
|
|
109
|
+
`Created default project "${scope.projectId}" in team "${scope.teamId}".`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
token: auth.token,
|
|
115
|
+
teamId: opts.teamId || scope.teamId,
|
|
116
|
+
projectId: opts.projectId || scope.projectId,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function signInAndGetToken(
|
|
121
|
+
auth: Pick<
|
|
122
|
+
Awaited<ReturnType<typeof importAuth>>,
|
|
123
|
+
"OAuth" | "getAuth" | "pollForToken"
|
|
124
|
+
>,
|
|
125
|
+
timeout: ms.StringValue,
|
|
126
|
+
) {
|
|
127
|
+
Log.write("warn", [
|
|
128
|
+
`No VERCEL_OIDC_TOKEN environment variable found, initiating device authorization flow...`,
|
|
129
|
+
`│ ${pico.bold("help:")} this flow only happens on development environment.`,
|
|
130
|
+
`│ In production, make sure to set up a proper token, or set up Vercel OIDC [https://vercel.com/docs/oidc].`,
|
|
131
|
+
]);
|
|
132
|
+
const oauth = await auth.OAuth();
|
|
133
|
+
const request = await oauth.deviceAuthorizationRequest();
|
|
134
|
+
Log.write("info", [
|
|
135
|
+
`╰▶ To authenticate, visit: ${request.verification_uri_complete}`,
|
|
136
|
+
` or visit ${pico.italic(request.verification_uri)} and type ${pico.bold(request.user_code)}`,
|
|
137
|
+
` Press ${pico.bold("<return>")} to open in your browser`,
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
let error: Error | undefined;
|
|
141
|
+
const generator = auth.pollForToken({ request, oauth });
|
|
142
|
+
let done = false;
|
|
143
|
+
let spawnedTimeout = setTimeout(() => {
|
|
144
|
+
if (done) return;
|
|
145
|
+
const message = [
|
|
146
|
+
`Authentication flow timed out after ${timeout}.`,
|
|
147
|
+
`│ Make sure to provide a token to avoid prompting an interactive flow.`,
|
|
148
|
+
`╰▶ ${pico.bold("help:")} Link your project with ${Log.code("npx vercel link")} to refresh OIDC token automatically.`,
|
|
149
|
+
].join("\n");
|
|
150
|
+
error = new Error(message);
|
|
151
|
+
// Note: generator.return() initiates cooperative cancellation. The generator's
|
|
152
|
+
// finally block will abort pending setTimeout calls, but any in-flight HTTP
|
|
153
|
+
// request will complete before the generator terminates. This is acceptable
|
|
154
|
+
// for this dev-only timeout scenario.
|
|
155
|
+
generator.return();
|
|
156
|
+
}, ms(timeout));
|
|
157
|
+
try {
|
|
158
|
+
for await (const event of generator) {
|
|
159
|
+
switch (event._tag) {
|
|
160
|
+
case "SlowDown":
|
|
161
|
+
case "Timeout":
|
|
162
|
+
case "Response":
|
|
163
|
+
break;
|
|
164
|
+
case "Error":
|
|
165
|
+
error = event.error;
|
|
166
|
+
break;
|
|
167
|
+
default:
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Unknown event type: ${JSON.stringify(event satisfies never)}`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} finally {
|
|
174
|
+
done = true;
|
|
175
|
+
clearTimeout(spawnedTimeout);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (error) {
|
|
179
|
+
Log.write(
|
|
180
|
+
"error",
|
|
181
|
+
`${pico.bold("error:")} Authentication failed: ${error.message}`,
|
|
182
|
+
);
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
Log.write("success", `${pico.bold("done!")} Authenticated successfully!`);
|
|
187
|
+
const stored = auth.getAuth();
|
|
188
|
+
return stored;
|
|
189
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { test, expect, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
getCredentials,
|
|
4
|
+
LocalOidcContextError,
|
|
5
|
+
VercelOidcContextError,
|
|
6
|
+
} from "./get-credentials";
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
delete process.env.VERCEL_OIDC_TOKEN;
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("explains how to set up oidc in local", async () => {
|
|
13
|
+
delete process.env.VERCEL_URL;
|
|
14
|
+
await expect(getCredentials()).rejects.toThrowError(LocalOidcContextError);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("explains how to set up oidc in vercel", async () => {
|
|
18
|
+
process.env.VERCEL_URL = "example.vercel.sh";
|
|
19
|
+
await expect(getCredentials()).rejects.toThrowError(VercelOidcContextError);
|
|
20
|
+
});
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { getVercelOidcToken } from "@vercel/oidc";
|
|
2
2
|
import { decodeBase64Url } from "./decode-base64-url";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
cachedGenerateCredentials,
|
|
6
|
+
shouldPromptForCredentials,
|
|
7
|
+
} from "./dev-credentials";
|
|
4
8
|
|
|
5
9
|
export interface Credentials {
|
|
6
10
|
/**
|
|
@@ -18,6 +22,57 @@ export interface Credentials {
|
|
|
18
22
|
teamId: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Error thrown when OIDC context is not available in local development,
|
|
27
|
+
* therefore we should guide how to ensure it is set up by linking a project
|
|
28
|
+
*/
|
|
29
|
+
export class LocalOidcContextError extends Error {
|
|
30
|
+
name = "LocalOidcContextError";
|
|
31
|
+
constructor(cause: unknown) {
|
|
32
|
+
const message = [
|
|
33
|
+
"Could not get credentials from OIDC context.",
|
|
34
|
+
"Please link your Vercel project using `npx vercel link`.",
|
|
35
|
+
"Then, pull an initial OIDC token with `npx vercel env pull`",
|
|
36
|
+
"and retry.",
|
|
37
|
+
].join("\n");
|
|
38
|
+
super(message, { cause });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Error thrown when OIDC context is not available in Vercel environment,
|
|
44
|
+
* therefore we should guide how to set it up.
|
|
45
|
+
*/
|
|
46
|
+
export class VercelOidcContextError extends Error {
|
|
47
|
+
name = "VercelOidcContextError";
|
|
48
|
+
constructor(cause: unknown) {
|
|
49
|
+
const message = [
|
|
50
|
+
"Could not get credentials from OIDC context.",
|
|
51
|
+
"Please make sure OIDC is set up for your project",
|
|
52
|
+
"Read more at https://vercel.com/docs/oidc",
|
|
53
|
+
].join("\n");
|
|
54
|
+
super(message, { cause });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function getVercelToken(opts: {
|
|
59
|
+
teamId?: string;
|
|
60
|
+
projectId?: string;
|
|
61
|
+
}): Promise<Credentials> {
|
|
62
|
+
try {
|
|
63
|
+
return getCredentialsFromOIDCToken(await getVercelOidcToken());
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (!shouldPromptForCredentials()) {
|
|
66
|
+
if (process.env.VERCEL_URL) {
|
|
67
|
+
throw new VercelOidcContextError(error);
|
|
68
|
+
} else {
|
|
69
|
+
throw new LocalOidcContextError(error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return await cachedGenerateCredentials(opts);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
21
76
|
/**
|
|
22
77
|
* Allow to get credentials to access the Vercel API. Credentials can be
|
|
23
78
|
* provided in two different ways:
|
|
@@ -34,15 +89,24 @@ export async function getCredentials(params?: unknown): Promise<Credentials> {
|
|
|
34
89
|
return credentials;
|
|
35
90
|
}
|
|
36
91
|
|
|
37
|
-
const oidcToken = await
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
92
|
+
const oidcToken = await getVercelToken({
|
|
93
|
+
teamId:
|
|
94
|
+
params &&
|
|
95
|
+
typeof params === "object" &&
|
|
96
|
+
"teamId" in params &&
|
|
97
|
+
typeof params.teamId === "string"
|
|
98
|
+
? params.teamId
|
|
99
|
+
: undefined,
|
|
100
|
+
projectId:
|
|
101
|
+
params &&
|
|
102
|
+
typeof params === "object" &&
|
|
103
|
+
"projectId" in params &&
|
|
104
|
+
typeof params.projectId === "string"
|
|
105
|
+
? params.projectId
|
|
106
|
+
: undefined,
|
|
107
|
+
});
|
|
41
108
|
|
|
42
|
-
|
|
43
|
-
"You must provide credentials to access the Vercel API \n" +
|
|
44
|
-
"either through parameters or using OpenID Connect [https://vercel.com/docs/oidc]",
|
|
45
|
-
);
|
|
109
|
+
return oidcToken;
|
|
46
110
|
}
|
|
47
111
|
|
|
48
112
|
/**
|
package/src/utils/log.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import pico from "picocolors";
|
|
2
|
+
const colors = {
|
|
3
|
+
warn: pico.yellow,
|
|
4
|
+
error: pico.red,
|
|
5
|
+
success: pico.green,
|
|
6
|
+
info: pico.blue,
|
|
7
|
+
};
|
|
8
|
+
const logPrefix = pico.dim("[vercel/sandbox]");
|
|
9
|
+
export function write(
|
|
10
|
+
level: "warn" | "error" | "info" | "success",
|
|
11
|
+
text: string | string[],
|
|
12
|
+
) {
|
|
13
|
+
text = Array.isArray(text) ? text.join("\n") : text;
|
|
14
|
+
const prefixed = text.replace(/^/gm, `${logPrefix} `);
|
|
15
|
+
console.error(colors[level](prefixed));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function code(text: string) {
|
|
19
|
+
return pico.italic(pico.dim("`") + text + pico.dim("`"));
|
|
20
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Autogenerated by inject-version.ts
|
|
2
|
-
export const VERSION = "1.1.
|
|
2
|
+
export const VERSION = "1.1.6";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function createNdjsonStream(
|
|
2
|
+
lines: object[],
|
|
3
|
+
): ReadableStream<Uint8Array> {
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
const ndjson = lines.map((line) => JSON.stringify(line)).join("\n") + "\n";
|
|
6
|
+
return new ReadableStream({
|
|
7
|
+
start(controller) {
|
|
8
|
+
controller.enqueue(encoder.encode(ndjson));
|
|
9
|
+
controller.close();
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|