Single Sign-On (SSO)

Learn how to implement custom Single Sign-On providers in VitNode

Getting Started

Want to let your users sign in with their favorite services? Let's build a custom SSO provider! We'll use Discord as an example, but you can adapt this guide for any OAuth2 provider.

Create Your SSO Plugin

Let's start with the basics. Create a new file for your SSO provider:

src/utils/sso/discord_api.ts
import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso';

export const DiscordSSOApiPlugin = ({
  clientId,
  clientSecret,
}: {
  clientId: string;
  clientSecret: string;
}): SSOApiPlugin => {
  const id = 'discord';
  const redirectUri = getRedirectUri(id);

  return { id, name: 'Discord' };
};

This is like creating a blueprint for your SSO provider. The id will be used in URLs and the name is what users will see.

Add Authentication URL Generator

Now let's add the magic that sends users to Discord for login:

src/utils/sso/discord_api.ts
import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso';

export const DiscordSSOApiPlugin = ({
  clientId,
  clientSecret,
}: {
  clientId: string;
  clientSecret: string;
}): SSOApiPlugin => {
  const id = 'discord';
  const redirectUri = getRedirectUri(id);

  return {
    id,
    name: 'Discord',
    getUrl: ({ state }) => {
      const url = new URL('https://discord.com/oauth2/authorize');
      url.searchParams.set('client_id', clientId);
      url.searchParams.set('redirect_uri', redirectUri);
      url.searchParams.set('response_type', 'code');
      url.searchParams.set('scope', 'identify email');
      url.searchParams.set('state', state);
      return url.toString();
    },
  };
};

Always include the state parameter - it's your security guard against CSRF attacks. Don't worry, VitNode handles this automatically!

Handle Token Exchange

After the user approves access, Discord sends us a code. Let's exchange it for an access token:

src/utils/sso/discord_api.ts
import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso';
import { HTTPException } from 'hono/http-exception';
import { ContentfulStatusCode } from 'hono/utils/http-status';
import { z } from 'zod';

const tokenSchema = z.object({
  access_token: z.string(),
  token_type: z.string(),
});

export const DiscordSSOApiPlugin = ({
  clientId,
  clientSecret,
}: {
  clientId: string;
  clientSecret: string;
}): SSOApiPlugin => {
  const id = 'discord';
  const redirectUri = getRedirectUri(id);

  return {
    id,
    name: 'Discord',
    fetchToken: async code => {
      const res = await fetch('https://discord.com/api/oauth2/token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Accept: 'application/json',
        },
        body: new URLSearchParams({
          code,
          redirect_uri: redirectUri,
          grant_type: 'authorization_code',
          client_id: clientId,
          client_secret: clientSecret,
        }),
      });

      if (!res.ok) {
        throw new HTTPException(
          +res.status.toString() as ContentfulStatusCode,
          {
            message: 'Internal error requesting token',
          },
        );
      }

      const { data, error } = tokenSchema.safeParse(await res.json());
      if (error || !data) {
        throw new HTTPException(400, {
          message: 'Invalid token response',
        });
      }

      return data;
    },
    getUrl: ({ state }) => {
      const url = new URL('https://discord.com/oauth2/authorize');
      url.searchParams.set('client_id', clientId);
      url.searchParams.set('redirect_uri', redirectUri);
      url.searchParams.set('response_type', 'code');
      url.searchParams.set('scope', 'identify email');
      url.searchParams.set('state', state);

      return url.toString();
    },
  };
};

Get User Information

Finally, let's get the user's profile data using our shiny new access token:

src/utils/sso/discord_api.ts
import { SSOApiPlugin, getRedirectUri } from '@vitnode/core/api/models/sso';
import { HTTPException } from 'hono/http-exception';
import { ContentfulStatusCode } from 'hono/utils/http-status';
import { z } from 'zod';

const userSchema = z.object({
  id: z.number(),
  email: z.string(),
  username: z.string(),
});

export const DiscordSSOApiPlugin = ({
  clientId,
  clientSecret,
}: {
  clientId: string;
  clientSecret: string;
}): SSOApiPlugin => {
  const id = 'discord';
  const redirectUri = getRedirectUri(id);

  return {
    id,
    name: 'Discord',
    fetchUser: async ({ token_type, access_token }) => {
      const res = await fetch('https://discord.com/api/users/@me', {
        headers: {
          Authorization: `${token_type} ${access_token}`,
        },
      });

      const { data, error } = userSchema.safeParse(await res.json());
      if (error || !data) {
        throw new HTTPException(400, {
          message: 'Invalid user response',
        });
      }

      return data;
    },
    fetchToken: async code => {
      const res = await fetch('https://discord.com/api/oauth2/token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          Accept: 'application/json',
        },
        body: new URLSearchParams({
          code,
          redirect_uri: redirectUri,
          grant_type: 'authorization_code',
          client_id: clientId,
          client_secret: clientSecret,
        }),
      });

      if (!res.ok) {
        throw new HTTPException(
          +res.status.toString() as ContentfulStatusCode,
          {
            message: 'Internal error requesting token',
          },
        );
      }

      const { data, error } = tokenSchema.safeParse(await res.json());
      if (error || !data) {
        throw new HTTPException(400, {
          message: 'Invalid token response',
        });
      }

      return data;
    },
    getUrl: ({ state }) => {
      const url = new URL('https://discord.com/oauth2/authorize');
      url.searchParams.set('client_id', clientId);
      url.searchParams.set('redirect_uri', redirectUri);
      url.searchParams.set('response_type', 'code');
      url.searchParams.set('scope', 'identify email');
      url.searchParams.set('state', state);

      return url.toString();
    },
  };
};

Pro tip: Some OAuth providers might return unverified email addresses. If your provider gives you an email verification status, add it to your validation to keep things secure!

Connect Everything Together

Last step! Let's plug your new SSO provider into your app:

src/app/api/[...route]/route.ts
import { OpenAPIHono } from '@hono/zod-openapi';
import { handle } from 'hono/vercel';
import { VitNodeAPI } from '@vitnode/core/api/config';
import { DiscordSSOApiPlugin } from '@/utils/sso/discord_api';

const app = new OpenAPIHono().basePath('/api');
VitNodeAPI({
  app,
  plugins: [],
  authorization: {
    ssoPlugins: [
      DiscordSSOApiPlugin({
        clientId: process.env.DISCORD_CLIENT_ID,
        clientSecret: process.env.DISCORD_CLIENT_SECRET,
      }),
    ],
  },
});