1 5
/**
2 5
 * Copyright 2018 Google LLC
3 5
 *
4 5
 * Distributed under MIT license.
5 5
 * See file LICENSE for detail or copy at https://opensource.org/licenses/MIT
6 5
 */
7 5

8 5
import * as fs from 'fs';
9 5
import {request} from 'gaxios';
10 5
import * as jws from 'jws';
11 5
import * as mime from 'mime';
12 5
import {promisify} from 'util';
13 5

14 5
const readFile = fs.readFile
15 5
  ? promisify(fs.readFile)
16 5
  : async () => {
17 5
      // if running in the web-browser, fs.readFile may not have been shimmed.
18 5
      throw new ErrorWithCode(
19 5
        'use key rather than keyFile.',
20 5
        'MISSING_CREDENTIALS'
21 5
      );
22 5
    };
23 5

24 5
const GOOGLE_TOKEN_URL = 'https://www.googleapis.com/oauth2/v4/token';
25 5
const GOOGLE_REVOKE_TOKEN_URL =
26 5
  'https://accounts.google.com/o/oauth2/revoke?token=';
27 5

28 5
export type GetTokenCallback = (err: Error | null, token?: TokenData) => void;
29 5

30 5
export interface Credentials {
31 5
  privateKey: string;
32 5
  clientEmail?: string;
33 5
}
34 5

35 5
export interface TokenData {
36 5
  refresh_token?: string;
37 5
  expires_in?: number;
38 5
  access_token?: string;
39 5
  token_type?: string;
40 5
  id_token?: string;
41 5
}
42 5

43 5
export interface TokenOptions {
44 5
  keyFile?: string;
45 5
  key?: string;
46 5
  email?: string;
47 5
  iss?: string;
48 5
  sub?: string;
49 5
  scope?: string | string[];
50 5
  additionalClaims?: {};
51 5
}
52 5

53 5
export interface GetTokenOptions {
54 5
  forceRefresh?: boolean;
55 5
}
56 5

57 5
class ErrorWithCode extends Error {
58 5
  constructor(message: string, public code: string) {
59 5
    super(message);
60 5
  }
61 5
}
62 5

63 5
let getPem: ((filename: string) => Promise<string>) | undefined;
64 5

