From 421c67aa80cf97a1e51e564b0ddfebb9dc7aadcc Mon Sep 17 00:00:00 2001 From: Sarthak Saini Date: Mon, 5 Apr 2021 06:22:02 +0530 Subject: [PATCH 1/2] feat: added support for schoology oAuth --- package.json | 1 + src/index.js | 3 +- src/oauth.js | 205 +++++++++++++++++++++++++++++++++++++++++++++++ src/schoology.js | 109 +++++++++++++++++++++++++ 4 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 src/oauth.js create mode 100644 src/schoology.js diff --git a/package.json b/package.json index c00bbce..b4771de 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "devDependencies": { "chai": "4.1.2", + "eslint": "^7.23.0", "hold-it": "^1.0.1" } } diff --git a/src/index.js b/src/index.js index 9ba5d9b..ed26e43 100644 --- a/src/index.js +++ b/src/index.js @@ -3,7 +3,8 @@ const GCL = require('./gcl'); const Edmodo = require('./edmodo'); const LMSError = require('./error'); const Canvas = require('./canvas'); +const Schoology = require('./schoology'); module.exports = { - GCL, LMSError, Edmodo, Canvas + GCL, LMSError, Edmodo, Canvas, Schoology }; diff --git a/src/oauth.js b/src/oauth.js new file mode 100644 index 0000000..25e96a0 --- /dev/null +++ b/src/oauth.js @@ -0,0 +1,205 @@ +// / + +const { URL, URLSearchParams } = require('url'); + +const _ = require('lodash'); +const axios = require('axios').default; +const { v4: uuidV4 } = require('uuid'); + +class OAuth { + constructor( { + consumerKey = '', + consumerSecret = '', + apiBase = '', + authRealm = '', + signatureMethod = 'PLAINTEXT', + nonceLength = 16, + requestToken, + accessToken, + errorHandler = (() => {}) + } ) { + this.consumerKey = consumerKey; + this.consumerSecret = consumerSecret; + this.apiBase = apiBase; + this.authRealm = authRealm; + this.signatureMethod = signatureMethod; + this.nonceLength = nonceLength; + this.requestToken = { + token: requestToken.token || '', + secret: requestToken.secret || '', + expiresAt: requestToken.expiresAt || new Date() + }; + this.accessToken = { + token: accessToken.token || '', + secret: accessToken.secret || '', + expiresAt: accessToken.expiresAt || new Date() + }; + this.errorHandler = errorHandler; + } + + static getTimeStamp () { + return parseInt(new Date().getTime()/1000, 10); + } + + static getNonce( nonceLength ) { + return uuidV4().replace(/-/g, '').slice(-16); + } + + static makeURL( apiURL, path, query ) { + const url = new URL(apiURL); + + if (path) { + url.pathname = path; + } + + url.search = new URLSearchParams(query); + return url.toString(); + } + + static post(host, path, query, data, headers = {}) { + const url = OAuth.makeURL(host, path, query); + + return axios.post(url, data, { + headers, + responseType: 'json', + }); + } + + static get(host, path, query, headers) { + const url = OAuth.makeURL(host, path, query); + + return axios.get(url, { + headers, + responseType: 'json', + }); + } + + static jsonifyResponseString(responseString) { + const strSplits = responseString.split( '&' ); + + return _.reduce( strSplits, ( result, keyValPair ) => { + const splits = keyValPair.split( '=' ); + + result[ splits[0] ] = splits[1]; + return result; + }, {} ); + } + + + async getRequestTokens( apiPath ) { + const oAuthDetails = this.getOAuthDetails(); + const requestedAt = Date.now(); + + try { + const response = await OAuth.get(this.apiBase, apiPath, oAuthDetails); + + const jsonified = OAuth.jsonifyResponseString(response.data); + const ttl = parseInt( jsonified[ 'xoauth_token_ttl' ] ) || 3600; + debugger; + return { + success: true, + response: { + token: jsonified[ 'oauth_token' ], + secret: jsonified[ 'oauth_token_secret' ], + expiresAt: new Date( requestedAt + ( ttl * 1000 ) ) + } + } + } catch ( error ) { + debugger; + this.errorHandler(error); + } + } + + async getAccessTokens( apiPath ) { + const oAuthDetails = this.getOAuthDetails(); + + try { + const response = await OAuth.get(this.apiBase, apiPath, oAuthDetails); + debugger; + const jsonified = OAuth.jsonifyResponseString(response.data); + this.accessToken = { + token: jsonified[ 'oauth_token' ], + secret: jsonified[ 'oauth_token_secret' ], + }; + + return { + success: true, + response: this.accessToken + } + } catch (error) { + debugger; + this.errorHandler(error); + } + } + + /** + * Makes a request, defined by the requestConfig, to the server + * Attempts to refresh the accessToken if server throws a "token expired" error and + * then re-attempts the request + */ + async makeRequest(requestConfig, retries = 0) { + try { + if (_.isEmpty(this.accessToken)) { + debugger; + // this.errorHandler( {} ) + return; + } + + const url = OAuth.makeURL(this.apiBase, requestConfig.url, requestConfig.query || {}); + const oAuthHeader = this.getOAuthHeader(); + + debugger; + + const response = await axios({ + ...requestConfig, + url, + headers: { + ...oAuthHeader, + ...requestConfig.headers + }, + }); + debugger; + const { data, status } = response; + + return { data, status }; + } catch (err) { + debugger; + } + } + + getOAuthDetails() { + const timestamp = OAuth.getTimeStamp(); + const nonce = OAuth.getNonce( this.nonceLength ); + const token = this.accessToken.token || this.requestToken.token || ''; + const secret = this.accessToken.secret || this.requestToken.secret || ''; + const oAuthConfig = { + 'oauth_version': '1.0', + 'oauth_nonce': nonce, + 'oauth_timestamp': timestamp, + 'oauth_signature_method': 'PLAINTEXT' + }; + + if ( !_.isEmpty( this.consumerKey ) ) { + oAuthConfig[ 'oauth_consumer_key' ] = this.consumerKey; + } + + if ( !_.isEmpty( token ) ) { + oAuthConfig[ 'oauth_token' ] = token; + } + + if ( !_.isEmpty( this.consumerSecret ) || !_.isEmpty(secret) ) { + oAuthConfig[ 'oauth_signature' ] = `${this.consumerSecret}&${secret}`; + } + + return oAuthConfig; + } + + getOAuthHeader() { + const oAuthDetails = this.getOAuthDetails(); + const headerParts = _.map(oAuthDetails, (value, key) => `${key}=${value}`); + + return { Authorization: `OAuth realm="${this.authRealm}", ${headerParts.join(', ')}` }; + } +} + +module.exports = OAuth; diff --git a/src/schoology.js b/src/schoology.js new file mode 100644 index 0000000..d97cf26 --- /dev/null +++ b/src/schoology.js @@ -0,0 +1,109 @@ + + +const _ = require('lodash'); + +const OAuth = require('./oauth'); +const LMSError = require('./error'); +const { paginatedCollect } = require('./helpers/utils'); + +/** + * @class Canvas + */ +class Schoology { + constructor({ + hostedUrl, + redirectUri, + clientId, + clientSecret, + userId, // mongoId + requestToken = {}, + accessToken = {}, + fxs = {}, + }) { + this.hostedUrl = hostedUrl; + this.redirectUri = redirectUri; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.userId = userId; + this.cacheRequestToken = fxs.cacheRequestToken || (() => {}); + this.getUserAccessToken = fxs.getAccessToken || (() => {}); + this.setUserAccessToken = fxs.setAccessToken || (() => {}); + + this.oAuth = new OAuth( { + consumerKey: this.clientId, + consumerSecret: this.clientSecret, + apiBase: 'https://api.schoology.com', + authRealm: 'Schoology API', + signatureMethod: 'PLAINTEXT', + nonceLength: 16, + requestToken, + accessToken, + handleError: this.handleError + } ); + } + + /** + * Returns a URL used to initiate the authorization process with Canvas and fetch + * the authorization code + */ + async getAuthorizationURL(options = {}) { + try { + const result = await this.oAuth.getRequestTokens('/v1/oauth/request_token'); + const tokenData = result.response; + + await this.cacheRequestToken(tokenData); + + return OAuth.makeURL( this.hostedUrl, '/oauth/authorize', { + 'oauth_token': tokenData.token, + 'oauth_callback': this.redirectUri, + } ) + } catch ( error ) { + this.handleError(err) + } + } + + async getAccessTokens() { + try { + const result = await this.oAuth.getAccessTokens('/v1/oauth/access_token'); + const tokenData = result.response; + + await this.setUserAccessToken( tokenData ); + + return tokenData; + } catch ( error ) { + this.handleError(error) + } + } + + makeRequest(requestConfig) { + return this.oAuth.makeRequest(requestConfig); + } + + /** + * Handles some schoology API errors + */ + handleError(error) { + console.log('\n YOLO SCHOOLOGY error', error) + if (error.response) { + switch (error.response.status) { + default: + throw new LMSError(`An error occured`, 'schoology.UKW', { + message: error.message, + stack: error.stack, + }); + } + + return; + } + + if ( error.type ) { + + } + + throw new LMSError(`An error occured`, 'schoology.UKW', { + message: error.message, + }); + } +} + +module.exports = Schoology; From 81ccac00dbb19821373824430271dba30d37b248 Mon Sep 17 00:00:00 2001 From: Sarthak Saini Date: Mon, 5 Apr 2021 17:55:01 +0530 Subject: [PATCH 2/2] fix: request_token request not working when user already authorized --- src/oauth.js | 26 ++++++++++---------------- src/schoology.js | 5 +++-- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/oauth.js b/src/oauth.js index 25e96a0..0e923d6 100644 --- a/src/oauth.js +++ b/src/oauth.js @@ -87,15 +87,14 @@ class OAuth { async getRequestTokens( apiPath ) { - const oAuthDetails = this.getOAuthDetails(); + const oAuthDetails = this.getOAuthDetails(false); const requestedAt = Date.now(); try { const response = await OAuth.get(this.apiBase, apiPath, oAuthDetails); - const jsonified = OAuth.jsonifyResponseString(response.data); const ttl = parseInt( jsonified[ 'xoauth_token_ttl' ] ) || 3600; - debugger; + return { success: true, response: { @@ -105,7 +104,6 @@ class OAuth { } } } catch ( error ) { - debugger; this.errorHandler(error); } } @@ -115,8 +113,8 @@ class OAuth { try { const response = await OAuth.get(this.apiBase, apiPath, oAuthDetails); - debugger; const jsonified = OAuth.jsonifyResponseString(response.data); + this.accessToken = { token: jsonified[ 'oauth_token' ], secret: jsonified[ 'oauth_token_secret' ], @@ -127,7 +125,6 @@ class OAuth { response: this.accessToken } } catch (error) { - debugger; this.errorHandler(error); } } @@ -140,7 +137,6 @@ class OAuth { async makeRequest(requestConfig, retries = 0) { try { if (_.isEmpty(this.accessToken)) { - debugger; // this.errorHandler( {} ) return; } @@ -148,8 +144,6 @@ class OAuth { const url = OAuth.makeURL(this.apiBase, requestConfig.url, requestConfig.query || {}); const oAuthHeader = this.getOAuthHeader(); - debugger; - const response = await axios({ ...requestConfig, url, @@ -158,16 +152,15 @@ class OAuth { ...requestConfig.headers }, }); - debugger; const { data, status } = response; return { data, status }; - } catch (err) { - debugger; + } catch (error) { + this.errorHandler(error); } } - getOAuthDetails() { + getOAuthDetails( attachAccessToken = true ) { const timestamp = OAuth.getTimeStamp(); const nonce = OAuth.getNonce( this.nonceLength ); const token = this.accessToken.token || this.requestToken.token || ''; @@ -183,14 +176,15 @@ class OAuth { oAuthConfig[ 'oauth_consumer_key' ] = this.consumerKey; } - if ( !_.isEmpty( token ) ) { + if ( attachAccessToken && !_.isEmpty( token ) ) { oAuthConfig[ 'oauth_token' ] = token; } if ( !_.isEmpty( this.consumerSecret ) || !_.isEmpty(secret) ) { - oAuthConfig[ 'oauth_signature' ] = `${this.consumerSecret}&${secret}`; + const secretToUse = attachAccessToken ? secret : ''; + oAuthConfig[ 'oauth_signature' ] = `${this.consumerSecret}&${secretToUse}`; } - + return oAuthConfig; } diff --git a/src/schoology.js b/src/schoology.js index d97cf26..83602b0 100644 --- a/src/schoology.js +++ b/src/schoology.js @@ -58,7 +58,7 @@ class Schoology { 'oauth_callback': this.redirectUri, } ) } catch ( error ) { - this.handleError(err) + this.handleError(error) } } @@ -83,7 +83,8 @@ class Schoology { * Handles some schoology API errors */ handleError(error) { - console.log('\n YOLO SCHOOLOGY error', error) + console.error('\n [Schoology] Error -', error) + if (error.response) { switch (error.response.status) { default: