Preventing account sharing with Clerk
Tags: webdev, javascript, tutorial, clerk
Published: 2024-06-03 14:40:28

# 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](https://github.com/t3-oss/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
```md
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
```

## Implementation
The full code is right [here](https://github.com/tanshunyuan/blog-session-invalidation/)

### Configuring a web socket service on your application
With a pusher account, follow it's JS SDK [walkthrough](https://pusher.com/docs/channels/getting_started/javascript/)to retrieve the environment variables from your account.

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

**Client**
```ts
// 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,
});
```
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**
```ts
// 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;
};
```

**Authenticating Client Pusher Request on the server**
```ts
// 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 });
  }
}
```


## Establish a client web socket connection

 **Client: Creating a hook** 
```ts
// 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]);
};
```
Notes:
* `didInit` 
	* Ensures that the hook only run once when mounted. More [here](https://react.dev/learn/you-might-not-need-an-effect#initializing-the-application)
* `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**
```tsx
// 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}
  </>
}
```
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**
```tsx
// 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 {};
    }
  }),
})
```
Notes
* Ensure both `channelName` & `eventName` for pusher is the same as the one on the client side

**Client**
```tsx
// 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}
  </>
}
```

## 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:
```tsx
// 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);
    }
  };
};
```
* Using `use` as a keyword to prevent typescript from complaining

Refer to [here](https://github.com/tanshunyuan/blog-prevent-account-sharing/blob/main/src/hooks/use-setup-session-management.ts) 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
```tsx
router.push(`${ROUTE_PATHS.SIGNIN}?forcedRedirect=true`);
```

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:
```tsx
// 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 />
  );
}
```
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](https://github.com/tanshunyuan/blog-prevent-account-sharing/tree/main)