Documentation

SSO

Custom adapter

Build a custom OAuth2 single sign-on adapter for VitNode with the SSOApiPlugin interface, using GitHub as the example.

VitNode includes 15 built-in SSO adapters, from Apple to Twitter (X). You can connect any other OAuth 2.0 or OpenID Connect provider (for example GitHub, Bitbucket or Zoom) by implementing the SSOApiPlugin interface. VitNode handles the sign-in attempt (state, PKCE verifier, nonce), account linking, email confirmation and the login buttons.

Before you begin

  • A VitNode app with SSO set up as on SSO, so you know which app holds your API config and .env.
  • A GitHub OAuth app. Create one under Settings → Developer settings → OAuth Apps → New OAuth App, with the Authorization callback URL http://localhost:3000/login/sso/github (your VITNODE_WEB_URL plus /login/sso/github). Copy the Client ID, then generate a client secret.
  • An email adapter if members may have no verified primary email on GitHub. They confirm an address by email first.

Build a GitHub adapter

An adapter has three functions: getUrl sends the browser to the provider, fetchToken exchanges the code for an access token, and fetchUser turns that token into a provider account. Everything runs on the API server.

Add the credentials

Add the GitHub credentials to the API's .env:

apps/web/.env (Single App with Turborepo)
GITHUB_CLIENT_ID=Ov23lixxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Create the adapter

Create src/utils/sso/github.ts next to vitnode.api.config.ts, in the same app:

src/utils/sso/github.ts
import type { SSOApiPlugin } from "@vitnode/core/api/models/sso";

import {
  bearerAuth,
  formPost,
  requestSsoProviderJson,
  requireSsoOption,
} from "@vitnode/core/api/lib/sso-protocol";
import { getRedirectUri } from "@vitnode/core/api/models/sso";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";

const tokenSchema = z.union([
  z.object({ access_token: z.string().min(1), token_type: z.string() }),
  z.object({ error: z.string() }),
]);

const userSchema = z.object({
  avatar_url: z.string().nullable().optional(),
  id: z.number().int().positive(),
  login: z.string(),
});

const emailsSchema = z.array(
  z.object({ email: z.string(), primary: z.boolean(), verified: z.boolean() }),
);

export const GitHubSSOApiPlugin = ({
  clientId,
  clientSecret,
}: {
  clientId: string | undefined;
  clientSecret: string | undefined;
}): SSOApiPlugin => {
  const id = "github";
  const redirectUri = getRedirectUri(id);

  return {
    id,
    name: "GitHub",
    profileFields: ["avatar"],
    getUrl: ({ codeChallenge, codeChallengeMethod, state }) => {
      const url = new URL("https://github.com/login/oauth/authorize");
      url.searchParams.set(
        "client_id",
        requireSsoOption(clientId, "GitHub", "client ID"),
      );
      url.searchParams.set("redirect_uri", redirectUri);
      url.searchParams.set("scope", "user:email");
      url.searchParams.set("state", state);
      url.searchParams.set("code_challenge", codeChallenge);
      url.searchParams.set("code_challenge_method", codeChallengeMethod);

      return url.toString();
    },
    fetchToken: async (code, { codeVerifier }) => {
      const token = await requestSsoProviderJson({
        init: formPost({
          client_id: requireSsoOption(clientId, "GitHub", "client ID"),
          client_secret: requireSsoOption(
            clientSecret,
            "GitHub",
            "client secret",
          ),
          code,
          code_verifier: codeVerifier,
          redirect_uri: redirectUri,
        }),
        schema: tokenSchema,
        step: "token",
        url: "https://github.com/login/oauth/access_token",
      });
      if ("error" in token) {
        throw new HTTPException(400, { message: "GitHub refused the code" });
      }

      return token;
    },
    fetchUser: async ({ access_token }) => {
      const init = {
        headers: {
          ...bearerAuth(access_token),
          Accept: "application/vnd.github+json",
        },
      };
      const user = await requestSsoProviderJson({
        init,
        schema: userSchema,
        step: "profile",
        url: "https://api.github.com/user",
      });
      const emails = await requestSsoProviderJson({
        init,
        schema: emailsSchema,
        step: "email",
        url: "https://api.github.com/user/emails",
      });
      const primary = emails.find(email => email.primary);

      return {
        avatarUrl: user.avatar_url ?? null,
        email: primary?.email ?? null,
        emailVerified: primary?.verified === true,
        id: String(user.id),
        username: user.login,
      };
    },
  };
};

