How to actually use the Twitter API after 3-legged OAuth? 🤔 #2961
-
Hey, there. With the help of the great docs I successfully set up a Next.js supabase application with working 3-legged Twitter-Authentication (https://followshare.xyz). But now I want to use the Twitter API and make requests on behalf of users. For making such requests I need the following parameters (according to the Twitter API docs): ✅ When I do a Twitter-Login in my Next.js via supabase the resulting session object has the following attributes (see code below). Is one of them what I need? For example is Help is appreciated. Thanks so much in advance!!
|
Beta Was this translation helpful? Give feedback.
Replies: 14 comments 28 replies
-
Hi @wottpal, yes, the provider_token returned is the |
Beta Was this translation helpful? Give feedback.
-
@wottpal What did you end up doing? I'm running exactly into the same scenario. Implementing my own Twitter sign-in flow sounds like a hassle to make it work with Supabase. @kangmingtay any example of such an implementation in case there's no other way? |
Beta Was this translation helpful? Give feedback.
-
@kangmingtay It sounded from your explanation to @wottpal like Twitter being stuck on OAuth1.0 was the fundamental problem here. Correct me if I'm wrong here. As of December 21st, 2021, Twitter has made OAuth 2.0 available to all developers. I'm curious if that changes the situation here at all. |
Beta Was this translation helpful? Give feedback.
-
I just tried to migrate my app to OAuth2 in the Twitter dev portal but I still have
There is still no way to get the @wottpal Did you make it? |
Beta Was this translation helpful? Give feedback.
-
@kangmingtay @angezanetti @eetzkorn @Timonzimm @gabrielperales @modbp Sorry for my late reply. What I ended up doing was re-creating the oath-2-login myself within supabase and my nextjs-app. It was quite a few hours of work but worked as intended in the end. Attached the code below. Probably, at least from a security perspective, it would be way better to have this implemented by supabase. // /pages/api/auth/[...twitter_auth].ts
import { User } from '@supabase/supabase-js'
import Cookies from 'cookies'
import { NextApiRequest, NextApiResponse } from 'next'
import { env } from '../../../shared/environment'
import { getIPAddressHash } from '../../../shared/helpers/ip_address_hash'
import { supabase } from '../../../shared/supabase_client'
import { twitterClient, twitterUserClient, twitterUserClientByToken, twitterUserClientForUserId } from '../../../shared/twitter_client'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { twitter_auth: slug } = req.query
const path = (slug as string[] || []).join('/')
switch (path) {
case 'twitter/login':
return await handleTwitterLogin(req, res)
case 'twitter/callback':
return await handleTwitterCallback(req, res)
case 'twitter/logout':
return await handleTwitterLogout(req, res)
case 'twitter/user':
return await handleTwitterGetUser(req, res)
default:
return res.status(404).end()
}
}
/**
* Generates Twitter authentication URL and temporarily stores `oauth_token` in database.
*/
export const handleTwitterLogin = async (req: NextApiRequest, res: NextApiResponse) => {
// Determine Twitter Auth-Link
let authLink
try {
authLink = await twitterClient.generateAuthLink(`${env.url}/api/auth/twitter/callback`)
} catch (e) {
console.error(e)
}
if (!authLink?.url || !authLink?.oauth_token || !authLink?.oauth_token_secret) {
return res.status(500).end()
}
// Save temporary token to database
const { user } = await supabase.auth.api.getUserByCookie(req)
const user_id = user?.id
const ip_address_hash = getIPAddressHash(req)
const { data, error } = await supabase
.from('tokens')
.insert({
redirect: req.body?.redirect,
oauth_token: authLink.oauth_token,
oauth_token_secret: authLink.oauth_token_secret,
...(user_id ? { user_id } : null),
...(!user_id ? { ip_address_hash } : null),
})
.limit(1)
.single()
if (error || !data?.id) {
return res.status(500).end()
}
return res.status(200).json({
token_id: data.id,
url: authLink.url,
})
}
/**
* Handles Twitters authentication callback, fetches permanent access-tokens,
* and saves them to the database.
*/
export const handleTwitterCallback = async (req: NextApiRequest, res: NextApiResponse) => {
const oauth_token = req.query?.oauth_token as string
const oauth_verifier = req.query?.oauth_verifier as string
if (!oauth_token || !oauth_verifier) {
console.error('No \'oauth_token\' in callback request')
return res.redirect('/')
}
// Get existing token from database
const { user } = await supabase.auth.api.getUserByCookie(req)
const user_id = user?.id
const ip_address_hash = getIPAddressHash(req)
const { data: token, error } = await supabase
.from('tokens')
.select('*')
.match({
oauth_token,
...(user_id ? { user_id } : null),
...(!user_id ? { ip_address_hash } : null),
})
.limit(1)
.single()
const oauth_token_secret = token?.oauth_token_secret
if (error || !oauth_token_secret) {
console.error('Error while accessing database or \'oauth_token_secret\' not found', error)
return res.redirect('/')
}
try {
// Create twitter user-client from temporary tokens
const twitterClient = twitterUserClient(oauth_token, oauth_token_secret)
const { accessToken: access_token, accessSecret: access_secret } = await twitterClient.login(oauth_verifier)
// Delete all existing with duplicate tokens
await supabase
.from('tokens')
.delete()
.neq('id', token.id)
.match({ access_token, access_secret })
if (user_id) {
await supabase
.from('tokens')
.delete()
.neq('id', token.id)
.match({ user_id })
}
// Update with permanent token, delete temporary tokens
const { data: newToken, error } = await supabase
.from('tokens')
.update({
access_token,
access_secret,
oauth_token: null,
oauth_token_secret: null,
})
.eq('oauth_token', oauth_token)
.limit(1)
.single()
if (error || !newToken?.id) {
console.error('Error while updating access-tokens', error)
return res.redirect('/')
}
// Store token_id as http-only cookie if no user is logged-in
if (!user_id) {
const cookies = new Cookies(req, res)
const maxAge = 24 * 60 * 60 * 1000
cookies.set('token_id', newToken.id, { maxAge })
}
// Sync User-Profile with authorized twitter-user client
if (user) await syncTwitterProfileDetails(req, user, access_token, access_secret)
// Successfully authenticated, redirecting…
return res.redirect(newToken?.redirect || '/')
} catch (e) {
console.error('Error while logging in with twitter user-client', e)
return res.redirect(token?.redirect || '/')
}
}
/**
* Handles logout of twitter user and removes token from the database and cookie
*/
export const handleTwitterLogout = async (req: NextApiRequest, res: NextApiResponse) => {
const cookies = new Cookies(req, res)
const tokenId = cookies.get('token_id')
const { error } = await supabase
.from('tokens')
.delete()
.eq('id', tokenId)
.limit(1)
.maybeSingle()
if (error) console.error(`Error while deleting token with id '${tokenId}' on logout`, error)
cookies.set('token_id', null)
return res.status(200).end()
}
/**
* After a user has signed-up after authenticating with Twitter,
* the existing token is assigned and persisted with the user-id.
*/
export const autoAssignTokenToUser = async (req: NextApiRequest, res: NextApiResponse, userId: string) => {
const cookies = new Cookies(req, res)
const tokenId = cookies.get('token_id')
if (!userId || !tokenId) return
// Update token with user_id
const { data: token, error } = await supabase
.from('tokens')
.update({
user_id: userId,
ip_address_hash: null,
})
.match({
id: tokenId,
ip_address_hash: getIPAddressHash(req),
})
.limit(1)
.single()
if (error || !token?.user_id) {
console.error('Error while updating access-tokens', error)
}
// Reset token_id cookies (only needed without user-auth)
cookies.set('token_id', null)
}
/**
* If User has successfully authenticated twitter user, it's
* Twitter-related profile information is updated automatically.
*/
export const syncTwitterProfileDetails = async (req: NextApiRequest, userl: User, twitterAccessToken: string, twitterAccessSecret: string) => {
if (!twitterAccessToken || !twitterAccessSecret) return
const { user } = await supabase.auth.api.getUserByCookie(req)
if (!user) return
supabase.auth.getSessionFromUrl()
const twitterClient = await twitterUserClient(twitterAccessToken, twitterAccessSecret)
const twitterUser = await twitterClient.v1.verifyCredentials()
const attributesExist = twitterUser?.id_str
&& twitterUser?.screen_name
&& twitterUser?.name
&& twitterUser?.profile_image_url_https
if (!attributesExist) return
const { data: profile, error } = await supabase
.from('profiles')
.update({
twitter_user_id: twitterUser.id_str,
display_name: twitterUser.name,
twitter_user_name: twitterUser.screen_name,
twitter_avatar_url: twitterUser.profile_image_url_https,
})
.eq('id', user.id)
.limit(1)
.single()
if (error || !profile) {
console.error('Error while syncing profile with twitter data', error)
}
}
/**
* Returns authenticated twitter user (by token_id cookie)
*/
export const handleTwitterGetUser = async (req: NextApiRequest, res: NextApiResponse) => {
const handleForbidden = () => {
const cookies = new Cookies(req, res)
cookies.set('token_id', null)
return res.status(200).end()
}
// Determine token either by token_id or logged-in user_id
const { user } = await supabase.auth.api.getUserByCookie(req)
let client = user
? await twitterUserClientForUserId(user.id)
: await twitterUserClientByToken(req, res)
if (user && !client) {
client = await twitterUserClientByToken(req, res)
if (client) {
await autoAssignTokenToUser(req, res, user.id)
}
}
if (!client) return handleForbidden()
// Verify credentials via twitter api
const twitterUser = await client.v1.verifyCredentials()
if (!twitterUser) return handleForbidden()
return res.status(200).json(twitterUser)
} And a shared twitter-client helper that either retrieves the twitter-client with the token or by the user id. // /shared/twitter_client.ts
import Cookies from 'cookies'
import { NextApiRequest, NextApiResponse } from 'next'
import { TwitterApi, TwitterApiTokens } from 'twitter-api-v2'
import { env } from './environment'
import { getIPAddressHash } from './helpers/ip_address_hash'
import { supabase } from './supabase_client'
export const twitterClient = new TwitterApi({
appKey: env.twitterApiKey,
appSecret: env.twitterApiSecret,
} as TwitterApiTokens)
export const twitterUserClient = (accessToken: string, accessSecret: string): TwitterApi => {
return new TwitterApi({
appKey: env.twitterApiKey,
appSecret: env.twitterApiSecret,
accessToken,
accessSecret,
} as TwitterApiTokens)
}
export const twitterUserClientByToken = async (req: NextApiRequest, res: NextApiResponse): Promise<TwitterApi | null> => {
// Gather token from cookie if null
const cookies = new Cookies(req, res)
const tokenId = cookies.get('token_id')
if (!tokenId) return null
// Gather token by id from datbase
const { data, error } = await supabase
.from('tokens')
.select('access_token,access_secret')
.match({
id: tokenId,
ip_address_hash: getIPAddressHash(req),
})
.limit(1)
.maybeSingle()
const { access_token, access_secret } = data || {}
if (error) console.error(`Error while searching token with id '${tokenId}'`, error)
if (!access_token || !access_secret) return null
return twitterUserClient(access_token, access_secret)
}
export const twitterUserClientForUserId = async (userId: string): Promise<TwitterApi | null> => {
if (!userId) return null
// Gather token by user_id from datbase
const { data, error } = await supabase
.from('tokens')
.select('access_token,access_secret')
.not('access_token', 'is', null)
.not('access_secret', 'is', null)
.match({ user_id: userId })
.limit(1)
.maybeSingle()
const { access_token, access_secret } = data || {}
if (error) console.error(`Error while searching token for user with id '${userId}'`, error)
if (!access_token || !access_secret) return null
return twitterUserClient(access_token, access_secret)
}
export const appropriateTwitterUserClient = async (req: NextApiRequest, res: NextApiResponse): Promise<TwitterApi | null> => {
const { user } = await supabase.auth.api.getUserByCookie(req)
return user
? await twitterUserClientForUserId(user.id)
: await twitterUserClientByToken(req, res)
} |
Beta Was this translation helpful? Give feedback.
-
@kangmingtay Any update about this? |
Beta Was this translation helpful? Give feedback.
-
This is very frustrating issue. Costs me few days to figure out what is going on. Authentication with Twitter to use API was in the reasons list for Supabase. I didn't see any information on the promo page tells that is not working this way 😔 @kangmingtay Twitter uses OAuth2.0 protocol for a while (https://developer.twitter.com/en/docs/authentication/oauth-2-0/authorization-code). Should this fix the problem? |
Beta Was this translation helpful? Give feedback.
-
Has there been any updates on this? |
Beta Was this translation helpful? Give feedback.
-
Does anyone here know a workaround? Preference for knowing a flutter-based solution. Right now we authenticate and store the user with supabase and would love to be able to make a call to authorize the app to make calls on behalf of the user (read/write tweets) without having to re-authenticate via raw http calls (prompting the user again). |
Beta Was this translation helpful? Give feedback.
-
Just noting this same issue still exists for Microsoft/Azure as well, and it will apply to all cloud providers if the app in question both signs in with the provider and uses one of the provider's APIs. We really need a better answer to this than "make the user sign in twice". |
Beta Was this translation helpful? Give feedback.
-
Looks like Twitter OAuth2 has not yet been implemented on the Supabase side? |
Beta Was this translation helpful? Give feedback.
-
Looking at this thread in March 2024, and it looks like Twitter OAuth2 implementation was initially planned for April/May 2022 ? Would love an update on this. |
Beta Was this translation helpful? Give feedback.
-
Also looking for an update. Any news? |
Beta Was this translation helpful? Give feedback.
-
Well this is disappointing. 2 years later and still no word on support for OAuth 2.0. Not being able to use the Twitter API without rolling my own authentication flow is a deal breaker for me. |
Beta Was this translation helpful? Give feedback.
Hi @wottpal, yes, the provider_token returned is the
oauth_token
. Unfortunately, we do not return theoauth_token_secret
because twitter supports an oauth1.0 flow which means that theoauth_token
andoauth_token_secret
are long-lived tokens. In the event of a CSRF attack, an attacker will be able to obtain both tokens in the session and make requests on the behalf of your users without your knowledge. This is one of the vulnerabilities of long-lived tokens especially for twitter because the tokens never expire unless a user revokes them.