OAuth & Social Login

@crvouga/mockingbird-service-oauth

Portable social-login mock with Google, Apple, Microsoft, GitHub and generic OIDC profiles, private relay identities, reproducible edge cases, signed tokens and an accessible UI.

ReadyIdentity Runs in the browser32 of 32 operationsIn-process fetchNode servermockingbird-oauth
$npm install -D @crvouga/mockingbird-service-oauth
Interactive example

Try a complete OAuth login

Choose popup or redirect, sign in through OAuth Mock, and return to an example app. The client, Hono server, and provider all run in process.

Loads on demand. All example requests stay in this tab.

Explore the example source
examples/google-login/app.ts
TypeScript
import { type Context, Hono } from "hono"
import * as oauth from "oauth4webapi"
import { type BehaviorInput, OAuthAPI, type Provider } from "../../src/index.js"
import { escapeHtml } from "../../src/ui.js"
import { COOKIE_HEADERS, ORIGIN_HEADER } from "./transport.js"

export const APP = "https://app.example.test"
export const IDENTITY = "https://accounts.example.test"
export type Trace = { sequence: number; actor: string; method: string; url: string; status: number }
type Identity = { sub: string; name?: string; email?: string }
const callback = `${APP}/auth/callback`
const client = { client_id: "example-app" }
const secret = "example-only-client-secret"
export type ExampleProvider = Extract<Provider, "google" | "apple" | "microsoft" | "github">

/** A complete application server. Every outbound OAuth request uses the local dispatcher. */
export function createExample(
  behavior: BehaviorInput = {},
  onTrace: (entry: Trace) => void = () => {},
  providerProfile: ExampleProvider = "google",
) {
  const provider = new OAuthAPI({
    provider: providerProfile,
    cookieHeaders: COOKIE_HEADERS,
    issuer: IDENTITY,
    behavior,
    accounts: [
      {
        id: "ada",
        name: "Ada Lovelace",
        email: "ada@example.test",
        github: {
          id: 101,
          login: "ada",
          publicEmail: null,
          emails: [
            {
              email: "ada@example.test",
              primary: true,
              verified: true,
              visibility: "private",
            },
          ],
        },
      },
      {
        id: "grace",
        name: "Grace Hopper",
        email: "grace@example.test",
        github: {
          id: 102,
          login: "grace",
          publicEmail: "grace@example.test",
          emails: [
            {
              email: "grace@example.test",
              primary: true,
              verified: true,
              visibility: "public",
            },
          ],
        },
      },
    ],
    clients: [
      {
        id: client.client_id,
        name: "Side A",
        secret,
        redirectUris: [callback],
        requirePkce: true,
      },
    ],
  })
  const preferences = { reuseLastAccount: false }
  const app = new Hono()
  const pending = new Map<
    string,
    { state: string; nonce: string; verifier: string; expires: number }
  >()
  const sessions = new Map<string, { user: Identity; expires: number }>()
  let sequence = 0
  async function dispatch(request: Request, actor = "Browser"): Promise<Response> {
    const url = new URL(request.url)
    if (![APP, IDENTITY].includes(url.origin))
      throw new Error("This example only routes its two in-process origins.")
    const id = ++sequence
    const response = await (url.origin === APP ? app.fetch(request) : provider.fetch(request))
    onTrace({
      sequence: id,
      actor,
      method: request.method,
      url: `${url.host}${url.pathname}`,
      status: response.status,
    })
    return response
  }
  const options = {
    [oauth.customFetch]: (
      input: string,
      init: oauth.CustomFetchOptions<string, URLSearchParams | undefined>,
    ) =>
      dispatch(
        new Request(input, {
          method: init.method,
          headers: init.headers,
          ...(init.body ? { body: init.body } : {}),
        }),
        "Hono server",
      ),
  }
  let discovery: Promise<oauth.AuthorizationServer> | undefined
  const metadata = () =>
    (discovery ??=
      providerProfile === "github"
        ? Promise.resolve({
            issuer: IDENTITY,
            authorization_endpoint: `${IDENTITY}/login/oauth/authorize`,
            token_endpoint: `${IDENTITY}/login/oauth/access_token`,
            userinfo_endpoint: `${IDENTITY}/user`,
          })
        : oauth
            .discoveryRequest(new URL(IDENTITY), options)
            .then((response) => oauth.processDiscoveryResponse(new URL(IDENTITY), response)))
  const current = (cookie: string | undefined) => {
    const session = cookie ? sessions.get(cookie) : undefined
    return session && session.expires > Date.now() ? session.user : undefined
  }
  app.get("/", (c) => c.html(document(current(cookie(c, "session")))))
  app.get("/api/session", (c) => c.json({ user: current(cookie(c, "session")) ?? null }))
  app.get("/auth/start", async (c) => {
    const as = await metadata()
    const state = oauth.generateRandomState()
    const nonce = oauth.generateRandomNonce()
    const verifier = oauth.generateRandomCodeVerifier()
    const key = crypto.randomUUID()
    for (const [id, value] of pending) if (value.expires < Date.now()) pending.delete(id)
    pending.set(key, { state, nonce, verifier, expires: Date.now() + 600_000 })
    setCookie(c, "login", key, {
      httpOnly: true,
      secure: true,
      sameSite: "Lax",
      path: "/",
      maxAge: 600,
    })
    const url = new URL(as.authorization_endpoint ?? "")
    url.search = new URLSearchParams({
      client_id: client.client_id,
      redirect_uri: callback,
      response_type: "code",
      ...(!preferences.reuseLastAccount ? { prompt: "select_account" } : {}),
      scope:
        providerProfile === "apple"
          ? "openid email name"
          : providerProfile === "github"
            ? "read:user user:email"
            : "openid email profile",
      ...(providerProfile === "apple" ? { response_mode: "form_post" } : {}),
      state,
      nonce,
      code_challenge: await oauth.calculatePKCECodeChallenge(verifier),
      code_challenge_method: "S256",
    }).toString()
    return c.redirect(url.href)
  })
  const callbackHandler = async (c: Context) => {
    const key = cookie(c, "login") ?? ""
    const transaction = pending.get(key)
    pending.delete(key)
    deleteCookie(c, "login", { path: "/" })
    try {
      if (!transaction || transaction.expires < Date.now()) throw new Error("Login expired")
      const as = await metadata()
      const callbackUrl = new URL(c.req.url)
      let appleUser: { name?: { firstName?: string; lastName?: string }; email?: string } = {}
      if (c.req.method === "POST") {
        const form = new URLSearchParams(await c.req.text())
        for (const name of ["code", "state", "error", "error_description"])
          if (form.has(name)) callbackUrl.searchParams.set(name, form.get(name) ?? "")
        try {
          appleUser = JSON.parse(form.get("user") ?? "{}")
        } catch {
          appleUser = {}
        }
      }
      const params = oauth.validateAuthResponse(as, client, callbackUrl, transaction.state)
      const response = await oauth.authorizationCodeGrantRequest(
        as,
        client,
        oauth.ClientSecretPost(secret),
        params,
        callback,
        transaction.verifier,
        options,
      )
      const tokens = await oauth.processAuthorizationCodeResponse(
        as,
        client,
        response,
        providerProfile === "github"
          ? { requireIdToken: false }
          : { expectedNonce: transaction.nonce, requireIdToken: true },
      )
      if (providerProfile !== "github")
        await oauth.validateApplicationLevelSignature(as, response, options)
      const claims =
        providerProfile === "github" ? undefined : oauth.getValidatedIdTokenClaims(tokens)
      const user: Identity =
        providerProfile === "github"
          ? await githubIdentity(as, tokens.access_token)
          : providerProfile === "apple" && claims
            ? {
                sub: claims.sub,
                ...(typeof claims.email === "string" ? { email: claims.email } : {}),
                ...(appleUser.name
                  ? {
                      name: [appleUser.name.firstName, appleUser.name.lastName]
                        .filter(Boolean)
                        .join(" "),
                    }
                  : {}),
              }
            : claims
              ? await oauth.processUserInfoResponse(
                  as,
                  client,
                  claims.sub,
                  await oauth.userInfoRequest(as, client, tokens.access_token, options),
                )
              : (() => {
                  throw new Error("Missing identity")
                })()
      const session = crypto.randomUUID()
      sessions.set(session, {
        user: {
          sub: user.sub,
          ...(typeof user.name === "string" ? { name: user.name } : {}),
          ...(typeof user.email === "string" ? { email: user.email } : {}),
        },
        expires: Date.now() + 3600_000,
      })
      setCookie(c, "session", session, {
        httpOnly: true,
        secure: true,
        sameSite: "Lax",
        path: "/",
        maxAge: 3600,
      })
      return c.redirect("/")
    } catch {
      return c.html(
        document(
          undefined,
          "Sign-in could not be completed. Permission may have been declined, the provider may be unavailable, or the login may have expired. You can safely try again.",
        ),
        400,
      )
    }
  }

  async function githubIdentity(as: oauth.AuthorizationServer, accessToken: string) {
    const profileResponse = await oauth.userInfoRequest(as, client, accessToken, options)
    if (!profileResponse.ok) throw new Error("GitHub profile request failed")
    const profile = (await profileResponse.json()) as {
      id?: number
      name?: string | null
      login?: string
      email?: string | null
    }
    if (!Number.isSafeInteger(profile.id)) throw new Error("GitHub profile has no stable ID")
    let email = profile.email ?? undefined
    if (!email) {
      const emailResponse = await dispatch(
        new Request(`${IDENTITY}/user/emails`, {
          headers: { authorization: `Bearer ${accessToken}` },
        }),
        "Hono server",
      )
      const emails = emailResponse.ok
        ? ((await emailResponse.json()) as { email: string; primary: boolean; verified: boolean }[])
        : []
      email = emails.find((item) => item.primary && item.verified)?.email
    }
    return {
      sub: String(profile.id),
      ...(profile.name || profile.login ? { name: profile.name ?? profile.login } : {}),
      ...(email ? { email } : {}),
    }
  }
  app.get("/auth/callback", callbackHandler)
  app.post("/auth/callback", callbackHandler)
  app.post("/auth/logout", (c) => {
    if (c.req.header(ORIGIN_HEADER) !== APP) return c.text("Invalid origin", 403)
    sessions.delete(cookie(c, "session") ?? "")
    deleteCookie(c, "session", { path: "/" })
    return c.redirect("/", 303)
  })
  return { dispatch, app, provider, preferences, providerProfile }
}

