shebang!

Sign in with sherlock

A complete, runnable Express app doing the OAuth 2.1 authorization-code flow end to end — login, callback, id_token, the Data API, and refresh.

Sign in with sherlock

Sign in with sherlock (redirect) walks through each step of the flow on its own. This page is the complete, runnable version — a small Express app with a /login, /callback, and /refresh route, wired against the real auth.shebang.pro endpoints. Register your own client first (either path that concept page describes), set CLIENT_ID and a publishable key for your database's Data API, and this runs as-is.

import express from "express";
import crypto from "node:crypto";
import { createClient } from "@supabase/supabase-js";

const ISSUER = "https://auth.shebang.pro/auth/v1";
const AUTHORIZE_ENDPOINT = `${ISSUER}/oauth/authorize`;
const TOKEN_ENDPOINT = `${ISSUER}/oauth/token`;
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const REDIRECT_URI = "https://myapp.example.com/callback";
const DATA_API_URL = "https://api.shebang.pro/db/<your-slug>";
const DATA_API_PUBLISHABLE_KEY = process.env.SHERBASE_PUBLISHABLE_KEY;

const app = express();
const pendingLogins = new Map(); // state -> { verifier }
let session = null;

function b64url(buf) {
  return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function decodeJwtPayload(jwt) {
  const [, payload] = jwt.split(".");
  return JSON.parse(Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
}

app.get("/login", (req, res) => {
  const verifier = b64url(crypto.randomBytes(32));
  const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
  const state = b64url(crypto.randomBytes(16));
  pendingLogins.set(state, { verifier });

  const url = new URL(AUTHORIZE_ENDPOINT);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("client_id", CLIENT_ID);
  url.searchParams.set("redirect_uri", REDIRECT_URI);
  url.searchParams.set("scope", "openid email profile");
  url.searchParams.set("code_challenge", challenge);
  url.searchParams.set("code_challenge_method", "S256");
  url.searchParams.set("state", state);

  res.redirect(url.toString());
});

app.get("/callback", async (req, res) => {
  const { code, state, error } = req.query;
  if (error) return res.status(400).send(`authorize error: ${error}`);

  const entry = pendingLogins.get(state);
  if (!entry) return res.status(400).send("unknown or expired state");
  pendingLogins.delete(state);

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code: String(code),
    redirect_uri: REDIRECT_URI,
    client_id: CLIENT_ID,
    code_verifier: entry.verifier,
  });

  const tokenResp = await fetch(TOKEN_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });
  const tokenJson = await tokenResp.json();
  if (!tokenResp.ok) return res.status(502).send("token exchange failed");

  const claims = decodeJwtPayload(tokenJson.id_token);
  session = {
    access_token: tokenJson.access_token,
    refresh_token: tokenJson.refresh_token,
    expires_at: Date.now() + (tokenJson.expires_in || 3600) * 1000,
    claims,
  };

  // Call the Data API as this signed-in user -- row-level security sees
  // them as auth.uid(), same as any other supabase-js session.
  const supabase = createClient(DATA_API_URL, DATA_API_PUBLISHABLE_KEY, {
    global: { headers: { Authorization: `Bearer ${session.access_token}` } },
  });
  const { data: notes } = await supabase.from("notes").select("*").order("id");

  res.send(`signed in as ${claims.email} (${notes?.length ?? 0} notes)`);
});

app.get("/refresh", async (req, res) => {
  if (!session) return res.status(400).send("not signed in");

  const body = new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: session.refresh_token,
    client_id: CLIENT_ID,
  });
  const tokenResp = await fetch(TOKEN_ENDPOINT, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: body.toString(),
  });
  const tokenJson = await tokenResp.json();
  if (!tokenResp.ok) return res.status(502).send("refresh failed");

  session.access_token = tokenJson.access_token;
  session.refresh_token = tokenJson.refresh_token || session.refresh_token;
  session.expires_at = Date.now() + (tokenJson.expires_in || 3600) * 1000;
  res.send("refreshed");
});

app.listen(3000);

What to notice:

  • /login builds the PKCE challenge and state, then redirects to sherlock's own authorize endpoint — your app never renders a password form.
  • /callback is the only route that talks to TOKEN_ENDPOINT directly; everything after the exchange (decoding id_token, calling the Data API) runs with the tokens already in hand.
  • /refresh is a plain grant_type=refresh_token call — no PKCE needed the second time, since the refresh token itself is the proof.
  • This example keeps its session in memory for clarity. A real app needs to persist it somewhere it can survive a restart — cookies, a session store, whatever your app already uses for sessions — that part is on you; it isn't specific to sherlock.

Next

The Data API — what auth.uid() and row-level security look like from the database side of this same token.