diff --git a/index.js b/index.js index fdf39e0..81b9630 100644 --- a/index.js +++ b/index.js @@ -71,8 +71,10 @@ assert(twitchAppOAuthRedirectUrl.length > 0, "TWITCH_APP_OAUTH_REDIRECT_URL"); assert.strictEqual(typeof twitchUserName, "string", "TWITCH_USER_NAME"); assert(twitchUserName.length > 0, "TWITCH_USER_NAME"); -const twitchUserOAuthTokenUri = "https://api.twitch.tv/kraken/oauth2/token"; -const twitchAppOAuthAuthorizationUri = "https://api.twitch.tv/kraken/oauth2/authorize"; +const twitchOAuthTokenUri = "https://api.twitch.tv/kraken/oauth2/token"; +const twitchOAuthTokenRevocationUri = "https://api.twitch.tv/kraken/oauth2/revoke"; +const twitchOAuthAuthorizationUri = "https://api.twitch.tv/kraken/oauth2/authorize"; +const twitchOAuthTokenVerificationUri = "https://api.twitch.tv/kraken"; const twitchUsersDataUri = "https://api.twitch.tv/helix/users"; const twitchPubSubWebSocketUri = "wss://pubsub-edge.twitch.tv/"; const twitchIrcWebSocketUri = "wss://irc-ws.chat.twitch.tv:443/"; @@ -81,8 +83,6 @@ const twitchAppScopes = [ "channel_feed_read", ]; const twitchAppTokenRefreshInterval = 45 * 60 * 1000; -const twitchAppTokenUri = "https://api.twitch.tv/kraken/oauth2/token"; -const twitchAppTokenRevocationUri = "https://api.twitch.tv/kraken/oauth2/revoke"; // NOTE: assuming that the user only joins their own channel, with a "#" prefix. const twitchChannelName = `#${twitchUserName}`; @@ -106,8 +106,8 @@ const rootLogger = new PinoLogger(rootPinoLogger); const indexLogger = rootLogger.child("index"); const shutdownManager = new ShutdownManager(rootLogger); const databaseConnection = new DatabaseConnection(rootLogger, databaseUri); -const twitchPollingApplicationTokenConnection = new TwitchPollingApplicationTokenConnection(rootLogger, twitchAppClientId, twitchAppClientSecret, twitchAppScopes, twitchAppTokenRefreshInterval, false, twitchAppTokenUri, "post"); -const twitchApplicationTokenManager = new TwitchApplicationTokenManager(rootLogger, twitchPollingApplicationTokenConnection, twitchAppClientId, twitchAppTokenRevocationUri); +const twitchPollingApplicationTokenConnection = new TwitchPollingApplicationTokenConnection(rootLogger, twitchAppClientId, twitchAppClientSecret, twitchAppScopes, twitchAppTokenRefreshInterval, false, twitchOAuthTokenUri, "post"); +const twitchApplicationTokenManager = new TwitchApplicationTokenManager(rootLogger, twitchPollingApplicationTokenConnection, twitchAppClientId, twitchOAuthTokenRevocationUri); const twitchCSRFHelper = new TwitchCSRFHelper(rootLogger); Promise.resolve() @@ -154,25 +154,61 @@ Promise.resolve() const twitchApplicationAccessTokenProvider = () => twitchApplicationTokenManager.getOrWait(); - const twitchUserHelper = new TwitchUserHelper(rootLogger, twitchCSRFHelper, UserRepository, twitchAppOAuthAuthorizationUri, twitchAppOAuthRedirectUrl, twitchUserOAuthTokenUri, twitchUsersDataUri, twitchAppClientId, twitchAppClientSecret, twitchApplicationAccessTokenProvider); - - return Promise.all([ + const twitchUserHelper = new TwitchUserHelper( + rootLogger, + twitchCSRFHelper, + UserRepository, + twitchOAuthTokenRevocationUri, + twitchOAuthAuthorizationUri, + twitchAppOAuthRedirectUrl, + twitchOAuthTokenUri, + twitchOAuthTokenVerificationUri, + twitchUsersDataUri, + twitchAppClientId, + twitchAppClientSecret, + twitchApplicationAccessTokenProvider + ); + + const userTokenManager = new TwitchUserTokenManager(rootLogger, twitchOAuthTokenUri, twitchOAuthTokenRevocationUri, twitchAppClientId, twitchAppClientSecret); + + const twitchUserAccessTokenProvider = () => { // TODO: replace with an https server. // TODO: revoke user token? - twitchUserHelper.getUserToken(twitchUserName), + return twitchUserHelper.getUserToken(twitchUserName) + .then((twitchUserToken) => { + return twitchUserHelper.isTokenValid(twitchUserToken) + .then((isValid) => { + if (isValid) { + return twitchUserToken; + } + + return twitchUserHelper.forgetUserToken(twitchUserName) + // TODO: user-wrappers with username for the generic token functions? + .then(() => twitchUserHelper.revokeToken(twitchUserToken)) + .then(() => twitchUserHelper.getUserToken(twitchUserName)); + }); + }) + // TODO: improve getting/refreshing the token to have a creation time, not just expiry time. + .then((twitchUserToken) => userTokenManager.get(twitchUserToken)) + // TODO: don't store the token here, but in the userTokenManager, or in the twitchUserHelper? + .tap((refreshedToken) => twitchUserHelper.storeUserToken(twitchUserName, refreshedToken)) + .then((refreshedToken) => refreshedToken.access_token); + }; + + return Promise.all([ twitchUserHelper.getUserIdByUserName(twitchUserName), + // TODO: move out of Promise.all? + twitchUserAccessTokenProvider(), ]) - .then(([twitchUserToken, twitchUserId]) => { + .then(( + [ + twitchUserId, + /* eslint-disable no-unused-vars */twitchUserToken, /* eslint-enable no-unused-vars */ + ] + ) => { // TODO: use twitchUserIdProvider instead of twitchUserId. // const twitchUserIdProvider = () => Promise.resolve(twitchUserId); - const userTokenManager = new TwitchUserTokenManager(rootLogger, twitchUserOAuthTokenUri, twitchAppTokenRevocationUri, twitchAppClientId, twitchAppClientSecret); - - const twitchUserAccessTokenProvider = () => userTokenManager.get(twitchUserToken) - // TODO: don't store the token here, but in the userTokenManager, or in the twitchUserHelper? - .tap((refreshedToken) => twitchUserHelper.storeUserToken(twitchUserName, refreshedToken)) - .then((refreshedToken) => refreshedToken.access_token); - const followingPollingUri = `https://api.twitch.tv/kraken/channels/${twitchUserId}/follows?limit=${followingPollingLimit}`; const twitchPubSubConnection = new TwitchPubSubConnection(rootLogger, twitchPubSubWebSocketUri); diff --git a/src/twitch/authentication/user-token-manager.js b/src/twitch/authentication/user-token-manager.js index c48ef7b..b8c6baa 100644 --- a/src/twitch/authentication/user-token-manager.js +++ b/src/twitch/authentication/user-token-manager.js @@ -52,6 +52,7 @@ export default class UserTokenManager { assert.strictEqual(typeof tokenToRefresh, "object"); return Promise.try(() => { + // TODO: improve getting/refreshing the token to have a creation time, not just expiry time. const refreshTokenWithClientIdAndSecret = { access_token: tokenToRefresh.access_token, refresh_token: tokenToRefresh.refresh_token, diff --git a/src/twitch/helper/user-helper.js b/src/twitch/helper/user-helper.js index 3403d71..de6b0a5 100644 --- a/src/twitch/helper/user-helper.js +++ b/src/twitch/helper/user-helper.js @@ -27,20 +27,39 @@ const axios = require("axios"); const qs = require("qs"); export default class UserHelper { - constructor(logger, csrfHelper, UserRepository, appOAuthAuthorizationUri, appOAuthRedirectUrl, userOAuthTokenUri, usersDataUri, appClientId, appClientSecret, applicationAccessTokenProvider) { - assert.strictEqual(arguments.length, 10); + constructor( + logger, + csrfHelper, + UserRepository, + oauthTokenRevocationUri, + oauthAuthorizationUri, + appOAuthRedirectUrl, + oauthTokenUri, + oauthTokenVerificationUri, + usersDataUri, + appClientId, + appClientSecret, + applicationAccessTokenProvider + ) { + assert.strictEqual(arguments.length, 12); assert.strictEqual(typeof logger, "object"); assert.strictEqual(typeof csrfHelper, "object"); assert.strictEqual(typeof UserRepository, "function"); - assert.strictEqual(typeof appOAuthAuthorizationUri, "string"); - assert(appOAuthAuthorizationUri.length > 0); - assert(appOAuthAuthorizationUri.startsWith("https://")); + assert.strictEqual(typeof oauthTokenRevocationUri, "string"); + assert(oauthTokenRevocationUri.length > 0); + assert(oauthTokenRevocationUri.startsWith("https://")); + assert.strictEqual(typeof oauthAuthorizationUri, "string"); + assert(oauthAuthorizationUri.length > 0); + assert(oauthAuthorizationUri.startsWith("https://")); assert.strictEqual(typeof appOAuthRedirectUrl, "string"); assert(appOAuthRedirectUrl.length > 0); assert(appOAuthRedirectUrl.startsWith("https://")); - assert.strictEqual(typeof userOAuthTokenUri, "string"); - assert(userOAuthTokenUri.length > 0); - assert(userOAuthTokenUri.startsWith("https://")); + assert.strictEqual(typeof oauthTokenUri, "string"); + assert(oauthTokenUri.length > 0); + assert(oauthTokenUri.startsWith("https://")); + assert.strictEqual(typeof oauthTokenVerificationUri, "string"); + assert(oauthTokenVerificationUri.length > 0); + assert(oauthTokenVerificationUri.startsWith("https://")); assert.strictEqual(typeof usersDataUri, "string"); assert(usersDataUri.length > 0); assert(usersDataUri.startsWith("https://")); @@ -53,10 +72,12 @@ export default class UserHelper { this._logger = logger.child("UserHelper"); this._csrfHelper = csrfHelper; this._UserRepository = UserRepository; - this._appOAuthAuthorizationUri = appOAuthAuthorizationUri; - this._usersDataUri = usersDataUri; + this._oauthTokenRevocationUri = oauthTokenRevocationUri; + this._oauthAuthorizationUri = oauthAuthorizationUri; this._appOAuthRedirectUrl = appOAuthRedirectUrl; - this._userOAuthTokenUri = userOAuthTokenUri; + this._oauthTokenUri = oauthTokenUri; + this._oauthTokenVerificationUri = oauthTokenVerificationUri; + this._usersDataUri = usersDataUri; this._appClientId = appClientId; this._appClientSecret = appClientSecret; this._applicationAccessTokenProvider = applicationAccessTokenProvider; @@ -164,7 +185,7 @@ export default class UserHelper { const serializedParams = this._twitchQuerystringSerializer(params); - const url = `${this._appOAuthAuthorizationUri}?${serializedParams}`; + const url = `${this._oauthAuthorizationUri}?${serializedParams}`; return url; }); @@ -296,7 +317,7 @@ export default class UserHelper { const serializedData = this._twitchQuerystringSerializer(data); // TODO: use an https class. - return Promise.resolve(axios.post(this._userOAuthTokenUri, serializedData)) + return Promise.resolve(axios.post(this._oauthTokenUri, serializedData)) // NOTE: axios response data. .get("data"); }) @@ -336,10 +357,11 @@ export default class UserHelper { }); } - storeUserToken(username, token) { + _writeUserToken(username, token) { assert.strictEqual(arguments.length, 2); assert.strictEqual(typeof username, "string"); assert(username.length > 0); + // NOTE: token can be null, to "forget" it. assert.strictEqual(typeof token, "object"); return Promise.resolve() @@ -360,11 +382,37 @@ export default class UserHelper { } )); }) + .tap((userAfterStoring) => { + this._logger.trace(userAfterStoring, "_writeUserToken"); + }); + } + + storeUserToken(username, token) { + assert.strictEqual(arguments.length, 2); + assert.strictEqual(typeof username, "string"); + assert(username.length > 0); + assert(token !== null); + assert.strictEqual(typeof token, "object"); + + return Promise.resolve() + .then(() => this._writeUserToken(username, token)) .tap((userAfterStoring) => { this._logger.trace(userAfterStoring, "storeUserToken"); }); } + forgetUserToken(username) { + assert.strictEqual(arguments.length, 1); + assert.strictEqual(typeof username, "string"); + assert(username.length > 0); + + return Promise.resolve() + .then(() => this._writeUserToken(username, null)) + .tap((userAfterStoring) => { + this._logger.trace(userAfterStoring, "forgetUserToken"); + }); + } + getUserToken(username) { assert.strictEqual(arguments.length, 1); assert.strictEqual(typeof username, "string"); @@ -384,4 +432,101 @@ export default class UserHelper { this._logger.trace(token, "getUserToken"); }); } + + _getTokenValidation(token) { + assert.strictEqual(arguments.length, 1); + assert.strictEqual(typeof token, "object"); + assert.strictEqual(typeof token.access_token, "string"); + assert(token.access_token.length > 0); + + // TODO: move/refactor/reuse function for application access tokens? + // TODO: use as a way to get username/userid/scopes from a user access token. + // https://dev.twitch.tv/docs/v5#root-url + // const sampleResponse = { + // "token": { + // "authorization": { + // "created_at": "2016-12-14T15:51:16Z", + // "scopes": [ + // "user_read", + // ], + // "updated_at": "2016-12-14T15:51:16Z", + // }, + // "client_id": "uo6dggojyb8d6soh92zknwmi5ej1q2", + // "user_id": "44322889", + // "user_name": "dallas", + // "valid": true, + // }, + // }; + + return Promise.try(() => { + const userAccessToken = token.access_token; + + // TODO: use an https class. + return Promise.resolve(axios.get( + this._oauthTokenVerificationUri, + { + headers: { + Accept: "application/vnd.twitchtv.v5+json", + "Client-ID": this._appClientId, + Authorization: `OAuth ${userAccessToken}`, + }, + } + )) + // NOTE: axios response data. + .get("data") + // NOTE: twitch response data. + .get("token") + .tap((validatedToken) => { + this._logger.trace(token, validatedToken, "_getTokenValidation"); + }); + }); + } + + revokeToken(token) { + assert.strictEqual(arguments.length, 1); + assert.strictEqual(typeof token, "object"); + + // TODO: move/refactor/reuse function for application access tokens? + // TODO: use as a way to get username/userid/scopes from a user access token. + // https://dev.twitch.tv/docs/authentication#revoking-access-tokens + + return Promise.try(() => { + const userAccessToken = token.access_token; + + const params = { + client_id: this._appClientId, + token: userAccessToken, + }; + + // TODO: use an https class. + return Promise.resolve(axios.post( + this._oauthTokenRevocationUri, + { + paramsSerializer: this._twitchQuerystringSerializer, + params: params, + } + )) + // NOTE: axios response data. + .get("data") + // NOTE: twitch response data. + .get("token") + .tap((validatedToken) => { + this._logger.trace(token, validatedToken, "revokeToken"); + }); + }); + } + + isTokenValid(token) { + assert.strictEqual(arguments.length, 1); + assert.strictEqual(typeof token, "object"); + + return Promise.try(() => { + return this._getTokenValidation(token) + // NOTE: twitch response data. + .get("valid") + .tap((valid) => { + this._logger.trace(token, valid, "isTokenValid"); + }); + }); + } }