First, we’ll go to the Twitter developer portal and choose OAuth 2. Redirect URLs require HTTPS, so if you want to test everything locally, you can use Ngrok tunnels. I didn't do it but instead went straight to the production. Finally, you should set the website URL.
Here I have the "Sign in with Twitter" link to open the Twitter OAuth page.
To construct this URL, I use the getTwitterOAuthUrl
function. It adds our app-specific parameters to the Twitter authorize endpoint. Here we set the response type, take the Twitter Client ID from environment variables (you can find it in the developer portal), pass redirect URI from arguments, and set scopes. The state parameter is a random string you provide to verify against CSRF attacks. Then we have two PKCE parameters. You can read more on that if you want to provide an extra level of security, but Twitter OAuth will also work if we won't pay attention to them. For now, we can assign a random string to the code_challenge parameter.
export const TWITTER_STATE = "twitter-increaser-state"
const TWITTER_CODE_CHALLENGE = "challenge"
const TWITTER_AUTH_URL = "https://twitter.com/i/oauth2/authorize"
const TWITTER_SCOPE = ["tweet.read", "users.read", "offline.access"].join(" ")
export const getTwitterOAuthUrl = (redirectUri: string) =>
getURLWithQueryParams(TWITTER_AUTH_URL, {
response_type: "code",
client_id: assertEnvVar("TWITTER_CLIENT_ID"),
redirect_uri: redirectUri,
scope: TWITTER_SCOPE,
state: TWITTER_STATE,
code_challenge: TWITTER_CODE_CHALLENGE,
code_challenge_method: "plain",
})
The getURLWithQueryParams
helper will convert parameters to an URL encoded string and will attach it to the base URL.
export const getURLWithQueryParams = (
baseUrl: string,
params: Record<string, any>
) => {
const query = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&")
return `${baseUrl}?${query}`
}
After the user finishes everything on the Twitter page, they will appear on the redirect URI. It is a page in our React app that would take care of finishing the authorization flow.
import { identificationQueryResult } from "constants/api"
import { assertQueryParams } from "auth/helpers/assertQueryParams"
import {
TWITTER_STATE,
getOAuthProviderRedirectUri,
} from "auth/helpers/OAuthProviderUrl"
import { useHandleIdentificationFailure } from "auth/hooks/useHandleIdentificationFailure"
import { useIdentificationMutation } from "auth/hooks/useIdentificationMutation"
import { OAuthProvider } from "auth/OAuthProvider"
import { useEffect } from "react"
import { useMutation } from "react-query"
import { Spinner } from "ui/Spinner"
import { HStack, VStack } from "ui/Stack"
import { Text } from "ui/Text"
import { offsetedUtils } from "utils/time"
import { AuthDestination } from "./AuthFlow/AuthFlowContext"
export const identifyWithOAuthQuery = `
query identifyWithOAuth($input: IdentifyWithOAuthInput!) {
identifyWithOAuth(input: $input) {
${identificationQueryResult}
}
}
`
interface SharedOAuthQueryParams {
code: string
provider: string
destination: AuthDestination
}
export const OAuthPage = () => {
const { mutateAsync: identify } = useIdentificationMutation()
const { mutate: processOAuthParams, error } = useMutation(async () => {
const {
code,
provider: loverCaseProvider,
destination,
} = assertQueryParams([
"code",
"provider",
"destination",
]) as SharedOAuthQueryParams
const provider = loverCaseProvider.toUpperCase() as OAuthProvider
if (!Object.values(OAuthProvider).includes(provider)) {
throw new Error("Unsupported OAuth provider")
}
if (provider === OAuthProvider.Twitter) {
const { state } = assertQueryParams(["state"])
if (state !== TWITTER_STATE) {
throw new Error("Invalid Twitter state in query params")
}
}
const input = {
provider,
code,
redirectUri: getOAuthProviderRedirectUri(provider, destination),
timeZone: offsetedUtils.getOffset().toString(),
}
await identify({
destination,
queryParams: { variables: { input }, query: identifyWithOAuthQuery },
})
})
useHandleIdentificationFailure(error)
useEffect(() => {
processOAuthParams()
}, [processOAuthParams])
return (
<VStack
gap={24}
alignItems="center"
justifyContent="center"
fullWidth
fullHeight
>
<HStack alignItems="center" gap={12}>
<Spinner />
<Text color="supporting" size={24}>
Please wait
</Text>
</HStack>
</VStack>
)
}
My app also has other OAuth sign-in options. Therefore, first, I will take shared query parameters using the assertQueryParams function. It will get the query string from the window's location and convert it to an object using queryStringToObject
util.
export const queryStringToObject = (queryString: string) => {
const pairsString =
queryString[0] === "?" ? queryString.slice(1) : queryString
const pairs = pairsString
.split("&")
.map((str) => str.split("=").map(decodeURIComponent))
return pairs.reduce<Record<string, any>>((acc, [key, value]) => {
if (key) {
acc[key] = value
}
return acc
}, {})
}
The provider and destination parameters are specific to my app, and I passed them as a part of the redirect URI. If you have only Twitter OAuth, you will extract only code and state parameters.
After that, I query my NodeJS GraphQL API to finish identification. If everything is alright, I should receive a JWT token with a response, save it to local storage, and I'm good to go.
import { GetValidatedUserArgs, getValidatedUser } from "./getValidatedUser"
import { OperationContext } from "../../graphql/OperationContext"
import { authorizeUser } from "./authorizeUser"
interface OAuthIdentificationResult {
firstIdentification: boolean
token: string
tokenExpirationTime: number
}
type Input = GetValidatedUserArgs & {
timeZone?: number
}
export const identifyWithOAuth = async (
_: any,
{ input }: { input: Input },
{ country }: OperationContext
): Promise<OAuthIdentificationResult> => {
const { name, email } = await getValidatedUser(input)
return authorizeUser({ timeZone: input.timeZone, email, name, country })
}
My back-end is an Appollo Server, and here we have a query for sign-in with OAuth. First, we will validate the user with the getValidatedUser
function. It has a record with validators, and one of them is validateWithTwitter.
import { validateWithGoogle } from "./validateWithGoogle"
import { validateWithFacebook } from "./validateWithFacebook"
import { validateWithLinkedIn } from "./validateWithLinkedIn"
import { ValidatorArgs } from "./OAuthValidator"
import { validateWithTwitter } from "./validateWithTwitter"
export enum OAuthProvider {
GOOGLE = "GOOGLE",
FACEBOOK = "FACEBOOK",
LINKEDIN = "LINKEDIN",
TWITTER = "TWITTER",
}
export interface GetValidatedUserArgs extends ValidatorArgs {
provider: OAuthProvider
}
export const getValidatedUser = async ({
provider,
code,
redirectUri,
}: GetValidatedUserArgs) => {
const validate = {
[OAuthProvider.GOOGLE]: validateWithGoogle,
[OAuthProvider.FACEBOOK]: validateWithFacebook,
[OAuthProvider.LINKEDIN]: validateWithLinkedIn,
[OAuthProvider.TWITTER]: validateWithTwitter,
}[provider]
if (!validate) {
throw new Error(`provider ${provider} is not supported`)
}
return validate({ code, redirectUri })
}
Twitter provides a typescript SDK, and we can take advantage of that. First, we initialize a client. It should have the same scopes we've set on the front end. To set the code challenge, we need to call the generateAuthURL method. It's a bit weird, but at the moment of recording, SDK doesn't provide any other ways to accomplish that.
import { assertEnvVar } from "../../../shared/assertEnvVar"
import { OAuthValidator } from "./OAuthValidator"
import { Client, auth } from "twitter-api-sdk"
interface RawTwitterUserInfo {
name?: string
}
export const validateWithTwitter: OAuthValidator = async ({
code,
redirectUri,
}) => {
const authClient = new auth.OAuth2User({
client_id: assertEnvVar("TWITTER_CLIENT_ID"),
client_secret: assertEnvVar("TWITTER_CLIENT_SECRET"),
callback: redirectUri,
scopes: ["tweet.read", "users.read", "offline.access"],
})
const client = new Client(authClient)
authClient.generateAuthURL({
state: "twitter-state",
code_challenge: "challenge",
code_challenge_method: "plain",
})
await authClient.requestAccessToken(code)
const { data } = await client.users.findMyUser()
return data as RawTwitterUserInfo
}
After that, we should be able to request auth token. To validate the user, we can call the findMyUser method, which returns information about the user.
The huge caveat is that Twitter API v2 does not give access to user email. It's a bit crucial for my app, so most likely, I will not use Twitter authorization until they add a way to get the user's email.
Once I validated the user, I would use the generateAuthData
util to create a JWT token and send it to the client.
import jwt from "jsonwebtoken"
import { assertEnvVar } from "../shared/assertEnvVar"
const jwtLifespanInSeconds = 15552000
const getTokenExpirationTime = (seconds: number) =>
Math.floor(Date.now() / 1000) + seconds
export const generateAuthData = (id: string) => {
const tokenExpirationTime = getTokenExpirationTime(jwtLifespanInSeconds)
const secret = assertEnvVar("SECRET")
return {
token: jwt.sign({ id, exp: tokenExpirationTime }, secret),
tokenExpirationTime,
} as const
}