Single Sign-On (SSO)

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

Custom SSO Provider

This guide explains how to create and implement custom SSO providers in VitNode applications.

Create plugin function

Start by creating a new file with a function that implements the SSOApiPlugin type, which requires id and name properties at minimum.

src/utils/sso/discord-api.ts
import { SSOApiPlugin, getRedirectUri } from 'vitnode/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 method

Implement the getUrl method inside your function to generate the OAuth authorization URL for your SSO provider.

This URL should include appropriate scope parameters to request access to the user's id, email, and username (if available).

src/utils/sso/discord-api.ts
import { SSOApiPlugin, getRedirectUri } from 'vitnode/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();
    },
  };
};

State Parameter Security

Always include the state parameter to prevent CSRF attacks. VitNode handles this for you when you use the provided parameters.

fetchToken method

Next, implement the fetchToken method to exchange the authorization code for an access token:

src/utils/sso/discord-api.ts
import { SSOApiPlugin, getRedirectUri } from 'vitnode/api/models/sso';
import { HTTPException } from 'hono/http-exception';
import { ContentfulStatusCode } from 'hono/utils/http-status';
import { z } from 'zod';
 
export const DiscordSSOApiPlugin = ({
  clientId,
  clientSecret,
}: {
  clientId: string;
  clientSecret: string;
}): SSOApiPlugin => {
  const id = 'discord';
  const redirectUri = getRedirectUri(id);
 
  const tokenSchema = z.object({
    access_token: z.string(),
    token_type: z.string(),
  });
 
  return {
    id,
    name: 'Discord',
    getUrl: ({ state }) => {
      // ...existing code...
    },
    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;
    },
  };
};

fetchUser method

Finally, implement the fetchUser method to retrieve the user's profile information using the access token:

src/utils/sso/discord-api.ts
import { SSOApiPlugin, getRedirectUri } from 'vitnode/api/models/sso';
import { HTTPException } from 'hono/http-exception';
import { ContentfulStatusCode } from 'hono/utils/http-status';
import { z } from 'zod';
 
export const DiscordSSOApiPlugin = ({
  clientId,
  clientSecret,
}: {
  clientId: string;
  clientSecret: string;
}): SSOApiPlugin => {
  const id = 'discord';
  const redirectUri = getRedirectUri(id);
 
  const tokenSchema = z.object({
    access_token: z.string(),
    token_type: z.string(),
  });
 
  const userSchema = z.object({
    id: z.string(),
    email: z.string(),
    username: z.string(),
  });
 
  return {
    id,
    name: 'Discord',
    getUrl: ({ state }) => {
      // ...existing code...
    },
    fetchToken: async code => {
      // ...existing code...
    },
    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;
    },
  };
};

Email Verification

Some OAuth2 providers return email addresses that may not be verified. If the API provides verification status (like verified_email), add it to your schema validation to ensure you're only accepting verified emails.

Register the SSO Plugin

The final step is to register your SSO plugin in the API route configuration:

src/app/api/[...route]/route.ts
import { OpenAPIHono } from '@hono/zod-openapi';
import { handle } from 'hono/vercel';
import { VitNodeAPI } from 'vitnode/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,
      }),
    ],
  },
});

Environment Variables

Remember to add the required environment variables (DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET) to your project configuration.

On this page