Repository: googlemaps/google-maps-services-js (ts)

Issue:
Support client + signature authentication
**Is your feature request related to a problem? Please describe.**
From everything that I can tell, this library only supports API key based authentication. However, client + signature based authentication is still an available option on the backing API: https://developers.google.com/maps/premium/authentication/client-id/request-signing

 **Describe the solution you'd like**
Ideally this library would be able to support either key-based auth or client + signature-based auth.

 **Describe alternatives you've considered**
I may be able to resolve this via messing with the underlying Axios code, I haven't tried that. That's not the best developer experience though.

 **Additional context**
I realize that this is a legacy authentication method and API keys are the preferred form. However I'm upgrading an existing application from [an unmaintained googlemaps library](https://github.com/moshen/node-googlemaps) in an attempt to clean things up and address security vulnerabilities. Generating a new key and working out the billing complexities therein would be a nontrivial task, solely upgrading the library without needing to change the actual account information would be ideal.

If nothing else, hopefully this issue will help anyone else who stumbles across this issue, as there didn't appear to be any existing issues open or references to this feature.

I'm also happy to take a stab at opening a PR for this, assuming this is something that isn't intentionally omitted.

Pull request description:
feat: add client_id and client_secret auth alternatives
Before submitting your PR, there are a few things you can do to make sure it goes smoothly:
- [x] Make sure to open a GitHub issue as a bug/feature request before writing your code!  That way we can discuss the change, evaluate designs, and agree on the general idea
- [x] Ensure the tests and linter pass
- [x] Code coverage does not decrease (if any source code was changed)
- [x] Appropriate docs were updated (if necessary)

Fixes #639

I have not updated the tests, changelog, or version yet. I wanted to get feedback on the overall approach first.

The approach that I've taken is adding a special case in the `serializer` method that checks the params passed in. If `client_id` and `client_secret` are present it'll do the appropriate encoding and attachment/removal of query params.

Because the current implementation just passes all given parameters straight through to Axios this seemed like the most straightforward place to put this logic. Since this is common logic it felt appropriate to try and keep it in a common layer rather than needing to add any special logic (or defining explicit serializer params) per function.

One possible alternative would be to add an additional param serializer (instead of modifying the existing one) and require callers to use that one. That seemed less ideal since that serializer would look almost identical, the other default parameters would then need to be specified as well (method and url), and overall it would require clients to understand somewhat more of the lower level details of this library.

Something could also potentially be done with Axios interceptors, but again that seems much more complicated as well.

