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:
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:
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:
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:
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:
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,
}),
],
},
});