diff --git a/package-lock.json b/package-lock.json index 081799b3..81645900 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-ses": "^3.515.0", "@discordjs/rest": "^0.5.0", + "@node-oauth/express-oauth-server": "^4.1.1", "@node-saml/node-saml": "^5.0.0", "@pretendonetwork/error-codes": "^1.0.3", "browserify": "^17.0.0", @@ -30,6 +31,7 @@ "mii-js": "github:PretendoNetwork/mii-js#v1.0.4", "mongoose": "^6.4.0", "morgan": "^1.10.0", + "node-cache": "^5.1.2", "nodemailer": "^6.7.5", "stripe": "^9.9.0" }, @@ -1420,6 +1422,41 @@ "sparse-bitfield": "^3.0.3" } }, + "node_modules/@node-oauth/express-oauth-server": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@node-oauth/express-oauth-server/-/express-oauth-server-4.1.1.tgz", + "integrity": "sha512-Ps6UFV7XAfggYvRpveoT3t3h4dluy9wViEgkSU1kBcTOhllpBNIKrlsHTC+hBd1PT/+SC7vzpIXd2uFrmwF8Vg==", + "license": "MIT", + "dependencies": { + "@node-oauth/oauth2-server": "^5.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "express": "*" + } + }, + "node_modules/@node-oauth/formats": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@node-oauth/formats/-/formats-1.0.0.tgz", + "integrity": "sha512-DwSbLtdC8zC5B5gTJkFzJj5s9vr9SGzOgQvV9nH7tUVuMSScg0EswAczhjIapOmH3Y8AyP7C4Jv7b8+QJObWZA==", + "license": "MIT" + }, + "node_modules/@node-oauth/oauth2-server": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@node-oauth/oauth2-server/-/oauth2-server-5.2.0.tgz", + "integrity": "sha512-tbw0aHPk1Pu/HmQlll4unYd+VHwoagbAmUBLys5g6hDh9khcKzTmE77Z0myMG5a66w3Yk3xBwCRPX9a7M+HTqA==", + "license": "MIT", + "dependencies": { + "@node-oauth/formats": "1.0.0", + "basic-auth": "2.0.1", + "type-is": "1.6.18" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@node-saml/node-saml": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.0.0.tgz", @@ -2764,6 +2801,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/clone-response": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", @@ -5049,6 +5095,18 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -7695,6 +7753,29 @@ "sparse-bitfield": "^3.0.3" } }, + "@node-oauth/express-oauth-server": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@node-oauth/express-oauth-server/-/express-oauth-server-4.1.1.tgz", + "integrity": "sha512-Ps6UFV7XAfggYvRpveoT3t3h4dluy9wViEgkSU1kBcTOhllpBNIKrlsHTC+hBd1PT/+SC7vzpIXd2uFrmwF8Vg==", + "requires": { + "@node-oauth/oauth2-server": "^5.2.0" + } + }, + "@node-oauth/formats": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@node-oauth/formats/-/formats-1.0.0.tgz", + "integrity": "sha512-DwSbLtdC8zC5B5gTJkFzJj5s9vr9SGzOgQvV9nH7tUVuMSScg0EswAczhjIapOmH3Y8AyP7C4Jv7b8+QJObWZA==" + }, + "@node-oauth/oauth2-server": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@node-oauth/oauth2-server/-/oauth2-server-5.2.0.tgz", + "integrity": "sha512-tbw0aHPk1Pu/HmQlll4unYd+VHwoagbAmUBLys5g6hDh9khcKzTmE77Z0myMG5a66w3Yk3xBwCRPX9a7M+HTqA==", + "requires": { + "@node-oauth/formats": "1.0.0", + "basic-auth": "2.0.1", + "type-is": "1.6.18" + } + }, "@node-saml/node-saml": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.0.0.tgz", @@ -8780,6 +8861,11 @@ "safe-buffer": "^5.0.1" } }, + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + }, "clone-response": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", @@ -10509,6 +10595,14 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "requires": { + "clone": "2.x" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", diff --git a/package.json b/package.json index 8a161b04..24962e19 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "dependencies": { "@aws-sdk/client-ses": "^3.515.0", "@discordjs/rest": "^0.5.0", + "@node-oauth/express-oauth-server": "^4.1.1", "@node-saml/node-saml": "^5.0.0", "@pretendonetwork/error-codes": "^1.0.3", "browserify": "^17.0.0", @@ -42,6 +43,7 @@ "mii-js": "github:PretendoNetwork/mii-js#v1.0.4", "mongoose": "^6.4.0", "morgan": "^1.10.0", + "node-cache": "^5.1.2", "nodemailer": "^6.7.5", "stripe": "^9.9.0" }, diff --git a/src/routes/oauth.js b/src/routes/oauth.js new file mode 100644 index 00000000..adef90c9 --- /dev/null +++ b/src/routes/oauth.js @@ -0,0 +1,141 @@ +const express = require('express'); +const OAuthServer = require('@node-oauth/express-oauth-server'); +const NodeCache = require('node-cache'); + +const config = require('../../config.json'); +const util = require('../util'); + +// * osTicket uses the authorization code grant type and requests a new +// * authorization code and access token for each sign-in +const authorizationCodeLifetime = 2 * 60; +const accessTokenLifetime = 2 * 60; +const authorizationCodeCache = new NodeCache({ + stdTTL: authorizationCodeLifetime, + checkperiod: authorizationCodeLifetime / 2 +}); +const accessTokenCache = new NodeCache({ + stdTTL: accessTokenLifetime, + checkperiod: accessTokenLifetime / 2 +}); + +const router = new express.Router(); + +async function getClient(clientId, clientSecret) { + console.log(`getClient(clientId: ${JSON.stringify(clientId, null, 2)}, clientSecret: ${JSON.stringify(clientSecret, null, 2)})`); + if (clientId === config.osticket.oauth.client_id && + (!clientSecret || clientSecret === config.osticket.oauth.client_secret)) { + return { + id: clientId, + redirectUris: [config.osticket.oauth.redirect_uri], + grants: ['authorization_code'] + }; + } else { + return null; + } +} + +async function saveAuthorizationCode(code, client, user) { + console.log(`saveAuthorizationCode(code: ${JSON.stringify(code, null, 2)}, client: ${JSON.stringify(client, null, 2)}, user: ${JSON.stringify(user, null, 2)})`); + const authorizationCodeData = { + authorizationCode: code.authorizationCode, + expiresAt: new Date(Date.now() + authorizationCodeLifetime * 1000), + redirectUri: code.redirectUri, + scope: code.scope, + client: client, + user: user + }; + + authorizationCodeCache.set(code.authorizationCode, authorizationCodeData); + + return authorizationCodeData; +} + +async function getAuthorizationCode(authorizationCode) { + console.log(`getAuthorizationCode(authorizationCode: ${JSON.stringify(authorizationCode, null, 2)})`); + const authorizationCodeData = authorizationCodeCache.get(authorizationCode); + + if (authorizationCodeData) { + return authorizationCodeData; + } else { + return null; + } +} +async function revokeAuthorizationCode(code) { + console.log(`revokeAuthorizationCode(code: ${JSON.stringify(code, null, 2)})`); + const deletedKeys = authorizationCodeCache.del(code.authorizationCode); + + return deletedKeys > 0; +} + +async function saveToken(token, client, user) { + console.log(`saveToken(token: ${JSON.stringify(token, null, 2)}, client: ${JSON.stringify(client, null, 2)}, user: ${JSON.stringify(user, null, 2)})`); + // * osTicket doesn't use refresh tokens + const accessTokenData = { + accessToken: token.accessToken, + accessTokenExpiresAt: new Date(Date.now() + accessTokenLifetime * 1000), + scope: token.scope, + client: client, + user: user + }; + + accessTokenCache.set(token.accessToken, accessTokenData); + + return accessTokenData; +} + + +async function getAccessToken(accessToken) { + console.log(`getAccessToken(accessToken: ${JSON.stringify(accessToken, null, 2)})`); + const accessTokenData = accessTokenCache.get(accessToken); + + if (accessTokenData) { + return accessTokenData; + } else { + return null; + } +} + +async function handleAuthentication(request, response) { + console.log('handleAuthentication(request, response)'); + if (request.cookies.access_token && request.cookies.refresh_token) { + try { + const accountData = await util.getUserAccountData(request, response); + + // * Use the same fake email address as Discourse SSO for forwarding + return { + username: accountData.username, + email: `${accountData.pid}@forward.local`, //TODO: Choose a domain + }; + } catch (error) { + console.log(error); + response.cookie('error_message', error.message, { domain: '.pretendo.network' }); + return false; + } + } else { + return false; + } +} + +const oauth = new OAuthServer({ + model: { + getClient, + saveAuthorizationCode, + getAuthorizationCode, + revokeAuthorizationCode, + saveToken, + getAccessToken + } +}); + + +router.use('/authorize', oauth.authorize({ + authenticateHandler: { + handle: handleAuthentication + } +})); +router.use('/token', oauth.token()); +router.get('/details', oauth.authenticate(), (request, response) => { + response.json(response.locals.oauth.token.user); +}); + +module.exports = router; diff --git a/src/server.js b/src/server.js index 04c714b7..76e0cfaf 100644 --- a/src/server.js +++ b/src/server.js @@ -99,6 +99,7 @@ const routes = { docs: require('./routes/docs'), progress: require('./routes/progress'), account: require('./routes/account'), + oauth: require('./routes/oauth'), blog: require('./routes/blog'), localization: require('./routes/localization'), aprilfools: require('./routes/aprilfools') @@ -109,6 +110,7 @@ app.use('/faq', routes.faq); app.use('/docs', routes.docs); app.use('/progress', routes.progress); app.use('/account', routes.account); +app.use('/oauth', routes.oauth); app.use('/localization', routes.localization); app.use('/blog', routes.blog); app.use('/nso-legacy-pack', routes.aprilfools);