Twitter Sign In OAuth2 with React and NodeJS

main

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.

dev

Here I have the "Sign in with Twitter" link to open the Twitter OAuth page.

button

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
}