Keelv0.86.0
Docs / Social authentication

"Sign in with GitHub / Google / Discord" — OAuth 2.0, without an SDK. Keel owns the OAuth handshake only: it hands you a normalized social user, and you find-or-create your own user and log them in (with a session, jwt, or an access token). It stores nothing.

Every driver is fetch-based — no dependencies, no native bindings — so it runs on Node and the edge alike.

Configure a provider

import { social } from "@shaferllc/keel/core";

const github = social.github({
  clientId: config("services.github.id"),
  clientSecret: config("services.github.secret"),
  redirectUri: "https://app.example.com/auth/github/callback",
});

Presets: social.github, social.google, social.discord. Each defaults to the scopes needed for id + email + profile; override with scopes in the config or per-redirect.

The two-step flow

1. Redirect the user to the provider. Generate a state for CSRF and stash it (in the session) to check on the way back:

import { social, session, redirect } from "@shaferllc/keel/core";

router.get("/auth/github", () => {
  const state = social.state();
  session().put("oauth_state", state);
  return redirect(github.redirect({ state }));
});

2. Handle the callback. Verify state, then exchange the code for the user:

router.get("/auth/github/callback", async () => {
  if (request.query("state") !== session().pull("oauth_state")) {
    throw new ForbiddenException("Invalid OAuth state");
  }

  const gh = await github.user(request.query("code")); // exchange + fetch profile

  const user = await User.query()
    .where("github_id", gh.id)
    .first() ?? await User.create({ github_id: gh.id, email: gh.email, name: gh.name });

  auth().login(user.id);
  return redirect("/dashboard");
});

The social user

user() returns a shape that's the same across every provider:

{
  id: string;            // the provider's stable id (always a string)
  email: string | null;
  name: string | null;
  nickname: string | null;   // handle / username
  avatarUrl: string | null;
  token: OAuthToken;         // { accessToken, refreshToken?, expiresIn?, … }
  raw: Record<string, unknown>; // the untouched provider payload
}

Reach raw for provider-specific fields not in the normalized shape. Use token to call the provider's API on the user's behalf.

Issuing your own credential

After you've found-or-created the user, log them in however your app authenticates — they're independent of the OAuth token:

// server-rendered app → session
auth().login(user.id);

// SPA / mobile → an opaque access token
const { token } = await createToken(user.id);
return response.json({ token });

Splitting the steps

user(code) is exchangeCode(code) then userFromToken(token). Call them apart when you already hold a token (e.g. a native mobile SDK did the OAuth dance):

const token = await github.exchangeCode(code);   // { accessToken, … }
const gh = await github.userFromToken(token);     // normalized user

A failed exchange or profile fetch throws OAuthError (with the provider name).

OAuth 1.0a (Twitter/X)

Some providers still speak the older, three-legged OAuth 1.0a — every request is HMAC-SHA1-signed (done here with Web Crypto, so it stays edge-native). The flow has an extra hop: get a temporary request token, send the user to authorize, then swap the returned oauth_verifier for the access token. Stash the request token's secret between the two steps.

import { social, session, redirect } from "@shaferllc/keel/core";

const twitter = social.twitter({
  clientId: config("services.twitter.key"),
  clientSecret: config("services.twitter.secret"),
  redirectUri: "https://app.example.com/auth/twitter/callback",
});

// 1. request token → redirect
router.get("/auth/twitter", async () => {
  const request = await twitter.requestToken();
  session().put("twitter_secret", request.tokenSecret); // needed on the way back
  return redirect(twitter.redirect(request));
});

// 2. callback → access token → user
router.get("/auth/twitter/callback", async () => {
  const tw = await twitter.user(
    request.query("oauth_token"),
    request.query("oauth_verifier"),
    session().pull("twitter_secret"),
  );
  // tw is a SocialUser — same shape as OAuth2, but tw.token is an OAuth1Token
  const user = await User.firstOrCreate({ twitter_id: tw.id }, { name: tw.name });
  auth().login(user.id);
  return redirect("/dashboard");
});

For any other OAuth 1.0a provider, use social.driver1(spec, config) with requestTokenUrl / authorizeUrl / accessTokenUrl and a fetchUser that calls driver.get(url, token) (a signed request). The low-level oauth1Signature() is exported too, if you need to sign an arbitrary API call yourself.

Any other OAuth2 provider

Build a driver for anything OAuth2 with social.driver(spec, config) — supply the authorizeUrl, tokenUrl, default scopes, and a fetchUser(token) that returns a SocialUser:

const gitlab = social.driver(
  {
    name: "gitlab",
    authorizeUrl: "https://gitlab.com/oauth/authorize",
    tokenUrl: "https://gitlab.com/oauth/token",
    defaultScopes: ["read_user"],
    async fetchUser(token) {
      const res = await fetch("https://gitlab.com/api/v4/user", {
        headers: { authorization: `Bearer ${token.accessToken}` },
      });
      const data = await res.json();
      return { id: String(data.id), email: data.email, name: data.name,
               nickname: data.username, avatarUrl: data.avatar_url, token, raw: data };
    },
  },
  { clientId, clientSecret, redirectUri },
);