Social login in Node.js

author photo
Michał Dziuba03 Apr, 2023 · 21 min read
Social login guide banner
Social login guide banner

Introduction

I have seen many tutorials and articles about social login in Node.js, and most of them don't cover the actual integration with the database. This article is not a typical tutorial. I just want to discuss the tricky parts of social authentication, possible database schemas and common mistakes I have observed in other people's codes. If you are facing a similar problem, I hope you will find this text helpful.

This article DOESN'T pretend to show you best practices - this text is based on my own opinions and thoughts.

Passport.js

Let's talk about the most popular auth middleware for Node.js - PassportJS. I found using this library in a modern TypeScript stack to be a pain for me. PassportJS just feels a little bit outdated.

Strategies graveyard

What scares me the most in PassportJS? The development activity of strategies (core library is doing fine). According to the official website, Passport.js contains over 500 strategies and some of them, even official strategies like passport-github are just kinda broken. For that reason I use passport-github2 in the code sample.

TypeScript problems

Some types are just incorrect. Here is an example: profile.emails[0].verified is actually BOOLEAN, but is typed as string literal.

I found this option exists by reading a source code of strategy...

Is Passport.js so bad?

Personally, I don't like Passport.js as it is. Frameworks like Nest.js often provides some sort of wrapper for Passport.js which makes it less painful with TypeScript. I also maintain my own strategies for my side projects. They are properly typed because they are written from scratch in TypeScript. All these things together give me a decent experience.

OAuth 2.0 standard

We cannot discuss social authentication without mentioning the OAuth 2.0 standard. This standard is the basis of most Passport.js strategies, so I think it's important to understand at least the data flow. Most Passport.js strategies use Authorization Code Flow, because this flow is suited for a regular server applications.

OAuth 2.0 Authorization Code Flow example

  1. User clicks Continue with GitHub button.
  2. Button redirects user to GitHub authorization page.
  3. On GitHub authorization page, user must first log in to the service. Then they will be prompted by the service to authorize or deny the application access to their account.
  4. After successful authorization, GitHub redirects user back to our application (to callback endpoint) and sends code in query params.
  5. Our application exchange code value for access token by sending code, client id and client secret to GitHub servers.
  6. Our application sends an authorized request (with obtained access token) to the GitHub API and retrieves the profile of the authorized user.
  7. Now we can use that profile data to login or register new account in our system.

Additional resources about OAuth 2.0

Common issues

In this section we will discuss common security flaws and general issues.

You should rely on provider's id first

Email address is not reliable for existing account lookup - use social account's id instead. In my implementations I use emails only for linking NEW social provider to existing account OR creating new account. Why email address from social provider is not reliable? Because in some providers you can CHANGE email address and in many implementations I've seen, you will end up with new account in that case.

// DON'T:
const user = await userRepository.findOneBy({ email: oauthProfile.email });
if (!user) {
    const newUser = await userRepository.save({
        email: oauthProfile.email,
        name: oauthProfile.name,
        picture: oauthProfile.picture,
    });

    return createSession(newUser.id);
}

return createSession(user.id);

Pseudo code somewhere in OAuth callback handler

CSRF Attacks

You can prevent Login CSRF attacks by using state parameter defined in OAuth2 standard. The easiest way to protect your app is generating nonce (random value which is supposed to be used just once). You need to save this value in cookie or session, and redirect user to OAuth2 Authorization Page with state={nonce} parameter.

const state = randomBytes(16).toString('hex');
// '21dc458cb0f8ddcbbb68c3c8b4ac3c92'
res.cookie('state', state);

const params = {
    'client_id': process.env.GITHUB_CLIENT_ID,
    'scope': 'user:email',
    'state': state,
}

const searchParams = new URLSearchParams(params).toString();
res.redirect(`https://github.com/login/oauth/authorize?${searchParams}`);

After successful authorization, identity provider will redirect user to the callback endpoint. Callback will get authorization code and state - you must compare state parameter from callback with state saved in cookie.