Patch:
diff --git a/README.md b/README.md
index af3625c14..f8d089f0c 100644
--- a/README.md
+++ b/README.md
@@ -145,6 +145,30 @@ The primary differences are in the following table.
 | Does not support interceptors | Supports [interceptors](https://github.com/axios/axios#interceptors)|
 | Does not support cancelalation | Supports [cancellation](https://github.com/axios/axios#cancellation) |
 
+## Premium Plan Authentication
+
+Authentication via client ID and URL signing secret is provided to support legacy applications that use the Google Maps Platform Premium Plan. The Google Maps Platform Premium Plan is no longer available for sign up or new customers. All new applications must use API keys.
+
+```js
+const client = new Client({});
+
+client
+  .elevation({
+    params: {
+      locations: [{ lat: 45, lng: -110 }],
+      client_id: process.env.GOOGLE_MAPS_CLIENT_ID,
+      client_secret: process.env.GOOGLE_MAPS_CLIENT_SECRET
+    },
+    timeout: 1000 // milliseconds
+  })
+  .then(r => {
+    console.log(r.data.results[0].elevation);
+  })
+  .catch(e => {
+    console.log(e.response.data.error_message);
+  });
+```
+
 
 ## Support
 
diff --git a/src/common.ts b/src/common.ts
index 325e1ff29..dbc738b8c 100644
--- a/src/common.ts
+++ b/src/common.ts
@@ -14,8 +14,10 @@
  * limitations under the License.
  */
 
-export interface RequestParams {
-  /**
+export type RequestParams = ApiKeyParams | PremiumPlanParams;
+
+export interface ApiKeyParams {
+/**
    * You must include an API key with every API request. We strongly recommend that you restrict your API key.
    * Restrictions provide added security and help ensure only authorized requests are made with your API key.
    *
@@ -32,6 +34,19 @@ export interface RequestParams {
   key: string;
 }
 
+/**
+ * The Google Maps Platform Premium Plan is no longer available for sign up or new customers. This option is
+ * only provided for maintaining existing legacy applications that use client IDs. For new applications,
+ * please use API keys.
+ * @deprecated
+ */
+export interface PremiumPlanParams {
+  /** project client ID */
+  client_id: string;
+  /** project URL signing secret. Used to create the request signature */
+  client_secret: string;
+}
+
 export interface ResponseData {
   /** contains metadata on the request. See Status Codes below. */
   status: Status;
diff --git a/src/directions.ts b/src/directions.ts
index 83f099acb..dc26b7c86 100644
--- a/src/directions.ts
+++ b/src/directions.ts
@@ -195,7 +195,7 @@ export const defaultParamsSerializer = serializer({
   waypoints: (o) => o.map(latLngToString),
   arrival_time: toTimestamp,
   departure_time: toTimestamp,
-});
+}, defaultUrl);
 
 export function directions(
   {
diff --git a/src/distance.ts b/src/distance.ts
index 21af24a1f..c298144b6 100644
--- a/src/distance.ts
+++ b/src/distance.ts
@@ -174,7 +174,7 @@ export const defaultParamsSerializer = serializer({
   destinations: o => o.map(latLngToString),
   arrival_time: toTimestamp,
   departure_time: toTimestamp
-});
+}, defaultUrl);
 
 export function distancematrix(
   {
diff --git a/src/elevation.ts b/src/elevation.ts
index 10a86fa82..f29e651ba 100644
--- a/src/elevation.ts
+++ b/src/elevation.ts
@@ -19,7 +19,7 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
 import { defaultAxiosInstance } from "./client";
 import { serializer, latLngToString } from "./serialize";
 
-export interface PositionalElevationParams extends RequestParams {
+export interface PositionalElevationParams {
   /**
    * defines the location(s) on the earth from which to return elevation data.
    * This parameter takes either a single location as a comma-separated {latitude,longitude} pair (e.g. "40.714728,-73.998672")
@@ -28,7 +28,7 @@ export interface PositionalElevationParams extends RequestParams {
   locations: LatLng[];
 }
 
-export interface SampledPathElevationParams extends RequestParams {
+export interface SampledPathElevationParams {
   /**
    * defines a path on the earth for which to return elevation data. This parameter defines a
    * set of two or more ordered pairs defining a path along the surface of the earth. This
@@ -44,7 +44,7 @@ export interface SampledPathElevationParams extends RequestParams {
 }
 
 export interface ElevationRequest extends Partial<AxiosRequestConfig> {
-  params: PositionalElevationParams | SampledPathElevationParams;
+  params: (PositionalElevationParams | SampledPathElevationParams) & RequestParams;
 }
 export interface ElevationResponseData extends ResponseData {
   results: {
@@ -74,7 +74,7 @@ export const defaultUrl = "https://maps.googleapis.com/maps/api/elevation/json";
 export const defaultParamsSerializer = serializer({
   locations: o => o.map(latLngToString),
   path: o => o.map(latLngToString)
-});
+}, defaultUrl);
 
 export function elevation(
   {
diff --git a/src/geocode/geocode.ts b/src/geocode/geocode.ts
index 55464bf41..bb6ac1a0d 100644
--- a/src/geocode/geocode.ts
+++ b/src/geocode/geocode.ts
@@ -100,7 +100,7 @@ export interface GeocodeResponse extends AxiosResponse {
 export const defaultParamsSerializer = serializer({
   bounds: latLngBoundsToString,
   components: objectToString
-});
+}, defaultUrl);
 
 export function geocode(
   {
diff --git a/src/geocode/reversegeocode.ts b/src/geocode/reversegeocode.ts
index 54313de6a..a6c257661 100644
--- a/src/geocode/reversegeocode.ts
+++ b/src/geocode/reversegeocode.ts
@@ -101,7 +101,7 @@ export const defaultUrl = "https://maps.googleapis.com/maps/api/geocode/json";
 
 export const defaultParamsSerializer = serializer({
   latlng: latLngToString
-});
+}, defaultUrl);
 
 export function reverseGeocode(
   {
diff --git a/src/places/autocomplete.ts b/src/places/autocomplete.ts
index 1f614058a..348ffb258 100644
--- a/src/places/autocomplete.ts
+++ b/src/places/autocomplete.ts
@@ -168,13 +168,13 @@ export interface PlaceAutocompleteResponse extends AxiosResponse {
   data: PlaceAutocompleteResponseData;
 }
 
+export const defaultUrl =
+  "https://maps.googleapis.com/maps/api/place/autocomplete/json";
+
 export const defaultParamsSerializer = serializer({
   location: latLngToString,
   origin: latLngToString
-});
-
-export const defaultUrl =
-  "https://maps.googleapis.com/maps/api/place/autocomplete/json";
+}, defaultUrl);
 
 export function placeAutocomplete(
   {
diff --git a/src/places/details.ts b/src/places/details.ts
index d0475e1a3..566003c55 100644
--- a/src/places/details.ts
+++ b/src/places/details.ts
@@ -67,7 +67,7 @@ export interface PlaceDetailsResponse extends AxiosResponse {
 export const defaultUrl =
   "https://maps.googleapis.com/maps/api/place/details/json";
 
-export const defaultParamsSerializer = serializer({}, {arrayFormat: "comma"});
+export const defaultParamsSerializer = serializer({}, defaultUrl, {arrayFormat: "comma"});
 
 export function placeDetails(
   {
diff --git a/src/places/findplacefromtext.ts b/src/places/findplacefromtext.ts
index 0dbfd7173..dcaa67948 100644
--- a/src/places/findplacefromtext.ts
+++ b/src/places/findplacefromtext.ts
@@ -55,7 +55,7 @@ export interface FindPlaceFromTextResponse extends AxiosResponse {
 export const defaultUrl =
   "https://maps.googleapis.com/maps/api/place/findplacefromtext/json";
 
-export const defaultParamsSerializer = serializer({}, {arrayFormat: "comma"});
+export const defaultParamsSerializer = serializer({}, defaultUrl, {arrayFormat: "comma"});
 
 export function findPlaceFromText(
   {
diff --git a/src/places/placesnearby.ts b/src/places/placesnearby.ts
index ffa3a99f7..e4576b079 100644
--- a/src/places/placesnearby.ts
+++ b/src/places/placesnearby.ts
@@ -115,7 +115,7 @@ export interface PlacesNearbyResponse extends AxiosResponse {
 export const defaultUrl =
   "https://maps.googleapis.com/maps/api/place/nearbysearch/json";
 
-export const defaultParamsSerializer = serializer({ location: latLngToString });
+export const defaultParamsSerializer = serializer({ location: latLngToString }, defaultUrl);
 
 export function placesNearby(
   {
diff --git a/src/places/queryautocomplete.ts b/src/places/queryautocomplete.ts
index d0cd3842a..9b2c37036 100644
--- a/src/places/queryautocomplete.ts
+++ b/src/places/queryautocomplete.ts
@@ -93,7 +93,7 @@ export interface PlaceQueryAutocompleteResponse extends AxiosResponse {
 export const defaultUrl =
   "https://maps.googleapis.com/maps/api/place/queryautocomplete/json";
 
-export const defaultParamsSerializer = serializer({ location: latLngToString });
+export const defaultParamsSerializer = serializer({ location: latLngToString }, defaultUrl);
 
 export function placeQueryAutocomplete(
   {
diff --git a/src/places/textsearch.ts b/src/places/textsearch.ts
index 701d689ca..c89c36822 100644
--- a/src/places/textsearch.ts
+++ b/src/places/textsearch.ts
@@ -98,7 +98,7 @@ export interface TextSearchResponse extends AxiosResponse {
 export const defaultUrl =
   "https://maps.googleapis.com/maps/api/place/textsearch/json";
 
-export const defaultParamsSerializer = serializer({ location: latLngToString });
+export const defaultParamsSerializer = serializer({ location: latLngToString }, defaultUrl);
 
 export function textSearch(
   {
diff --git a/src/roads/nearestroads.ts b/src/roads/nearestroads.ts
index 1f85f8181..bd043fa74 100644
--- a/src/roads/nearestroads.ts
+++ b/src/roads/nearestroads.ts
@@ -40,7 +40,7 @@ export interface NearestRoadsResponse extends AxiosResponse {
 export const defaultUrl = "https://roads.googleapis.com/v1/nearestRoads";
 export const defaultParamsSerializer = serializer({
   points: o => o.map(latLng => latLngToString(latLng))
-});
+}, defaultUrl);
 
 export function nearestRoads(
   {
diff --git a/src/roads/snaptoroads.ts b/src/roads/snaptoroads.ts
index e7450a568..dd4c98664 100644
--- a/src/roads/snaptoroads.ts
+++ b/src/roads/snaptoroads.ts
@@ -54,7 +54,7 @@ export interface SnapToRoadsResponse extends AxiosResponse {
 export const defaultUrl = "https://roads.googleapis.com/v1/snapToRoads";
 export const defaultParamsSerializer = serializer({
   path: o => o.map(latLngToString)
-});
+}, defaultUrl);
 
 export function snapToRoads(
   {
diff --git a/src/serialize.ts b/src/serialize.ts
index 2336f2178..a2f715491 100644
--- a/src/serialize.ts
+++ b/src/serialize.ts
@@ -17,7 +17,9 @@
 import { LatLng, LatLngBounds, LatLngLiteral } from "./common";
 
 import { encodePath } from "./util";
+import { createHmac } from "crypto";
 import { stringify as qs } from "query-string";
+import { URL } from "url";
 
 const separator = "|";
 
@@ -99,6 +101,7 @@ export type serializerFormat = { [key: string]: serializerFunction };
 
 export function serializer(
   format: serializerFormat,
+  baseUrl: string,
   queryStringOptions: object = {
     arrayFormat: "separator",
     arrayFormatSeparator: separator,
@@ -114,6 +117,11 @@ export function serializer(
       }
     });
 
+    if ("client_id" in serializedParams && "client_secret" in serializedParams) {
+      // Special case to handle premium plan signature
+      return createPremiumPlanQueryString(serializedParams, queryStringOptions, baseUrl);
+    }
+
     return qs(serializedParams, queryStringOptions);
   };
 }
@@ -127,3 +135,35 @@ export function toTimestamp(o: "now" | number | Date): number | "now" {
   }
   return o;
 }
+
+export function createPremiumPlanQueryString(
+  serializedParams: { [key: string]: string },
+  queryStringOptions: object,
+  baseUrl: string,
+): string {
+  serializedParams.client = serializedParams.client_id;
+  const clientSecret = serializedParams.client_secret;
+  delete serializedParams.client_id;
+  delete serializedParams.client_secret;
+
+  const partialQueryString = qs(serializedParams, queryStringOptions);
+  const unsignedUrl = `${baseUrl}?${partialQueryString}`;
+  const signature = createPremiumPlanSignature(unsignedUrl, clientSecret);
+
+  // The signature must come last
+  return `${partialQueryString}&signature=${signature}`;
+}
+
+export function createPremiumPlanSignature(unsignedUrl: string, clientSecret: string): string {
+  // Strip off the protocol, scheme, and host portions of the URL, leaving only the path and the query
+  const fullUrl = new URL(unsignedUrl);
+  const pathAndQuery = `${fullUrl.pathname}${fullUrl.search}`;
+  // Convert from 'web safe' base64 to true base64
+  const unsafeClientSecret = clientSecret.replace(/-/g, "+").replace(/_/g, "/");
+  // Base64 decode the secret
+  const decodedSecret = Buffer.from(unsafeClientSecret, "base64");
+  // Sign the url with the decoded secret
+  const unsafeSignature = createHmac("sha1", decodedSecret).update(pathAndQuery).digest("base64");
+  // Convert from true base64 to 'web safe' base64
+  return unsafeSignature.replace(/\+/g, "-").replace(/\//g, "_");
+}
diff --git a/src/timezone.ts b/src/timezone.ts
index 9b6282794..e3f4ab5d9 100644
--- a/src/timezone.ts
+++ b/src/timezone.ts
@@ -74,7 +74,7 @@ export const defaultUrl = "https://maps.googleapis.com/maps/api/timezone/json";
 export const defaultParamsSerializer = serializer({
   timestamp: toTimestamp,
   location: latLngToString
-});
+}, defaultUrl);
 export function timezone(
   {
     params,


Test patch:
diff --git a/src/directions.test.ts b/src/directions.test.ts
index d8526928d..ff44a26c2 100644
--- a/src/directions.test.ts
+++ b/src/directions.test.ts
@@ -42,3 +42,14 @@ test("elevation should call axios correctly", () => {
     url: defaultUrl
   });
 });
+
+test("serializer should transform correctly", () => {
+  const params = {
+    origin: "Seattle, WA",
+    destination: "San Francisco, CA",
+    key: "foo"
+  };
+
+  expect(defaultParamsSerializer(params))
+    .toEqual("destination=San%20Francisco%2C%20CA&key=foo&origin=Seattle%2C%20WA");
+});
diff --git a/src/distance.test.ts b/src/distance.test.ts
index 99176cbf6..72fbf00ce 100644
--- a/src/distance.test.ts
+++ b/src/distance.test.ts
@@ -46,3 +46,14 @@ test("elevation should call axios correctly", () => {
     url: defaultUrl
   });
 });
+
+test("serializer should transform correctly", () => {
+  const params = {
+    origins: ["Seattle, WA"],
+    destinations: ["San Francisco, CA", "New York, NY"],
+    key: "foo"
+  };
+
+  expect(defaultParamsSerializer(params))
+    .toEqual("destinations=San%20Francisco%2C%20CA|New%20York%2C%20NY&key=foo&origins=Seattle%2C%20WA");
+});
diff --git a/src/elevation.test.ts b/src/elevation.test.ts
index b6c4a36b5..a1a364a24 100644
--- a/src/elevation.test.ts
+++ b/src/elevation.test.ts
@@ -59,3 +59,17 @@ test("elevation should call axios correctly with path params", () => {
     url: defaultUrl
   });
 });
+
+test("serializer should transform correctly", () => {
+  const params = {
+    path: [
+      { lat: 35, lng: -110 },
+      { lat: 45, lng: -110 }
+    ],
+    samples: 10,
+    key: "foo"
+  };
+
+  expect(defaultParamsSerializer(params))
+    .toEqual("key=foo&path=35%2C-110|45%2C-110&samples=10");
+});
diff --git a/src/roads/nearestroads.test.ts b/src/roads/nearestroads.test.ts
index 751004aa6..88d17be69 100644
--- a/src/roads/nearestroads.test.ts
+++ b/src/roads/nearestroads.test.ts
@@ -42,3 +42,10 @@ test("nearestRoads should call axios correctly", () => {
     url: defaultUrl
   });
 });
+
+test("serializer should transform correctly", () => {
+  const params = { points: ["0,0"], key: "foo" };
+
+  expect(defaultParamsSerializer(params))
+    .toEqual("key=foo&points=0%2C0");
+});
diff --git a/src/roads/snaptoroads.test.ts b/src/roads/snaptoroads.test.ts
index cdb9de94e..dc5f7e8b9 100644
--- a/src/roads/snaptoroads.test.ts
+++ b/src/roads/snaptoroads.test.ts
@@ -42,3 +42,10 @@ test("snapToRoads should call axios correctly", () => {
     url: defaultUrl
   });
 });
+
+test("serializer should transform correctly", () => {
+  const params = { path: ["0,0"], key: "foo" };
+
+  expect(defaultParamsSerializer(params))
+    .toEqual("key=foo&path=0%2C0");
+});
diff --git a/src/serialize.test.ts b/src/serialize.test.ts
index 8b2bba933..72a6769b8 100644
--- a/src/serialize.test.ts
+++ b/src/serialize.test.ts
@@ -16,6 +16,8 @@
 
 import { LatLng, LatLngLiteral } from "./common";
 import {
+  createPremiumPlanQueryString,
+  createPremiumPlanSignature,
   latLngArrayToStringMaybeEncoded,
   latLngBoundsToString,
   latLngToString,
@@ -47,11 +49,11 @@ test("latLngBoundsToString is correct", () => {
 });
 
 test("serializer", () => {
-  expect(serializer({ quz: (o) => o })({ foo: ["bar"] })).toBe("foo=bar");
+  expect(serializer({ quz: (o) => o }, "http://mock.url")({ foo: ["bar"] })).toBe("foo=bar");
   expect(
     serializer({
       foo: (o) => o.map((latLng: LatLng) => latLngToString(latLng)),
-    })({
+    }, "http://mock.url")({
       foo: [
         [0, 1],
         [2, 3],
@@ -66,12 +68,37 @@ test("serializer should not mutate params", () => {
     location,
   };
 
-  serializer({ location: latLngToString })(params);
+  serializer({ location: latLngToString }, "http://mock.url")(params);
   expect(params.location).toBe(location);
 });
 
 test("serializer should return pipe joined arrays by default", () => {
-  expect(serializer({})({ foo: ["b", "a", "r"] })).toBe("foo=b|a|r");
+  expect(serializer({}, "http://mock.url")({ foo: ["b", "a", "r"] })).toBe("foo=b|a|r");
+});
+
+
+test("serializer creates premium plan query string if premium plan params are included", () => {
+  const params = {
+    avoid: "ferries",
+    destination: {
+      lat: "38.8977",
+      lng: "-77.0365",
+    },
+    mode: "driving",
+    origin: {
+      lat: "33.8121",
+      lng: "-117.9190",
+    },
+    units: "imperial",
+    client_id: "testClient",
+    client_secret: "testClientSecret",
+  };
+
+  expect(serializer({
+    origin: latLngToString,
+    destination: latLngToString,
+  }, "https://test.url/maps/api/directions/json")(params))
+    .toEqual('avoid=ferries&client=testClient&destination=38.8977%2C-77.0365&mode=driving&origin=33.8121%2C-117.9190&units=imperial&signature=YRJoTd6ohbpsR14WkWv3S7H6MqU=');
 });
 
 test("objectToString", () => {
@@ -114,3 +141,30 @@ test("toTimestamp", () => {
   expect(toTimestamp(dt)).toEqual(seconds);
   expect(toTimestamp("now")).toEqual("now");
 });
+
+test("createPremiumPlanQueryString", () => {
+  const serializedParams = {
+    avoid: "ferries",
+    destination: "38.8977,-77.0365",
+    mode: "driving",
+    origin: "33.8121,-117.9190",
+    units: "imperial",
+    client_id: "testClient",
+    client_secret: "testClientSecret",
+  };
+  const queryStringOptions = {
+    arrayFormat: "separator",
+    arrayFormatSeparator: "|",
+  };
+  const baseUrl = "https://test.url/maps/api/directions/json";
+
+  expect(createPremiumPlanQueryString(serializedParams, queryStringOptions, baseUrl))
+    .toEqual('avoid=ferries&client=testClient&destination=38.8977%2C-77.0365&mode=driving&origin=33.8121%2C-117.9190&units=imperial&signature=YRJoTd6ohbpsR14WkWv3S7H6MqU=');
+});
+
+test("createPremiumPlanSignature", () => {
+  const unsignedUrl = "https://test.url/maps/api/directions/json?avoid=ferries&client=testClient&destination=38.8977%2C-77.0365&mode=driving&origin=33.8121%2C-117.9190&units=imperial";
+  const clientSecret = "testClientSecret";
+
+  expect(createPremiumPlanSignature(unsignedUrl, clientSecret)).toEqual("YRJoTd6ohbpsR14WkWv3S7H6MqU=");
+});