├── .gitignore ├── .npmignore ├── CHANGELOG ├── LICENSE ├── README.md ├── index.js ├── lib ├── auth.js ├── request-jwt.js └── token-cache.js ├── package.json └── test ├── jwt-settings.json.sample └── test-google-oauth-jwt.js /.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | .idea 3 | node_modules 4 | test/jwt-settings.json 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.1.6 2 | ===== 3 | Request plugin is no longer compatible with older versions, forcing version >= 2.55 4 | 5 | 0.1.5 6 | ===== 7 | * Fixed: request plugin on version 2.55.1+ (https://github.com/extrabacon/google-oauth-jwt/issues/14) 8 | * Fixed: some unit tests are failing on Node 0.12 (https://github.com/extrabacon/google-oauth-jwt/issues/13) 9 | * Cache key now includes the delegation email, if specified 10 | 11 | 0.1.4 12 | ===== 13 | Fixed: reset state after authentication failure (https://github.com/extrabacon/google-oauth-jwt/pull/6) 14 | 15 | 0.1.3 16 | ===== 17 | * Revisited documentation to update instructions for use with the new Google Developers Console. 18 | * Moved change history to a new CHANGELOG file 19 | 20 | 0.1.2 21 | ===== 22 | Better integration with `request` providing JWT support in helper methods as well (ex: `request.post`). 23 | 24 | 0.1.1 25 | ===== 26 | Re-introduced debugging, now with [debug](https://github.com/visionmedia/debug). 27 | 28 | 0.1.0 29 | ===== 30 | * Improved documentation 31 | * Introduced unit tests 32 | * Refactoring aimed at testability 33 | 34 | 0.0.7 35 | ===== 36 | Fixed token expiration check. 37 | 38 | 0.0.6 39 | ===== 40 | Fixed `request` function call when using a URI string without options. 41 | 42 | 0.0.5 43 | ===== 44 | Token is now passed using the `Authorization` header (thanks to [https://github.com/jpd236](jpd236)). 45 | 46 | 0.0.4 47 | ===== 48 | Fixed pending callbacks accumulating indefinitely in TokenCache. 49 | 50 | 0.0.3 51 | ===== 52 | Introduction of TokenCache, preventing the need to constantly request a new token or caching it manually. TokenCache handles expiration automatically. 53 | 54 | 0.0.2 55 | ===== 56 | Vastly improved error handling and documentation. 57 | 58 | 0.0.1 59 | ===== 60 | Initial release. 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013, Nicolas Mercier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # google-oauth-jwt 2 | 3 | Google OAuth 2.0 authentication for server-to-server applications with Node.js. 4 | 5 | This library generates [JWT](http://self-issued.info/docs/draft-ietf-oauth-json-web-token.html) tokens to establish 6 | identity for an API, without an end-user being involved. This is the preferred scenario for server-side communications. 7 | It can be used to interact with Google APIs requiring access to user data (such as Google Drive, Calendar, etc.) for 8 | which URL-based callbacks and user authorization prompts are not appropriate. 9 | 10 | Tokens are generated for a service account, which is created from the Google API console. Service accounts must also 11 | be granted access to resources, using traditional assignation of permissions using the unique service account email 12 | address. 13 | 14 | The authentication process is implemented following the specifications found 15 | [here](https://developers.google.com/accounts/docs/OAuth2ServiceAccount). 16 | 17 | This package also integrates with [request](https://github.com/mikeal/request) to seamlessly query Google RESTful APIs, 18 | which is optional. Integration with [request](https://github.com/mikeal/request) provides automatic requesting of 19 | tokens, as well as built-in token caching. 20 | 21 | ## Documentation 22 | 23 | ### Installation 24 | ```bash 25 | npm install google-oauth-jwt 26 | ``` 27 | 28 | ### How does it work? 29 | 30 | When using Google APIs from the server (or any non-browser based application), authentication is performed through a 31 | Service Account, which is a special account representing your application. This account has a unique email address that 32 | can be used to grant permissions to. If a user wants to give access to his Google Drive to your application, he must share the files or folders with the Service Account using the supplied email address. 33 | 34 | Now that the Service Account has permission to some user resources, the application can query the API with OAuth2. 35 | When using OAuth2, authentication is performed using a token that has been obtained first by submitting a JSON Web 36 | Token (JWT). The JWT identifies the user as well as the scope of the data he wants access to. The JWT is also signed 37 | with a cryptographic key to prevent tampering. Google generates the key and keeps only the public key for validation. 38 | You must keep the private key secure with your application so that you can sign the JWT in order to guarantee its authenticity. 39 | 40 | The application requests a token that can be used for authentication in exchange with a valid JWT. The resulting token 41 | can then be used for multiple API calls, until it expires and a new token must be obtained by submitting another JWT. 42 | 43 | ### Creating a Service Account using the Google Developers Console 44 | 45 | 1. From the [Google Developers Console](https://cloud.google.com/console), select your project or create a new one. 46 | 47 | 2. Under "APIs & auth", click "Credentials". 48 | 49 | 3. Under "OAuth", click the "Create new client ID" button. 50 | 51 | 4. Select "Service account" as the application type and click "Create Client ID". 52 | 53 | 5. The key for your new service account should prompt for download automatically. Note that your key is protected with a password. 54 | IMPORTANT: keep a secure copy of the key, as Google keeps only the public key. 55 | 56 | 6. Convert the downloaded key to PEM, so we can use it from the Node [crypto](http://nodejs.org/api/crypto.html) module. 57 | 58 | To do this, run the following in Terminal: 59 | ```bash 60 | openssl pkcs12 -in downloaded-key-file.p12 -out your-key-file.pem -nodes 61 | ``` 62 | 63 | You will be asked for the password you received during step 5. 64 | 65 | That's it! You now have a service account with an email address and a key that you can use from your Node application. 66 | 67 | ### Granting access to resources to be requested through an API 68 | 69 | In order to query resources using the API, access must be granted to the Service Account. Each Google application that 70 | has security settings must be configured individually. Access is granted by assigning permissions to the service 71 | account, using its email address found in the API console. 72 | 73 | For example, in order to list files in Google Drive, folders and files must be shared with the service account email 74 | address. Likewise, to access a calendar, the calendar must be shared with the service account. 75 | 76 | ### Querying Google APIs with "request" 77 | 78 | In this example, we use a modified instance of [request](https://github.com/mikeal/request) to query the 79 | Google Drive API. [request](https://github.com/mikeal/request) is a full-featured HTTP client which can be extended with Google OAuth2 capabilities by using the `requestWithJWT` method. The modified module will request and cache tokens automatically when supplied with a `jwt` setting in the options. 80 | 81 | ```javascript 82 | // obtain a JWT-enabled version of request 83 | var request = require('google-oauth-jwt').requestWithJWT(); 84 | 85 | request({ 86 | url: 'https://www.googleapis.com/drive/v2/files', 87 | jwt: { 88 | // use the email address of the service account, as seen in the API console 89 | email: 'my-service-account@developer.gserviceaccount.com', 90 | // use the PEM file we generated from the downloaded key 91 | keyFile: 'my-service-account-key.pem', 92 | // specify the scopes you wish to access - each application has different scopes 93 | scopes: ['https://www.googleapis.com/auth/drive.readonly'] 94 | } 95 | }, function (err, res, body) { 96 | console.log(JSON.parse(body)); 97 | }); 98 | ``` 99 | 100 | Note that the `options` object includes a `jwt` object we use to configure the JWT generation. The token will then 101 | automatically be requested and inserted in the authorization header. It will also be cached and reused for subsequent calls using the same service account and scopes. 102 | 103 | If you want to use a specific version of `request`, simply pass it to the the `requestWithJWT` method as such: 104 | 105 | ```javascript 106 | // my version of request 107 | var request = require('request'); 108 | // my modified version of request 109 | request = require('google-oauth-jwt').requestWithJWT(request); 110 | ``` 111 | 112 | ### Requesting the token manually 113 | 114 | If you wish to simply request the token for use with a Google API, use the `authenticate` method. 115 | 116 | ```javascript 117 | var googleAuth = require('google-oauth-jwt'); 118 | 119 | googleAuth.authenticate({ 120 | // use the email address of the service account, as seen in the API console 121 | email: 'my-service-account@developer.gserviceaccount.com', 122 | // use the PEM file we generated from the downloaded key 123 | keyFile: 'my-service-account-key.pem', 124 | // specify the scopes you wish to access 125 | scopes: ['https://www.googleapis.com/auth/drive.readonly'] 126 | }, function (err, token) { 127 | console.log(token); 128 | }); 129 | ``` 130 | 131 | If you want to use the built-in token cache, use the `TokenCache` class. Tokens are cached using the email address and 132 | the scopes as the key. 133 | 134 | ```javascript 135 | var TokenCache = require('google-oauth-jwt').TokenCache, 136 | tokens = new TokenCache(); 137 | 138 | tokens.get({ 139 | // use the email address of the service account, as seen in the API console 140 | email: 'my-service-account@developer.gserviceaccount.com', 141 | // use the PEM file we generated from the downloaded key 142 | keyFile: 'my-service-account-key.pem', 143 | // specify the scopes you wish to access 144 | scopes: ['https://www.googleapis.com/auth/drive.readonly'] 145 | }, function (err, token) { 146 | console.log(token); 147 | }); 148 | ``` 149 | 150 | Using `TokenCache` will request only one token for multiple concurrent requests to `get`. A new token request will 151 | automatically be issued if the token is expired. 152 | 153 | ### Encoding the JWT manually 154 | 155 | It is also possible to encode the JWT manually using the `encodeJWT` method. 156 | 157 | ```javascript 158 | var googleAuth = require('google-oauth-jwt'); 159 | 160 | googleAuth.encodeJWT({ 161 | // use the email address of the service account, as seen in the API console 162 | email: 'my-service-account@developer.gserviceaccount.com', 163 | // use the PEM file we generated from the downloaded key 164 | keyFile: 'my-service-account-key.pem', 165 | // specify the scopes you which to access 166 | scopes: ['https://www.googleapis.com/auth/drive.readonly'] 167 | }, function (err, jwt) { 168 | console.log(jwt); 169 | }); 170 | ``` 171 | 172 | ### Specifying JWT generation options 173 | 174 | The following options can be specified in order to generate the JWT used for authentication: 175 | 176 | ```javascript 177 | var options = { 178 | 179 | // the email address of the service account (required) 180 | // this information is obtained via the API console 181 | email: 'my-service-account@developer.gserviceaccount.com', 182 | 183 | // an array of scopes uris to request access to (required) 184 | // different scopes are available for each application, refer to the app documentation 185 | // scopes are limitations applied to the API access 186 | scopes: [...], 187 | 188 | // the cryptographic key as a string, can be the contents of the PEM file 189 | // the key will be used to sign the JWT and validated by Google OAuth 190 | key: 'KEY_CONTENTS', 191 | 192 | // the path to the PEM file to use for the cryptographic key (ignored if 'key' is also defined) 193 | // the key will be used to sign the JWT and validated by Google OAuth 194 | keyFile: 'path/to/key.pem', 195 | 196 | // the duration of the requested token in milliseconds (optional) 197 | // default is 1 hour (60 * 60 * 1000), which is the maximum allowed by Google 198 | expiration: 3600000, 199 | 200 | // if access is being granted on behalf of someone else, specifies who is impersonating the service account 201 | delegationEmail: 'email_address@mycompany.com' 202 | 203 | }; 204 | ``` 205 | 206 | For more information: 207 | [https://developers.google.com/accounts/docs/OAuth2ServiceAccount#formingclaimset](https://developers.google.com/accounts/docs/OAuth2ServiceAccount#formingclaimset) 208 | 209 | Options are used to encode the JWT that will be sent to Google OAuth servers in order to issue a token that can then be 210 | used for authentification to Google APIs. The same options are used for `authenticate`, `TokenCache.get` or the `jwt` 211 | setting passed to `request` options. 212 | 213 | ## Running the tests 214 | 215 | Running the unit tests for `google-oauth-jwt` requires a valid Service Account, its encryption key and a URL to test. 216 | 217 | To launch the tests, first configure your account in "test/jwt-settings.json" using the sample file. Make sure your 218 | test URL also matches with the requested scopes. The tests do not make any assumption on the results from the API, so 219 | you can use any OAuth2 enabled API. 220 | 221 | For example, to run the tests by listing Google Drive files, you can use the following configuration: 222 | 223 | ```javascript 224 | { 225 | "email": "my-account@developer.gserviceaccount.com", 226 | "scopes": ["https://www.googleapis.com/auth/drive.readonly"], 227 | "keyFile": "./test/key.pem", 228 | "test_url": "https://www.googleapis.com/drive/v2/files" 229 | } 230 | ``` 231 | 232 | To run the tests: 233 | 234 | ```bash 235 | npm test 236 | ``` 237 | 238 | or 239 | 240 | ```bash 241 | mocha -t 5000 242 | ``` 243 | 244 | The 5 seconds timeout is required since some tests make multiple calls to the API. If you get timeout exceeded errors, 245 | you can bump this value since not all Google APIs may respond with the same timings. 246 | 247 | ## Debugging 248 | 249 | To turn on debugging, add "google-oauth-jwt" to your `DEBUG` variable. Debugging events include JWT generation, token 250 | requests to the OAuth server and token expirations through `TokenCache`. 251 | 252 | For example, to turn on debugging while running the unit tests, use this: 253 | 254 | ```bash 255 | DEBUG=google-oauth-jwt mocha -t 5000 256 | ``` 257 | 258 | ## Compatibility 259 | 260 | + Tested with Node 0.10, 0.12, 4.2, 5.5 261 | 262 | ## License 263 | 264 | The MIT License (MIT) 265 | 266 | Copyright (c) 2013, Nicolas Mercier 267 | 268 | Permission is hereby granted, free of charge, to any person obtaining a copy 269 | of this software and associated documentation files (the "Software"), to deal 270 | in the Software without restriction, including without limitation the rights 271 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 272 | copies of the Software, and to permit persons to whom the Software is 273 | furnished to do so, subject to the following conditions: 274 | 275 | The above copyright notice and this permission notice shall be included in 276 | all copies or substantial portions of the Software. 277 | 278 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 279 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 280 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 281 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 282 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 283 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 284 | THE SOFTWARE. 285 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var auth = require('./lib/auth'), 2 | request = require('./lib/request-jwt'); 3 | 4 | exports.TokenCache = require('./lib/token-cache'); 5 | exports.authenticate = auth.authenticate; 6 | exports.encodeJWT = auth.encodeJWT; 7 | exports.requestWithJWT = request.requestWithJWT; 8 | -------------------------------------------------------------------------------- /lib/auth.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'), 2 | crypto = require('crypto'), 3 | request = require('request'), 4 | debug = require('debug')('google-oauth-jwt'); 5 | 6 | // constants 7 | var GOOGLE_OAUTH2_URL = 'https://accounts.google.com/o/oauth2/token'; 8 | 9 | /** 10 | * Request an authentication token by submitting a signed JWT to Google OAuth2 service. 11 | * 12 | * @param {Object} options The JWT generation options. 13 | * @param {Function} callback The callback function to invoke with the resulting token. 14 | */ 15 | exports.authenticate = function (options, callback) { 16 | 17 | callback = callback || function () {}; 18 | 19 | exports.encodeJWT(options, function (err, jwt) { 20 | 21 | if (err) return callback(err); 22 | 23 | return request.post(GOOGLE_OAUTH2_URL, { 24 | headers: { 25 | 'Content-Type': 'application/x-www-form-urlencoded' 26 | }, 27 | form: { 28 | grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', 29 | assertion: jwt 30 | } 31 | }, function (err, res, body) { 32 | 33 | if (err) { 34 | return callback(err); 35 | } else { 36 | debug('response from OAuth server: HTTP %d -> %j', res.statusCode, body); 37 | } 38 | 39 | try { 40 | body = JSON.parse(body); 41 | } 42 | catch (e) { 43 | return callback(new Error('failed to parse response body: ' + body)); 44 | } 45 | 46 | if (res.statusCode != 200) { 47 | err = new Error( 48 | 'failed to obtain an authentication token, request failed with HTTP code ' + 49 | res.statusCode + ': ' + body.error 50 | ); 51 | err.statusCode = res.statusCode; 52 | err.body = body; 53 | return callback(err); 54 | } 55 | 56 | return callback(null, body.access_token); 57 | 58 | }); 59 | }); 60 | }; 61 | 62 | /** 63 | * Encode a JSON Web Token (JWT) using the supplied options. 64 | * 65 | * The token represents an authentication request for a specific user and is signed with a private key to ensure 66 | * authenticity. 67 | * 68 | * Available options are: 69 | * `options.email`: the email address of the service account (required) 70 | * `options.scopes`: an array of scope URIs to demand access for (required) 71 | * `options.key` or options.keyFile: the private key to use to sign the token (required) 72 | * `options.expiration`: the duration of the requested token, in milliseconds (default: 1 hour) 73 | * `options.delegationEmail`: an email address for which access is being granted on behalf of (optional) 74 | * 75 | * @param {Object} options The options to use to generate the JWT 76 | * @param {Function} callback The callback function to invoke with the encoded JSON Web Token (JWT) 77 | */ 78 | exports.encodeJWT = function (options, callback) { 79 | 80 | if (!options) throw new Error('options is required'); 81 | if (!options.email) throw new Error('options.email is required'); 82 | if (!options.scopes) throw new Error('options.scopes is required'); 83 | if (!Array.isArray(options.scopes)) throw new Error('options.scopes must be an array'); 84 | if (options.scopes.length == 0) throw new Error('options.scopes must contain at least one scope'); 85 | if (!options.key && !options.keyFile) throw new Error('options.key or options.keyFile are required'); 86 | callback = callback || function () {}; 87 | 88 | debug('generating jwt for %j', options); 89 | 90 | var iat = Math.floor(new Date().getTime() / 1000), 91 | exp = iat + Math.floor((options.expiration || 60 * 60 * 1000) / 1000), 92 | claims = { 93 | iss: options.email, 94 | scope: options.scopes.join(' '), 95 | aud: GOOGLE_OAUTH2_URL, 96 | exp: exp, 97 | iat: iat 98 | }; 99 | 100 | if (options.delegationEmail) { 101 | claims.sub = options.delegationEmail; 102 | } 103 | 104 | var JWT_header = new Buffer(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString('base64'), 105 | JWT_claimset = new Buffer(JSON.stringify(claims)).toString('base64'), 106 | unsignedJWT = [JWT_header, JWT_claimset].join('.'); 107 | 108 | obtainKey(function (err, key) { 109 | 110 | if (err) return callback(err); 111 | 112 | try { 113 | var JWT_signature = crypto.createSign('RSA-SHA256').update(unsignedJWT).sign(key, 'base64'), 114 | signedJWT = [unsignedJWT, JWT_signature].join('.'); 115 | } catch (e) { 116 | // in Node 0.12, an error is thrown 117 | var signErr = new Error('failed to sign JWT, the key is probably invalid'); 118 | signErr.inner = e; 119 | return callback(signErr); 120 | } 121 | 122 | if (JWT_signature === '') { 123 | return callback(new Error('failed to sign JWT, the key is probably invalid')); 124 | } 125 | 126 | debug('signed jwt: %s', signedJWT); 127 | return callback(null, signedJWT); 128 | 129 | }); 130 | 131 | function obtainKey(callback) { 132 | if (options.key && options.key != '') { 133 | // key is supplied as a string 134 | return callback(null, options.key); 135 | } else if (options.keyFile) { 136 | // read the key from the specified file 137 | return fs.readFile(options.keyFile, callback); 138 | } 139 | return callback(new Error( 140 | 'key is not specified, use "options.key" or "options.keyFile" to specify the key to use to sign the JWT' 141 | )); 142 | } 143 | 144 | }; 145 | -------------------------------------------------------------------------------- /lib/request-jwt.js: -------------------------------------------------------------------------------- 1 | var TokenCache = require('./token-cache'); 2 | 3 | /** 4 | * Returns a Google OAuth2 enabled request module. 5 | * The modified function accepts a "jwt" setting in the options parameter to configure token-based authentication. 6 | * 7 | * When a "jwt" setting is defined, a token will automatically be requested (or reused) and inserted into the 8 | * "authorization" header. 9 | * 10 | * The "jwt" setting accepts the following parameters: 11 | * `email`: the email address of the service account (required) 12 | * `scopes`: an array of scope URIs to demand access for (required) 13 | * `key` or `keyFile`: the private key to use to sign the token (required) 14 | * `expiration`: the duration of the requested token, in milliseconds (default: 1 hour) 15 | * `delegationEmail`: an email address for which access is being granted on behalf of (optional) 16 | * 17 | * @param {Object} tokens The TokenCache instance to use. If not specified, `TokenCache.global` will be used. 18 | * @param {Function} request The request module to modify to enable Google OAuth2 support. If not supplied, the bundled 19 | * version will be used. 20 | * @returns {Function} The modified request module with Google OAuth2 support. 21 | */ 22 | exports.requestWithJWT = function (tokens, request) { 23 | 24 | if (typeof tokens === 'function') { 25 | request = tokens; 26 | tokens = null; 27 | } 28 | if (!tokens) { 29 | // use the global token cache 30 | tokens = TokenCache.global; 31 | } 32 | if (!request) { 33 | // use the request module from our dependency 34 | request = require('request'); 35 | } 36 | 37 | var _init = request.Request.prototype.init; 38 | 39 | request.Request.prototype.init = function initWithJWT(options) { 40 | var self = this; 41 | if (options && options.jwt) { 42 | tokens.get(options.jwt, function (err, token) { 43 | if (err) return options.callback(err); 44 | options.auth = { bearer: token }; 45 | _init.call(self, options); 46 | }); 47 | delete options.jwt; 48 | } else { 49 | return _init.call(self, options); 50 | } 51 | } 52 | 53 | return request; 54 | 55 | }; 56 | -------------------------------------------------------------------------------- /lib/token-cache.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug')('google-oauth-jwt'); 2 | 3 | /** 4 | * A cache of tokens for reusing previously requested tokens until they expire. 5 | * 6 | * Tokens are requested by calling the `authenticate` method and cached for any combination of `options.email` and 7 | * `options.scopes`. 8 | * 9 | * @constructor TokenCache 10 | */ 11 | function TokenCache() { 12 | // cache is just a key/value pair 13 | this._cache = {}; 14 | }; 15 | 16 | /** 17 | * Retrieve an authentication token, or reuse a previously obtained one if it is not expired. 18 | * Only one request will be performed for any combination of `options.email`, `options.scopes` 19 | * and `options.delegationEmail`. 20 | * 21 | * @param options The JWT generation options. 22 | * @callback {Function} The callback to invoke with the resulting token. 23 | */ 24 | TokenCache.prototype.get = function (options, callback) { 25 | var key = options.email + ':' + options.scopes.join(','); 26 | if (options.delegationEmail) key += ':' + options.delegationEmail; 27 | if (!this._cache[key]) { 28 | this._cache[key] = new TokenRequest(this.authenticate, options); 29 | } 30 | this._cache[key].get(callback); 31 | }; 32 | 33 | /** 34 | * Clear all tokens previously requested by this instance. 35 | */ 36 | TokenCache.prototype.clear = function () { 37 | this._cache = {}; 38 | }; 39 | 40 | /** 41 | * The method to use to perform authentication and retrieving a token. 42 | * Used for overriding the authentication mechanism. 43 | * 44 | * @param {Object} options The JWT generation options. 45 | * @callback {Function} callback The callback to invoke with the resulting token. 46 | */ 47 | TokenCache.prototype.authenticate = require('./auth').authenticate; 48 | 49 | /** 50 | * A single cacheable token request with support for concurrency. 51 | * @private 52 | * @constructor 53 | */ 54 | function TokenRequest(authenticate, options) { 55 | 56 | var self = this; 57 | this.status = 'expired'; 58 | this.pendingCallbacks = []; 59 | 60 | // execute accumulated callbacks during the 'pending' state 61 | function fireCallbacks(err, token) { 62 | self.pendingCallbacks.forEach(function (callback) { 63 | callback(err, token); 64 | }); 65 | self.pendingCallbacks = []; 66 | } 67 | 68 | TokenRequest.prototype.get = function (callback) { 69 | 70 | if (this.status == 'expired') { 71 | 72 | this.status = 'pending'; 73 | this.pendingCallbacks.push(callback); 74 | 75 | authenticate(options, function (err, token) { 76 | if (err) { 77 | self.status = 'expired'; 78 | return fireCallbacks(err, null); 79 | } 80 | self.issued = Date.now(); 81 | self.duration = options.expiration || 60 * 60 * 1000; 82 | self.token = token; 83 | self.status = 'completed'; 84 | return fireCallbacks(null, token); 85 | }); 86 | 87 | } else if (this.status == 'pending') { 88 | // wait for the pending request instead of issuing a new one 89 | this.pendingCallbacks.push(callback); 90 | } else if (this.status == 'completed') { 91 | 92 | if (this.issued + this.duration < Date.now()) { 93 | this.status = 'expired'; 94 | debug('token is expired, a new token will be requested'); 95 | this.get(callback); 96 | } else { 97 | callback(null, this.token); 98 | } 99 | 100 | } 101 | }; 102 | } 103 | 104 | /** 105 | * The global token cache that can be used as a default instance. 106 | * @type TokenCache 107 | */ 108 | TokenCache.global = new TokenCache(); 109 | 110 | module.exports = TokenCache; 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "google-oauth-jwt", 3 | "version": "0.2.0", 4 | "author": { 5 | "name": "Nicolas Mercier", 6 | "email": "nicolas@extrabacon.net" 7 | }, 8 | "description": "Implementation of Google OAuth 2.0 for server-to-server interactions, allowing secure use of Google APIs without interaction from an end-user.", 9 | "keywords": [ 10 | "google", 11 | "api", 12 | "oauth", 13 | "oauth2", 14 | "service account", 15 | "jwt", 16 | "token", 17 | "server to server" 18 | ], 19 | "dependencies": { 20 | "request": ">=2.55", 21 | "debug": "^2.1.3" 22 | }, 23 | "devDependencies": { 24 | "underscore": "*", 25 | "async": "*", 26 | "mocha": "*", 27 | "chai": "*", 28 | "chai-spies": "*" 29 | }, 30 | "scripts": { 31 | "test": "mocha -t 5000 -R spec" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "http://github.com/extrabacon/google-oauth-jwt" 36 | }, 37 | "homepage": "http://github.com/extrabacon/google-oauth-jwt", 38 | "bugs": "http://github.com/extrabacon/google-oauth-jwt/issues", 39 | "license": "MIT", 40 | "engines": { 41 | "node": ">=0.8" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/jwt-settings.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "email": "email_address_of_your_service_account", 3 | "scopes": ["scopes_to_use_for_your_tests"], 4 | "keyFile": "path/to/key.pem", 5 | "test_url": "https://www.googleapis.com/your-favorite-api" 6 | } 7 | -------------------------------------------------------------------------------- /test/test-google-oauth-jwt.js: -------------------------------------------------------------------------------- 1 | var moduleToTest = require('..'), 2 | _ = require('underscore'), 3 | async = require('async'), 4 | fs = require('fs'), 5 | chai = require('chai'), 6 | spies = require('chai-spies'), 7 | expect = chai.expect, 8 | jwt_settings = require('./jwt-settings.json'); 9 | 10 | console.log('WARNING: running these tests require a per-user configuration!!'); 11 | console.log('To specify your configuration, copy `jwt-settings.json.sample` to `jwt-settings.json`'); 12 | console.log('\nMake sure that your service account is setup, has been granted access to the requested scopes, and that you have converted your key to PEM'); 13 | 14 | chai.use(spies); 15 | 16 | function jwtSettings(settings) { 17 | return _.extend({}, jwt_settings, settings); 18 | } 19 | 20 | describe('encodeJWT()', function () { 21 | 22 | it('should sign a token with a string-based key', function (done) { 23 | 24 | // read the key from the file 25 | var key = ''+fs.readFileSync(jwt_settings.keyFile); 26 | expect(key).to.not.be.null.and.to.be.a.string; 27 | expect(key).to.have.length.above(1); 28 | 29 | moduleToTest.encodeJWT(jwtSettings({ key: key }), function (err, jwt) { 30 | if (err) throw err; 31 | expect(jwt).to.not.be.null.and.to.be.a.string; 32 | expect(jwt).to.have.length.above(1); 33 | done(); 34 | }); 35 | 36 | }); 37 | 38 | it('should sign a token with a key obtained from a file', function (done) { 39 | moduleToTest.encodeJWT(jwtSettings(), function (err, jwt) { 40 | if (err) throw err; 41 | expect(jwt).to.not.be.null.and.to.be.a.string; 42 | expect(jwt).to.have.length.above(1); 43 | done(); 44 | }); 45 | }); 46 | 47 | it('should fail when options are missing or invalid', function () { 48 | function encodeJWT(settings) { 49 | return function () { 50 | moduleToTest.encodeJWT(settings); 51 | } 52 | } 53 | expect(encodeJWT(null)).to.throw(Error); 54 | expect(encodeJWT({})).to.throw(/email/); 55 | expect(encodeJWT({ key: 'key', scopes: ['scope'] })).to.throw(/email/); 56 | expect(encodeJWT({ email: 'email', scopes: ['scope'] })).to.throw(/key/); 57 | expect(encodeJWT({ email: 'email', key: 'key' })).to.throw(/scopes/); 58 | expect(encodeJWT({ email: 'email', key: 'key', scopes: '' })).to.throw(/scopes/); 59 | expect(encodeJWT({ email: 'email', key: 'key', scopes: [] })).to.throw(/scopes/); 60 | }); 61 | 62 | it('should fail when signing with an invalid key', function (done) { 63 | moduleToTest.encodeJWT(jwtSettings({ key: 'this is not a key' }), function (err, jwt) { 64 | expect(err).to.be.an.instanceOf(Error); 65 | expect(jwt).to.be.undefined; 66 | done(); 67 | }); 68 | }); 69 | 70 | }); 71 | 72 | describe('authenticate()', function () { 73 | 74 | it('should return a valid token for a legitimate request', function (done) { 75 | moduleToTest.authenticate(jwtSettings(), function (err, token) { 76 | if (err) throw err; 77 | expect(token).to.not.be.null.and.to.be.a.string; 78 | expect(token).to.have.length.above(1); 79 | done(); 80 | }); 81 | }); 82 | 83 | it('should fail for an invalid request', function (done) { 84 | moduleToTest.authenticate(jwtSettings({ email: 'invalid email!' }), function (err, token) { 85 | expect(err).to.be.an.instanceOf(Error); 86 | expect(token).to.be.undefined; 87 | done(); 88 | }); 89 | }); 90 | 91 | }); 92 | 93 | describe('TokenCache', function () { 94 | 95 | var fakeAuth = function (options, callback) { 96 | callback(null, 'fake_token'); 97 | }; 98 | 99 | describe('get()', function () { 100 | 101 | it('should request a token on first call', function (done) { 102 | var tokens = new moduleToTest.TokenCache(); 103 | tokens.authenticate = chai.spy(fakeAuth); 104 | 105 | tokens.get(jwtSettings(), function (err, token) { 106 | if (err) throw err; 107 | expect(tokens.authenticate).to.have.been.called.once; 108 | expect(token).to.equal('fake_token'); 109 | done(); 110 | }); 111 | }); 112 | 113 | it('should reuse a token on subsequent calls', function (done) { 114 | 115 | var tokens = new moduleToTest.TokenCache(); 116 | tokens.authenticate = chai.spy(fakeAuth); 117 | 118 | tokens.get(jwtSettings(), function (err, firstToken) { 119 | if (err) throw err; 120 | expect(tokens.authenticate).to.have.been.called.once; 121 | 122 | // request tokens at different intervals 123 | async.each([100, 200, 500], function (interval, next) { 124 | setTimeout(function () { 125 | tokens.get(jwt_settings, function (err, cachedToken) { 126 | if (err) throw err; 127 | expect(cachedToken).to.equal(firstToken); 128 | expect(tokens.authenticate).to.have.been.called.once; 129 | next(); 130 | }); 131 | }, interval); 132 | }, done); 133 | 134 | }); 135 | }); 136 | 137 | it('should issue only one request on concurrent calls', function (done) { 138 | 139 | var tokens = new moduleToTest.TokenCache(); 140 | tokens.authenticate = chai.spy(fakeAuth); 141 | tokens.get = chai.spy(tokens.get); 142 | 143 | // make 5 simultaneous calls on an empty cache - only one should make the request 144 | async.parallel([ 145 | function (next) { tokens.get(jwt_settings, next) }, 146 | function (next) { tokens.get(jwt_settings, next) }, 147 | function (next) { tokens.get(jwt_settings, next) }, 148 | function (next) { tokens.get(jwt_settings, next) }, 149 | function (next) { tokens.get(jwt_settings, next) } 150 | ], function (err, results) { 151 | if (err) throw err; 152 | expect(tokens.authenticate).to.have.been.called.once; 153 | expect(tokens.get).to.have.been.called.exactly(5); 154 | results.forEach(function (token) { 155 | expect(token).to.equal('fake_token'); 156 | }); 157 | done(); 158 | }); 159 | 160 | }); 161 | 162 | it('should discard an expired token and request another one', function (done) { 163 | 164 | var settings = jwtSettings({ expiration: 500 }); 165 | var tokens = new moduleToTest.TokenCache(); 166 | tokens.authenticate = chai.spy(fakeAuth); 167 | 168 | async.auto({ 169 | initial_request: function (next) { 170 | // this token should expire after 500ms 171 | tokens.get(settings, function (err, token) { 172 | expect(tokens.authenticate).to.have.been.called.once; 173 | next(err); 174 | }); 175 | }, 176 | wait_for_expiration: function (next) { 177 | setTimeout(next, settings.expiration + 5); 178 | }, 179 | get_new_token: ['wait_for_expiration', function (next) { 180 | // the token should no longer be available, a new request should be made 181 | tokens.get(settings, function (err, token) { 182 | expect(tokens.authenticate).to.have.been.called.twice; 183 | next(err); 184 | }); 185 | }] 186 | }, done); 187 | 188 | }); 189 | }); 190 | 191 | describe('clear()', function () { 192 | 193 | it('should remove previously requested tokens', function (done) { 194 | 195 | var tokens = new moduleToTest.TokenCache(); 196 | expect(_.keys(tokens._cache)).to.be.empty; 197 | 198 | tokens.get(jwtSettings(), function (err, token) { 199 | if (err) throw err; 200 | expect(_.keys(tokens._cache)).to.have.length(1); 201 | tokens.clear(); 202 | expect(_.keys(tokens._cache)).to.be.empty; 203 | done(); 204 | }); 205 | 206 | }); 207 | }); 208 | }); 209 | 210 | describe('requestWithJWT', function () { 211 | 212 | it('should work normally, without jwt settings', function (done) { 213 | var request = moduleToTest.requestWithJWT(); 214 | request('http://www.google.com/', function (err, res) { 215 | expect(res.statusCode).to.equal(200); 216 | done(err); 217 | }); 218 | }); 219 | 220 | it('should request a token automatically', function (done) { 221 | 222 | var tokens = new moduleToTest.TokenCache(); 223 | tokens.get = chai.spy(tokens.get); 224 | tokens.authenticate = chai.spy(tokens.authenticate); 225 | 226 | var request = moduleToTest.requestWithJWT(tokens); 227 | 228 | // test multiple variants of calls to request 229 | async.parallel({ 230 | get_helper: function (next) { 231 | request.get(jwt_settings.test_url, { jwt: jwtSettings() }, function (err, res, body) { 232 | expect(res.statusCode).to.equal(200); 233 | next(err); 234 | }); 235 | }, 236 | with_url_and_options: function (next) { 237 | request(jwt_settings.test_url, { jwt: jwtSettings() }, function (err, res, body) { 238 | expect(res.statusCode).to.equal(200); 239 | next(err); 240 | }); 241 | }, 242 | only_with_options: function (next) { 243 | request({ 244 | url: jwt_settings.test_url, 245 | jwt: jwtSettings() 246 | }, function (err, res, body) { 247 | expect(res.statusCode).to.equal(200); 248 | next(err); 249 | }); 250 | } 251 | }, function (err) { 252 | if (err) throw err; 253 | expect(tokens.get).to.have.been.called.exactly(3); 254 | expect(tokens.authenticate).to.have.been.called.once; 255 | done(); 256 | }); 257 | 258 | }); 259 | 260 | it('should use a global token cache', function (done) { 261 | 262 | var tokens = moduleToTest.TokenCache.global; 263 | tokens.get = chai.spy(tokens.get); 264 | tokens.authenticate = chai.spy(tokens.authenticate); 265 | 266 | // clear the global cache in case it has been used by other tests 267 | tokens.clear(); 268 | expect(_.keys(tokens._cache)).to.have.length(0); 269 | 270 | function requestWithoutTokenCache(next) { 271 | // use a new instance each time 272 | var request = moduleToTest.requestWithJWT(); 273 | request({ 274 | url: jwt_settings.test_url, 275 | jwt: jwtSettings() 276 | }, function (err, res, body) { 277 | expect(res.statusCode).to.equal(200); 278 | next(err); 279 | }); 280 | } 281 | 282 | // perform 3 simultaneous requests with 3 separate instances - the same cache should be used 283 | async.parallel([ 284 | requestWithoutTokenCache, 285 | requestWithoutTokenCache, 286 | requestWithoutTokenCache 287 | ], function (err) { 288 | if (err) throw er 289 | expect(_.keys(tokens._cache)).to.have.length(1); 290 | expect(tokens.get).to.have.been.called.exactly(3); 291 | expect(tokens.authenticate).to.have.been.called.once; 292 | done(); 293 | }); 294 | 295 | }); 296 | 297 | }); 298 | --------------------------------------------------------------------------------