@vercel/sandbox 1.2.0 → 1.2.1
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/dist/api-client/api-client.js +7 -0
- package/dist/api-client/api-client.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +6 -1
- package/.turbo/turbo-build.log +0 -4
- package/.turbo/turbo-test.log +0 -24
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -277
- package/__mocks__/picocolors.ts +0 -13
- package/scripts/inject-version.ts +0 -11
- package/src/api-client/api-client.test.ts +0 -228
- package/src/api-client/api-client.ts +0 -592
- package/src/api-client/api-error.ts +0 -46
- package/src/api-client/base-client.ts +0 -171
- package/src/api-client/file-writer.ts +0 -90
- package/src/api-client/index.ts +0 -2
- package/src/api-client/validators.ts +0 -146
- package/src/api-client/with-retry.ts +0 -131
- package/src/auth/api.ts +0 -31
- package/src/auth/error.ts +0 -8
- package/src/auth/file.ts +0 -69
- package/src/auth/index.ts +0 -9
- package/src/auth/infer-scope.test.ts +0 -178
- package/src/auth/linked-project.test.ts +0 -86
- package/src/auth/linked-project.ts +0 -40
- package/src/auth/oauth.ts +0 -333
- package/src/auth/poll-for-token.ts +0 -89
- package/src/auth/project.ts +0 -92
- package/src/auth/zod.ts +0 -16
- package/src/command.test.ts +0 -103
- package/src/command.ts +0 -287
- package/src/constants.ts +0 -1
- package/src/index.ts +0 -4
- package/src/sandbox.test.ts +0 -171
- package/src/sandbox.ts +0 -677
- package/src/snapshot.ts +0 -110
- package/src/utils/array.ts +0 -15
- package/src/utils/consume-readable.ts +0 -12
- package/src/utils/decode-base64-url.ts +0 -14
- package/src/utils/dev-credentials.test.ts +0 -217
- package/src/utils/dev-credentials.ts +0 -196
- package/src/utils/get-credentials.test.ts +0 -20
- package/src/utils/get-credentials.ts +0 -183
- package/src/utils/jwt-expiry.test.ts +0 -125
- package/src/utils/jwt-expiry.ts +0 -105
- package/src/utils/log.ts +0 -20
- package/src/utils/normalizePath.test.ts +0 -114
- package/src/utils/normalizePath.ts +0 -33
- package/src/utils/resolveSignal.ts +0 -24
- package/src/utils/types.test.js +0 -7
- package/src/utils/types.ts +0 -23
- package/src/version.ts +0 -2
- package/test-utils/mock-response.ts +0 -12
- package/tsconfig.json +0 -16
- package/turbo.json +0 -9
- package/typedoc.json +0 -13
- package/vercel.json +0 -9
- package/vitest.config.ts +0 -9
- package/vitest.setup.ts +0 -4
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { inferScope, selectTeam } from "./project";
|
|
2
|
-
import {
|
|
3
|
-
beforeEach,
|
|
4
|
-
describe,
|
|
5
|
-
test,
|
|
6
|
-
vi,
|
|
7
|
-
Mock,
|
|
8
|
-
expect,
|
|
9
|
-
onTestFinished,
|
|
10
|
-
} from "vitest";
|
|
11
|
-
import { fetchApi } from "./api";
|
|
12
|
-
import { NotOk } from "./error";
|
|
13
|
-
import * as fs from "node:fs/promises";
|
|
14
|
-
import * as path from "node:path";
|
|
15
|
-
import * as os from "node:os";
|
|
16
|
-
|
|
17
|
-
const fetchApiMock = fetchApi as Mock<typeof fetchApi>;
|
|
18
|
-
vi.mock("./api");
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
vi.clearAllMocks();
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
async function getTempDir(): Promise<string> {
|
|
25
|
-
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "infer-scope-test-"));
|
|
26
|
-
onTestFinished(() => fs.rm(dir, { recursive: true }));
|
|
27
|
-
return dir;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
describe("selectTeam", () => {
|
|
31
|
-
test("returns the first team", async () => {
|
|
32
|
-
fetchApiMock.mockResolvedValue({
|
|
33
|
-
teams: [{ slug: "one" }, { slug: "two" }],
|
|
34
|
-
});
|
|
35
|
-
const team = await selectTeam("token");
|
|
36
|
-
expect(fetchApiMock).toHaveBeenCalledWith({
|
|
37
|
-
endpoint: "/v2/teams?limit=1",
|
|
38
|
-
token: "token",
|
|
39
|
-
});
|
|
40
|
-
expect(team).toBe("one");
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("inferScope", () => {
|
|
45
|
-
test("uses provided teamId", async () => {
|
|
46
|
-
fetchApiMock.mockResolvedValue({});
|
|
47
|
-
const scope = await inferScope({ teamId: "my-team", token: "token" });
|
|
48
|
-
expect(scope).toEqual({
|
|
49
|
-
created: false,
|
|
50
|
-
projectId: "vercel-sandbox-default-project",
|
|
51
|
-
teamId: "my-team",
|
|
52
|
-
});
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("team creation", () => {
|
|
56
|
-
test("project 404 triggers project creation", async () => {
|
|
57
|
-
fetchApiMock.mockImplementation(async ({ method }) => {
|
|
58
|
-
if (!method || method === "GET") {
|
|
59
|
-
throw new NotOk({ statusCode: 404, responseText: "Not Found" });
|
|
60
|
-
}
|
|
61
|
-
return {};
|
|
62
|
-
});
|
|
63
|
-
const scope = await inferScope({ teamId: "my-team", token: "token" });
|
|
64
|
-
expect(scope).toEqual({
|
|
65
|
-
created: true,
|
|
66
|
-
projectId: "vercel-sandbox-default-project",
|
|
67
|
-
teamId: "my-team",
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("non-404 throws", async () => {
|
|
72
|
-
fetchApiMock.mockImplementation(async ({ method }) => {
|
|
73
|
-
if (!method || method === "GET") {
|
|
74
|
-
throw new NotOk({ statusCode: 403, responseText: "Forbidden" });
|
|
75
|
-
}
|
|
76
|
-
return {};
|
|
77
|
-
});
|
|
78
|
-
await expect(
|
|
79
|
-
inferScope({ teamId: "my-team", token: "token" }),
|
|
80
|
-
).rejects.toThrowError(
|
|
81
|
-
new NotOk({ statusCode: 403, responseText: "Forbidden" }),
|
|
82
|
-
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("non-status errors are thrown", async () => {
|
|
86
|
-
fetchApiMock.mockImplementation(async ({ method }) => {
|
|
87
|
-
if (!method || method === "GET") {
|
|
88
|
-
throw new Error("Oops!");
|
|
89
|
-
}
|
|
90
|
-
return {};
|
|
91
|
-
});
|
|
92
|
-
await expect(inferScope({ token: "token" })).rejects.toThrowError(
|
|
93
|
-
"Oops!",
|
|
94
|
-
);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("infers the team", async () => {
|
|
99
|
-
fetchApiMock.mockImplementation(async ({ endpoint }) => {
|
|
100
|
-
if (endpoint === "/v2/teams?limit=1") {
|
|
101
|
-
return { teams: [{ slug: "inferred-team" }] };
|
|
102
|
-
}
|
|
103
|
-
return {};
|
|
104
|
-
});
|
|
105
|
-
const scope = await inferScope({ token: "token" });
|
|
106
|
-
expect(scope).toEqual({
|
|
107
|
-
created: false,
|
|
108
|
-
projectId: "vercel-sandbox-default-project",
|
|
109
|
-
teamId: "inferred-team",
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
describe("linked project", () => {
|
|
114
|
-
test("uses linked project when .vercel/project.json exists", async () => {
|
|
115
|
-
const dir = await getTempDir();
|
|
116
|
-
await fs.mkdir(path.join(dir, ".vercel"));
|
|
117
|
-
await fs.writeFile(
|
|
118
|
-
path.join(dir, ".vercel", "project.json"),
|
|
119
|
-
JSON.stringify({
|
|
120
|
-
projectId: "prj_linked",
|
|
121
|
-
orgId: "team_linked",
|
|
122
|
-
}),
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const scope = await inferScope({ token: "token", cwd: dir });
|
|
126
|
-
|
|
127
|
-
expect(scope).toEqual({
|
|
128
|
-
created: false,
|
|
129
|
-
projectId: "prj_linked",
|
|
130
|
-
teamId: "team_linked",
|
|
131
|
-
});
|
|
132
|
-
// Should not call API when using linked project
|
|
133
|
-
expect(fetchApiMock).not.toHaveBeenCalled();
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("falls back to default project when .vercel/project.json does not exist", async () => {
|
|
137
|
-
const dir = await getTempDir();
|
|
138
|
-
fetchApiMock.mockResolvedValue({});
|
|
139
|
-
|
|
140
|
-
const scope = await inferScope({
|
|
141
|
-
token: "token",
|
|
142
|
-
teamId: "my-team",
|
|
143
|
-
cwd: dir,
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
expect(scope).toEqual({
|
|
147
|
-
created: false,
|
|
148
|
-
projectId: "vercel-sandbox-default-project",
|
|
149
|
-
teamId: "my-team",
|
|
150
|
-
});
|
|
151
|
-
expect(fetchApiMock).toHaveBeenCalled();
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("falls back to default project when .vercel/project.json is invalid", async () => {
|
|
155
|
-
const dir = await getTempDir();
|
|
156
|
-
await fs.mkdir(path.join(dir, ".vercel"));
|
|
157
|
-
await fs.writeFile(
|
|
158
|
-
path.join(dir, ".vercel", "project.json"),
|
|
159
|
-
"not valid json",
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
fetchApiMock.mockResolvedValue({});
|
|
163
|
-
|
|
164
|
-
const scope = await inferScope({
|
|
165
|
-
token: "token",
|
|
166
|
-
teamId: "my-team",
|
|
167
|
-
cwd: dir,
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
expect(scope).toEqual({
|
|
171
|
-
created: false,
|
|
172
|
-
projectId: "vercel-sandbox-default-project",
|
|
173
|
-
teamId: "my-team",
|
|
174
|
-
});
|
|
175
|
-
expect(fetchApiMock).toHaveBeenCalled();
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
});
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { readLinkedProject } from "./linked-project";
|
|
2
|
-
import { describe, test, expect } from "vitest";
|
|
3
|
-
import * as fs from "node:fs/promises";
|
|
4
|
-
import * as path from "node:path";
|
|
5
|
-
import * as os from "node:os";
|
|
6
|
-
|
|
7
|
-
async function withTempDir(fn: (dir: string) => Promise<void>): Promise<void> {
|
|
8
|
-
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "linked-project-test-"));
|
|
9
|
-
try {
|
|
10
|
-
await fn(dir);
|
|
11
|
-
} finally {
|
|
12
|
-
await fs.rm(dir, { recursive: true });
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe("readLinkedProject", () => {
|
|
17
|
-
test("returns null when .vercel/project.json does not exist", async () => {
|
|
18
|
-
await withTempDir(async (dir) => {
|
|
19
|
-
const result = await readLinkedProject(dir);
|
|
20
|
-
expect(result).toBeNull();
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
test("returns null when .vercel directory exists but project.json does not", async () => {
|
|
25
|
-
await withTempDir(async (dir) => {
|
|
26
|
-
await fs.mkdir(path.join(dir, ".vercel"));
|
|
27
|
-
const result = await readLinkedProject(dir);
|
|
28
|
-
expect(result).toBeNull();
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
test("returns projectId and teamId when project.json exists", async () => {
|
|
33
|
-
await withTempDir(async (dir) => {
|
|
34
|
-
await fs.mkdir(path.join(dir, ".vercel"));
|
|
35
|
-
await fs.writeFile(
|
|
36
|
-
path.join(dir, ".vercel", "project.json"),
|
|
37
|
-
JSON.stringify({
|
|
38
|
-
projectId: "prj_123",
|
|
39
|
-
orgId: "team_456",
|
|
40
|
-
settings: { framework: null },
|
|
41
|
-
}),
|
|
42
|
-
);
|
|
43
|
-
const result = await readLinkedProject(dir);
|
|
44
|
-
expect(result).toEqual({
|
|
45
|
-
projectId: "prj_123",
|
|
46
|
-
teamId: "team_456",
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("returns null when project.json is invalid JSON", async () => {
|
|
52
|
-
await withTempDir(async (dir) => {
|
|
53
|
-
await fs.mkdir(path.join(dir, ".vercel"));
|
|
54
|
-
await fs.writeFile(
|
|
55
|
-
path.join(dir, ".vercel", "project.json"),
|
|
56
|
-
"not valid json",
|
|
57
|
-
);
|
|
58
|
-
const result = await readLinkedProject(dir);
|
|
59
|
-
expect(result).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("returns null when project.json is missing projectId", async () => {
|
|
64
|
-
await withTempDir(async (dir) => {
|
|
65
|
-
await fs.mkdir(path.join(dir, ".vercel"));
|
|
66
|
-
await fs.writeFile(
|
|
67
|
-
path.join(dir, ".vercel", "project.json"),
|
|
68
|
-
JSON.stringify({ orgId: "team_456" }),
|
|
69
|
-
);
|
|
70
|
-
const result = await readLinkedProject(dir);
|
|
71
|
-
expect(result).toBeNull();
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("returns null when project.json is missing orgId", async () => {
|
|
76
|
-
await withTempDir(async (dir) => {
|
|
77
|
-
await fs.mkdir(path.join(dir, ".vercel"));
|
|
78
|
-
await fs.writeFile(
|
|
79
|
-
path.join(dir, ".vercel", "project.json"),
|
|
80
|
-
JSON.stringify({ projectId: "prj_123" }),
|
|
81
|
-
);
|
|
82
|
-
const result = await readLinkedProject(dir);
|
|
83
|
-
expect(result).toBeNull();
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import * as fs from "node:fs/promises";
|
|
3
|
-
import * as path from "node:path";
|
|
4
|
-
import { json } from "./zod";
|
|
5
|
-
|
|
6
|
-
const LinkedProjectSchema = json.pipe(
|
|
7
|
-
z.object({
|
|
8
|
-
projectId: z.string(),
|
|
9
|
-
orgId: z.string(),
|
|
10
|
-
}),
|
|
11
|
-
);
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Reads the linked project configuration from `.vercel/project.json`.
|
|
15
|
-
*
|
|
16
|
-
* @param cwd - The directory to search for `.vercel/project.json`.
|
|
17
|
-
* @returns The linked project's `projectId` and `teamId`, or `null` if not found.
|
|
18
|
-
*/
|
|
19
|
-
export async function readLinkedProject(
|
|
20
|
-
cwd: string,
|
|
21
|
-
): Promise<{ projectId: string; teamId: string } | null> {
|
|
22
|
-
const projectJsonPath = path.join(cwd, ".vercel", "project.json");
|
|
23
|
-
|
|
24
|
-
let content: string;
|
|
25
|
-
try {
|
|
26
|
-
content = await fs.readFile(projectJsonPath, "utf-8");
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const parsed = LinkedProjectSchema.safeParse(content);
|
|
32
|
-
if (!parsed.success) {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
projectId: parsed.data.projectId,
|
|
38
|
-
teamId: parsed.data.orgId,
|
|
39
|
-
};
|
|
40
|
-
}
|
package/src/auth/oauth.ts
DELETED
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
import os from "os";
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
import { VERSION } from "../version";
|
|
4
|
-
|
|
5
|
-
const USER_AGENT = `${os.hostname()} @ vercel/sandbox/${VERSION} node-${
|
|
6
|
-
process.version
|
|
7
|
-
} ${os.platform()} (${os.arch()})`;
|
|
8
|
-
|
|
9
|
-
const ISSUER = new URL("https://vercel.com");
|
|
10
|
-
const CLIENT_ID = "cl_HYyOPBNtFMfHhaUn9L4QPfTZz6TP47bp";
|
|
11
|
-
|
|
12
|
-
const AuthorizationServerMetadata = z.object({
|
|
13
|
-
issuer: z.string().url(),
|
|
14
|
-
device_authorization_endpoint: z.string().url(),
|
|
15
|
-
token_endpoint: z.string().url(),
|
|
16
|
-
revocation_endpoint: z.string().url(),
|
|
17
|
-
jwks_uri: z.string().url(),
|
|
18
|
-
introspection_endpoint: z.string().url(),
|
|
19
|
-
});
|
|
20
|
-
type AuthorizationServerMetadata = z.infer<typeof AuthorizationServerMetadata>;
|
|
21
|
-
let _as: AuthorizationServerMetadata;
|
|
22
|
-
|
|
23
|
-
const DeviceAuthorization = z.object({
|
|
24
|
-
device_code: z.string(),
|
|
25
|
-
user_code: z.string(),
|
|
26
|
-
verification_uri: z.string().url(),
|
|
27
|
-
verification_uri_complete: z.string().url(),
|
|
28
|
-
expires_in: z.number(),
|
|
29
|
-
interval: z.number(),
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
const IntrospectionResponse = z
|
|
33
|
-
.object({
|
|
34
|
-
active: z.literal(true),
|
|
35
|
-
client_id: z.string(),
|
|
36
|
-
session_id: z.string(),
|
|
37
|
-
})
|
|
38
|
-
.or(z.object({ active: z.literal(false) }));
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Returns the Authorization Server Metadata
|
|
42
|
-
*
|
|
43
|
-
* @see https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
|
|
44
|
-
* @see https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationResponse
|
|
45
|
-
*/
|
|
46
|
-
async function authorizationServerMetadata(): Promise<AuthorizationServerMetadata> {
|
|
47
|
-
if (_as) return _as;
|
|
48
|
-
|
|
49
|
-
const response = await fetch(
|
|
50
|
-
new URL(".well-known/openid-configuration", ISSUER),
|
|
51
|
-
{
|
|
52
|
-
headers: { "Content-Type": "application/json", "user-agent": USER_AGENT },
|
|
53
|
-
},
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
_as = AuthorizationServerMetadata.parse(await response.json());
|
|
57
|
-
return _as;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export async function OAuth() {
|
|
61
|
-
const as = await authorizationServerMetadata();
|
|
62
|
-
return {
|
|
63
|
-
/**
|
|
64
|
-
* Perform the Device Authorization Request
|
|
65
|
-
*
|
|
66
|
-
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.1
|
|
67
|
-
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.2
|
|
68
|
-
*/
|
|
69
|
-
async deviceAuthorizationRequest(): Promise<DeviceAuthorizationRequest> {
|
|
70
|
-
const response = await fetch(as.device_authorization_endpoint, {
|
|
71
|
-
method: "POST",
|
|
72
|
-
headers: {
|
|
73
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
74
|
-
"user-agent": USER_AGENT,
|
|
75
|
-
},
|
|
76
|
-
body: new URLSearchParams({
|
|
77
|
-
client_id: CLIENT_ID,
|
|
78
|
-
scope: "openid offline_access",
|
|
79
|
-
}),
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const json = await response.json();
|
|
83
|
-
const parsed = DeviceAuthorization.safeParse(json);
|
|
84
|
-
|
|
85
|
-
if (!parsed.success) {
|
|
86
|
-
throw new OAuthError(
|
|
87
|
-
`Failed to parse device authorization response: ${parsed.error.message}`,
|
|
88
|
-
json,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
device_code: parsed.data.device_code,
|
|
94
|
-
user_code: parsed.data.user_code,
|
|
95
|
-
verification_uri: parsed.data.verification_uri,
|
|
96
|
-
verification_uri_complete: parsed.data.verification_uri_complete,
|
|
97
|
-
expiresAt: Date.now() + parsed.data.expires_in * 1000,
|
|
98
|
-
interval: parsed.data.interval,
|
|
99
|
-
};
|
|
100
|
-
},
|
|
101
|
-
/**
|
|
102
|
-
* Perform the Device Access Token Request
|
|
103
|
-
*
|
|
104
|
-
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.4
|
|
105
|
-
*/
|
|
106
|
-
async deviceAccessTokenRequest(
|
|
107
|
-
device_code: string,
|
|
108
|
-
): Promise<[Error] | [null, Response]> {
|
|
109
|
-
try {
|
|
110
|
-
return [
|
|
111
|
-
null,
|
|
112
|
-
await fetch(as.token_endpoint, {
|
|
113
|
-
method: "POST",
|
|
114
|
-
headers: {
|
|
115
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
116
|
-
"user-agent": USER_AGENT,
|
|
117
|
-
},
|
|
118
|
-
body: new URLSearchParams({
|
|
119
|
-
client_id: CLIENT_ID,
|
|
120
|
-
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
121
|
-
device_code,
|
|
122
|
-
}),
|
|
123
|
-
signal: AbortSignal.timeout(10 * 1000),
|
|
124
|
-
}),
|
|
125
|
-
];
|
|
126
|
-
} catch (error) {
|
|
127
|
-
if (error instanceof Error) return [error];
|
|
128
|
-
return [
|
|
129
|
-
new Error("An unknown error occurred. See the logs for details.", {
|
|
130
|
-
cause: error,
|
|
131
|
-
}),
|
|
132
|
-
];
|
|
133
|
-
}
|
|
134
|
-
},
|
|
135
|
-
/**
|
|
136
|
-
* Process the Token request Response
|
|
137
|
-
*
|
|
138
|
-
* @see https://datatracker.ietf.org/doc/html/rfc8628#section-3.5
|
|
139
|
-
*/
|
|
140
|
-
async processTokenResponse(
|
|
141
|
-
response: Response,
|
|
142
|
-
): Promise<[OAuthError] | [null, TokenSet]> {
|
|
143
|
-
const json = await response.json();
|
|
144
|
-
const processed = TokenSet.safeParse(json);
|
|
145
|
-
|
|
146
|
-
if (!processed.success) {
|
|
147
|
-
return [
|
|
148
|
-
new OAuthError(
|
|
149
|
-
`Failed to parse token response: ${processed.error.message}`,
|
|
150
|
-
json,
|
|
151
|
-
),
|
|
152
|
-
];
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return [null, processed.data];
|
|
156
|
-
},
|
|
157
|
-
/**
|
|
158
|
-
* Perform a Token Revocation Request.
|
|
159
|
-
*
|
|
160
|
-
* @see https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
|
|
161
|
-
* @see https://datatracker.ietf.org/doc/html/rfc7009#section-2.2
|
|
162
|
-
*/
|
|
163
|
-
async revokeToken(token: string): Promise<OAuthError | void> {
|
|
164
|
-
const response = await fetch(as.revocation_endpoint, {
|
|
165
|
-
method: "POST",
|
|
166
|
-
headers: {
|
|
167
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
168
|
-
"user-agent": USER_AGENT,
|
|
169
|
-
},
|
|
170
|
-
body: new URLSearchParams({ token, client_id: CLIENT_ID }),
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
if (response.ok) return;
|
|
174
|
-
const json = await response.json();
|
|
175
|
-
|
|
176
|
-
return new OAuthError("Revocation request failed", json);
|
|
177
|
-
},
|
|
178
|
-
/**
|
|
179
|
-
* Perform Refresh Token Request.
|
|
180
|
-
*
|
|
181
|
-
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-6
|
|
182
|
-
*/
|
|
183
|
-
async refreshToken(token: string): Promise<TokenSet> {
|
|
184
|
-
const response = await fetch(as.token_endpoint, {
|
|
185
|
-
method: "POST",
|
|
186
|
-
headers: {
|
|
187
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
188
|
-
"user-agent": USER_AGENT,
|
|
189
|
-
},
|
|
190
|
-
body: new URLSearchParams({
|
|
191
|
-
client_id: CLIENT_ID,
|
|
192
|
-
grant_type: "refresh_token",
|
|
193
|
-
refresh_token: token,
|
|
194
|
-
}),
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
const [tokensError, tokenSet] = await this.processTokenResponse(response);
|
|
198
|
-
if (tokensError) throw tokensError;
|
|
199
|
-
return tokenSet;
|
|
200
|
-
},
|
|
201
|
-
/**
|
|
202
|
-
* Perform Token Introspection Request.
|
|
203
|
-
*
|
|
204
|
-
* @see https://datatracker.ietf.org/doc/html/rfc7662#section-2.1
|
|
205
|
-
*/
|
|
206
|
-
async introspectToken(
|
|
207
|
-
token: string,
|
|
208
|
-
): Promise<z.infer<typeof IntrospectionResponse>> {
|
|
209
|
-
const response = await fetch(as.introspection_endpoint, {
|
|
210
|
-
method: "POST",
|
|
211
|
-
headers: {
|
|
212
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
213
|
-
"user-agent": USER_AGENT,
|
|
214
|
-
},
|
|
215
|
-
body: new URLSearchParams({ token }),
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
const json = await response.json();
|
|
219
|
-
const processed = IntrospectionResponse.safeParse(json);
|
|
220
|
-
if (!processed.success) {
|
|
221
|
-
throw new OAuthError(
|
|
222
|
-
`Failed to parse introspection response: ${processed.error.message}`,
|
|
223
|
-
json,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return processed.data;
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export type OAuth = Awaited<ReturnType<typeof OAuth>>;
|
|
233
|
-
|
|
234
|
-
const TokenSet = z.object({
|
|
235
|
-
/** The access token issued by the authorization server. */
|
|
236
|
-
access_token: z.string(),
|
|
237
|
-
/** The type of the token issued */
|
|
238
|
-
token_type: z.literal("Bearer"),
|
|
239
|
-
/** The lifetime in seconds of the access token. */
|
|
240
|
-
expires_in: z.number(),
|
|
241
|
-
/** The refresh token, which can be used to obtain new access tokens. */
|
|
242
|
-
refresh_token: z.string().optional(),
|
|
243
|
-
/** The scope of the access token. */
|
|
244
|
-
scope: z.string().optional(),
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
type TokenSet = z.infer<typeof TokenSet>;
|
|
248
|
-
|
|
249
|
-
const OAuthErrorResponse = z.object({
|
|
250
|
-
error: z.enum([
|
|
251
|
-
"invalid_request",
|
|
252
|
-
"invalid_client",
|
|
253
|
-
"invalid_grant",
|
|
254
|
-
"unauthorized_client",
|
|
255
|
-
"unsupported_grant_type",
|
|
256
|
-
"invalid_scope",
|
|
257
|
-
"server_error",
|
|
258
|
-
// Device Authorization Response Errors
|
|
259
|
-
"authorization_pending",
|
|
260
|
-
"slow_down",
|
|
261
|
-
"access_denied",
|
|
262
|
-
"expired_token",
|
|
263
|
-
// Revocation Response Errors
|
|
264
|
-
"unsupported_token_type",
|
|
265
|
-
]),
|
|
266
|
-
error_description: z.string().optional(),
|
|
267
|
-
error_uri: z.string().optional(),
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
type OAuthErrorResponse = z.infer<typeof OAuthErrorResponse>;
|
|
271
|
-
|
|
272
|
-
function processOAuthErrorResponse(
|
|
273
|
-
json: unknown,
|
|
274
|
-
): OAuthErrorResponse | TypeError {
|
|
275
|
-
try {
|
|
276
|
-
return OAuthErrorResponse.parse(json);
|
|
277
|
-
} catch (error) {
|
|
278
|
-
if (error instanceof z.ZodError) {
|
|
279
|
-
return new TypeError(`Invalid OAuth error response: ${error.message}`);
|
|
280
|
-
}
|
|
281
|
-
return new TypeError("Failed to parse OAuth error response");
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
class OAuthError extends Error {
|
|
286
|
-
name = "OAuthError";
|
|
287
|
-
code: OAuthErrorResponse["error"];
|
|
288
|
-
cause: Error;
|
|
289
|
-
constructor(message: string, response: unknown) {
|
|
290
|
-
super(message);
|
|
291
|
-
const error = processOAuthErrorResponse(response);
|
|
292
|
-
if (error instanceof TypeError) {
|
|
293
|
-
const message = `Unexpected server response: ${JSON.stringify(response)}`;
|
|
294
|
-
this.cause = new Error(message, { cause: error });
|
|
295
|
-
this.code = "server_error";
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
let cause = error.error;
|
|
299
|
-
if (error.error_description) cause += `: ${error.error_description}`;
|
|
300
|
-
if (error.error_uri) cause += ` (${error.error_uri})`;
|
|
301
|
-
|
|
302
|
-
this.cause = new Error(cause);
|
|
303
|
-
this.code = error.error;
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
export function isOAuthError(error: unknown): error is OAuthError {
|
|
308
|
-
return error instanceof OAuthError;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export interface DeviceAuthorizationRequest {
|
|
312
|
-
/** The device verification code. */
|
|
313
|
-
device_code: string;
|
|
314
|
-
/** The end-user verification code. */
|
|
315
|
-
user_code: string;
|
|
316
|
-
/**
|
|
317
|
-
* The minimum amount of time in seconds that the client
|
|
318
|
-
* SHOULD wait between polling requests to the token endpoint.
|
|
319
|
-
*/
|
|
320
|
-
interval: number;
|
|
321
|
-
/** The end-user verification URI on the authorization server. */
|
|
322
|
-
verification_uri: string;
|
|
323
|
-
/**
|
|
324
|
-
* The end-user verification URI on the authorization server,
|
|
325
|
-
* including the `user_code`, without redirection.
|
|
326
|
-
*/
|
|
327
|
-
verification_uri_complete: string;
|
|
328
|
-
/**
|
|
329
|
-
* The absolute lifetime of the `device_code` and `user_code`.
|
|
330
|
-
* Calculated from `expires_in`.
|
|
331
|
-
*/
|
|
332
|
-
expiresAt: number;
|
|
333
|
-
}
|