Preventing account sharing with Clerk

shunyuan

Tan Shun Yuan

Posted on June 3, 2024

Preventing account sharing with Clerk

Scenario

Imagine the following scenario:

When User B logs in using User A's credentials from a different device, the platform invalidates User A's active session, logs them out, and allows User B to access the account to prevent account sharing.

The Problem

While Clerk provides a way to invalidate a user session, there aren't any docs or guides on achieving the said scenario. Thus, I've written this guide to illustrate the process.

The Guide

Before we continue

Disclaimer

This walkthrough will not cover how Clerk was set up with TRPC & NextJS 14 as seen in the example repo. It'll only go through the core logic/implementation of preventing account sharing.

Tech Stack

This guide uses the following tech stack:

  1. Create T3 APP
    • NextJS 14 app directory
    • TRPC 11
  2. Pusher Channels
    • We'll be using the free tier which allows for 200k messages per day & 100 concurrent connection.
  3. Clerk

While it's using bleeding edge tech, I believe the concept described in the guide can be used on a traditional client-server architecture as well.

Folder Structure

src
├── app
│   ├── (auth)
│   │   ├── layout.tsx
│   │   └── sign-in
│   │       └── [[...sign-in]]
│   │           └── page.tsx
│   ├── (internal)
│   │   └── app
│   │       ├── dashboard
│   │       │   └── page.tsx
│   │       └── layout.tsx
│   ├── api
│   │   └── pusher
│   │      └── auth
│   │          └── route.ts
│   └── layout.tsx
├── hooks
│   └── use-setup-session-management.ts
├── lib
│   └── pusher
│       ├── client.ts
│       └── server.ts
├── server
│   └── api
│       └── routers
│          └── user.ts
└── utils
    ├── app-provider.tsx
    └── route-paths.ts
Enter fullscreen mode Exit fullscreen mode

Implementation

The full code is right here

Configuring a web socket service on your application

With a pusher account, follow it's JS SDK walkthroughto retrieve the environment variables from your account.

Now we need to setup a client and server pusher instance as follows:

Client

// src/lib/pusher/client.ts

import PusherClient from "pusher-js";
import { env } from "~/env.js";

const PUSHER_AUTH_ENDPOINT = "/api/pusher/auth";
export const pusherClient = new PusherClient(env.NEXT_PUBLIC_PUSHER_KEY, {
  cluster: env.NEXT_PUBLIC_PUSHER_CLUSTER,
  authEndpoint: PUSHER_AUTH_ENDPOINT,
});
Enter fullscreen mode Exit fullscreen mode

Notes

  • PUSHER_AUTH_ENDPOINT is an authentication endpoint that will be called on the client side to authenticate the outgoing request to the server.

Server

// src/lib/pusher/server.ts

import PusherServer from "pusher";
import { env } from "~/env.js";

let pusherInstance: PusherServer | null = null;

export const getPusherInstance = () => {
  if (!pusherInstance) {
    pusherInstance = new PusherServer({
      appId: env.PUSHER_APP_ID,
      key: env.NEXT_PUBLIC_PUSHER_KEY,
      secret: env.PUSHER_SECRET,
      cluster: env.NEXT_PUBLIC_PUSHER_CLUSTER,
      useTLS: true,
    });
  }
  return pusherInstance;
};
Enter fullscreen mode Exit fullscreen mode

Authenticating Client Pusher Request on the server

// src/app/api/pusher/auth/route.ts

import { getPusherInstance } from "~/lib/pusher/server";

const pusherServer = getPusherInstance();

export async function POST(req: Request) {
  // see https://pusher.com/docs/channels/server_api/authenticating-users
  const data = await req.text();
  const [socket_id, channel_name] = data
    .split("&")
    .map((str) => str.split("=")[1]);

  // use JWTs here to authenticate users before continuing
  try {
    const auth = pusherServer.authorizeChannel(socket_id!, channel_name!);
    return new Response(JSON.stringify(auth));
  } catch (error) {
    console.error("pusher/auth.handler.catch", { details: error });
  }
}
Enter fullscreen mode Exit fullscreen mode

Establish a client web socket connection

Client: Creating a hook

// src/hooks/use-setup-session-management.ts

import { useUser } from "@clerk/nextjs";
import { pusherClient } from "~/lib/pusher/client";
import { useState } from "react";

let didInit = false;
export const useSubscribeToSessionChannel = () => {
  const { user } = useUser();

  useEffect(() => {
    if (!didInit) {
      const channel = pusherClient
        .subscribe("private-session")
        .bind(
          `evt::revoke-${user?.id}`,
          (data: { type: string; data: string[] }) => {
            if (data.type === "session-revoked") {
                // handle session removal
            }
          },
        );
      didInit = true;

      return () => {
        channel.unbind();
        didInit = false;
      };
    }
  }, [user, handleSessionRemoval]);
};
Enter fullscreen mode Exit fullscreen mode

