Implementing Authentication with Twitter OAuth 2.0 using Typescript, Node.js, Express.js and Next.js in a Full Stack Application
Table of contents
- Table of contents
- What will we learn
- Requirements
- Project Setup
- Twitter OAuth2 Implementation
- Finishing the web app
- Conclusion
What will we learn
Here, we will learn to implement authentication using Twitter OAuth 2.0 on a minimal working full-stack web application. We will not be using Passport.js or similar libraries to handle authentication for us. As a result, we will understand the OAuth 2.0 flow better. We will also learn about the following stacks:
- express.js backend framework
- prisma to create and login users, you can really use anything to communicate with any database.
- next.js, a React.js framework, for the frontend
- typescript (optional) type-safety for javascript
Requirements
Anyone with a basic knowledge of javascript can follow along with this blog. If you already have a similar project setup, you can also jump straight to the Twitter OAuth2 Implementation section.
Project Setup
Firstly, let's add a package.json
file at the root directory and add the following content:
{
"private": true,
"workspaces": [
"server",
"client"
],
"scripts": {
"client:dev": "yarn workspace client dev",
"server:dev": "yarn workspace server dev",
"client:add": "yarn workspace client add",
"server:add": "yarn workspace server add",
"migrate-db": "yarn workspace server prisma-migrate"
}
}
You can set up version control in this directory, but that is optional. Either way, we will now add a client and server for our web app.
Client setup
Make a Next.js app by running the following commands:
yarn create next-app --typescript client
Skip the --typescript
flag if you want to work with javascript.
This will create a client
folder in the project directory. Navigate there and delete the files we don't need, i.e. client\styles\Home.module.css
and client\pages\api
. Also, let's replace all the code in client\pages\index.ts
with the following:
import { NextPage } from "next";
const Home: NextPage = () => {
return (
<div className="column-container">
<p>Hello!</p>
</div>
);
};
export default Home;
Starting the client with our command yarn client:dev
and going to the address at http://www.localhost:3000/ should display a webpage saying Hello!
Now that the frontend is set up, let's move on to our backend.
Server setup
Make a directory called server
and make a package.json
file in the project directory with the following content:
{
"name": "server",
"version": "1.0.0",
"scripts": {
"start": "node dist/index.js",
"build": "tsc --sourceMap false",
"build:watch": "tsc -w",
"start:watch": "nodemon dist/index.js",
"dev": "concurrently \"yarn build:watch\" \"yarn start:watch\" --names \"tsc,node\" -c \"blue,green\"",
"prisma-migrate": "prisma migrate dev",
"prisma-gen": "prisma generate"
}
}
Here, we added various scripts to help us in our development stage. We will mostly use the dev
and the migrate-db
scripts. They allow us to start the server in watch mode and let us migrate the database respectively. Now we can return to the workspace directory and use our yarn server:add
to add packages. So its time to install the required dependencies using the following commands in the terminal:
yarn server:add @prisma/client argon2 axios cookie-parser cors dotenv express jsonwebtoken
yarn server:add -D nodemon prisma typescript concurrently @types/cookie-parser @types/cors @types/express @types/jsonwebtoken @types/node
After installing the dependencies we need, make a few files to have a minimal running express server:
server/tsconfig.json
Edit according to preferences or skip if not using typescript{ "compilerOptions": { "target": "ES2018", "module": "commonjs", "lib": [ "esnext", "esnext.asynciterable" ], "strict": true, "skipLibCheck": true, "sourceMap": true, "declaration": true, "moduleResolution": "node", "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "noImplicitThis": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "resolveJsonModule": true, "incremental": false, "baseUrl": "./src", "watch": false, "removeComments": true, "outDir": "./dist", "rootDir": "./src" }, "types": ["node"], "include": ["./src/**/*.ts"], }
server/src/index.ts
to listen to the server.import { CLIENT_URL, SERVER_PORT } from "./config"; import cookieParser from "cookie-parser"; import cors from "cors"; import express from "express"; const app = express(); const origin = [CLIENT_URL]; app.use(cookieParser()); app.use(cors({ origin, credentials: true })) app.get("/ping", (_, res) => res.json("pong")); app.listen(SERVER_PORT, () => console.log(`Server listening on port ${SERVER_PORT}`))
server/src/config.ts
to export some constant configuration variablesimport { PrismaClient } from "@prisma/client" export const CLIENT_URL = process.env.CLIENT_URL! export const SERVER_PORT = process.env.SERVER_PORT! export const prisma = new PrismaClient()
server/.env
to setup the port, client URL and database URL for the server. Make sure to ignore this file if you are working with version controlDATABASE_URL=postgres://postgres:postgres@localhost:5432/twitter-oauth2 CLIENT_URL=http://www.localhost:3000 SERVER_PORT=3001
server/prisma/schema.prisma
to let prisma handle the database structuregenerator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } enum UserType { local twitter } model User { id String @id @default(uuid()) name String username String @unique type UserType @default(local) }
Now migrate the database using the
yarn migrate-db
command, and then we can run the server usingyarn server:dev
.
We should now be able to ping our server at http://localhost:3001/ping
Twitter OAuth2 Implementation
We are ready to implement authentication via Twitter OAuth 2.0 into our app. We will follow this approach to do so. Firstly, we have to make an app on Twitter.
Setup twitter user authentication settings
Head over to twitter's developer portal and make a project and a development app in the project with any name. Twitter will show you the things needed. It may take a few hours to get approval from Twitter to make these apps. Once it is done, head over to the settings page of the app to set some necessary fields. Set up or edit the user authentication as needed by your app.
As I only need to read profile information for this minimal web app, these are the settings I used:
Save the Twitter Client ID and client secret securely.
Note: http://www.localhost:3000 works but not http://localhost:3000. So, I added
www.
in both websites.
Client
Frontend authentication button
Now we add the button in the client, which will lead to our backend for authentication. To do so, we need to use a valid Twitter OAuth URL getter function and a button to go to the URL.
import twitterIcon from "../public/twitter.svg";
import Image from "next/image";
const TWITTER_CLIENT_ID = "T1dLaHdFSWVfTnEtQ2psZThTbnI6MTpjaQ" // give your twitter client id here
// twitter oauth Url constructor
function getTwitterOauthUrl() {
const rootUrl = "https://twitter.com/i/oauth2/authorize";
const options = {
redirect_uri: "http://www.localhost:3001/oauth/twitter", // client url cannot be http://localhost:3000/ or http://127.0.0.1:3000/
client_id: TWITTER_CLIENT_ID,
state: "state",
response_type: "code",
code_challenge: "y_SfRG4BmOES02uqWeIkIgLQAlTBggyf_G7uKT51ku8",
code_challenge_method: "S256",
scope: ["users.read", "tweet.read", "follows.read", "follows.write"].join(" "), // add/remove scopes as needed
};
const qs = new URLSearchParams(options).toString();
return `${rootUrl}?${qs}`;
}
// the component
export function TwitterOauthButton() {
return (
<a className="a-button row-container" href={getTwitterOauthUrl()}>
<Image src={twitterIcon} alt="twitter icon" />
<p>{" twitter"}</p>
</a>
);
}
Note: We are hard coding
code_challenge
andcode_verifier
for simplicity. You can randomly generate it.
After adding the above code in client\components\TwitterOauthButton.tsx
, we will add a twitter SVG icon (from online resources like this) on path client\public\twitter.svg
.
Then we will import the component on the homepage:
import { TwitterOauthButton } from "../components/TwitterOauthButton";
const Home: NextPage = () => {
return (
<div className="column-container">
<p>Hello!</p>
<TwitterOauthButton />
</div>
);
};
This is how it should look like afterwards:
Clicking on the Twitter icon will lead us to the Twitter page where we can authorize the app:
Of course, clicking on the authorize app
button leads to a Cannot GET /oauth/twitter
response, as we haven't implemented the backend yet.
Me query
Let's request for the current logged in user from the frontend through a hook, client\hooks\useMeQuery.ts
:
import { useEffect, useState } from "react";
import axios, { AxiosResponse } from "axios";
export type User = {
id: string;
name: string;
username: string;
type: "local" | "twitter";
};
export function useMeQuery() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [data, setData] = useState<User | null>(null);
useEffect(() => {
setLoading(true);
axios
.get<any, AxiosResponse<User>>(`http://www.localhost:3001/me`, {
withCredentials: true,
})
.then((v) => {
if (v.data) setData(v.data);
})
.catch(() => setError("Not Authenticated"))
.finally(() => setLoading(false));
}, []);
return { error, data, loading };
}
This will do a good enough job for our minimal app. We will use it to determine what to render. We will render the username if we get a user from the hook. Otherwise, we will render the Login with Twitter
button
import type { NextPage } from "next";
import { TwitterOauthButton } from "../components/TwitterOauthButton";
import { useMeQuery } from "../hooks/useMeQuery";
const Home: NextPage = () => {
const { data: user } = useMeQuery();
return (
<div className="column-container">
<p>Hello!</p>
{user ? (// user present so only display user's name
<p>{user.name}</p>
) : (// user not present so prompt to login
<div>
<p>You are not Logged in! Login with:</p>
<TwitterOauthButton />
</div>
)}
</div>
);
};
export default Home;
The above is how the final client\pages\index.tsx
will look like. Go to http://www.localhost:3000 and inspect the network window of the browser while the page is loading. You should see the Me query being executed there.
Its 404 because we havent implemented it in the backend
Styling
Let's just add some basic styling while we are at it by modifying the client\styles\globals.css
file:
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
.column-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.row-container {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.a-button {
border: 2px solid grey;
border-radius: 5px;
}
.a-button:hover {
background-color: #111133;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
body {
color: white;
background: black;
}
}
That is all we have to do on our client-side. The final homepage should look like this:
Server
As we saw from our frontend, we need to implement GET /oauth/twitter
route in our server to make the Twitter OAuth part of the app work. A look at the twitter documentation reveals the steps we need to perform so that we can read the info we mentioned in our scopes here.
These steps are summarized below:
- getting the access token
- getting the Twitter user from the access token
- upsert the user in our database
- create cookie so that the server can validate the user
- redirect to the client with the cookie
Note: Only the first two steps are related to Twitter OAuth Implementation
Lets add a file server\src\oauth2.ts
where we will add our OAuth related codes. We will complete the steps above by defining a function there:
// the function which will be called when twitter redirects to the server at http://www.localhost:3001/oauth/twitter
export async function twitterOauth(req: Request<any, any, any, {code:string}>, res: Response) {
const code = req.query.code; // getting the code if the user authorized the app
// 1. get the access token with the code
// 2. get the twitter user using the access token
// 3. upsert the user in our db
// 4. create cookie so that the server can validate the user
// 5. finally redirect to the client
return res.redirect(CLIENT_URL);
}
Before doing any of that make sure we the Twitter OAuth client secret in our .env
file. We will also add a JWT secret there so that we can encrypt the cookie we send to the client. The final .env
file should look like this:
DATABASE_URL=postgres://postgres:postgres@localhost:5432/twitter-oauth2
CLIENT_URL=http://www.localhost:3000
SERVER_PORT=3001
JWT_SECRET=put-your-jwt-secret-here
TWITTER_CLIENT_SECRET=put-your-twitter-client-secret-here
Getting the access token with the code
Add the following code(the commented lines explain what they do) to get the access token:
// add your client id and secret here:
const TWITTER_OAUTH_CLIENT_ID = "T1dLaHdFSWVfTnEtQ2psZThTbnI6MTpjaQ";
const TWITTER_OAUTH_CLIENT_SECRET = process.env.TWITTER_CLIENT_SECRET!;
// the url where we get the twitter access token from
const TWITTER_OAUTH_TOKEN_URL = "https://api.twitter.com/2/oauth2/token";
// we need to encrypt our twitter client id and secret here in base 64 (stated in twitter documentation)
const BasicAuthToken = Buffer.from(`${TWITTER_OAUTH_CLIENT_ID}:${TWITTER_OAUTH_CLIENT_SECRET}`, "utf8").toString(
"base64"
);
// filling up the query parameters needed to request for getting the token
export const twitterOauthTokenParams = {
client_id: TWITTER_OAUTH_CLIENT_ID,
// based on code_challenge
code_verifier: "8KxxO-RPl0bLSxX5AWwgdiFbMnry_VOKzFeIlVA7NoA",
redirect_uri: `http://www.localhost:3001/oauth/twitter`,
grant_type: "authorization_code",
};
// the shape of the object we should recieve from twitter in the request
type TwitterTokenResponse = {
token_type: "bearer";
expires_in: 7200;
access_token: string;
scope: string;
};
// the main step 1 function, getting the access token from twitter using the code that twitter sent us
export async function getTwitterOAuthToken(code: string) {
try {
// POST request to the token url to get the access token
const res = await axios.post<TwitterTokenResponse>(
TWITTER_OAUTH_TOKEN_URL,
new URLSearchParams({ ...twitterOauthTokenParams, code }).toString(),
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BasicAuthToken}`,
},
}
);
return res.data;
} catch (err) {
console.error(err);
return null;
}
}
Getting the Twitter User from access token
Similar code to get user from access token:
// the shape of the response we should get
export interface TwitterUser {
id: string;
name: string;
username: string;
}
// getting the twitter user from access token
export async function getTwitterUser(accessToken: string): Promise<TwitterUser | null> {
try {
// request GET https://api.twitter.com/2/users/me
const res = await axios.get<{ data: TwitterUser }>("https://api.twitter.com/2/users/me", {
headers: {
"Content-type": "application/json",
// put the access token in the Authorization Bearer token
Authorization: `Bearer ${accessToken}`,
},
});
return res.data.data ?? null;
} catch (err) {
console.error(err);
return null;
}
}
Checking if they work
Let's see if they successfully gets us the user. After adding all the code in the server\src\oauth2.ts
file it should look like this:
import { CLIENT_URL } from "./config";
import axios from "axios";
import { Request, Response } from "express";
// add your client id and secret here:
const TWITTER_OAUTH_CLIENT_ID = "T1dLaHdFSWVfTnEtQ2psZThTbnI6MTpjaQ";
const TWITTER_OAUTH_CLIENT_SECRET = process.env.TWITTER_CLIENT_SECRET!;
// the url where we get the twitter access token from
const TWITTER_OAUTH_TOKEN_URL = "https://api.twitter.com/2/oauth2/token";
// we need to encrypt our twitter client id and secret here in base 64 (stated in twitter documentation)
const BasicAuthToken = Buffer.from(`${TWITTER_OAUTH_CLIENT_ID}:${TWITTER_OAUTH_CLIENT_SECRET}`, "utf8").toString(
"base64"
);
// filling up the query parameters needed to request for getting the token
export const twitterOauthTokenParams = {
client_id: TWITTER_OAUTH_CLIENT_ID,
code_verifier: "8KxxO-RPl0bLSxX5AWwgdiFbMnry_VOKzFeIlVA7NoA",
redirect_uri: `http://www.localhost:3001/oauth/twitter`,
grant_type: "authorization_code",
};
// the shape of the object we should recieve from twitter in the request
type TwitterTokenResponse = {
token_type: "bearer";
expires_in: 7200;
access_token: string;
scope: string;
};
// the main step 1 function, getting the access token from twitter using the code that the twitter sent us
export async function getTwitterOAuthToken(code: string) {
try {
// POST request to the token url to get the access token
const res = await axios.post<TwitterTokenResponse>(
TWITTER_OAUTH_TOKEN_URL,
new URLSearchParams({ ...twitterOauthTokenParams, code }).toString(),
{
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${BasicAuthToken}`,
},
}
);
return res.data;
} catch (err) {
return null;
}
}
// the shape of the response we should get
export interface TwitterUser {
id: string;
name: string;
username: string;
}
// getting the twitter user from access token
export async function getTwitterUser(accessToken: string): Promise<TwitterUser | null> {
try {
// request GET https://api.twitter.com/2/users/me
const res = await axios.get<{ data: TwitterUser }>("https://api.twitter.com/2/users/me", {
headers: {
"Content-type": "application/json",
// put the access token in the Authorization Bearer token
Authorization: `Bearer ${accessToken}`,
},
});
return res.data.data ?? null;
} catch (err) {
return null;
}
}
// the function which will be called when twitter redirects to the server at http://www.localhost:3001/oauth/twitter
export async function twitterOauth(req: Request<any, any, any, {code:string}>, res: Response) {
const code = req.query.code;
// 1. get the access token with the code
const TwitterOAuthToken = await getTwitterOAuthToken(code);
console.log(TwitterOAuthToken);
if (!TwitterOAuthToken) {
// redirect if no auth token
return res.redirect(CLIENT_URL);
}
// 2. get the twitter user using the access token
const twitterUser = await getTwitterUser(TwitterOAuthToken.access_token);
console.log(twitterUser);
if (!twitterUser) {
// redirect if no twitter user
return res.redirect(CLIENT_URL);
}
// 3. upsert the user in our db
// 4. create cookie so that the server can validate the user
// 5. finally redirect to the client
return res.redirect(CLIENT_URL);
}
Import and add the route to our express app:
app.get("/ping", (_, res) => res.json("pong"));
// activate twitterOauth function when visiting the route
app.get("/oauth/twitter", twitterOauth);
app.listen(SERVER_PORT, () => console.log(`Server listening on port ${SERVER_PORT}`))
Now run the client and server, and look at the server console on what happens if we click on the Twitter button in the frontend and authorize the app.
We successfully got the user from Twitter now! The most important part, i.e. getting the user from Twitter, is done. Now we can finish up our project.
Finishing the web app
Let's finish up the rest of the steps needed for GET /oauth/twitter
to work. Since they are not related to OAuth, I will add the functions in the server\src\config.ts
file.
import { PrismaClient, User } from "@prisma/client"
import { CookieOptions, Response } from "express";
import { TwitterUser } from "./oauth2";
import jwt from "jsonwebtoken";
export const CLIENT_URL = process.env.CLIENT_URL!
export const SERVER_PORT = process.env.SERVER_PORT!
export const prisma = new PrismaClient()
// step 3
export function upsertUser(twitterUser: TwitterUser) {
// create a new user in our database or return an old user who already signed up earlier
return prisma.user.upsert({
create: {
username: twitterUser.username,
id: twitterUser.id,
name: twitterUser.name,
type: "twitter",
},
update: {
id: twitterUser.id,
},
where: { id: twitterUser.id},
});
}
// JWT_SECRET from our environment variable file
export const JWT_SECRET = process.env.JWT_SECRET!
// cookie name
export const COOKIE_NAME = 'oauth2_token'
// cookie setting options
const cookieOptions: CookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production'
sameSite: "strict"
}
// step 4
export function addCookieToRes(res: Response, user: User, accessToken: string) {
const { id, type } = user;
const token = jwt.sign({ // Signing the token to send to client side
id,
accessToken,
type
}, JWT_SECRET);
res.cookie(COOKIE_NAME, token, { // adding the cookie to response here
...cookieOptions,
expires: new Date(Date.now() + 7200 * 1000),
});
}
Import the functions and use them in the server\src\oauth2.ts
:
import { prisma, CLIENT_URL, addResCookie } from "./config";
...
// the function which will be called when twitter redirects to the server at http://www.localhost:3001/oauth/twitter
export async function twitterOauth(req: Request<any, any, any, {code:string}>, res: Response) {
const code = req.query.code;
// 1. get the access token with the code
const twitterOAuthToken = await getTwitterOAuthToken(code);
if (!twitterOAuthToken) {
// redirect if no auth token
return res.redirect(CLIENT_URL);
}
// 2. get the twitter user using the access token
const twitterUser = await getTwitterUser(twitterOAuthToken.access_token);
if (!twitterUser) {
// redirect if no twitter user
return res.redirect(CLIENT_URL);
}
// 3. upsert the user in our db
const user = await upsertUser(twitterUser)
// 4. create cookie so that the server can validate the user
addCookieToRes(res, user, twitterOAuthToken.access_token)
// 5. finally redirect to the client
return res.redirect(CLIENT_URL);
}
Note: We are sending the access token in the cookie for simplicity. For a web application, we should store it somewhere more secure, like a database.
And finally, add the me
query in the server\src\index.ts
file.
import { CLIENT_URL, COOKIE_NAME, JWT_SECRET, prisma, SERVER_PORT } from "./config";
import cookieParser from "cookie-parser";
import cors from "cors";
import express from "express";
import jwt from 'jsonwebtoken'
import { getTwitterUser, twitterOauth } from "./oauth2";
import { User } from "@prisma/client";
const app = express();
const origin= [CLIENT_URL];
app.use(cookieParser());
app.use(cors({
origin,
credentials: true
}))
app.get("/ping", (_, res) => res.json("pong"));
type UserJWTPayload = Pick<User, 'id'|'type'> & {accessToken: string}
app.get('/me', async (req, res)=>{
try {
const token = req.cookies[COOKIE_NAME];
if (!token) {
throw new Error("Not Authenticated");
}
const payload = await jwt.verify(token, JWT_SECRET) as UserJWTPayload;
const userFromDb = await prisma.user.findUnique({
where: { id: payload?.id },
});
if (!userFromDb) throw new Error("Not Authenticated");
if (userFromDb.type === "twitter") {
if (!payload.accessToken) {
throw new Error("Not Authenticated");
}
const twUser = await getTwitterUser(payload.accessToken);
if (twUser?.id !== userFromDb.id) {
throw new Error("Not Authenticated");
}
}
res.json(userFromDb)
} catch (err) {
res.status(401).json("Not Authenticated")
}
})
// activate twitterOauth function when visiting the route
app.get("/oauth/twitter", twitterOauth);
app.listen(SERVER_PORT, () => console.log(`Server listening on port ${SERVER_PORT}`))
It is done now! Let's see what happens when we click the Twitter button on our client and authorize the app there.
We see our Twitter username in there instead of the Twitter button now, which shows that the me
query is being executed successfully. As a result, we now have a working user authentication system, via Twitter OAuth 2.0, in our minimal full-stack web application.
Conclusion
Thanks for reading! This is the Github repository with all the codes. Find more fun things you can do with the Twitter API here. Another example implementation of authentication via Twitter OAuth 2.0 can be found here.
Written by Rafid Hamid