|
| 1 | +import assert from "node:assert"; |
1 | 2 | import { createHash } from "node:crypto";
|
| 3 | +import http from "node:http"; |
| 4 | +import { setTimeout as setTimeoutPromise } from "node:timers/promises"; |
2 | 5 | import { fetchResult } from "../cfetch";
|
| 6 | +import { getCloudflareApiEnvironmentFromEnv } from "../environment-variables/misc-variables"; |
| 7 | +import { UserError } from "../errors"; |
| 8 | +import { logger } from "../logger"; |
| 9 | +import openInBrowser from "../open-in-browser"; |
3 | 10 | import type { R2BucketInfo } from "../r2/helpers";
|
4 | 11 |
|
5 | 12 | // ensure this is in sync with:
|
@@ -96,44 +103,102 @@ export type PermissionGroup = {
|
96 | 103 | scopes: string[];
|
97 | 104 | };
|
98 | 105 |
|
99 |
| -// Generate a Service Token to write to R2 for a pipeline |
| 106 | +interface S3AccessKey { |
| 107 | + accessKeyId: string; |
| 108 | + secretAccessKey: string; |
| 109 | +} |
| 110 | + |
| 111 | +/** |
| 112 | + * Generate an R2 service token for the given account ID, bucket name, and pipeline name. |
| 113 | + * |
| 114 | + * This function kicks off its own OAuth process using the Workers Pipelines OAuth client requesting the scope |
| 115 | + * `pipelines:setup`. Once the user confirms, our OAuth callback endpoint will validate the request, exchange the |
| 116 | + * authorization code and return a bucket-scoped R2 token. |
| 117 | + * |
| 118 | + * This OAuth flow is distinct from the one used in `wrangler login` to ensure these tokens are generated server-side |
| 119 | + * and that only the tokens of concern are returned to the user. |
| 120 | + * @param accountId |
| 121 | + * @param bucketName |
| 122 | + * @param pipelineName |
| 123 | + */ |
100 | 124 | export async function generateR2ServiceToken(
|
101 |
| - label: string, |
102 | 125 | accountId: string,
|
103 |
| - bucket: string |
104 |
| -): Promise<ServiceToken> { |
105 |
| - const res = await fetchResult<PermissionGroup[]>( |
106 |
| - `/user/tokens/permission_groups`, |
107 |
| - { |
108 |
| - method: "GET", |
109 |
| - } |
110 |
| - ); |
111 |
| - const perm = res.find( |
112 |
| - (g) => g.name == "Workers R2 Storage Bucket Item Write" |
113 |
| - ); |
114 |
| - if (!perm) { |
115 |
| - throw new Error("Missing R2 Permissions"); |
116 |
| - } |
| 126 | + bucketName: string, |
| 127 | + pipelineName: string |
| 128 | +): Promise<S3AccessKey> { |
| 129 | + // TODO: Refactor into startHttpServerWithTimeout function and update `getOauthToken` |
| 130 | + const controller = new AbortController(); |
| 131 | + const signal = controller.signal; |
117 | 132 |
|
118 |
| - // generate specific bucket write token for pipeline |
119 |
| - const body = JSON.stringify({ |
120 |
| - policies: [ |
121 |
| - { |
122 |
| - effect: "allow", |
123 |
| - permission_groups: [{ id: perm.id }], |
124 |
| - resources: { |
125 |
| - [`com.cloudflare.edge.r2.bucket.${accountId}_default_${bucket}`]: "*", |
126 |
| - }, |
127 |
| - }, |
128 |
| - ], |
129 |
| - name: label, |
130 |
| - }); |
| 133 | + // Create timeout promise to prevent hanging forever |
| 134 | + const timeoutPromise = setTimeoutPromise(120000, "timeout", { signal }); |
131 | 135 |
|
132 |
| - return await fetchResult<ServiceToken>(`/user/tokens`, { |
133 |
| - method: "POST", |
134 |
| - headers: API_HEADERS, |
135 |
| - body, |
| 136 | + // Create server promise to handle the callback and register the cleanup handler on the controller |
| 137 | + const serverPromise = new Promise<S3AccessKey>((resolve, reject) => { |
| 138 | + const server = http.createServer(async (request, response) => { |
| 139 | + assert(request.url, "This request doesn't have a URL"); // This should never happen |
| 140 | + |
| 141 | + if (request.method !== "GET") { |
| 142 | + response.writeHead(405); |
| 143 | + response.end("Method not allowed."); |
| 144 | + return; |
| 145 | + } |
| 146 | + |
| 147 | + const { pathname, searchParams } = new URL( |
| 148 | + request.url, |
| 149 | + `http://${request.headers.host}` |
| 150 | + ); |
| 151 | + |
| 152 | + if (pathname !== "/") { |
| 153 | + response.writeHead(404); |
| 154 | + response.end("Not found."); |
| 155 | + return; |
| 156 | + } |
| 157 | + |
| 158 | + // Retrieve values from the URL parameters |
| 159 | + const accessKeyId = searchParams.get("access-key-id"); |
| 160 | + const secretAccessKey = searchParams.get("secret-access-key"); |
| 161 | + |
| 162 | + if (!accessKeyId || !secretAccessKey) { |
| 163 | + reject(new UserError("Missing required URL parameters")); |
| 164 | + return; |
| 165 | + } |
| 166 | + |
| 167 | + resolve({ accessKeyId, secretAccessKey } as S3AccessKey); |
| 168 | + // Do a final redirect to "clear" the URL of the sensitive URL parameters that were returned. |
| 169 | + response.writeHead(307, { |
| 170 | + Location: |
| 171 | + "https://welcome.developers.workers.dev/wrangler-oauth-consent-granted", |
| 172 | + }); |
| 173 | + response.end(); |
| 174 | + }); |
| 175 | + |
| 176 | + // Register cleanup handler |
| 177 | + signal.addEventListener("abort", () => { |
| 178 | + server.close(); |
| 179 | + }); |
| 180 | + server.listen(8976, "localhost"); |
136 | 181 | });
|
| 182 | + |
| 183 | + const env = getCloudflareApiEnvironmentFromEnv(); |
| 184 | + const oauthDomain = |
| 185 | + env === "staging" |
| 186 | + ? "oauth.pipelines-staging.cloudflare.com" |
| 187 | + : "oauth.pipelines.cloudflare.com"; |
| 188 | + |
| 189 | + const urlToOpen = `https://${oauthDomain}/oauth/login?accountId=${accountId}&bucketName=${bucketName}&pipelineName=${pipelineName}`; |
| 190 | + logger.log(`Opening a link in your default browser: ${urlToOpen}`); |
| 191 | + await openInBrowser(urlToOpen); |
| 192 | + |
| 193 | + const result = await Promise.race([timeoutPromise, serverPromise]); |
| 194 | + controller.abort(); |
| 195 | + if (result === "timeout") { |
| 196 | + throw new UserError( |
| 197 | + "Timed out waiting for authorization code, please try again." |
| 198 | + ); |
| 199 | + } |
| 200 | + |
| 201 | + return result as S3AccessKey; |
137 | 202 | }
|
138 | 203 |
|
139 | 204 | // Get R2 bucket information from v4 API
|
|
0 commit comments