app.get('/oauth/callback', (req, res) => {
    const { code, state } = req.query;
    const cookieState = req.cookies['state'];

    if (state !== cookieState) {
        return res.status(422).json({ error: 'Invalid state' });
    }

    res.clearCookie('state');
    ...
});

OAuth2 strategies for Passport.js can handle this for you. We will use this feature in the code sample to protect social logins against CSRF.

Additional resources about state parameter:

Classic-Federated Merge Attack

You should implement email verification mechanism in your application. It's very important if you want to link new social provider to existing account (registered with email and password). Attacker can create an account in your application with someone's email address. If after some time the legitimate email owner will login with identity provider, bad actor can access this account. For that reason you should require email verification during sign up. You probably may want to revoke active sessions after successful password reset, because victim may want to regain access to their account (created by attacker with email & password method) using forgot password feature.

You may also want to prompt user for the password before merging "local" account with new federated identity to ensure account ownership. But still - everything comes to implement session invalidation after successful password reset.

  1. Attacker creates new account with victim's email address
Pre-hijacking
  1. After some time victim signs up with social login provider connected to the same email. Backend merges both accounts.
  1. Attacker has access to the victim's account.

Non-Verifying IdP Attack

Email address from identity provider must be verified. Some providers don't guarantee that email address is verified - you may need to verify email ownership on your own before account merging. Otherwise, attacker can create account on identity provider's website with victim's email address.

Implementation overview

Implementation sample is obviously oversimplified. In better implementation you need two important things discussed previously:

  • email verification as part of sign up flow.
  • session invalidation after successful password reset.

Sample flow

  1. After successful OAuth2 authorization, identity provider gives us user's data like name, email and profile picture.
  2. We query database if user with identity provider's id already exists.
  3. If user exists - we just create session for them.
  4. If user doesn't exist - we query database if user with email from identity provider already exists (for example user registered with email & password previously).
  5. If user with same email exists - we will link federated identity to the existing user, and then create session.
  6. Otherwise, we will register brand-new user in our system and create session for them.

Database schema