Notes:

  • didInit
    • Ensures that the hook only run once when mounted. More here
  • pusherClient.subscribe('private-session')
    • private-session is the channel name
  • pusherClient.subscribe('private-session').bind('evt::revoke-${user?.id})
    • evt::revoke-${user?.id} is the event name to be triggered within the channel.

Client: Mounting it in the protected directory provider

// src/utils/app-provider.tsx

'use client'
import { useSubscribeToSessionChannel } from "~/hooks/use-setup-session-management";
import { type BaseChildrenProps } from "~/types/common";

export const AppProvider = (props: BaseChildrenProps) => {
  const { children } = props

  useSubscribeToSessionChannel();

  return <>
    {children}
  </>
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • By putting it in a provider it means that every time the user visits the main application. This connection will be setup ## Query for extra session on the server side

Server

// src/server/api/routers/user.ts

import { clerkClient } from "@clerk/nextjs/server";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { getPusherInstance } from '~/lib/pusher/server';

const pusherServer = getPusherInstance();

export const userRouter = createTRPCRouter({
  getExcessSessions: protectedProcedure.query(async ({ ctx }) => {
    const { userId, sessionId: currentSessionId } = ctx.auth;

    const { data: activeSessions } = await clerkClient.sessions.getSessionList({
      userId,
      status: "active",
    });

    if (activeSessions.length <= 1) return null;

    const excessSessionsIds = activeSessions
      .filter((session) => session.id !== currentSessionId)
      .map((session) => session.id);

    const revokeSessionsPromises = excessSessionsIds.map((sessionId) =>
      clerkClient.sessions.revokeSession(sessionId),
    );

    try {
      await Promise.all(revokeSessionsPromises).then(async () => {
        await pusherServer
          .trigger("private-session", `evt::revoke-${userId}`, {
            type: "session-revoked",
            data: excessSessionsIds,
          })
      });
    } catch (error) {
      console.error(error);
    } finally {
      return {};
    }
  }),
})
Enter fullscreen mode Exit fullscreen mode

Notes

  • Ensure both channelName & eventName for pusher is the same as the one on the client side

Client

// src/utils/app-provider.tsx 

'use client'
import { useSubscribeToSessionChannel } from "~/hooks/use-setup-session-management";
import { api } from "~/trpc/react";
import { type BaseChildrenProps } from "~/types/common";

export const AppProvider = (props: BaseChildrenProps) => {
  const { children } = props

  useSubscribeToSessionChannel();
  const excessSessionQuery = api.user.getExcessSessions.useQuery()

  if (excessSessionQuery.isLoading) return <p>Loading...</p>

  return <>
    {children}
  </>
}
Enter fullscreen mode Exit fullscreen mode

Sign user out based on extra sessions

We're going to handle the information from the callback in the web socket connection

We'd need to define how to handle the session removal when the web socket receives an item:

// src/hooks/use-setup-session-management.ts

const useHandleSignOut = () => {
  const { signOut } = useClerk();
  const router = useRouter();

  return async (currentSessionId: string) => {
    await signOut(() => {
      router.push(`${ROUTE_PATHS.SIGNIN}?forcedRedirect=true`);
    }, {
      sessionId: currentSessionId
    })
  }
};

const useHandleSessionRemoval = () => {
  const { session: currentSession } = useClerk();
  const handleSignOut = useHandleSignOut();

  return async (excessSessionIds: string[]) => {
    try {
      const hasExcess = excessSessionIds.length > 0;
      const isCurrentSessionExcess =
        hasExcess && currentSession && excessSessionIds.includes(currentSession.id);

      if (!isCurrentSessionExcess) return;

      await handleSignOut(currentSession.id);
    } catch (error) {
      console.error('Error removing session:', error);
    }
  };
};
Enter fullscreen mode Exit fullscreen mode
  • Using use as a keyword to prevent typescript from complaining

Refer to here for the full code.

Touching up the UI

After the user is signed out, they'll be redirected to the sign in page. At this point we'd want to notify the user that they've been logged out.

Recall this is how we redirected the user

router.push(`${ROUTE_PATHS.SIGNIN}?forcedRedirect=true`);
Enter fullscreen mode Exit fullscreen mode

Notice the query parameter of forcedRedirect=true, this is used to trigger a toast to indicate the user they've been logged out on the sign in page.

Here's how:

// src/app/(auth)/sign-in/[[...sign-in]]/page.tsx

"use client";
import { SignIn } from "@clerk/nextjs";
import { useSearchParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { ROUTE_PATHS } from "~/utils/route-paths";
import toast from "react-hot-toast";

export default function SignInPage() {
  const router = useRouter();
  const searchParams = useSearchParams();

  const forcedRedirect = searchParams.get("forcedRedirect");

  useEffect(() => {
    if (forcedRedirect) {
      toast.error('Detected additional sessions, kicking you out');
      router.replace(ROUTE_PATHS.SIGNIN, undefined);
    }
  }, [forcedRedirect]);
  return (
    <SignIn />
  );
}
Enter fullscreen mode Exit fullscreen mode

Notes

  • We detect if there's a searchParam called forcedRedirect
  • If there is we display a toast telling the user that they've been logged out
  • We also perform a router.replace to remove the query param on the URL to hide the search query.

Conclusion

By the end of this guide, you should have grasp on how to leverage Clerk's session management to prevent account sharing.

If there's any questions or remarks, feel free to leave it in the comments!

The full code to this guide is here

💖 💪 🙅 🚩
shunyuan
Tan Shun Yuan

Posted on June 3, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related