Skip to content

Commit

Permalink
Merge branch 'main' into singup
Browse files Browse the repository at this point in the history
  • Loading branch information
a0m0rajab authored Sep 28, 2023
2 parents f67940c + 696ba02 commit ea03178
Show file tree
Hide file tree
Showing 64 changed files with 1,865 additions and 419 deletions.
3 changes: 2 additions & 1 deletion backend/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@
},
"twitter": {
"clientId": "CROWD_TWITTER_CLIENT_ID",
"clientSecret": "CROWD_TWITTER_CLIENT_SECRET"
"clientSecret": "CROWD_TWITTER_CLIENT_SECRET",
"callbackUrl": "CROWD_TWITTER_CALLBACK_URL"
},
"slack": {
"clientId": "CROWD_SLACK_CLIENT_ID",
Expand Down
156 changes: 131 additions & 25 deletions backend/src/api/integration/helpers/twitterAuthenticate.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,131 @@
// import passport from 'passport'
// import { PlatformType } from '@crowd/types'
// import Permissions from '../../../security/permissions'
// import PermissionChecker from '../../../services/user/permissionChecker'

// export default async (req, res, next) => {
// // Checking we have permision to edit the project
// new PermissionChecker(req).validateHas(Permissions.values.integrationEdit)

// const state = {
// tenantId: req.params.tenantId,
// redirectUrl: req.query.redirectUrl,
// hashtags: req.query.hashtags ? req.query.hashtags : '',
// crowdToken: req.query.crowdToken,
// platform: PlatformType.TWITTER,
// userId: req.currentUser.id,
// }

// const authenticator = passport.authenticate('twitter', {
// scope: ['tweet.read', 'tweet.write', 'users.read', 'follows.read', 'offline.access'],
// state,
// })

// authenticator(req, res, next)
// }
import crypto from 'crypto'
import { PlatformType } from '@crowd/types'
import { Response } from 'express'
import { RedisCache } from '@crowd/redis'
import { generateUUIDv4 as uuid } from '@crowd/common'
import { TWITTER_CONFIG } from '../../../conf'
import Permissions from '../../../security/permissions'
import PermissionChecker from '../../../services/user/permissionChecker'
import SequelizeRepository from '../../../database/repositories/sequelizeRepository'

/// credits to lucia-auth library for these functions

const createUrl = (url: string | URL, urlSearchParams: Record<string, string | undefined>): URL => {
const newUrl = new URL(url)
for (const [key, value] of Object.entries(urlSearchParams)) {
// eslint-disable-next-line no-continue
if (!value) continue
newUrl.searchParams.set(key, value)
}
return newUrl
}

const getRandomValues = (bytes: number): Uint8Array => {
const buffer = crypto.randomBytes(bytes)
return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
}

const DEFAULT_ALPHABET = 'abcdefghijklmnopqrstuvwxyz1234567890'

export const generateRandomString = (size: number, alphabet = DEFAULT_ALPHABET): string => {
// eslint-disable-next-line no-bitwise
const mask = (2 << (Math.log(alphabet.length - 1) / Math.LN2)) - 1
// eslint-disable-next-line no-bitwise
const step = -~((1.6 * mask * size) / alphabet.length)

let bytes = getRandomValues(step)
let id = ''
let index = 0

while (id.length !== size) {
// eslint-disable-next-line no-bitwise
id += alphabet[bytes[index] & mask] ?? ''
index += 1
if (index > bytes.length) {
bytes = getRandomValues(step)
index = 0
}
}
return id
}

const encodeBase64 = (data: string | ArrayLike<number> | ArrayBufferLike) => {
if (typeof Buffer === 'function') {
// node or bun
const bufferData = typeof data === 'string' ? data : new Uint8Array(data)
return Buffer.from(bufferData).toString('base64')
}
if (typeof data === 'string') return btoa(data)
return btoa(String.fromCharCode(...new Uint8Array(data)))
}

const encodeBase64Url = (data: string | ArrayLike<number> | ArrayBufferLike) =>
encodeBase64(data).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_')

const generatePKCECodeChallenge = (method: 'S256', verifier: string) => {
if (method === 'S256') {
const hash = crypto.createHash('sha256')
hash.update(verifier)
const challengeBuffer = hash.digest()
return encodeBase64Url(challengeBuffer)
}
throw new TypeError('Invalid PKCE code challenge method')
}

/// end credits

export default async (req, res: Response) => {
// Checking we have permision to edit the project
new PermissionChecker(req).validateHas(Permissions.values.integrationEdit)

const cache = new RedisCache('twitterPKCE', req.redis, req.log)

// Generate code verifier and challenge for PKCE
const codeVerifier = generateRandomString(
96,
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-_.~',
)
const codeChallenge = generatePKCECodeChallenge('S256', codeVerifier)

const handle = uuid()

const callbackUrl = TWITTER_CONFIG.callbackUrl

const state = {
handle,
tenantId: req.params.tenantId,
redirectUrl: req.query.redirectUrl,
callbackUrl,
hashtags: req.query.hashtags ? req.query.hashtags : '',
crowdToken: req.query.crowdToken,
platform: PlatformType.TWITTER,
userId: req.currentUser.id,
codeVerifier,
segmentIds: SequelizeRepository.getSegmentIds(req),
}

const twitterState = {
crowdToken: req.query.crowdToken,
tenantId: req.params.tenantId,
handle,
}

// Save state to redis
await cache.set(req.currentUser.id, JSON.stringify(state), 300)

const scopes = ['tweet.read', 'tweet.write', 'users.read', 'follows.read', 'offline.access']

// Build the authorization URL
const authUrl = createUrl('https://twitter.com/i/oauth2/authorize', {
client_id: TWITTER_CONFIG.clientId,
response_type: 'code',
state: encodeBase64Url(JSON.stringify(twitterState)),
redirect_uri: callbackUrl,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
scope: scopes.join(' '),
})

// Redirect user to the authorization URL
res.redirect(authUrl.toString())
}
92 changes: 82 additions & 10 deletions backend/src/api/integration/helpers/twitterAuthenticateCallback.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,94 @@
import { RedisCache } from '@crowd/redis'
import axios from 'axios'
import PermissionChecker from '../../../services/user/permissionChecker'
import Permissions from '../../../security/permissions'
import IntegrationService from '../../../services/integrationService'
import { API_CONFIG, TWITTER_CONFIG } from '../../../conf'
import SegmentRepository from '../../../database/repositories/segmentRepository'

const errorURL = `${API_CONFIG.frontendUrl}/integrations?twitter-error=true`

const decodeBase64Url = (data) => {
data = data.replaceAll('-', '+').replaceAll('_', '/')
while (data.length % 4) {
data += '='
}
return atob(data)
}

export default async (req, res) => {
// Checking we have permision to edit the integration
new PermissionChecker(req).validateHas(Permissions.values.integrationEdit)

const urlSearchParams = new URLSearchParams(req.query.state)
const redirectUrl = urlSearchParams.get('redirectUrl')
const hashtags = urlSearchParams.get('hashtags')
const cache = new RedisCache('twitterPKCE', req.redis, req.log)

const userId = req.currentUser.id
const decodedState = decodeBase64Url(req.query.state)
const externalState = JSON.parse(decodedState)

const { handle } = externalState

const existingValue = await cache.get(userId)
if (!existingValue) {
res.redirect(errorURL)
}

const stateObj = JSON.parse(existingValue)

const integrationData = {
profileId: req.user.twitter.profile.id,
token: req.user.twitter.accessToken,
refreshToken: req.user.twitter.refreshToken,
hashtags,
await cache.delete(userId)
if (stateObj.handle !== handle) {
res.redirect(errorURL)
}
await new IntegrationService(req).twitterCallback(integrationData)

res.redirect(redirectUrl)
const callbackUrl = stateObj.callbackUrl
const redirectUrl = stateObj.redirectUrl
const codeVerifier = stateObj.codeVerifier
const segmentIds = stateObj.segmentIds
const oauthVerifier = req.query.code
const hashtags = stateObj.hashtags

// attach segments to request
const segmentRepository = new SegmentRepository(req)
req.currentSegments = await segmentRepository.findInIds(segmentIds)

try {
const response = await axios.post(
'https://api.twitter.com/2/oauth2/token',
{},
{
params: {
client_id: TWITTER_CONFIG.clientId,
code: oauthVerifier,
grant_type: 'authorization_code',
redirect_uri: callbackUrl,
code_verifier: codeVerifier,
},
auth: {
username: TWITTER_CONFIG.clientId,
password: TWITTER_CONFIG.clientSecret,
},
},
)

// with the token let's get user info
const userResponse = await axios.get('https://api.twitter.com/2/users/me', {
headers: {
Authorization: `Bearer ${response.data.access_token}`,
},
})

const twitterUserId = userResponse.data.data.id

const integrationData = {
profileId: twitterUserId,
token: response.data.access_token,
refreshToken: response.data.refresh_token,
hashtags,
}
await new IntegrationService(req).twitterCallback(integrationData)

res.redirect(redirectUrl)
} catch (error) {
res.redirect(errorURL)
}
}
Loading

0 comments on commit ea03178

Please sign in to comment.