@coko/server
Version:
Reusable server for use by Coko's projects
226 lines (187 loc) • 6.09 kB
JavaScript
const axios = require('axios')
const config = require('config')
const moment = require('moment')
const logger = require('../../logger')
const subscriptionManager = require('../../graphql/pubsub')
const { getExpirationTime, foreverDate } = require('../../utils/time')
const { jobManager, defaultJobQueueNames } = require('../../jobManager')
const { getUser } = require('../user/user.controller')
const Identity = require('./identity.model')
const {
subscriptions: { USER_UPDATED },
} = require('../user/constants')
const {
labels: { IDENTITY_CONTROLLER },
} = require('./constants')
const envUtils = require('../../utils/env')
const getUserIdentities = async userId => {
try {
return Identity.find({ userId })
} catch (e) {
throw new Error(e)
}
}
const getDefaultIdentity = async userId => {
try {
return Identity.findOne({
userId,
isDefault: true,
})
} catch (e) {
throw new Error(e)
}
}
const hasValidRefreshToken = identity => {
const { oauthRefreshTokenExpiration, oauthRefreshToken } = identity
const UTCNowTimestamp = moment().utc().toDate().getTime()
return (
!!oauthRefreshToken &&
!!oauthRefreshTokenExpiration &&
oauthRefreshTokenExpiration.getTime() > UTCNowTimestamp
)
}
/**
* Authorise user OAuth.
* Save OAuth access and refresh tokens.
* Trigger subscription indicating the identity has changed.
*/
const createOAuthIdentity = async (userId, provider, sessionState, code) => {
// Throw error if unable to acquire and then store authorisation
try {
let identity = await Identity.findOne({ userId, provider })
if (identity && hasValidRefreshToken(identity)) {
return identity
}
const { ...authData } = await authorizeOAuth(provider, sessionState, code)
const {
email,
given_name: givenNames,
family_name: surname,
sub: providerUserId,
} = JSON.parse(
Buffer.from(authData.oauthAccessToken.split('.')[1], 'base64').toString(),
)
if (!identity) {
identity = await Identity.insert({
email,
provider,
userId,
profileData: {
givenNames,
surname,
providerUserId,
},
...authData,
})
} else {
identity = await Identity.patchAndFetchById(identity.id, { ...authData })
}
const { oauthRefreshTokenExpiration } = authData
if (oauthRefreshTokenExpiration.getTime() !== foreverDate.getTime()) {
const expiresIn = (oauthRefreshTokenExpiration - moment().utc()) / 1000
await jobManager.sendToQueue(
defaultJobQueueNames.REFRESH_TOKEN_EXPIRED,
{ userId, providerLabel: provider },
{ startAfter: expiresIn },
)
}
return identity
} catch (e) {
logger.error(`${IDENTITY_CONTROLLER} createOAuthIdentity: ${e.message}`)
throw e
}
}
/** authorizeOAuth
* Send an Oauth2 authorisation code requesting access and refresh tokens.
* Return the validated tokens or throw an error.
*/
const authorizeOAuth = async (provider, sessionState, code) => {
const tokenUrl = config.get(`integrations.${provider}.tokenUrl`)
const clientId = config.get(`integrations.${provider}.clientId`)
const redirectUri = config.get(`integrations.${provider}.redirectUri`)
const postData = {
code,
grant_type: 'authorization_code',
session_state: sessionState,
client_id: clientId,
redirect_uri: redirectUri,
}
const params = new URLSearchParams(postData)
// Get tokens
const { data } = await axios({
method: 'POST',
url: tokenUrl,
data: params.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
if (data.token_type?.toLowerCase() !== 'bearer') {
throw new Error(`Invalid "token_type": ${data.token_type}`)
}
if (data.session_state !== sessionState) {
throw new Error(`Invalid "session_state": ${data.session_state}`)
}
/* eslint-disable camelcase */
const { access_token, expires_in, refresh_token, refresh_expires_in } = data
if (!access_token) {
throw new Error('Missing access_token from response!')
}
if (!envUtils.isValidPositiveIntegerOrZero(expires_in)) {
throw new Error('Missing expires_in from response!')
}
if (!refresh_token) {
throw new Error('Missing refresh_token from response!')
}
if (!envUtils.isValidPositiveIntegerOrZero(refresh_expires_in)) {
throw new Error('Missing refresh_expires_in from response!')
}
return {
oauthAccessToken: access_token,
oauthRefreshToken: refresh_token,
oauthAccessTokenExpiration:
expires_in === 0 ? foreverDate : getExpirationTime(expires_in),
oauthRefreshTokenExpiration:
refresh_expires_in === 0
? foreverDate
: getExpirationTime(refresh_expires_in),
}
/* eslint-enable camelcase */
}
const invalidateProviderAccessToken = async (userId, providerLabel) => {
const providerUserIdentity = await Identity.findOne({
userId,
provider: providerLabel,
})
await Identity.patchAndFetchById(providerUserIdentity.id, {
oauthAccessTokenExpiration: moment().utc().toDate(),
})
logger.info(
`access token for provider ${providerLabel} became invalid, trying to get a new one via the refresh token`,
)
}
const invalidateProviderTokens = async (userId, providerLabel) => {
const updatedUser = await getUser(userId)
const providerUserIdentity = await Identity.findOne({
userId,
provider: providerLabel,
})
await Identity.patchAndFetchById(providerUserIdentity.id, {
oauthAccessTokenExpiration: moment().utc().toDate(),
oauthRefreshTokenExpiration: moment().utc().toDate(),
})
subscriptionManager.publish(USER_UPDATED, {
userUpdated: updatedUser,
})
logger.error(
`refresh token for provider ${providerLabel} became invalid, authorization flow (provider login) should be followed by the user`,
)
}
module.exports = {
createOAuthIdentity,
getUserIdentities,
getDefaultIdentity,
hasValidRefreshToken,
invalidateProviderAccessToken,
invalidateProviderTokens,
}