UNPKG

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