For SQL I suggest to use one-to-many relationship (single user can link many federated identities). We need to identify each provider by name (google, github, facebook). Subject is just unique id for user from identity provider (usually it's some integer or uuid).

image

Entity diagram generated with dbdiagram.io

MongoDB schema is more flexible and there is no reason to normalize data (you shouldn't create additional collection to store federated accounts, just store them in user document). Example schemas for users collection:

{
  "_id": <ObjectId>,
  "name": "John Doe",
  "email": "[email protected]",
  "isVerified": true,
  "password": null,
  "googleId": "142984872137006300000",
  "githubId": "42580000"
}

This is the simplest schema you can apply with unique sparse indexes for googleId and githubId. The biggest disadvantage is that you have to create a separate query for each provider.

{
  "_id": <ObjectId>,
  "name": "John Doe",
  "email": "[email protected]",
  "isVerified": true,
  "password": null,
  "accounts": [
    { "provider": "google", "subject": "142984872137006300000" },
    { "provider": "github", "subject": "42580000" }
  ]
}

This schema is more generic and you can use the same query for each social auth provider. I think you can create unique compound index for accounts.provider and accounts.subject.

Code sample

Let's implement simple social login flow in TypeScript and Node.js. I will focus on most important parts of code. You can find full code in my GitHub repository.

Database setup

I gonna use TypeORM with SQLite driver (I use SQLite to make project easier to run, without requiring knowledge of tools like Docker).

/setup/db.ts

import { DataSource } from 'typeorm';
import { User } from '../entities/User';
import { FederatedAccount } from '../entities/FederatedAccount';

export const sampleDataSource = new DataSource({
    type: 'sqlite',
    database: "sample.db",
    entities: [User, FederatedAccount],
    synchronize: true,
});

export const userRepository = sampleDataSource.getRepository(User);
export const federatedAccountRepository = sampleDataSource.getRepository(FederatedAccount);

export async function startDatabase() {
    await sampleDataSource.initialize();
}

/entities/User.ts

import { Column, Entity, JoinColumn, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { FederatedAccount } from './FederatedAccount';

@Entity('users')
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    picture: string;

    @Column()
    name: string;

    @Column()
    email: string;

    @Column({ name: 'is_verified', default: false })
    isVerified: boolean;

    @Column({ nullable: true })
    password?: string;

    @OneToMany(() => FederatedAccount, account => account.user)
    accounts: FederatedAccount[];
}

/entities/FederatedAccount.ts

import {
    Column,
    Entity,
    JoinColumn,
    ManyToOne,
    PrimaryColumn,
    Unique
} from "typeorm";
import { User } from './User';
import { Providers } from '../types';

@Entity('federated_accounts')
@Unique(['provider', 'subject'])
export class FederatedAccount {
    @PrimaryColumn()
    provider: Providers;

    @PrimaryColumn()
    subject: string; // id from provider account

    @ManyToOne(() => User, (user) => user.id)
    @JoinColumn({ name: 'user_id'})
    user: User;

    @Column({ name: 'user_id' })
    userId: number;
}

Express.js and middlewares setup

Let's create two simple guard middlewares. We want page like /login and /register to be available only for guests (unauthenticated users) and /me page to be available only for authenticated users.

/setup/middlewares.ts

import { NextFunction, Request, Response } from 'express';

export function authenticatedOnly(req: Request, res: Response, next: NextFunction) {
    if (req.isUnauthenticated()) {
        return res.redirect('/auth/login');
    }

    next();
}

export function guestOnly(req: Request, res: Response, next: NextFunction) {
    if (req.isAuthenticated()) {
        return res.redirect('/me');
    }

    next();
}

In server.ts I configure Express.js and simply add request handlers for rendering pages. In profile page /me I query database for the currently authenticated user with their all connected social accounts.

/server.ts

import 'reflect-metadata';
import { config } from 'dotenv';
config();
import express from 'express';
import { setupPassport } from './setup/passport';
import { authenticatedOnly, guestOnly } from './setup/middlewares';
import { startDatabase, userRepository } from './setup/db';
import { createGravatar } from './utils';
import argon2 from 'argon2';
import flash from 'express-flash';

startDatabase();
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));
app.set('view engine', 'ejs');
app.use(flash());
setupPassport(app);

app.get('/', (req, res) => {
    res.redirect('/me');
});

app.get('/me', authenticatedOnly, async (req, res) => {
    const user = await userRepository.findOne({
        where: { id: req.user!.id },
        select: ['id', 'picture', 'name', 'accounts'],
        relations: { accounts: true },
    });

    return res.render('me', { user });
});

app.get('/auth/login', guestOnly, (req, res) => {
    const errors = req.flash('error') || [];
    res.render('login', { error: errors[0] });
});

app.get('/auth/register', guestOnly, (req, res) => {
    const errors = req.flash('error') || [];
    res.render('register', { error: errors[0] });
});


app.listen(3000, () => {
    console.log('Server started');
});

Views setup

I use EJS for HTML templates and Tailwind for styles. The simplest approach is to use the <a> element for social login buttons. In the Passport setup section we will configure /auth/google and /auth/github routes. These paths will redirect users to the provider authorization page.

/views/login.ejs

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link href="/style.min.css" rel="stylesheet">
    <title>Login page</title>