function document(user?: Identity, error?: string) {
  const e = escapeHtml
  const record = `<div class="record-art" aria-hidden="true"><div class="sleeve"><span class="sleeve-label">SIDE A<br>SELECTS / 001</span><span class="sleeve-title">THE<br>GOOD<br>STUFF.</span><span class="sleeve-bottom">A collection of things you felt.</span></div><div class="vinyl"><div class="vinyl-label">SIDE A<span>33⅓ RPM</span></div></div><span class="sticker">ON<br>REPEAT ↗</span></div>`
  return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Side A — Your listening journal</title><style>
  :root{color-scheme:light dark;--paper:light-dark(#f1eee6,#171b24);--surface:light-dark(#fffdf7,#222735);--ink:light-dark(#191f32,#f1eee6);--muted:light-dark(#666a70,#b0b4be);--line:light-dark(#c9c8bf,#414755);--acid:#e2ff54;--blue:#3448e9;--orange:#ff7656}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.5 system-ui,sans-serif}header{padding:20px 30px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;gap:20px}.brand{font-size:27px;font-weight:900;letter-spacing:-1.8px;display:flex;align-items:center;gap:10px;white-space:nowrap}.brand-mark{width:25px;height:25px;border:7px solid currentColor;border-radius:50%;position:relative}.brand-mark:after{content:"";position:absolute;width:3px;height:3px;top:4px;left:4px;background:currentColor;border-radius:50%}.edition,.eyebrow,.caption,.tag{font:10px/1.5 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.12em}.edition{color:var(--muted);text-align:right}main{max-width:1100px;margin:auto;padding:38px 30px 28px}h1{font:clamp(42px,7vw,68px)/.98 Georgia,serif;letter-spacing:-2.6px;margin:18px 0 22px;font-weight:400}h1 em{font-weight:400}h1[tabindex="-1"]:focus{outline:none}.eyebrow{display:flex;align-items:center;gap:8px}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--blue)}.hero{display:grid;grid-template-columns:1.1fr 1fr;align-items:center;gap:28px}.intro{font-size:15px;line-height:1.7;color:var(--muted);max-width:330px;margin:0 0 24px}.signin{max-width:340px}.primary{display:flex;align-items:center;justify-content:space-between;gap:16px;width:100%;background:var(--acid);color:#191f32;border:1px solid #191f32;border-radius:0;padding:15px 18px;box-shadow:4px 4px 0 var(--ink);font:700 13px system-ui;cursor:pointer;text-decoration:none}.primary:hover{transform:translate(-1px,-1px);box-shadow:5px 5px 0 var(--ink)}.primary span{font-size:20px;line-height:1}.fine{font-size:11px;color:var(--muted);margin:14px 0 0}.record-art{position:relative;aspect-ratio:1.03;min-width:0;isolation:isolate}.sleeve{position:absolute;inset:10% 17% 7% 0;background:var(--blue);color:#fff8db;padding:18px;box-shadow:0 14px 25px #0002;transform:rotate(-7deg);display:flex;flex-direction:column;justify-content:space-between;z-index:2}.sleeve-label,.sleeve-bottom{font:9px/1.4 ui-monospace,monospace;letter-spacing:.06em}.sleeve-title{font-size:clamp(25px,4.5vw,48px);font-weight:900;line-height:.92;letter-spacing:-2px}.vinyl{position:absolute;width:82%;aspect-ratio:1;border-radius:50%;right:-3%;top:13%;background:repeating-radial-gradient(circle at center,#202127 0 2px,#33343b 3px,#18191e 4px 5px);box-shadow:0 12px 26px #0003;display:grid;place-items:center}.vinyl-label{width:36%;aspect-ratio:1;background:var(--orange);color:#191f32;border-radius:50%;display:flex;align-items:center;justify-content:center;flex-direction:column;font-size:15px;font-weight:900;transform:rotate(18deg)}.vinyl-label span{font:7px ui-monospace,monospace;margin-top:5px}.sticker{position:absolute;right:0;top:4%;z-index:3;width:70px;height:70px;display:grid;place-content:center;background:var(--acid);color:#191f32;border-radius:50%;text-align:center;font:800 12px/1.15 system-ui;transform:rotate(13deg)}.manifesto{border-top:1px solid var(--line);margin-top:34px;padding-top:18px;display:flex;justify-content:space-between;gap:18px;color:var(--muted);font-size:11px}.manifesto strong{color:var(--ink);font-weight:600}.notice{border-left:3px solid var(--orange);padding:12px 15px;background:var(--surface);font-size:13px;margin:0 0 22px}.welcome{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.welcome h1{font-size:46px;margin-bottom:16px}.logout{background:transparent;border:1px solid var(--line);color:var(--ink);padding:8px 12px;font:11px ui-monospace,monospace;cursor:pointer;white-space:nowrap}.logout:hover{border-color:var(--ink)}.shelf-title{display:flex;justify-content:space-between;align-items:center;margin:24px 0 12px}.shelf-title h2{font-size:14px;margin:0;font-weight:650}.tag{color:var(--muted);font-size:9px}.shelf{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px}.album{margin:0}.cover{aspect-ratio:1.35;position:relative;overflow:hidden;display:flex;align-items:end;padding:12px;color:#fff;font:800 17px/1 system-ui;letter-spacing:-.7px}.cover:before{content:"";position:absolute;border-radius:50%;width:80%;aspect-ratio:1;right:-10%;top:-20%;border:22px solid #ffffff38}.cover span{position:relative;z-index:1}.cover-blue{background:#3448e9}.cover-orange{background:#df5034}.cover-green{background:#286752}.album h3{font-size:12px;margin:10px 0 3px;font-weight:600}.album p{font-size:11px;color:var(--muted);margin:0}.account{border-top:1px solid var(--line);margin-top:26px;padding-top:15px;display:flex;justify-content:space-between;align-items:start;gap:15px;font-size:11px}.account small{display:block;color:var(--muted);font-size:10px}.account span{overflow-wrap:anywhere}.account details{color:var(--muted);max-width:50%;text-align:right;overflow-wrap:anywhere}.account summary{cursor:pointer}.account p{margin:8px 0 0}:is(a,button,summary):focus-visible{outline:3px solid var(--blue);outline-offset:5px}@media(max-width:600px){header{padding:18px 20px}main{padding:28px 22px}.hero{grid-template-columns:1fr;gap:12px}h1{font-size:54px;max-width:350px}.intro{max-width:340px}.record-art{width:min(260px,80%);margin:10px auto 0}.sleeve-title{font-size:34px}.manifesto{margin-top:24px;flex-wrap:wrap}.welcome{gap:10px}.welcome h1{font-size:37px}.shelf{gap:9px}.cover{aspect-ratio:1;padding:9px;font-size:14px}.cover:before{border-width:15px}.edition{font-size:9px}.account{flex-wrap:wrap}}@media(prefers-reduced-motion:no-preference){.primary{transition:transform .15s,box-shadow .15s}}
  </style></head><body><header><div class="brand"><span class="brand-mark" aria-hidden="true"></span>SIDE A</div><span class="edition">A home for your good taste.<br>Independent listening journal</span></header><main>${error ? `<p class="notice" role="alert">${e(error)}</p>` : ""}${user ? `<section class="welcome"><div><div class="eyebrow"><span class="dot" aria-hidden="true"></span>Your personal rotation</div><h1 tabindex="-1">Welcome, ${e(user.name?.split(" ")[0] ?? "friend")}.</h1><p class="intro">Every great collection starts with a feeling.<br>Make room for your next favorite.</p></div><form method="post" action="/auth/logout"><button class="logout">Sign out ↗</button></form></section><div class="shelf-title"><h2>A little shelf inspiration</h2><span class="tag">Sample collection / 001—003</span></div><section class="shelf" aria-label="Sample listening journal"><article class="album"><div class="cover cover-blue" aria-hidden="true"><span>BLUE<br>HOUR.</span></div><h3>For the long way home</h3><p>Late-night listening</p></article><article class="album"><div class="cover cover-orange" aria-hidden="true"><span>SOFT<br>FOCUS.</span></div><h3>Sunday, on repeat</h3><p>Slow mornings</p></article><article class="album"><div class="cover cover-green" aria-hidden="true"><span>OFF<br>THE GRID.</span></div><h3>Somewhere new</h3><p>Outside the usual</p></article></section><div class="account"><span><small>Signed in as</small>${e(user.name ?? "Name not shared")}<small>${e(user.email ?? "Email not shared")}</small></span><details><summary>Account details</summary><p>Account ID: ${e(user.sub)}</p></details></div>` : `<section class="hero"><div><div class="eyebrow"><span class="dot" aria-hidden="true"></span>For the love of listening</div><h1 tabindex="-1">Good records.<br><em>Better memories.</em></h1><p class="intro">Your favorite albums. The places they take you. A little space to keep it all.</p><div class="signin"><a class="primary" href="/auth/start">Continue with OAuth Mock <span aria-hidden="true">↗</span></a><p class="fine">Your next favorite thing starts here.</p></div></div>${record}</section><footer class="manifesto"><strong>Less algorithm. More you.</strong><span>Collect the music. Keep the feeling.</span></footer>`}</main></body></html>`
}

// Explicit local cookie envelope: browser Fetch strips native Cookie/Set-Cookie headers.
// The envelope is only used by this example's allowlisted in-process dispatcher.
function cookie(c: Context, name: string) {
  return c.req
    .header(COOKIE_HEADERS.request)
    ?.split(";")
    .map((value) => value.trim())
    .find((value) => value.startsWith(`${name}=`))
    ?.slice(name.length + 1)
}
function setCookie(
  c: Context,
  name: string,
  value: string,
  options: { maxAge: number; httpOnly: boolean; secure: boolean; sameSite: string; path: string },
) {
  c.header(
    COOKIE_HEADERS.response,
    `${name}=${value}; Path=${options.path}; HttpOnly; Secure; SameSite=${options.sameSite}; Max-Age=${options.maxAge}`,
    { append: true },
  )
}
function deleteCookie(c: Context, name: string, options: { path: string }) {
  c.header(COOKIE_HEADERS.response, `${name}=; Path=${options.path}; Max-Age=0`, { append: true })
}
examples/google-login/transport.ts
TypeScript
export const COOKIE_HEADERS = { request: "x-example-cookie", response: "x-example-set-cookie" }
export const ORIGIN_HEADER = "x-example-origin"

/** A tiny, portable browser transport. Cookies and redirects never leave this instance. */
export function createBrowser(fetch: (request: Request) => Promise<Response>) {
  const cookies = new Map<
    string,
    { origin: string; path: string; name: string; value: string; expires: number }
  >()
  return {
    async navigate(
      input: string,
      init: { method?: string; body?: URLSearchParams; origin?: string } = {},
    ) {
      let url = new URL(input)
      let method = init.method ?? "GET"
      let body = init.body
      for (let redirects = 0; redirects < 15; redirects++) {
        const headers = new Headers()
        const matching = [...cookies.values()].filter(
          (c) =>
            c.origin === url.origin &&
            (url.pathname === c.path ||
              url.pathname.startsWith(c.path.endsWith("/") ? c.path : `${c.path}/`)) &&
            c.expires > Date.now(),
        )
        if (matching.length)
          headers.set(
            COOKIE_HEADERS.request,
            matching.map((c) => `${c.name}=${c.value}`).join("; "),
          )
        if (method !== "GET" && init.origin) headers.set(ORIGIN_HEADER, init.origin)
        const response = await fetch(
          new Request(url, { method, headers, ...(method !== "GET" && body ? { body } : {}) }),
        )
        for (const line of response.headers
          .get(COOKIE_HEADERS.response)
          ?.split(/,(?=\s*[^;,]+=)/) ?? []) {
          const [pair = "", ...attributes] = line.split(";")
          const split = pair.indexOf("=")
          const name = pair.slice(0, split).trim()
          const value = pair.slice(split + 1)
          const attrs = new Map(
            attributes.map((s) => {
              const [key = "", ...v] = s.trim().split("=")
              return [key.toLowerCase(), v.join("=")]
            }),
          )
          const path = attrs.get("path") ?? "/"
          const expires = attrs.has("max-age")
            ? Date.now() + Number(attrs.get("max-age")) * 1000
            : attrs.has("expires")
              ? Date.parse(attrs.get("expires") ?? "")
              : Infinity
          cookies.set(`${url.origin}:${path}:${name}`, {
            origin: url.origin,
            path,
            name,
            value,
            expires,
          })
        }
        const location = response.headers.get("location")
        if (!location || ![301, 302, 303, 307, 308].includes(response.status))
          return { url: url.href, response }
        url = new URL(location, url)
        if (
          response.status === 303 ||
          ((response.status === 301 || response.status === 302) && method === "POST")
        ) {
          method = "GET"
          body = undefined
        }
      }
      throw new Error("Too many redirects")
    },
  }
}
examples/google-login/index.ts
TypeScript
import type { BehaviorInput } from "../../src/index.js"
import { APP, createExample, type ExampleProvider, IDENTITY, type Trace } from "./app.js"
import { createBrowser } from "./transport.js"

const closeIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="m6 6 12 12M18 6 6 18"/></svg>`
const closeButtonStyle = `display:grid;place-items:center;flex:0 0 36px;width:36px;height:36px;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:inherit;cursor:pointer;appearance:none`

export type ExampleOptions = {
  flow?: "popup" | "redirect"
  theme?: "system" | "light" | "dark"
  reuseLastAccount?: boolean
}

/** The only DOM-dependent layer: mount the portable application in any browser host. */
export async function mount(host: HTMLElement, options: ExampleOptions = {}) {
  const root = host.shadowRoot ?? host.attachShadow({ mode: "open" })
  root.innerHTML = `<style>
  :host{display:block;color:var(--fg,#242c29);font:14px/1.5 system-ui,sans-serif}*{box-sizing:border-box}.shell{border:1px solid var(--border,#d6ddd8);border-radius:14px;overflow:hidden;background:var(--bg,#fff)}.toolbar{display:flex;gap:16px;align-items:end;justify-content:space-between;padding:18px;flex-wrap:wrap}label{display:grid;gap:5px;font-size:12px;font-weight:600}select,button{font:inherit;color:inherit;background:var(--bg,#fff);border:1px solid var(--border,#c8d0ca);border-radius:7px;padding:9px 12px}button{cursor:pointer}button:hover{border-color:currentColor}:focus-visible{outline:3px solid var(--accent,#268462);outline-offset:3px}.route{padding:10px 18px;border-block:1px solid var(--border,#d6ddd8);font-size:12px;display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}.route span{opacity:.7}.address{padding:10px 18px;font:11px ui-monospace,monospace;overflow-wrap:anywhere;border-bottom:1px solid var(--border,#d6ddd8)}iframe{display:block;border:0;width:100%;height:700px;color-scheme:inherit}.status{padding:10px 18px;margin:0;font-size:12px;border-top:1px solid var(--border,#d6ddd8)}details{border-top:1px solid var(--border,#d6ddd8);padding:14px 18px}summary{cursor:pointer;font-weight:600}.trace{max-height:260px;overflow:auto;padding:0;list-style:none;font:11px/1.7 ui-monospace,monospace}.trace li{padding:6px 0;border-bottom:1px solid var(--border,#d6ddd8);overflow-wrap:anywhere}.trace b{display:inline-block;min-width:92px}.hint{font-size:12px;opacity:.7;margin:8px 0 0}
  dialog{padding:0;border:1px solid var(--border,#d6ddd8);border-radius:16px;width:min(540px,calc(100vw - 32px));max-height:calc(100dvh - 40px);background:var(--bg,#fff);color:inherit;box-shadow:0 24px 100px #0005;overflow:hidden}dialog::backdrop{background:#0b141969;backdrop-filter:blur(3px)}.popup-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;border-bottom:1px solid var(--border,#d6ddd8)}.popup-bar strong{display:block;font-size:13px}.popup-bar small{font:11px ui-monospace,monospace;opacity:.7}.popup-bar button{${closeButtonStyle}}.popup-bar button:hover{background:light-dark(#f4f4f5,#242428);border-color:transparent}.popup-bar button svg{display:block}dialog iframe{height:min(740px,calc(100dvh - 112px))}.provider-status{padding:14px;margin:0;font-size:13px}@media(max-width:500px){iframe{height:750px}.toolbar label{width:100%}select{width:100%}}
  </style><div class="shell"><div class="toolbar"><label>Provider profile<select class="profile" aria-label="Provider profile"><option value="google">Google-style OIDC</option><option value="apple">Apple-style OIDC</option><option value="microsoft">Microsoft-style OIDC</option><option value="github">GitHub-style OAuth</option></select></label><label>Provider behavior<select aria-label="Provider behavior"></select></label><label>Sign-in flow<select class="flow" aria-label="Sign-in flow"><option value="popup">Popup</option><option value="redirect">Redirect</option></select></label><label>Appearance<select class="theme-choice" aria-label="Appearance"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><label>Account selection<select class="account-choice" aria-label="Account selection"><option value="choose">Always choose</option><option value="reuse">Reuse last account</option></select></label><button type="button" class="reset">Reset example</button></div><div class="route"><strong>App → OAuth Mock</strong><span>In process · no network</span></div><div class="address" aria-label="Virtual browser address"></div><iframe class="app-frame" title="Side A listening journal" sandbox="allow-same-origin allow-forms"></iframe><p class="status" role="status" aria-live="polite">Starting the app…</p><details><summary>Request trace <span class="count">(0)</span></summary><p class="hint">Real Request / Response objects. Credentials and query strings are omitted.</p><ol class="trace"></ol></details></div><dialog aria-label="OAuth Mock sign-in"><div class="popup-bar"><div><strong>OAuth Mock</strong><small class="provider-address">accounts.example.test</small></div><button type="button" class="close-provider" aria-label="Close sign-in popup">${closeIcon}</button></div><p class="provider-status" role="status">Connecting to the identity provider…</p><iframe class="provider-frame" title="OAuth Mock account selection and consent" sandbox="allow-same-origin allow-forms"></iframe></dialog>`
  function element<T extends Element>(selector: string): T {
    const value = root.querySelector<T>(selector)
    if (!value) throw new Error(`Missing example element: ${selector}`)
    return value
  }
  const frame = element<HTMLIFrameElement>(".app-frame")
  const dialog = element<HTMLDialogElement>("dialog")
  const dialogFrame = element<HTMLIFrameElement>(".provider-frame")
  let popup: Window | null = null
  let popupTimer: ReturnType<typeof setInterval> | undefined
  let providerFrame = dialogFrame
  let providerAddress = element<HTMLElement>(".provider-address")
  let providerStatus = element<HTMLElement>(".provider-status")
  const frameUrls = new WeakMap<HTMLIFrameElement, string>()
  let navigation = 0
  const status = element<HTMLElement>(".status")
  const address = element<HTMLElement>(".address")
  const profile = element<HTMLSelectElement>(".profile")
  const select = element<HTMLSelectElement>('[aria-label="Provider behavior"]')
  const flow = element<HTMLSelectElement>(".flow")
  const appearance = element<HTMLSelectElement>(".theme-choice")
  const accountChoice = element<HTMLSelectElement>(".account-choice")
  flow.value = options.flow ?? "popup"
  appearance.value = options.theme ?? "system"
  accountChoice.value = options.reuseLastAccount ? "reuse" : "choose"
  const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
  let preferences = { reuseLastAccount: false }
  const traceList = element<HTMLOListElement>(".trace")
  const count = element<HTMLElement>(".count")
  let generation = 0
  let disposed = false
  let busy = false
  let browser: ReturnType<typeof createBrowser>
  let entries: Trace[] = []
  const behaviorChoices: Record<ExampleProvider, [string, string][]> = {
    google: [
      ["normal", "Normal sign-in"],
      ["missing_email", "Email claim omitted"],
      ["missing_name", "Profile name omitted"],
      ["unverified_email", "Email not verified"],
      ["consent_denied", "Consent declined"],
      ["unavailable", "Token endpoint unavailable"],
    ],
    apple: [
      ["normal", "Ask whether to share email"],
      ["apple_private_relay", "Always hide email"],
      ["apple_share_email", "Always share email"],
      ["apple_returning_user", "Returning user (no profile payload)"],
      ["apple_boolean_claims", "Boolean privacy claims"],
      ["missing_email", "Email claim omitted"],
      ["missing_name", "Name omitted"],
      ["consent_denied", "Consent declined"],
      ["unavailable", "Token endpoint unavailable"],
    ],
    microsoft: [
      ["normal", "Normal sign-in"],
      ["microsoft_missing_email", "Email claim omitted"],
      ["missing_name", "Display name omitted"],
      ["microsoft_spa_expiry", "24-hour refresh lifetime"],
      ["consent_denied", "Consent declined"],
      ["unavailable", "Token endpoint unavailable"],
    ],
    github: [
      ["normal", "Private email list fallback"],
      ["github_unverified_email", "Unverified primary email"],
      ["missing_email", "No usable email"],
      ["missing_name", "Profile name omitted"],
      ["consent_denied", "Authorization declined"],
      ["unavailable", "Token endpoint unavailable"],
    ],
  }
  function updateBehaviorChoices() {
    const previous = select.value
    select.replaceChildren(
      ...behaviorChoices[profile.value as ExampleProvider].map(([value, label]) => {
        const option = document.createElement("option")
        option.value = value
        option.textContent = label
        return option
      }),
    )
    if ([...select.options].some((option) => option.value === previous)) select.value = previous
  }
  function log(entry: Trace) {
    entries.push(entry)
    entries = entries.sort((a, b) => a.sequence - b.sequence).slice(-100)
    traceList.replaceChildren(
      ...entries.map((item) => {
        const li = document.createElement("li")
        const actor = document.createElement("b")
        actor.textContent = item.actor
        li.append(actor, ` ${item.method} ${item.url} → ${item.status}`)
        return li
      }),
    )
    count.textContent = `(${entries.length})`
  }
  function focusApp() {
    const doc = frame.contentDocument
    const target =
      doc?.querySelector<HTMLElement>('a[href="/auth/start"]') ??
      doc?.querySelector<HTMLElement>("h1")
    if (target) {
      if (target.tagName === "H1") target.tabIndex = -1
      target.focus({ preventScroll: true })
    }
  }
  function closeProvider() {
    if (popupTimer) clearInterval(popupTimer)
    popupTimer = undefined
    popup?.close()
    popup = null
    if (dialog.open) dialog.close()
    providerFrame.srcdoc = ""
    providerFrame = dialogFrame
  }
  function cancelProvider() {
    // Discard a response already in flight, so closing the popup cannot reopen it.
    navigation++
    busy = false
    closeProvider()
    status.textContent = "Sign-in window closed. You can try again."
    focusApp()
  }
  function colorScheme() {
    return appearance.value === "system"
      ? systemTheme.matches
        ? "dark"
        : "light"
      : appearance.value
  }
  function applyDocumentTheme(doc: Document) {
    doc.documentElement.style.colorScheme = colorScheme()
    doc.documentElement.dataset.theme = appearance.value
    for (const input of doc.querySelectorAll<HTMLInputElement>('input[name="oauth-theme"]'))
      input.checked = input.value === appearance.value
  }
  function syncTheme() {
    for (const target of new Set([frame, dialogFrame, providerFrame])) {
      target.style.colorScheme = colorScheme()
      if (target.contentDocument?.documentElement) applyDocumentTheme(target.contentDocument)
    }
    if (popup && !popup.closed) popup.document.documentElement.style.colorScheme = colorScheme()
  }
  function openProvider() {
    // Called synchronously from the host app's click to retain popup user activation.
    if (popup && !popup.closed) {
      popup.focus()
      return
    }
    if (dialog.open) return
    try {
      popup = window.open("about:blank", "", "popup,width=540,height=820")
    } catch {
      popup = null
    }
    if (popup) {
      try {
        popup.document.open()
        popup.document.write(
          `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; frame-src about:; form-action 'none'; base-uri 'none'"><title>OAuth Mock</title><style>*{box-sizing:border-box}html{height:100%;overflow:hidden}body{height:100%;display:flex;flex-direction:column;overflow:hidden;margin:0;font:14px/1.5 system-ui;background:light-dark(#fff,#111113);color:light-dark(#18181b,#f4f4f5)}header{flex-shrink:0;display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid light-dark(#dedee3,#36363c)}strong{display:block;font-size:13px}small{font:11px ui-monospace,monospace;opacity:.7}button{${closeButtonStyle}}button:hover{background:light-dark(#f4f4f5,#242428)}button svg{display:block}button:focus-visible{outline:3px solid #71717a;outline-offset:3px}p{padding:14px;margin:0}iframe{display:block;width:100%;flex:1;min-height:0;border:0;color-scheme:inherit}</style></head><body><header><div><strong>OAuth Mock</strong><small>accounts.example.test</small></div><button aria-label="Close sign-in popup">${closeIcon}</button></header><p role="status">Connecting to the identity provider…</p><iframe title="OAuth Mock account selection and consent" sandbox="allow-same-origin allow-forms"></iframe></body></html>`,
        )
        popup.document.close()
        const child = popup.document.querySelector("iframe")
        const label = popup.document.querySelector("small")
        const message = popup.document.querySelector("p")
        if (!child || !label || !message) throw new Error("Popup unavailable")
        providerFrame = child
        providerAddress = label
        providerStatus = message
        child.addEventListener("load", () => loaded(child))
        popup.document.querySelector("button")?.addEventListener("click", cancelProvider)
        popup.document.addEventListener("keydown", (event) => {
          if (event.key === "Escape") cancelProvider()
        })
        syncTheme()
        popupTimer = setInterval(() => {
          if (popup?.closed) cancelProvider()
        }, 250)
        return
      } catch {
        popup.close()
        popup = null
      }
    }
    providerFrame = dialogFrame
    providerAddress = element<HTMLElement>(".provider-address")
    providerStatus = element<HTMLElement>(".provider-status")
    providerStatus.hidden = false
    providerStatus.textContent = "Connecting to the identity provider…"
    dialog.showModal()
  }
  function render(target: HTMLIFrameElement, url: string, html: string) {
    // Each surface gets its own document. The provider always renders its real response HTML.
    const doc = new DOMParser().parseFromString(html, "text/html")
    const csp = doc.createElement("meta")
    csp.httpEquiv = "Content-Security-Policy"
    csp.content =
      "default-src 'none'; style-src 'unsafe-inline'; img-src data:; form-action 'none'; base-uri 'none'"
    doc.head.prepend(csp)
    // This sandbox's host bridges theme changes; standalone HTTP pages use their own script.
    for (const script of doc.querySelectorAll("script")) script.remove()
    applyDocumentTheme(doc)
    target.style.colorScheme = colorScheme()
    frameUrls.set(target, url)
    target.srcdoc = `<!doctype html>${doc.documentElement.outerHTML}`
  }
  async function navigate(url: string, init: Parameters<typeof browser.navigate>[1] = {}) {
    if (busy || disposed) return
    const run = generation
    const step = ++navigation
    busy = true
    status.textContent =
      flow.value === "popup" ? "Waiting for sign-in in the provider window…" : "Redirecting…"
    try {
      const result = await browser.navigate(url, init)
      const html = await result.response.text()
      if (run !== generation || step !== navigation || disposed) return
      const location = new URL(result.url)
      if (location.origin === APP) {
        closeProvider()
        address.textContent = `${location.host}${location.pathname}`
        render(frame, result.url, html)
        status.textContent = result.response.ok
          ? flow.value === "popup"
            ? "App ready. Sign-in opens a separate provider window."
            : "App ready. Sign-in redirects to the provider and back."
          : "Sign-in did not complete. You’re back in the app and can try again."
      } else if (location.origin === IDENTITY) {
        if (flow.value === "redirect") {
          address.textContent = `${location.host}${location.pathname}`
          render(frame, result.url, html)
          status.textContent =
            "Redirected to OAuth Mock. Complete or cancel sign-in to return to the app."
          return
        }
        if (!popup && !dialog.open) throw new Error("The sign-in window was closed")
        providerAddress.textContent = `${location.host}${location.pathname}`
        providerStatus.hidden = true
        render(providerFrame, result.url, html)
        status.textContent =
          "Complete sign-in in the separate provider window. The app stays open here."
      }
    } catch (error) {
      if (run === generation && step === navigation && !disposed) {
        closeProvider()
        status.textContent = `Unable to continue: ${error instanceof Error ? error.message : "Unknown error"}. Try signing in again.`
        focusApp()
      }
    } finally {
      if (run === generation && step === navigation) busy = false
    }
  }
  function loaded(target: HTMLIFrameElement) {
    if (disposed) return
    const doc = target.contentDocument
    const currentUrl = frameUrls.get(target)
    if (!doc || !currentUrl || !doc.querySelector("main")) return
    applyDocumentTheme(doc)
    doc.addEventListener("change", (event) => {
      const input = event.target as HTMLInputElement
      if (input.name === "oauth-theme" && ["system", "light", "dark"].includes(input.value)) {
        appearance.value = input.value
        syncTheme()
      }
    })
    // A closed popup's delayed load event must never steal focus or submit another request.
    if (target !== frame && !popup && !dialog.open) return
    doc.addEventListener(
      "submit",
      (event) => {
        event.preventDefault()
        const form = event.target as HTMLFormElement
        const data = new FormData(form, (event as SubmitEvent).submitter)
        const params = new URLSearchParams()
        for (const [name, value] of data) if (typeof value === "string") params.append(name, value)
        const url = new URL(form.getAttribute("action") ?? currentUrl, currentUrl)
        const method = form.method.toUpperCase()
        if (method === "GET") url.search = params.toString()
        void navigate(url.href, {
          method,
          ...(method === "GET" ? {} : { body: params }),
          origin: new URL(currentUrl).origin,
        })
      },
      true,
    )
    doc.addEventListener(
      "click",
      (event) => {
        const link = (event.target as Element).closest?.("a[href]")
        if (!link) return
        event.preventDefault()
        const href = link.getAttribute("href") ?? ""
        if (href.startsWith("#")) {
          const anchor = doc.getElementById(href.slice(1))
          anchor?.focus()
          anchor?.scrollIntoView()
          return
        }
        const url = new URL(href, currentUrl)
        if (target === frame && url.origin === APP && url.pathname === "/auth/start") {
          if (busy) return
          if (flow.value === "popup") openProvider()
        }
        void navigate(url.href)
      },
      true,
    )
    doc.addEventListener("keydown", (event) => {
      if (event.key === "Escape" && target !== frame) {
        event.preventDefault()
        cancelProvider()
      }
    })
    const heading = doc.querySelector<HTMLElement>("h1")
    if (heading) {
      heading.tabIndex = -1
      heading.focus({ preventScroll: true })
    }
    // Apple's response_mode=form_post page auto-submits in a real browser. Scripts are removed
    // from the sandboxed source document, so the host performs that same native form submission.
    doc.querySelector<HTMLFormElement>("form#callback")?.requestSubmit()
  }
  function reset() {
    const run = ++generation
    navigation++
    closeProvider()
    busy = false
    entries = []
    traceList.replaceChildren()
    count.textContent = "(0)"
    const behavior: BehaviorInput =
      select.value === "unavailable"
        ? { probabilities: { tokenUnavailable: 1 } }
        : select.value === "normal"
          ? {}
          : { preset: select.value as Exclude<BehaviorInput["preset"], undefined> }
    const example = createExample(
      behavior,
      (entry) => {
        if (run === generation && !disposed) log(entry)
      },
      profile.value as ExampleProvider,
    )
    preferences = example.preferences
    preferences.reuseLastAccount = accountChoice.value === "reuse"
    browser = createBrowser(example.dispatch)
    void navigate(APP)
  }
  const appLoaded = () => loaded(frame)
  const providerLoaded = () => loaded(dialogFrame)
  frame.addEventListener("load", appLoaded)
  dialogFrame.addEventListener("load", providerLoaded)
  dialog.addEventListener("cancel", (event) => {
    event.preventDefault()
    cancelProvider()
  })
  element<HTMLButtonElement>(".close-provider").addEventListener("click", cancelProvider)
  appearance.addEventListener("change", syncTheme)
  systemTheme.addEventListener("change", syncTheme)
  accountChoice.addEventListener("change", () => {
    preferences.reuseLastAccount = accountChoice.value === "reuse"
  })
  profile.addEventListener("change", () => {
    updateBehaviorChoices()
    reset()
  })
  flow.addEventListener("change", () => {
    cancelProvider()
    void navigate(APP)
  })
  select.addEventListener("change", reset)
  element<HTMLButtonElement>(".reset").addEventListener("click", reset)
  updateBehaviorChoices()
  reset()
  return () => {
    disposed = true
    generation++
    closeProvider()
    systemTheme.removeEventListener("change", syncTheme)
    frame.removeEventListener("load", appLoaded)
    dialogFrame.removeEventListener("load", providerLoaded)
    root.replaceChildren()
  }
}

Playground

Requests go to the real mock running in this tab. State persists across requests, and the journal shows what it received. A green dot marks operations whose sample succeeds as-is; others need ids from earlier responses.

Loads the real @crvouga/mockingbird-service-oauth into this tab on first send

Send a request to see the mock's response.

Operations

32 of 32 operations in the vendored contract are mocked.

MethodPathOperationStatus
GET/.well-known/openid-configurationDiscoveryDiscovery
GET/jwksJwksJwks
GET/authorizeAuthorizeAuthorize
GET/interactionInteractionPageInteractionPage
POST/interactionInteractInteract
POST/tokenTokenToken
GET/userinfoUserInfoUserInfo
POST/userinfoUserInfoPostUserInfo
POST/revokeRevokeRevoke
GET/o/oauth2/v2/authGoogleAuthorizeAuthorize
GET/o/oauth2/authGoogleLegacyAuthorizeAuthorize
GET/auth/authorizeAppleAuthorizeAuthorize
POST/auth/tokenAppleTokenToken
GET/auth/keysAppleKeysJwks
GET/oauth2/v3/certsGoogleKeysJwks
GET/v1/userinfoGoogleUserInfoUserInfo
POST/v1/userinfoGoogleUserInfoPostUserInfo
GET/oauth2/v3/userinfoGoogleV3UserInfoUserInfo
POST/oauth2/v3/userinfoGoogleV3UserInfoPostUserInfo
POST/auth/revokeAppleRevokeRevoke
GET/oauth2/v2.0/authorizeMicrosoftAuthorizeAuthorize
POST/oauth2/v2.0/tokenMicrosoftTokenToken
GET/discovery/v2.0/keysMicrosoftKeysJwks
GET/oidc/userinfoMicrosoftUserInfoUserInfo
POST/oidc/userinfoMicrosoftUserInfoPostUserInfo
GET/login/oauth/authorizeGitHubAuthorizeAuthorize
POST/login/oauth/access_tokenGitHubTokenToken
GET/userGitHubUserUserInfo
POST/userGitHubUserPostUserInfo
GET/user/emailsGitHubEmailsUserInfo
POST/user/emailsGitHubEmailsPostUserInfo
GET/WelcomeInteractionPage

Documentation

The package README, the same file that ships in the npm tarball.Edit on GitHub

A portable, stateful OAuth 2.0 / OpenID Connect identity sandbox. Google, Apple, Microsoft and GitHub wire profiles share a vendor-neutral account chooser, signup and consent UI. Generic OIDC works with other configurable identity clients. Uses real RS256 signatures, discovery, JWKS, authorization codes, S256 PKCE, refresh tokens and revocation.

Install

Shell
npm install @crvouga/mockingbird-service-oauth

Usage

TypeScript
import { createRuntime } from "@crvouga/mockingbird-service-oauth"

const identity = createRuntime({
  provider: "google", // "apple", "microsoft", "github" or "oidc"
  accounts: [
    { id: "ada", email: "ada@example.test", name: "Ada Lovelace" },
    { id: "grace", email: "grace@example.test", name: "Grace Hopper" },
  ],
  clients: [{
    id: "my-app",
    name: "My application",
    secret: "local-test-client-secret",
    redirectUris: ["http://localhost:3000/auth/callback"],
  }],
})

const discovery = await identity.fetch(
  new Request("http://localhost:8810/.well-known/openid-configuration"),
)
console.log(await discovery.json())

// The same interface can be mounted in Bun, Deno, a worker or an HTTP adapter.
// Browser execution needs a secure context for Web Crypto.
const fetchHandler = (request: Request) => identity.fetch(request)
void fetchHandler

Serve from Node (also works in Bun):

TypeScript
import { createServer } from "@crvouga/mockingbird-service-oauth/server"

const server = await createServer({ port: 8810, provider: "apple" })
console.log(server.url)
// Register clients and seed accounts through /__admin, or pass them to createServer.
await server.close()
Shell
npx mockingbird-oauth serve --provider google --port 8810

Point an app at the mock

Override the authorization, token, userinfo and JWKS endpoints in your application's OAuth provider configuration. Set its expected issuer to the mock's public base URL. Discovery is at /.well-known/openid-configuration for OIDC profiles; GitHub uses explicit OAuth endpoints and does not issue ID tokens. Register the exact callback URL (including scheme, port, path and query); wildcard callbacks are not accepted. No outgoing requests to a vendor occur.

Profile Authorization Token JWKS Userinfo
google /o/oauth2/v2/auth /token /oauth2/v3/certs /v1/userinfo
apple /auth/authorize /auth/token /auth/keys None, as with Apple
microsoft /oauth2/v2.0/authorize /oauth2/v2.0/token /discovery/v2.0/keys /oidc/userinfo
github /login/oauth/authorize /login/oauth/access_token Not an OIDC provider /user, /user/emails
oidc /authorize /token /jwks /userinfo

/authorize, /token, /jwks, /revoke are common aliases. Google also accepts /o/oauth2/auth and /oauth2/v3/userinfo; Apple revocation is /auth/revoke. Token and revocation requests use application/x-www-form-urlencoded. Token client authentication supports Basic, body credentials, and public clients. Public clients must use S256 PKCE; confidential clients can opt in with requirePkce: true. Google mock client secrets are fixture strings configured on the client. Apple clients can use either a fixture string or apple: { teamId, keyId, publicKey }, where publicKey is an EC P-256 public JWK. In JWT mode the mock verifies the ES256 signature, key ID, team, subject, Apple audience, issue/expiry times and maximum lifetime. The application can keep generating its usual Apple client-secret JWTs with the corresponding test private key.

For example, an Auth.js-style OIDC provider can use type: "oidc", issuer: "http://localhost:8810", clientId, clientSecret, and checks: ["pkce", "state"]. For existing Google/Apple presets, override all remote endpoints and issuer validation; changing the authorization URL alone is insufficient. In-process HTTP clients can route requests to identity.fetch. Browser navigation must reach a served mock or a service worker that routes those requests.

The issuer defaults to the incoming origin (and /ns/<name> when used). Set issuer to the public URL behind a reverse proxy; it may include a mount path. Run a separate runtime for each provider profile. Avoid a fixed issuer shared across namespaces: use the namespace URL and its own discovery/JWKS so each namespace remains an independent issuer.

Accounts and signup

The chooser displays seeded, enabled test accounts. Choosing an account opens explicit consent; creating an account validates the email/name, rejects duplicate email addresses, persists the identity and opens the same consent flow. This is intentionally passwordless test identity selection; never use real passwords or personal data.

Shell
curl http://localhost:8810/__admin/clients -H 'content-type: application/json' \
  -d '{"id":"app","name":"Example app","secret":"fixture-secret","redirectUris":["http://localhost:3000/callback"]}'
curl http://localhost:8810/__admin/accounts -H 'content-type: application/json' \
  -d '{"id":"ada","email":"ada@example.test","name":"Ada Lovelace"}'
curl http://localhost:8810/__admin/accounts

Set adminKey (CLI --admin-key) to require x-mockingbird-admin-key. Programmatically, runtime.instance().seedAccount(account) inserts or updates a stable subject; registerClient(client) inserts or updates a client. Accounts support emailVerified, picture, givenName, familyName, locale, hostedDomain, privateEmail, relayEmail, omitEmail, omitName and disabled. Apple fixtures also accept realUserStatus (0, 1, or 2) and transferSub for risk and app-transfer claim tests. Microsoft fixtures accept preferredUsername, tenantId, objectId; GitHub fixtures accept github: { id, login, publicEmail, emails }. An email-list entry contains email, primary, verified and visibility (public, private or null).

Fidelity and lifecycle

  • Authorization-code flow with exact redirect matching, state and nonce; duplicate parameters rejected. Invalid clients/callbacks never redirect.
  • Real RSA-2048 / RS256 ID tokens and independent public JWKS; correct issuer, audience, expiry, auth time and scope-filtered claims. Keys remain stable until explicitly rotated.
  • Codes expire after 5 minutes and are consumed atomically, including concurrent PKCE redemption. Access/ID tokens last 1 hour. Generic OIDC refresh tokens default to 30 days; Microsoft defaults to 90 days. Apple/Google refresh tokens have no fixed deadline by default; Google inactivity, testing mode and issuance limits still apply. GitHub OAuth app access tokens have no fixed deadline in the mock. The injected mock clock controls expiry.
  • Google access_type=offline issues refresh tokens on first consent or prompt=consent; generic/Microsoft offline_access and Apple issue refresh tokens. Refresh cannot expand scopes. Revocation invalidates related access and refresh tokens; unknown tokens succeed idempotently. Refresh tokens are reusable by default; Microsoft returns a replacement without invalidating the old token. Opt-in strict rotation detects reuse and revokes the token family.
  • prompt=none returns login_required or consent_required; login, consent, select_account, login_hint and max_age are supported. HttpOnly, SameSite=Lax browser sessions last 24 hours. Cancel returns access_denied with state.
  • Apple supports code id_token, c_hash, form_post with an automatic POST and a no-JavaScript Continue button, string email_verified / is_private_email, no userinfo endpoint, and first-consent-only user data. name / email scopes require form_post.
  • Semantic server-rendered HTML needs no frontend framework, hydration, external fonts, images or network assets. Native forms, labelled fields, visible focus rings, a skip link, error announcements, responsive layout, reduced-motion preference and automatic system light/dark colors are included.

Reproducible provider edge cases

Behavioral randomness is off by default. Configure exact scenarios or probabilities; these are test frequencies you choose, not estimates of vendor incidence. OAuth credentials, authorization codes and signing keys always use cryptographic randomness.

TypeScript
import { createRuntime } from "@crvouga/mockingbird-service-oauth"

const identity = createRuntime({
  provider: "apple",
  seed: "signup-regression-42",
  behavior: {
    probabilities: {
      hideEmail: 0.5,
      omitEmail: 0.1,
      omitName: 0.1,
      denyConsent: 0.05,
      tokenUnavailable: 0.1,
    },
  },
})

// Force one case instead. Configuration replaces the old behavior and restarts its sequence.
identity.instance().configureBehavior({ preset: "apple_private_relay" })
console.log(identity.instance().behavior.events) // outcomes only; no tokens or account details

Identity/consent decisions are sampled once per authorization and kept with the grant, including refresh. Token failures are sampled per token attempt: a transient 503 preserves the code for retry and includes Retry-After. The seed, configuration and same ordered requests reproduce the outcomes. The decision cursor, recent 100 events, identities, consent and token state participate in namespace snapshots; reset returns to constructor configuration. Signup subjects remain random: seed stable account IDs for identical relay addresses across runs.

Provider Modeled behavior and controls
Apple First consent offers keyboard-accessible Share/Hide My Email radio buttons. Hidden email becomes a stable @privaterelay.appleid.com alias in both callback user and ID tokens, including refresh. It never merely flips the privacy flag. account.relayEmail sets an explicit alias. The choice persists until consent revocation. apple.emailMode: "hide" / "share" fixes the initial choice; "choose" lets the user choose.
Apple user is returned once; later ID tokens still include email when the email scope was granted. An openid-only grant does not leak email or privacy claims. apple.omitUser simulates an already-authorized app. apple.booleanClaims selects string or boolean verification/privacy claims, including string "false". Subjects and relay addresses are grouped by client.subjectGroup, then Apple team ID, then client ID; grouped apps share first-use disclosure state. realUserStatus and transferSub fixtures cover Apple risk and app-transfer claims.
Google Refresh tokens normally appear only on first consent or explicit consent. google.refreshToken selects first-consent, always, or never. include_granted_scopes=true combines prior grants; consent.deniedScopes models partial consent. Userinfo scope URL aliases are accepted. Hosted-domain claims remain distinct from an email suffix.
Google google.testing=true expires refresh tokens in seven days only when non-basic scopes are requested. google.maxRefreshTokens defaults to 100 per account/client and evicts the oldest refresh token. Six calendar months without use expires a refresh token. tokens.refreshError: "invalid_rapt" returns the reauthentication error subtype; invalid_grant revokes the family.
Microsoft Client-scoped subject plus oid, tid and mutable preferred_username; fixtures can omit email even when requested. Refresh returns a replacement while retaining the old token. Use microsoft_spa_expiry for a 24-hour refresh window. Supply real-shaped tenant/object fixture IDs when the app validates UUIDs.
GitHub OAuth app endpoints, JSON or form token responses, no ID token, and a nullable /user.email even with email scope. /user/emails returns primary/secondary and verified/unverified addresses and requires user:email or user. Resource responses expose X-OAuth-Scopes and X-Accepted-OAuth-Scopes. An unverified primary account fails token exchange with unverified_user_email. Incorrect credentials/code/redirect produce GitHub error names.
Any Missing/unverified email, missing names, denied consent, partial scopes, configurable token/code expiry, transient token failures, revoked grants, strict refresh rotation/reuse detection, and signing-key rotation. Non-Apple account email changes retain the subject. Additional scopes can be accepted via additionalScopes; associated resource APIs are not implied.

The complete typed controls are OAuthBehavior. probabilities accepts hideEmail, omitEmail, omitName, unverifiedEmail, denyConsent, tokenUnavailable, and invalidGrant, each in [0,1]. Static claims flags force omissions or unverified email. consent.error supports access_denied, interaction_required, or temporarily_unavailable. tokens accepts positive integer accessTtlSeconds, codeTtlSeconds, refreshTtlSeconds, refreshRotation: "reuse" | "rotate", and refreshError. These controls are local testing overrides, not claims that all providers implement every variation.

OAUTH_SCENARIOS supplies: apple_private_relay, apple_share_email, apple_returning_user, apple_boolean_claims, microsoft_missing_email, microsoft_spa_expiry, github_unverified_email, missing_email, missing_name, unverified_email, google_no_refresh_token, google_reauthentication, revoked_refresh_token, rotating_refresh_tokens, short_lived_tokens, consent_denied, intermittent_token_failure. Explicit fields override the chosen preset's fields. Unknown keys and invalid values fail validation.

Shell
npx mockingbird-oauth serve --provider apple --seed regression-42 --scenario apple_private_relay
curl http://localhost:8810/__admin/scenarios
curl -X PUT http://localhost:8810/__admin/behavior -H 'content-type: application/json' \
  -d '{"preset":"apple_private_relay","probabilities":{"omitName":0.25}}'
curl http://localhost:8810/__admin/behavior
curl -X POST http://localhost:8810/__admin/consents/revoke -H 'content-type: application/json' \
  -d '{"clientId":"app","accountId":"ada"}'
curl -X POST http://localhost:8810/__admin/keys/rotate -H 'content-type: application/json' \
  -d '{"retainPrevious":true}'

These routes use the shared admin-key and namespace controls. revokeConsent(clientId, accountId) removes that client's grants and resets first-use disclosure; it does not disable the account. rotateSigningKey(true) retains up to four previous public keys so existing tokens still verify; false withdraws them to test stale JWKS caches. Keys themselves are not included in snapshots, so restoring state does not undo a key rotation.

Shared service controls

The runtime supplies /health, /__admin/reset, snapshots, mock clock, request journal, metrics, fault injection and namespace isolation. Use x-mockingbird-namespace for in-process tests or /ns/<name>/… for complete browser flows. Header-selected namespaces alone cannot persist across ordinary browser navigation. State, grants, sessions and consent live in the shared SQLite abstraction; there are no filesystem or Node imports in the main entry.

OAUTH_PRESETS includes token_unavailable and access_denied. Fault rules can also target a provider-specific path, e.g. POST /__admin/faults with {"pathPrefix":"/auth/token","status":503,"body":{"error":"temporarily_unavailable"}}. No outbound webhooks are modeled. Journals contain request metadata, never passwords or request bodies.

API

  • createRuntime(options?): shared service runtime; fetch, instance, reset, snapshot, restore, clock, faults and journals.
  • OAuthAPI: standalone portable handler with fetch, reset, seedAccount, registerClient, accounts, clients, provider, configureBehavior, behavior, revokeConsent, rotateSigningKey.
  • OAUTH_PRESETS: named transport fault presets.
  • OAUTH_SCENARIOS: named provider-behavior scenarios.
  • document, operationIds, supportedOperationIds: generated OpenAPI metadata.
  • createServer(options?) from ./server: Node HTTP adapter, returning url, close and runtime.
  • DEFAULT_PORT, serveTarget from ./server: CLI defaults and multi-service launcher integration.
  • Types: Account, Client, Provider, OAuthAPIOptions, OAuthRuntimeOptions, OAuthRuntime, OAuthServerOptions, OAuthBehavior, BehaviorInput, OAuthScenario, EdgeCase, BehaviorEvent.

Verification

bun test covers protocol security, every published behavior scenario, all provider profiles, independent JOSE verification, an unmodified oauth4webapi client, the complete in-process app, and randomized self-parity across provider, privacy, omission, and verification combinations. bun run parity safely checks current public discovery and JWKS contracts against Google, Apple, and Microsoft, plus GitHub's unauthenticated REST error shape. It needs no credentials and makes no grants or account changes. Interactive vendor flows cannot be run unattended without owned provider applications, so the focused contract tests use the official behavior documented in the references below.

Deliberately not modelled

This is a ready-to-use local/test identity provider, not a production authentication server or a claim that every proprietary provider feature is implemented. Its ready tier covers the documented OAuth/OIDC login, identity, consent, token, provider-edge-case, and UI surface. Applications should still run a small final check against each real provider before release.

Vendor-hosted Google Identity Services/One Tap, native Apple AuthenticationServices, passkeys, MFA, CAPTCHA, password recovery, email delivery/relay forwarding, app-transfer migration, vendor risk engines, tokeninfo/introspection, logout, GitHub Apps installation/device flows, and Microsoft Graph/tenant administration are not implemented. Other configurable OIDC providers can use the generic profile, but their proprietary scopes and claims are not emulated. Scopes are limited to each profile plus explicitly configured additional scopes. Microsoft uses the configured mock issuer, not real Entra tenant routing. GitHub is the OAuth app login surface, not the full REST API.

No implicit flow, dynamic client registration, arbitrary custom redirect schemes, cross-origin browser token CORS policy, persistent signing-key import, or distributed-session coordination is provided. Snapshot restore is for the same runtime/instance; signing keys are not serialized. The default backing store is in-memory and state disappears when the process exits. A secure browser context and Web Crypto, Fetch and standard Web APIs are required; Node 22+, Bun and modern browsers provide them.

References: Google OpenID Connect, Apple authorization request, and OpenID Connect Core.

Provider references for the edge cases: Apple first-use profile data, Apple token response, Google consent and refresh, Google expiry and limits, Microsoft claims, Microsoft refresh behavior, GitHub OAuth, GitHub token errors.

Interactive application example

The OAuth service page includes complete in-process Google-style, Apple-style, Microsoft-style, and GitHub-style login profiles. Launch the example app, select a seeded account or create one, approve consent, and return to a signed-in app. Apple mode exercises real form_post, first-use name disclosure, Share My Email, and stable Hide My Email relay addresses. Switch scenarios to exercise missing identity fields, declined consent, boolean/string claims, returning Apple users, a GitHub account whose public email is null, or a failing token endpoint. Reset creates fresh, isolated app and provider state.

The service-owned source lives in examples/google-login/: a real Hono app uses oauth4webapi to discover OIDC providers, generate PKCE/state/nonce, exchange the code, verify JWT signatures against JWKS, fetch identity data, and establish a session. GitHub mode uses its explicit OAuth endpoints, numeric account ID, and /user/emails fallback. Popup mode keeps the host app visible while a separate sign-in window renders the mock's actual HTML response. Redirect mode replaces the preview with the provider document and returns to the app on callback. The popup closes on callback and the app updates with the result. Browsers that block new windows use a separate modal dialog with its own provider document. Closing the popup or pressing Escape returns focus to the app without completing sign-in. Native forms use an in-memory Fetch dispatcher. The request trace exposes the protocol without showing credentials. Both the app and its cookie/redirect transport run without DOM APIs; only the mounting component needs a browser. No authentication request leaves the process.

For an entirely browser-hosted Fetch dispatcher, configure cookieHeaders: { request: "x-example-cookie", response: "x-example-set-cookie" }. Browser Fetch strips the standard Cookie and Set-Cookie headers from synthetic objects; this explicit local mapping lets an in-process cookie jar preserve sessions. The example uses it for both Hono and the provider. Leave it unset for normal HTTP serving, which uses standard cookie headers. This mapping is a transport detail, not a browser cookie-policy emulator.

Presentation and account selection

The provider UI is neutral and labelled OAuth Mock, with no vendor or product branding. Its System / Light / Dark controls work on standalone HTML pages; the selection persists across pages in that browser session. The in-process example bridges the same controls into its sandboxed documents. System mode follows the operating system, independently of the docs site's selected theme.

The example toolbar configures Popup / Redirect, appearance, and Always choose / Reuse last account. Changing these settings does not clear accounts or existing consent. Only Reset example (or switching a failure scenario) creates a fresh app and provider. Initial values can also be passed to the component:

JavaScript
import { mount } from "./examples/google-login/index.js"
const dispose = await mount(host, {
  flow: "redirect", // default: "popup"
  theme: "system", // also "light" or "dark"
  reuseLastAccount: false, // default: always show the chooser
})

Popup versus redirect is an application presentation choice; both use the same authorization endpoint and callback validation. The demo uses prompt=select_account to force the chooser. For any integrating app, session reuse can also be disabled on the mock itself:

TypeScript
import { OAuthAPI } from "@crvouga/mockingbird-service-oauth"

const api = new OAuthAPI({
  behavior: { session: { reuseLastAccount: false } },
})

The provider default is true to emulate normal social login. false prevents automatic account selection and makes prompt=none return login_required. prompt=select_account always forces interactive choice, regardless of this setting. The same configuration can be changed with the behavior admin endpoint and is included in snapshots.

to navigate to open