What keeps it safe:

  • Failed or malformed responses. requestSsoProviderJson adds a 10-second timeout, rejects redirects and non-2xx answers, and validates the JSON with the schema. Any failure becomes a 502 whose message never repeats the provider's body. GitHub reports a bad or expired code as an error field instead, which becomes a 400.
  • No leaked secrets. The client secret and the access token stay inside these functions on the server. Nothing here logs them or puts them in an error message, and a missing option throws Missing GitHub client ID (or client secret) to the server log, not the visitor.
  • A stable ID. id is GitHub's numeric user ID, which never changes. The login can be renamed, so it is only the suggested username.
  • A truthful email. user:email lets fetchUser read the primary address and whether GitHub verified it. An unverified primary address returns emailVerified: false, and no primary address returns email: null. Either way a new member confirms an email first.
  • PKCE. GitHub supports S256, so getUrl sends VitNode's challenge and fetchToken sends the matching verifier.

Add it to ssoAdapters

In vitnode.api.config.ts, import the adapter and add it to authorization.ssoAdapters. Keep everything else, including adapters you already have:

src/vitnode.api.config.ts (excerpt)
import { buildApiConfig } from "@vitnode/core/vitnode.config";

import { GitHubSSOApiPlugin } from "./utils/sso/github"; 

export const vitNodeApiConfig = buildApiConfig({
  // ...
  authorization: {
    ssoAdapters: [
      // ...
      GitHubSSOApiPlugin({
        clientId: process.env.GITHUB_CLIENT_ID,
        clientSecret: process.env.GITHUB_CLIENT_SECRET,
      }),
    ],
  },
});

No authorization block yet? Add it with just ssoAdapters, as in SSO.

getRedirectUri(id) returns {VITNODE_WEB_URL}/login/sso/{id}; send the same value from getUrl and fetchToken. Throw an HTTPException to fail a sign-in with a clear status; a plain Error becomes a 500.

Check the result

  1. Restart the dev server and open /login in a private window. A GitHub button appears under Continue with on /login and /register. It is a neutral, text-only button until you give the adapter an icon and a brand color.
  2. Press it and authorize the app on GitHub. You return to /login/sso/github and are signed in as a new member. If your primary GitHub email is not verified, Confirm your email appears instead.
  3. Sign out and press the button again. The same GitHub account signs straight in.

SSOApiPlugin reference

Prop

Type

Adapters written for older versions, with getUrl({ state }), fetchToken(code) and fetchUser(token), still work, because JavaScript ignores the extra arguments.

PKCE, ID tokens and provider calls

@vitnode/core/api/lib/sso-protocol has the small helpers the built-in adapters use. All of them are server-only:

HelperUse it to
requestSsoProviderJsonCall a provider endpoint with a 10-second timeout, no redirects, and validate the JSON with a Zod schema (502 otherwise)
requireSsoOptionRead a required option such as the client ID; a missing one throws to the server log, not the visitor
formPost, basicClientAuth, bearerAuthBuild a form-encoded token request, HTTP Basic client auth or a bearer header
verifySsoIdTokenValidate an ID token with jose: signature, algorithm, iss, aud, exp/iat and nonce
remoteJwksFetch and cache the provider's signing keys
src/utils/sso/acme.ts
import { remoteJwks, verifySsoIdToken } from '@vitnode/core/api/lib/sso-protocol'

// inside the adapter
fetchUser: async ({ id_token }, { nonce }) => {
  const claims = await verifySsoIdToken({
    audience: clientId,
    idToken: id_token,
    issuer: 'https://id.acme.example',
    keys: remoteJwks('https://id.acme.example/.well-known/jwks.json'),
    nonce,
  })

  return { id: String(claims.sub), email: null }
},

See Provider icons for the accepted icon and brandColor formats, and for registering a React component instead.

Profile data

Connected accounts can import a member's avatar, first name and last name from your provider. Two steps:

  1. Declare what the provider can supply in profileFields.
  2. Return those values from fetchUser as avatarUrl, firstName and lastName.

The GitHub adapter does both for the avatar: profileFields: ["avatar"], and avatarUrl: user.avatar_url ?? null. GitHub has only a display name, so it supplies no first or last name.

All three are optional, so older adapters still work and have nothing to import. A few rules keep imports honest:

  • Return null (or leave it out) when the provider has no value. VitNode never erases a member's value with an empty one.
  • Only send separate first and last names the provider actually has. Don't split a full name. "Mary Ann Smith" has no safe answer.
  • avatarUrl must be https. VitNode downloads and re-stores it itself, with SSRF protection and size and type checks, so you don't have to.
  • Don't add scopes just for profile data. If a field needs extra permission, leave it out of profileFields.