</head>
<body>
    <div class="flex min-h-screen justify-center items-center login-bg">
        <div class="bg-white sm:drop-shadow-xl py-11 px-8 rounded w-full sm:w-auto">
            <h1 class="text-2xl font-bold mb-8">Welcome back</h1>
            <form method="post" action="/auth/login" class="flex flex-col gap-2 sm:w-96">
                <a class="social-btn" href="/auth/google">
                    <img src="/google.svg" alt="Google logo" />
                    Continue with Google
                </a>
                <a class="social-btn" href="/auth/github">
                    <img src="/github.svg" alt="GitHub logo" />
                    Continue with Github
                </a>

                <div class="flex justify-center items-center gap-4 my-4 select-none">
                    <hr class="border-gray-400 w-full"/>
                    <span class="text-gray-400">OR</span>
                    <hr class="border-gray-400 w-full"/>
                </div>

                <% if (messages.success) { %>
                    <div class="border border-green-600 bg-green-100 text-green-600 flex items-center my-3 p-3 rounded gap-3">
                        <%= messages.success %>
                    </div>
                <% } %>

                <% if (error) { %>
                    <div class="error">
                        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
                            <path d="M11 11H9V5H11M11 15H9V13H11M10 0C8.68678 0 7.38642 0.258658 6.17317 0.761205C4.95991 1.26375 3.85752 2.00035 2.92893 2.92893C1.05357 4.8043 0 7.34784 0 10C0 12.6522 1.05357 15.1957 2.92893 17.0711C3.85752 17.9997 4.95991 18.7362 6.17317 19.2388C7.38642 19.7413 8.68678 20 10 20C12.6522 20 15.1957 18.9464 17.0711 17.0711C18.9464 15.1957 20 12.6522 20 10C20 8.68678 19.7413 7.38642 19.2388 6.17317C18.7362 4.95991 17.9997 3.85752 17.0711 2.92893C16.1425 2.00035 15.0401 1.26375 13.8268 0.761205C12.6136 0.258658 11.3132 0 10 0Z" fill="#D20F1B"/>
                        </svg>
                        <%= error %>
                    </div>
                <% } %>

                <div class="flex flex-col">
                    <label for="email-input" class="text-sm">Email</label>
                    <input name="email" class="form-element" required type="email" placeholder="Enter email" id="email-input">
                </div>

                <div class="flex flex-col">
                    <label for="password-input" class="text-sm">Password</label>
                    <input name="password" class="form-element" required type="password" placeholder="Enter password" id="password-input">
                </div>

                <button class="btn" type="submit">Sign In</button>

                <span class="text-center text-gray-600">Don’t have an account?&nbsp;
                    <a class="font-bold text-gray-800" href="/auth/register">Sign up </a>
                </span>
            </form>
        </div>
    </div>
</body>
</html>

/views/register.ejs

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link href="/style.min.css" rel="stylesheet">
    <title>Register</title>
</head>
<body>
<div class="flex min-h-screen justify-center items-center login-bg">
    <div class="bg-white sm:drop-shadow-xl py-11 px-8 rounded w-full sm:w-auto">
        <h1 class="text-2xl font-bold mb-8">Create new account</h1>
        <form method="post" action="/auth/register" class="flex flex-col gap-2 sm:w-96">
            <a class="social-btn" href="/auth/google">
                <img src="/google.svg" alt="Google logo" />
                Continue with Google
            </a>
            <a class="social-btn" href="/auth/github">
                <img src="/github.svg" alt="Google logo" />
                Continue with Github
            </a>

            <div class="flex justify-center items-center gap-4 my-4 select-none">
                <hr class="border-gray-400 w-full"/>
                <span class="text-gray-400">OR</span>
                <hr class="border-gray-400 w-full"/>
            </div>

            <% if (error) { %>
                <div class="error">
                    <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
                        <path d="M11 11H9V5H11M11 15H9V13H11M10 0C8.68678 0 7.38642 0.258658 6.17317 0.761205C4.95991 1.26375 3.85752 2.00035 2.92893 2.92893C1.05357 4.8043 0 7.34784 0 10C0 12.6522 1.05357 15.1957 2.92893 17.0711C3.85752 17.9997 4.95991 18.7362 6.17317 19.2388C7.38642 19.7413 8.68678 20 10 20C12.6522 20 15.1957 18.9464 17.0711 17.0711C18.9464 15.1957 20 12.6522 20 10C20 8.68678 19.7413 7.38642 19.2388 6.17317C18.7362 4.95991 17.9997 3.85752 17.0711 2.92893C16.1425 2.00035 15.0401 1.26375 13.8268 0.761205C12.6136 0.258658 11.3132 0 10 0Z" fill="#D20F1B"/>
                    </svg>
                    <%= error %>
                </div>
            <% } %>

            <div class="flex flex-col">
                <label for="email-input" class="text-sm">Email</label>
                <input name="email" class="form-element" required type="email" placeholder="Enter email" id="email-input">
            </div>

            <div class="flex flex-col">
                <label for="name-input" class="text-sm">Name</label>
                <input name="name" class="form-element" required type="text" placeholder="Enter name" id="name-input">
            </div>

            <div class="flex flex-col">
                <label for="password-input" class="text-sm">Password</label>
                <input name="password" class="form-element" required type="password" placeholder="Enter password" id="password-input">
            </div>

            <button class="btn" type="submit">Sign Up</button>

            <span class="text-center text-gray-600">Already have an account?&nbsp;
                    <a class="font-bold text-gray-800" href="/auth/login">Sign in </a>
                </span>
        </form>
    </div>
