From ffc4ac9459e4b1e3266c7ae699b62da64ff68e53 Mon Sep 17 00:00:00 2001 From: Alex Erhardt Date: Mon, 19 Aug 2019 21:42:37 +0200 Subject: [PATCH] Add support for Application Only Auth (#207) * Add ability to make requests using Application Only Auth * Add ability to choose either INSTALLED_CLIENT or CLIENT_CREDENTIAL for user-less token authorization * Simplfy application auth testing credentials * Update Babelify to 10.0.0 * Change last two .fromApplicationOnlyAuth tests to run in browser * Document DO_NOT_TRACK_THIS_DEVICE param in fromApplicationOnlyAuth * Add documentation tweaks regarding app-only auth --- package.json | 2 +- src/README.md | 1 + src/snoowrap.js | 88 +++++++++++++++++++++++++++++++++++++++++++ test/snoowrap.spec.js | 42 ++++++++++++++++++++- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 025c1f78..759fb1fa 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@types/request": "^2.48.2", "@typescript-eslint/eslint-plugin": "^1.13.0", "@typescript-eslint/parser": "^1.13.0", - "babelify": "^7.3.0", + "babelify": "^10.0.0", "browserify": "^13.1.0", "chai": "^3.5.0", "dirty-chai": "^1.2.2", diff --git a/src/README.md b/src/README.md index 8d12ab37..f0d16877 100644 --- a/src/README.md +++ b/src/README.md @@ -27,6 +27,7 @@ Put these credentials in a file called `oauth_info.json` in the project root dir "username": "put a username here", "password": "put a password here", "redirect_uri": "put the redirect URI here" + "installed_app_client_id" "put_your_installed_app_client_id_here" } ``` diff --git a/src/snoowrap.js b/src/snoowrap.js index 9b2039fb..ae74371c 100644 --- a/src/snoowrap.js +++ b/src/snoowrap.js @@ -238,6 +238,94 @@ const snoowrap = class snoowrap { }); } + /** + * @summary Returns the grant types available for app-only authentication + * @desc Per the Reddit API OAuth docs, there are two different grant types depending on whether the app is an installed client + * or a confidential client such as a web app or string. This getter returns the possible values for the "grant_type" field + * in application-only auth. + * @returns {object} The enumeration of possible grant_type values + */ + static get grantType () { + return { + CLIENT_CREDENTIALS: 'client_credentials', + INSTALLED_CLIENT: 'https://oauth.reddit.com/grants/installed_client' + }; + } + /** + * @summary Creates a snoowrap requester from a "user-less" Authorization token + * @desc In some cases, 3rd party app clients may wish to make API requests without a user context. App clients can request + * a "user-less" Authorization token via either the standard client_credentials grant, or the reddit specific + * extension to this grant, https://oauth.reddit.com/grants/installed_client. Which grant type an app uses depends on + * the app-type and its use case. + * @param {object} options + * @param {string} options.userAgent A unique description of what your app does. This argument is not necessary when snoowrap + is running in a browser. + * @param {string} options.clientId The client ID of your app (assigned by reddit). If your code is running clientside in a + * browser, using an "Installed" app type is recommended. From the reddit docs: "reddit *may* choose to use this ID to generate + * aggregate data about user counts. Clients that wish to remain anonymous should use the value DO_NOT_TRACK_THIS_DEVICE." + * @param {string} [options.clientSecret] The client secret of your app. Only required for "client_credentials" grant type. + * @param {string} [options.deviceId] A unique, per-device ID generated by your client. Only required + * for "Installed" grant type. + * @param {string} [options.grantType=snoowrap.grantType.INSTALLED_CLIENT] The type of "user-less" + * token to use {@link snoowrap.grantType} + * @param {string} [options.endpointDomain='reddit.com'] The endpoint domain that the returned requester should be configured + to use. If the user is authenticating on reddit.com (as opposed to some other site with a reddit-like API), you can omit this + value. + * @returns {Promise} A Promise that fulfills with a `snoowrap` instance + * @example + * + * snoowrap.fromApplicationOnlyAuth({ + * userAgent: 'My app', + * clientId: 'foobarbazquuux', + * deviceId: 'unique device id' + * grantType: snoowrap.grantType.INSTALLED_CLIENT + * }).then(r => { + * // Now we have a requester that can access reddit through a "user-less" Auth token + * return r.getHot().then(posts => { + * // do something with posts from the front page + * }); + * }) + * + * snoowrap.fromApplicationOnlyAuth({ + * userAgent: 'My app', + * clientId: 'foobarbazquuux', + * clientSecret: 'your web app secret' + * grantType: snoowrap.grantType.CLIENT_CREDENTIALS + * }).then(r => { + * // Now we have a requester that can access reddit through a "user-less" Auth token + * return r.getHot().then(posts => { + * // do something with posts from the front page + * }); + * }) + */ + static fromApplicationOnlyAuth ({ + userAgent = isBrowser ? global.navigator.userAgent : requiredArg('userAgent'), + clientId = requiredArg('clientId'), + clientSecret, + deviceId, + grantType = requiredArg('grantType'), + endpointDomain = 'reddit.com' + }) { + return this.prototype.credentialedClientRequest.call({ + clientId, + clientSecret, + // Use `this.prototype.rawRequest` function to allow for custom `rawRequest` method usage in subclasses. + rawRequest: this.prototype.rawRequest + }, { + method: 'post', + baseUrl: `https://www.${endpointDomain}/`, + uri: 'api/v1/access_token', + form: {grant_type: grantType, device_id: deviceId} + }).then(response => { + if (response.error) { + throw new Error(`API Error: ${response.error}`); + } + // Use `new this` instead of `new snoowrap` to ensure that subclass instances can be returned + const requester = new this({userAgent, clientId, clientSecret, ...response}); + requester.config({endpointDomain}); + return requester; + }); + } _newObject (objectType, content, _hasFetched = false) { return Array.isArray(content) ? content : new snoowrap.objects[objectType](content, this, _hasFetched); } diff --git a/test/snoowrap.spec.js b/test/snoowrap.spec.js index cdaf5883..729aed4f 100644 --- a/test/snoowrap.spec.js +++ b/test/snoowrap.spec.js @@ -23,7 +23,8 @@ describe('snoowrap', function () { refresh_token: process.env.SNOOWRAP_REFRESH_TOKEN, username: process.env.SNOOWRAP_USERNAME, password: process.env.SNOOWRAP_PASSWORD, - redirect_uri: process.env.SNOOWRAP_REDIRECT_URI + redirect_uri: process.env.SNOOWRAP_REDIRECT_URI, + installed_app_client_id: process.env.SNOOWRAP_INSTALLED_APP_CLIENT_ID } : require('../oauth_info.json'); r = new snoowrap({ @@ -219,6 +220,45 @@ describe('snoowrap', function () { }); }); + describe('.fromApplicationOnlyAuth', () => { + it('throws a TypeError if no userAgent is provided in node', function () { + if (isBrowser) { + return this.skip(); + } + expect(() => { + snoowrap.fromApplicationOnlyAuth({clientId: 'bar', deviceId: 'baz'}); + }).to.throw(TypeError); + }); + it('throws a TypeError if no clientId is provided', () => { + expect(() => { + snoowrap.fromApplicationOnlyAuth({userAgent: 'bar'}); + }).to.throw(TypeError); + }); + it('throws a TypeError if no grantType is provided', () => { + expect(() => { + snoowrap.fromApplicationOnlyAuth({userAgent: 'bar'}); + }).to.throw(TypeError); + }); + it('returns a snoowrap instance for a valid installed requester', async () => { + const newRequester = await snoowrap.fromApplicationOnlyAuth({ + userAgent: oauthInfo.user_agent, + grantType: snoowrap.grantType.INSTALLED_CLIENT, + clientId: oauthInfo.installed_app_client_id, + deviceId: oauthInfo.device_id + }); + expect(await newRequester.getHot('redditdev', {limit: 1})).to.have.lengthOf(1); + }); + it('returns a snoowrap instance for a valid client requester', async () => { + const newRequester = await snoowrap.fromApplicationOnlyAuth({ + userAgent: oauthInfo.user_agent, + grantType: snoowrap.grantType.CLIENT_CREDENTIALS, + clientId: oauthInfo.client_id, + clientSecret: oauthInfo.client_secret + }); + expect(await newRequester.getHot('redditdev', {limit: 1})).to.have.lengthOf(1); + }); + }); + describe('internal helpers', () => { it('can deeply clone a RedditContent instance', async () => { const some_user = r.getUser('someone');