diff --git a/CHANGELOG.md b/CHANGELOG.md index c919be46ff..ed6fc7b354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ way to update this template, but currently, we follow a pattern: ## Upcoming version 2020-XX-XX +- [add] Add helper functions for setting up your own OIDC authentication and using FTW server as + proxy when needed. [#1383](https://github.com/sharetribe/ftw-daily/pull/1383) + ## [v7.1.0] 2020-12-15 - [change] Handle entity update with sparse attributes. diff --git a/package.json b/package.json index b60dc1f301..a362c09473 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "full-icu": "^1.3.1", "helmet": "^4.0.0", "intl-pluralrules": "^1.0.3", + "jose": "3.1.0", "lodash": "^4.17.19", "mapbox-gl-multitouch": "^1.0.3", "moment": "^2.22.2", diff --git a/server/api-util/idToken.js b/server/api-util/idToken.js new file mode 100644 index 0000000000..9ee87664db --- /dev/null +++ b/server/api-util/idToken.js @@ -0,0 +1,100 @@ +const crypto = require('crypto'); +const { default: fromKeyLike } = require('jose/jwk/from_key_like'); +const { default: SignJWT } = require('jose/jwt/sign'); + +const radix = 10; +const PORT = parseInt(process.env.REACT_APP_DEV_API_SERVER_PORT, radix); +const rootUrl = process.env.REACT_APP_CANONICAL_ROOT_URL; +const useDevApiServer = process.env.NODE_ENV === 'development' && !!PORT; + +const issuerUrl = useDevApiServer ? `http://localhost:${PORT}/api` : `${rootUrl}/api`; + +/** + * Gets user information and creates the signed jwt for id token. + * + * @param {string} idpClientId the client id of the idp provider in Console + * @param {Object} options signing options containing signingAlg and required key information + * @param {Object} user user information containing at least firstName, lastName, email and emailVerified + * + * @return {Promise} idToken + */ +exports.createIdToken = (idpClientId, user, options) => { + if (!idpClientId) { + console.error('Missing idp client id!'); + return; + } + if (!user) { + console.error('Missing user information!'); + return; + } + + const signingAlg = options.signingAlg; + + // Currently Flex supports only RS256 signing algorithm. + if (signingAlg !== 'RS256') { + console.error(`${signingAlg} is not currently supported!`); + return; + } + + const { rsaPrivateKey, keyId } = options; + + if (!rsaPrivateKey) { + console.error('Missing RSA private key!'); + return; + } + + // We use jose library which requires the RSA key + // to be KeyLike format: + // https://github.com/panva/jose/blob/master/docs/types/_types_d_.keylike.md + const privateKey = crypto.createPrivateKey(rsaPrivateKey); + + const { userId, firstName, lastName, email, emailVerified } = user; + + const jwt = new SignJWT({ + given_name: firstName, + family_name: lastName, + email: email, + email_verified: emailVerified, + }) + .setProtectedHeader({ alg: signingAlg, kid: keyId }) + .setIssuedAt() + .setIssuer(issuerUrl) + .setSubject(userId) + .setAudience(idpClientId) + .setExpirationTime('1h') + .sign(privateKey); + + return jwt; +}; + +// Serves the discovery document in json format +// this document is expected to be found from +// api/.well-known/openid-configuration endpoint +exports.openIdConfiguration = (req, res) => { + res.json({ + issuer: issuerUrl, + jwks_uri: `${issuerUrl}/.well-known/jwks.json`, + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + }); +}; + +/** + * @param {String} signingAlg signing algorithm, currently only RS256 is supported + * @param {Array} list containing keys to be served in json endpoint + * + * // Serves the RSA public key as JWK +// this document is expected to be found from +// api/.well-known/jwks.json endpoint as stated in discovery document + */ +exports.jwksUri = keys => (req, res) => { + const jwkKeys = keys.map(key => { + return fromKeyLike(crypto.createPublicKey(key.rsaPublicKey)).then(res => { + return { alg: key.alg, kid: key.keyId, ...res }; + }); + }); + + Promise.all(jwkKeys).then(resolvedJwkKeys => { + res.json({ keys: resolvedJwkKeys }); + }); +}; diff --git a/server/api/auth/loginWithIdp.js b/server/api/auth/loginWithIdp.js index b6a6c18871..1ad2086213 100644 --- a/server/api/auth/loginWithIdp.js +++ b/server/api/auth/loginWithIdp.js @@ -20,7 +20,7 @@ const httpsAgent = new https.Agent({ keepAlive: true }); const baseUrl = BASE_URL ? { baseUrl: BASE_URL } : {}; -module.exports = (err, user, req, res, clientID, idpId) => { +module.exports = (err, user, req, res, idpClientId, idpId) => { if (err) { log.error(err, 'fetching-user-data-from-idp-failed'); @@ -87,7 +87,7 @@ module.exports = (err, user, req, res, clientID, idpId) => { return sdk .loginWithIdp({ idpId, - idpClientId: clientID, + idpClientId, idpToken: user.idpToken, }) .then(response => { diff --git a/server/apiRouter.js b/server/apiRouter.js index c9a4b887eb..4618883639 100644 --- a/server/apiRouter.js +++ b/server/apiRouter.js @@ -21,6 +21,8 @@ const createUserWithIdp = require('./api/auth/createUserWithIdp'); const { authenticateFacebook, authenticateFacebookCallback } = require('./api/auth/facebook'); const { authenticateGoogle, authenticateGoogleCallback } = require('./api/auth/google'); +const { openIdConfiguration, jwksUri } = require('./api-util/idToken'); + const router = express.Router(); // ================ API router middleware: ================ // @@ -80,4 +82,20 @@ router.get('/auth/google', authenticateGoogle); // loginWithIdp endpoint in Flex API to authenticate user to Flex router.get('/auth/google/callback', authenticateGoogleCallback); +// These endpoints will be used if you FTW as OIDC proxy +// https://www.sharetribe.com/docs/cookbook-social-logins-and-sso/setup-open-id-connect-proxy/ +// All identity providers should provide an OpenID Connect discovery document: +// https://openid.net/specs/openid-connect-discovery-1_0.html +// And in the discovery document we need to define jwks_uri attribute +// which denotes the location of public signing keys + +const rsaSecretKey = process.env.RSA_SECRET_KEY; +const rsaPublicKey = process.env.RSA_PUBLIC_KEY; +const keyId = process.env.KEY_ID; + +if (rsaPublicKey && rsaSecretKey) { + router.get('/.well-known/openid-configuration', openIdConfiguration); + router.get('/.well-known/jwks.json', jwksUri([{ alg: 'RS256', rsaPublicKey, keyId }])); +} + module.exports = router; diff --git a/yarn.lock b/yarn.lock index d826caab6d..8cc502c712 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7634,6 +7634,11 @@ jest@26.6.0: import-local "^3.0.2" jest-cli "^26.6.0" +jose@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-3.1.0.tgz#31a48b76a2e0f5da4e9a1be261e430e0bfaa4a43" + integrity sha512-TLZFF0qAPlG0GZDrPw9HAiWKJcDuUbOp1WdjuS5cJ0reTzd1zS718zrUPOt7BIOViTA6PZpEnMt5cMQttJq3QA== + js-cookie@^2.1.3: version "2.2.1" resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"