</div>
</body>
</html>

/views/me.ejs

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link href="/style.min.css" rel="stylesheet">
    <title>Profile (<%= user.name %>)</title>
</head>
<body>
<div class="flex flex-col gap-5 min-h-screen justify-center items-center">
    <img class="rounded-full w-40 h-40" alt="avatar" src="<%= user.picture %>" />
    <h1 class="text-xl font-bold"><%= user.name %></h1>

    <% if (user.accounts.length) { %>
        <% for (const account of user.accounts) { %>
            <div class="flex justify-center items-center w-64 gap-2 p-3 border rounded capitalize select-none">
                <img src="/<%= account.provider %>.svg" alt="<%= account.provider %> logo" />
                <%= account.provider %>
            </div>
        <% } %>
    <% } else { %>
        <div class="flex text-gray-600 justify-center items-center w-64 gap-2 p-3 border rounded capitalize select-none">
            No external auth providers
        </div>
    <% } %>

    <a href="/logout" class="btn mt-12">Logout</a>
</div>
</body>
</html>

Register and logout

Let's create simple function to generate gravatar URL for every new user registered with "local" provider.

/utils.ts

import { createHash } from 'crypto';

export function createGravatar(email: string) {
    const hash = createHash('md5').update(email).digest('hex');
    return `https://www.gravatar.com/avatar/${hash}`;
}

Register handler is also nothing special - just query database for user by email address, if user already exists flash error, otherwise hash password and insert new user to database.

/server.ts - continuation

app.post('/auth/register', guestOnly, async (req, res) => {
   const { email, name, password } = req.body;
   const exists = await userRepository.exist({ where: { email } });
   if (exists) {
       req.flash('error', ['User already exists']);
       return res.redirect('/auth/register');
   }

   const hashedPassword = await argon2.hash(password);
   await userRepository.save({
       email,
       name,
       password: hashedPassword,
       picture: createGravatar(email),
       /*
        Email verification is out of scope of this guide.
        You should require email verification during registration.
       */
       isVerified: true,
   });

    req.flash('success', 'Now you can login to created account');
    return res.redirect('/auth/login');
});

app.get('/logout', authenticatedOnly, (req, res) => {
   req.logout({ keepSessionInfo: false }, () => {});
   return res.redirect('/auth/login');
});

Passport setup

In passport.ts I decided to put session configuration and obviously passport strategy configurations.

Still nothing special...

/setup/passport.ts

import passport from 'passport';
import { Express } from 'express';
import { googleStrategy } from '../strategies/google-strategy';
import { githubStrategy } from '../strategies/github-strategy';
import session from 'express-session';
import { guestOnly } from './middlewares';
import { localStrategy } from '../strategies/local-strategy';

