UNPKG

12.5 kBJavaScriptView Raw
1// Copyright 2020 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14import * as crypto from 'crypto';
15import * as url from 'url';
16import { ExceptionMessages, Storage } from './storage.js';
17import { encodeURI, qsStringify, objectEntries, formatAsUTCISO } from './util.js';
18export var SignerExceptionMessages;
19(function (SignerExceptionMessages) {
20 SignerExceptionMessages["ACCESSIBLE_DATE_INVALID"] = "The accessible at date provided was invalid.";
21 SignerExceptionMessages["EXPIRATION_BEFORE_ACCESSIBLE_DATE"] = "An expiration date cannot be before accessible date.";
22 SignerExceptionMessages["X_GOOG_CONTENT_SHA256"] = "The header X-Goog-Content-SHA256 must be a hexadecimal string.";
23})(SignerExceptionMessages || (SignerExceptionMessages = {}));
24/*
25 * Default signing version for getSignedUrl is 'v2'.
26 */
27const DEFAULT_SIGNING_VERSION = 'v2';
28const SEVEN_DAYS = 7 * 24 * 60 * 60;
29/**
30 * @const {string}
31 * @deprecated - unused
32 */
33export const PATH_STYLED_HOST = 'https://storage.googleapis.com';
34export class URLSigner {
35 constructor(auth, bucket, file,
36 /**
37 * A {@link Storage} object.
38 *
39 * @privateRemarks
40 *
41 * Technically this is a required field, however it would be a breaking change to
42 * move it before optional properties. In the next major we should refactor the
43 * constructor of this class to only accept a config object.
44 */
45 storage = new Storage()) {
46 this.auth = auth;
47 this.bucket = bucket;
48 this.file = file;
49 this.storage = storage;
50 }
51 getSignedUrl(cfg) {
52 const expiresInSeconds = this.parseExpires(cfg.expires);
53 const method = cfg.method;
54 const accessibleAtInSeconds = this.parseAccessibleAt(cfg.accessibleAt);
55 if (expiresInSeconds < accessibleAtInSeconds) {
56 throw new Error(SignerExceptionMessages.EXPIRATION_BEFORE_ACCESSIBLE_DATE);
57 }
58 let customHost;
59 // Default style is `path`.
60 const isVirtualHostedStyle = cfg.virtualHostedStyle || false;
61 if (cfg.cname) {
62 customHost = cfg.cname;
63 }
64 else if (isVirtualHostedStyle) {
65 customHost = `https://${this.bucket.name}.storage.${this.storage.universeDomain}`;
66 }
67 const secondsToMilliseconds = 1000;
68 const config = Object.assign({}, cfg, {
69 method,
70 expiration: expiresInSeconds,
71 accessibleAt: new Date(secondsToMilliseconds * accessibleAtInSeconds),
72 bucket: this.bucket.name,
73 file: this.file ? encodeURI(this.file.name, false) : undefined,
74 });
75 if (customHost) {
76 config.cname = customHost;
77 }
78 const version = cfg.version || DEFAULT_SIGNING_VERSION;
79 let promise;
80 if (version === 'v2') {
81 promise = this.getSignedUrlV2(config);
82 }
83 else if (version === 'v4') {
84 promise = this.getSignedUrlV4(config);
85 }
86 else {
87 throw new Error(`Invalid signed URL version: ${version}. Supported versions are 'v2' and 'v4'.`);
88 }
89 return promise.then(query => {
90 var _a;
91 query = Object.assign(query, cfg.queryParams);
92 const signedUrl = new url.URL(((_a = cfg.host) === null || _a === void 0 ? void 0 : _a.toString()) || config.cname || this.storage.apiEndpoint);
93 signedUrl.pathname = this.getResourcePath(!!config.cname, this.bucket.name, config.file);
94 // eslint-disable-next-line @typescript-eslint/no-explicit-any
95 signedUrl.search = qsStringify(query);
96 return signedUrl.href;
97 });
98 }
99 getSignedUrlV2(config) {
100 const canonicalHeadersString = this.getCanonicalHeaders(config.extensionHeaders || {});
101 const resourcePath = this.getResourcePath(false, config.bucket, config.file);
102 const blobToSign = [
103 config.method,
104 config.contentMd5 || '',
105 config.contentType || '',
106 config.expiration,
107 canonicalHeadersString + resourcePath,
108 ].join('\n');
109 const sign = async () => {
110 var _a;
111 const auth = this.auth;
112 try {
113 const signature = await auth.sign(blobToSign, (_a = config.signingEndpoint) === null || _a === void 0 ? void 0 : _a.toString());
114 const credentials = await auth.getCredentials();
115 return {
116 GoogleAccessId: credentials.client_email,
117 Expires: config.expiration,
118 Signature: signature,
119 };
120 }
121 catch (err) {
122 const error = err;
123 const signingErr = new SigningError(error.message);
124 signingErr.stack = error.stack;
125 throw signingErr;
126 }
127 };
128 return sign();
129 }
130 getSignedUrlV4(config) {
131 var _a;
132 config.accessibleAt = config.accessibleAt
133 ? config.accessibleAt
134 : new Date();
135 const millisecondsToSeconds = 1.0 / 1000.0;
136 const expiresPeriodInSeconds = config.expiration - config.accessibleAt.valueOf() * millisecondsToSeconds;
137 // v4 limit expiration to be 7 days maximum
138 if (expiresPeriodInSeconds > SEVEN_DAYS) {
139 throw new Error(`Max allowed expiration is seven days (${SEVEN_DAYS} seconds).`);
140 }
141 const extensionHeaders = Object.assign({}, config.extensionHeaders);
142 const fqdn = new url.URL(((_a = config.host) === null || _a === void 0 ? void 0 : _a.toString()) || config.cname || this.storage.apiEndpoint);
143 extensionHeaders.host = fqdn.hostname;
144 if (config.contentMd5) {
145 extensionHeaders['content-md5'] = config.contentMd5;
146 }
147 if (config.contentType) {
148 extensionHeaders['content-type'] = config.contentType;
149 }
150 let contentSha256;
151 const sha256Header = extensionHeaders['x-goog-content-sha256'];
152 if (sha256Header) {
153 if (typeof sha256Header !== 'string' ||
154 !/[A-Fa-f0-9]{40}/.test(sha256Header)) {
155 throw new Error(SignerExceptionMessages.X_GOOG_CONTENT_SHA256);
156 }
157 contentSha256 = sha256Header;
158 }
159 const signedHeaders = Object.keys(extensionHeaders)
160 .map(header => header.toLowerCase())
161 .sort()
162 .join(';');
163 const extensionHeadersString = this.getCanonicalHeaders(extensionHeaders);
164 const datestamp = formatAsUTCISO(config.accessibleAt);
165 const credentialScope = `${datestamp}/auto/storage/goog4_request`;
166 const sign = async () => {
167 var _a;
168 const credentials = await this.auth.getCredentials();
169 const credential = `${credentials.client_email}/${credentialScope}`;
170 const dateISO = formatAsUTCISO(config.accessibleAt ? config.accessibleAt : new Date(), true);
171 const queryParams = {
172 'X-Goog-Algorithm': 'GOOG4-RSA-SHA256',
173 'X-Goog-Credential': credential,
174 'X-Goog-Date': dateISO,
175 'X-Goog-Expires': expiresPeriodInSeconds.toString(10),
176 'X-Goog-SignedHeaders': signedHeaders,
177 ...(config.queryParams || {}),
178 };
179 // eslint-disable-next-line @typescript-eslint/no-explicit-any
180 const canonicalQueryParams = this.getCanonicalQueryParams(queryParams);
181 const canonicalRequest = this.getCanonicalRequest(config.method, this.getResourcePath(!!config.cname, config.bucket, config.file), canonicalQueryParams, extensionHeadersString, signedHeaders, contentSha256);
182 const hash = crypto
183 .createHash('sha256')
184 .update(canonicalRequest)
185 .digest('hex');
186 const blobToSign = [
187 'GOOG4-RSA-SHA256',
188 dateISO,
189 credentialScope,
190 hash,
191 ].join('\n');
192 try {
193 const signature = await this.auth.sign(blobToSign, (_a = config.signingEndpoint) === null || _a === void 0 ? void 0 : _a.toString());
194 const signatureHex = Buffer.from(signature, 'base64').toString('hex');
195 const signedQuery = Object.assign({}, queryParams, {
196 'X-Goog-Signature': signatureHex,
197 });
198 return signedQuery;
199 }
200 catch (err) {
201 const error = err;
202 const signingErr = new SigningError(error.message);
203 signingErr.stack = error.stack;
204 throw signingErr;
205 }
206 };
207 return sign();
208 }
209 /**
210 * Create canonical headers for signing v4 url.
211 *
212 * The canonical headers for v4-signing a request demands header names are
213 * first lowercased, followed by sorting the header names.
214 * Then, construct the canonical headers part of the request:
215 * <lowercasedHeaderName> + ":" + Trim(<value>) + "\n"
216 * ..
217 * <lowercasedHeaderName> + ":" + Trim(<value>) + "\n"
218 *
219 * @param headers
220 * @private
221 */
222 getCanonicalHeaders(headers) {
223 // Sort headers by their lowercased names
224 const sortedHeaders = objectEntries(headers)
225 // Convert header names to lowercase
226 .map(([headerName, value]) => [
227 headerName.toLowerCase(),
228 value,
229 ])
230 .sort((a, b) => a[0].localeCompare(b[0]));
231 return sortedHeaders
232 .filter(([, value]) => value !== undefined)
233 .map(([headerName, value]) => {
234 // - Convert Array (multi-valued header) into string, delimited by
235 // ',' (no space).
236 // - Trim leading and trailing spaces.
237 // - Convert sequential (2+) spaces into a single space
238 const canonicalValue = `${value}`.trim().replace(/\s{2,}/g, ' ');
239 return `${headerName}:${canonicalValue}\n`;
240 })
241 .join('');
242 }
243 getCanonicalRequest(method, path, query, headers, signedHeaders, contentSha256) {
244 return [
245 method,
246 path,
247 query,
248 headers,
249 signedHeaders,
250 contentSha256 || 'UNSIGNED-PAYLOAD',
251 ].join('\n');
252 }
253 getCanonicalQueryParams(query) {
254 return objectEntries(query)
255 .map(([key, value]) => [encodeURI(key, true), encodeURI(value, true)])
256 .sort((a, b) => (a[0] < b[0] ? -1 : 1))
257 .map(([key, value]) => `${key}=${value}`)
258 .join('&');
259 }
260 getResourcePath(cname, bucket, file) {
261 if (cname) {
262 return '/' + (file || '');
263 }
264 else if (file) {
265 return `/${bucket}/${file}`;
266 }
267 else {
268 return `/${bucket}`;
269 }
270 }
271 parseExpires(expires, current = new Date()) {
272 const expiresInMSeconds = new Date(expires).valueOf();
273 if (isNaN(expiresInMSeconds)) {
274 throw new Error(ExceptionMessages.EXPIRATION_DATE_INVALID);
275 }
276 if (expiresInMSeconds < current.valueOf()) {
277 throw new Error(ExceptionMessages.EXPIRATION_DATE_PAST);
278 }
279 return Math.floor(expiresInMSeconds / 1000); // The API expects seconds.
280 }
281 parseAccessibleAt(accessibleAt) {
282 const accessibleAtInMSeconds = new Date(accessibleAt || new Date()).valueOf();
283 if (isNaN(accessibleAtInMSeconds)) {
284 throw new Error(SignerExceptionMessages.ACCESSIBLE_DATE_INVALID);
285 }
286 return Math.floor(accessibleAtInMSeconds / 1000); // The API expects seconds.
287 }
288}
289/**
290 * Custom error type for errors related to getting signed errors and policies.
291 *
292 * @private
293 */
294export class SigningError extends Error {
295 constructor() {
296 super(...arguments);
297 this.name = 'SigningError';
298 }
299}