-
Notifications
You must be signed in to change notification settings - Fork 752
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
64 changed files
with
1,865 additions
and
419 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
156 changes: 131 additions & 25 deletions
156
backend/src/api/integration/helpers/twitterAuthenticate.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
92
backend/src/api/integration/helpers/twitterAuthenticateCallback.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
Oops, something went wrong.