export function setupPassport(app: Express) {
    //  We are using session authentication
    app.use(session({
        name: 'sid',
        secret: process.env.SESSION_SECRET!,
        resave: false,
        saveUninitialized: false,
    }));

    app.use(passport.initialize());
    app.use(passport.session());

    passport.serializeUser(function(user, cb) {
        return cb(null, user);
    });

    passport.deserializeUser(function(user: any, cb) {
        return cb(null, user);
    });

    // Register all 3 strategies
    passport.use(localStrategy);
    passport.use(googleStrategy);
    passport.use(githubStrategy);

    // Redirects to Google authorization page
    app.get('/auth/google', guestOnly, passport.authenticate('google'));
    // Redirects to GitHub authorization page
    app.get('/auth/github', guestOnly, passport.authenticate('github'));

    // @ts-ignore: for some reason badRequestMessage is not typed
    app.post('/auth/login', guestOnly, passport.authenticate('local', {
        session: true,
        successRedirect: '/me',
        failureRedirect: '/auth/login',
        failureFlash: true,
        badRequestMessage: 'Invalid email or password',
    }));

    // Callback endpoints must be definied in OAuth application settings
    app.get('/auth/google/callback', guestOnly, passport.authenticate('google', {
        session: true,
        failureRedirect: '/auth/login',
        successRedirect: '/me',
    }));

    // Callback endpoints must be definied in OAuth application settings
    app.get('/auth/github/callback', guestOnly, passport.authenticate('github', {
        session: true,
        successRedirect: '/me',
        failureRedirect: '/auth/login',
    }));
}

Local login strategy

In the local strategy, I just query the database for the user by email. If the password is undefined, it means they are using a social authentication provider, otherwise I can verify the password and create a session for the user.

/strategies/local-strategy.ts

import { Strategy } from 'passport-local';
import { userRepository } from '../setup/db';
import argon2 from 'argon2';

const invalidMatch = { message: 'Invalid email or password' };

export const localStrategy = new Strategy({
    usernameField: 'email',
    passReqToCallback: true,
}, async (req, email, password, done) => {
    const user = await userRepository.findOneBy({ email });
    if (!user) {
        return done(null, false, invalidMatch);
    }

    if (!user.password) {
        return done(null, false, invalidMatch);
    }

    const areSame = await argon2.verify(user.password, password);
    if (!areSame) {
        return done(null, false, invalidMatch);
    }

    return done(null, { id: user.id });
});

Google and GitHub strategies

I create simple helper functions to get verified emails and picture from social providers.

/utils.ts

import { createHash } from 'crypto';

export function createGravatar(email: string) {
    const hash = createHash('md5').update(email).digest('hex');
    return `https://www.gravatar.com/avatar/${hash}`;
}

interface Picture {
    value: string;
}

export function getPicture(email: string, photosArray?: Picture[]) {
    if (photosArray && photosArray.length) {
        return photosArray[0].value;
    }

    // create gravatar if social provider for some reason doesn't return picture
    return createGravatar(email);
}

export interface GoogleEmail {
    value: string;
    verified: boolean;
}

export function getGoogleEmail(emails?: GoogleEmail[]) {
    if (!emails) {
        return;
    }

    const verifiedEmail = emails.find(email => email.verified);
    if (!verifiedEmail) {
        return;
    }

    return verifiedEmail.value;
}

export interface GithubEmail {
    value: string;
    verified: boolean;
    primary: boolean;
}

export function getGithubEmail(emails?: GithubEmail[]) {
    if (!emails) {
        return;
    }

    const primaryEmail = emails.find(email => email.primary && email.verified);
    if (!primaryEmail) {
        return;
    }

    return primaryEmail.value;
}

Code for both social strategies is very similar. As you can see I check if email is verified before I even perform any action. You can also observe two functions:

  • findLinkedAccount searches for existing social account. Returns user's id.
  • linkAccount links new social provider to existing account OR creates completely new user. Returns user's id as well.

/strategies/google-strategy.ts

import { Strategy } from "passport-google-oauth20";
import { findLinkedAccount, linkAccount } from "../link-account";
import { Providers } from "../types";
import { getGoogleEmail, getPicture } from "../utils";

export const googleStrategy = new Strategy({
    clientID: process.env.GOOGLE_CLIENT_ID!,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    callbackURL: '/auth/google/callback',
    state: true,
    scope: ['email', 'profile'],
}, async (_accessToken, _refreshToken, profile, done) => {
    // get email from Google payload - we want only VERIFIED emails
    const email = getGoogleEmail(profile.emails as any);
    if (!email) {
        return done(new Error('Google account email must be verified'));
    }

    // search database for federated account and return userId (implementation details will be later).
    // Remember - search by social account id instead of email.
    const linkedUserId = await findLinkedAccount(Providers.GOOGLE, profile.id);
    if (linkedUserId) {
        return done(null, { id: linkedUserId });
    }

    // if federated account connected to any user doesn't exist, we will search by email.
    const createdUserId = await linkAccount(Providers.GOOGLE, {
        name: profile.displayName,
        email: email,
        picture: getPicture(email, profile.photos),
        subject: profile.id,
    });

    if (!createdUserId) {
        return done(new Error('Google account cannot be linked'));
    }

    return done(null, { id: createdUserId });
});