65 5
export class GoogleToken {
66 5
  get accessToken() {
67 5
    return this.rawToken ? this.rawToken.access_token : undefined;
68 5
  }
69 5
  get idToken() {
70 5
    return this.rawToken ? this.rawToken.id_token : undefined;
71 5
  }
72 5
  get tokenType() {
73 5
    return this.rawToken ? this.rawToken.token_type : undefined;
74 5
  }
75 5
  get refreshToken() {
76 5
    return this.rawToken ? this.rawToken.refresh_token : undefined;
77 5
  }
78 5
  expiresAt?: number;
79 5
  key?: string;
80 5
  keyFile?: string;
81 5
  iss?: string;
82 5
  sub?: string;
83 5
  scope?: string;
84 5
  rawToken?: TokenData;
85 5
  tokenExpires?: number;
86 5
  email?: string;
87 5
  additionalClaims?: {};
88 5

89 5
  private inFlightRequest?: undefined | Promise<TokenData>;
90 5

91 5
  /**
92 5
   * Create a GoogleToken.
93 5
   *
94 5
   * @param options  Configuration object.
95 5
   */
96 5
  constructor(options?: TokenOptions) {
97 5
    this.configure(options);
98 5
  }
99 5

100 5
  /**
101 5
   * Returns whether the token has expired.
102 5
   *
103 5
   * @return true if the token has expired, false otherwise.
104 5
   */
105 5
  hasExpired() {
106 5
    const now = new Date().getTime();
107 5
    if (this.rawToken && this.expiresAt) {
108 5
      return now >= this.expiresAt;
109 5
    } else {
110 5
      return true;
111 5
    }
112 5
  }
113 5

114 5
  /**
115 5
   * Returns a cached token or retrieves a new one from Google.
116 5
   *
117 5
   * @param callback The callback function.
118 5
   */
119 5
  getToken(opts?: GetTokenOptions): Promise<TokenData>;
120 5
  getToken(callback: GetTokenCallback, opts?: GetTokenOptions): void;
121 5
  getToken(
122 5
    callback?: GetTokenCallback | GetTokenOptions,
123 5
    opts = {} as GetTokenOptions
124 5
  ): void | Promise<TokenData> {
125 5
    if (typeof callback === 'object') {
126 5
      opts = callback as GetTokenOptions;
127 5
      callback = undefined;
128 5
    }
129 5
    opts = Object.assign(
130 5
      {
131 5
        forceRefresh: false,
132 5
      },
133 5
      opts
134 5
    );
135 5

136 5
    if (callback) {
137 5
      const cb = callback as GetTokenCallback;
138 5
      this.getTokenAsync(opts).then(t => cb(null, t), callback);
139 5
      return;
140 5
    }
141 5

142 5
    return this.getTokenAsync(opts);
143 5
  }
144 5

145 5
  /**
146 5
   * Given a keyFile, extract the key and client email if available
147 5
   * @param keyFile Path to a json, pem, or p12 file that contains the key.
148 5
   * @returns an object with privateKey and clientEmail properties
149 5
   */
150 5
  async getCredentials(keyFile: string): Promise<Credentials> {
151 5
    const mimeType = mime.getType(keyFile);
152 5
    switch (mimeType) {
153 5
      case 'application/json': {
154 5
        // *.json file
155 5
        const key = await readFile(keyFile, 'utf8');
156 5
        const body = JSON.parse(key);
157 5
        const privateKey = body.private_key;
158 5
        const clientEmail = body.client_email;
159 5
        if (!privateKey || !clientEmail) {
160 5
          throw new ErrorWithCode(
161 5
            'private_key and client_email are required.',
162 5
            'MISSING_CREDENTIALS'
163 5
          );
164 5
        }
165 5
        return {privateKey, clientEmail};
166 5
      }
167 5
      case 'application/x-x509-ca-cert': {
168 5
        // *.pem file
169 5
        const privateKey = await readFile(keyFile, 'utf8');
170 5
        return {privateKey};
171 5
      }
172 5
      case 'application/x-pkcs12': {
173 5
        // *.p12 file
174 5
        // NOTE:  The loading of `google-p12-pem` is deferred for performance
175 5
        // reasons.  The `node-forge` npm module in `google-p12-pem` adds a fair
176 5
        // bit time to overall module loading, and is likely not frequently
177 5
        // used.  In a future release, p12 support will be entirely removed.
178 5
        if (!getPem) {
179 5
          getPem = (await import('google-p12-pem')).getPem;
180 5
        }
181 5
        const privateKey = await getPem(keyFile);
182 5
        return {privateKey};
183 5
      }
184 5
      default:
185 5
        throw new ErrorWithCode(
186 5
          'Unknown certificate type. Type is determined based on file extension. ' +
187 5
            'Current supported extensions are *.json, *.pem, and *.p12.',
188 5
          'UNKNOWN_CERTIFICATE_TYPE'
189 5
        );
190 5
    }
191 4
  }
192 5

193 5
  private async getTokenAsync(opts: GetTokenOptions): Promise<TokenData> {
194 5
    if (this.inFlightRequest && !opts.forceRefresh) {
195 5
      return this.inFlightRequest;
196 5
    }
197 5

198 5
    try {
199 5
      return await (this.inFlightRequest = this.getTokenAsyncInner(opts));
200 5
    } finally {
201 5
      this.inFlightRequest = undefined;
202 5
    }
203 5
  }
204 5
  private async getTokenAsyncInner(opts: GetTokenOptions): Promise<TokenData> {
205 5
    if (this.hasExpired() === false && opts.forceRefresh === false) {
206 5
      return Promise.resolve(this.rawToken!);
207 5
    }
208 5

209 5
    if (!this.key && !this.keyFile) {
210 5
      throw new Error('No key or keyFile set.');
211 5
    }
212 5

213 5
    if (!this.key && this.keyFile) {
214 5
      const creds = await this.getCredentials(this.keyFile);
215 5
      this.key = creds.privateKey;
216 5
      this.iss = creds.clientEmail || this.iss;
217 5
      if (!creds.clientEmail) {
218 5
        this.ensureEmail();
219 5
      }
220 5
    }
221 5
    return this.requestToken();
222 5
  }
223 5

224 5
  private ensureEmail() {
225 5
    if (!this.iss) {
226 5
      throw new ErrorWithCode('email is required.', 'MISSING_CREDENTIALS');
227 5
    }
228 5
  }
229 5

230 5
  /**
231 5
   * Revoke the token if one is set.
232 5
   *
233 5
   * @param callback The callback function.
234 5
   */
235 5
  revokeToken(): Promise<void>;
236 5
  revokeToken(callback: (err?: Error) => void): void;
237 5
  revokeToken(callback?: (err?: Error) => void): void | Promise<void> {
238 5
    if (callback) {
239 5
      this.revokeTokenAsync().then(() => callback(), callback);
240 5
      return;
241 5
    }
242 5
    return this.revokeTokenAsync();
243 5
  }
244 5

245 5
  private async revokeTokenAsync() {
246 5
    if (!this.accessToken) {
247 5
      throw new Error('No token to revoke.');
248 5
    }
249 5
    const url = GOOGLE_REVOKE_TOKEN_URL + this.accessToken;
250 5
    await request({url});
251 5
    this.configure({
252 5
      email: this.iss,
253 5
      sub: this.sub,
254 5
      key: this.key,
255 5
      keyFile: this.keyFile,
256 5
      scope: this.scope,
257 5
      additionalClaims: this.additionalClaims,
258 5
    });
259 5
  }
260 5

261 5
  /**
262 5
   * Configure the GoogleToken for re-use.
263 5
   * @param  {object} options Configuration object.
264 5
   */
265 5
  private configure(options: TokenOptions = {}) {
266 5
    this.keyFile = options.keyFile;
267 5
    this.key = options.key;
268 5
    this.rawToken = undefined;
269 5
    this.iss = options.email || options.iss;
270 5
    this.sub = options.sub;
271 5
    this.additionalClaims = options.additionalClaims;
272 5
    if (typeof options.scope === 'object') {
273 5
      this.scope = options.scope.join(' ');
274 5
    } else {
275 5
      this.scope = options.scope;
276 5
    }
277 5
  }
278 5

279 5
  /**
280 5
   * Request the token from Google.
281 5
   */
282 5
  private async requestToken(): Promise<TokenData> {
283 5
    const iat = Math.floor(new Date().getTime() / 1000);
284 5
    const additionalClaims = this.additionalClaims || {};
285 5
    const payload = Object.assign(
286 5
      {
287 5
        iss: this.iss,
288 5
        scope: this.scope,
289 5
        aud: GOOGLE_TOKEN_URL,
290 5
        exp: iat + 3600,
291 5
        iat,
292 5
        sub: this.sub,
293 5
      },
294 5
      additionalClaims
295 5
    );
296 5
    const signedJWT = jws.sign({
297 5
      header: {alg: 'RS256'},
298 5
      payload,
299 5
      secret: this.key,
300 5
    });
301 5
    try {
302 5
      const r = await request<TokenData>({
303 5
        method: 'POST',
304 5
        url: GOOGLE_TOKEN_URL,
305 5
        data: {
306 5
          grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
307 5
          assertion: signedJWT,
308 5
        },
309 5
        headers: {'Content-Type': 'application/x-www-form-urlencoded'},
310 5
        responseType: 'json',
311 5
      });
312 5
      this.rawToken = r.data;
313 5
      this.expiresAt =
314 5
        r.data.expires_in === null || r.data.expires_in === undefined
315 5
          ? undefined
316 5
          : (iat + r.data.expires_in!) * 1000;
317 5
      return this.rawToken;
318 5
    } catch (e) {
319 5
      this.rawToken = undefined;
320 5
      this.tokenExpires = undefined;
321 5
      const body = e.response && e.response.data ? e.response.data : {};
322 5
      if (body.error) {
323 5
        const desc = body.error_description
324 5
          ? `: ${body.error_description}`
325 5
          : '';
326 5
        e.message = `${body.error}${desc}`;
327 5
      }
328 5
      throw e;
329 5
    }
330 5
  }
331 5
}

Read our documentation on viewing source code .

Loading