/strategies/github-strategy.ts

import { Strategy, Profile } from 'passport-github2';
import { getGithubEmail, getPicture } from '../utils';
import { findLinkedAccount, linkAccount } from '../link-account';
import { Providers } from '../types';

export const githubStrategy = new Strategy({
    clientID: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    callbackURL: '/auth/github/callback',
    // @ts-ignore: for some reason is string type required
    state: true,
    scope: ['user:email'],
    // @ts-ignore:
    allRawEmails: true,
}, async (_accessToken, _refreshToken, profile: Profile, done) => {
    const email = getGithubEmail(profile.emails as any);
    if (!email) {
        return done(new Error('GitHub account primary email must be verified'));
    }

    const linkedUserId = await findLinkedAccount(Providers.GITHUB, profile.id);
    if (linkedUserId) {
        return done(null, { id: linkedUserId });
    }

    const createdUserId = await linkAccount(Providers.GITHUB, {
        name: profile.displayName,
        email: email,
        picture: getPicture(email, profile.photos),
        subject: profile.id,
    });

    if (!createdUserId) {
        return done(new Error('GitHub account cannot be linked'));
    }

    return done(null, { id: createdUserId });
});

In the findLinkedAccount function we just query the federated_accounts table, if federated account found, we return the id of the actual user.

/link-account.ts

import { LinkAccountOptions, Providers } from './types';
import {
    federatedAccountRepository,
    sampleDataSource,
    userRepository
} from './setup/db';

export async function findLinkedAccount(provider: Providers, subject: string) {
    const federatedAccount = await federatedAccountRepository.findOneBy({
        provider, subject
    });

    if (!federatedAccount) {
        return;
    }

    return federatedAccount.userId;
}

In the linkAccount function, we simply search user by email from social provider, if user with that email already exists - we just create new federated account linked to the existing user. If user does not exist - we register new user and create federated account linked to the created user.

Last operation is running in transaction to avoid data inconsistency in case of failure.

/link-account.ts - continuation

export async function linkAccount(provider: Providers, options: LinkAccountOptions) {
    const { subject, picture, name, email } = options;
    const user = await userRepository.findOneBy({ email });
    if (user && !user.isVerified) {
        /*
          IMPORTANT: Abort linking account process, to prevent pre-Authentication Account Takeover attack.
          If user already exists in our database and their email address is unverified, you should disallow social account linking.
        */
        return;
    }

    if (user) {
        await federatedAccountRepository.save({
            subject,
            provider,
            userId: user.id,
        });

        return user.id;
    }

    return await sampleDataSource.transaction(async t => {
        const newUser = userRepository.create({
            name,
            email,
            picture,
            // as we deny unverified emails from social providers I think we can create user as verified:
            isVerified: true,
        });

        const createdUser = await t.save(newUser);
        const newFederatedAccount = federatedAccountRepository.create({
            userId: createdUser.id,
            provider,
            subject,
        });

        await t.save(newFederatedAccount);
        return createdUser.id;
    });
}

Summary

Thanks for reading this guide, I hope you found it helpful and interesting. Any feedback is welcome. Let me know if you know a better approach :)

Additional resources

author photo

Michał Dziuba

I started with programming in 2018. I’m interested in backend development, operating systems and open-source-software movement.

Latest posts

article banner
Reset password in Node.js
15 Apr, 2023 · 7 min read
article banner
Social login in Node.js
03 Apr, 2023 · 21 min read
article banner
Hello world
01 Apr, 2023 · 3 min read
MD

© 2023 Michał Dziuba. All rights reserved.