@freebuff/auth
v0.1.0
Published
Sign in with Freebuff: add Freebuff accounts to any site with no secrets. OIDC + PKCE for Cloudflare Workers, Bun, Node and Next.js.
Readme
Sign in with Freebuff
@freebuff/auth lets anyone with a Freebuff account sign in to your site:
Google, GitHub or Apple through Freebuff, with nothing to configure and no
secrets to keep. Your server learns who the visitor is (sub, and with their
consent their email, name and picture). It never sees their password,
provider credentials or Freebuff account token.
It is standard OpenID Connect (authorization code + PKCE) with a
server-side session, and runs anywhere with fetch and Web Crypto:
Cloudflare Workers, Bun, Node 20+, Deno and Next.js.
1. Get a client ID
Each project environment has its own public client ID, registered by the project's creator against exact callback URLs:
# Production: the site's real origin
npx @freebuff/auth register --project notes --name "Notes" \
--environment production --redirect-uri https://notes.example/auth/freebuff/callback
# Local development: any port on localhost
npx @freebuff/auth register --project notes --name "Notes" \
--environment development --redirect-uri http://localhost:8787/auth/freebuff/callbackIt signs in as you with your Freebuff CLI login (or FREEBUFF_TOKEN) and
prints FREEBUFF_CLIENT_ID. That, and your site's origin, are all the
configuration there is. Neither is a secret. Re-running it is safe; add a
new --redirect-uri when the site gets a new domain.
2. Mount it
Cloudflare Workers, Bun, Deno, Hono
Wrap your fetch handler. It serves the four auth routes, keeps sessions
fresh, and makes getUser available anywhere inside:
import { createFreebuffAuth } from '@freebuff/auth'
const auth = createFreebuffAuth({
clientId: env.FREEBUFF_CLIENT_ID,
baseUrl: 'https://notes.example', // your canonical origin
})
export default {
fetch: auth.wrap(async (request) => {
const user = await auth.getUser(request)
if (new URL(request.url).pathname.startsWith('/api/') && !user) {
return Response.json({ error: 'Sign in required' }, { status: 401 })
}
return app.fetch(request)
}),
}See examples/cloudflare-worker.ts (static
assets + API) and examples/hono.ts.
Next.js (App Router)
// lib/freebuff.ts
import { createFreebuffNext } from '@freebuff/auth/next'
export const freebuff = createFreebuffNext({
clientId: process.env.FREEBUFF_CLIENT_ID!,
baseUrl: process.env.APP_ORIGIN!,
})
// app/auth/freebuff/[...freebuff]/route.ts
export const { GET, POST } = freebuff.handlers
// proxy.ts (middleware.ts before Next 16): refreshes sessions before render
export const proxy = freebuff.proxy
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }
// Any Server Component, Route Handler or Server Action
const user = await freebuff.getUser()See examples/nextjs.
Buttons and client state (React)
import { SignInWithFreebuff, SignOutButton, useFreebuffUser } from '@freebuff/auth/react'
<SignInWithFreebuff /> // link to /auth/freebuff/signin, returns here
<SignOutButton returnTo="/" /> // a POST form; sign-out is never a link
const { user, isLoading } = useFreebuffUser() // reads /auth/freebuff/sessionWithout React: link to /auth/freebuff/signin?returnTo=/somewhere, sign out
with <form method="post" action="/auth/freebuff/signout">, and read
GET /auth/freebuff/session → { "user": { ... } | null }.
The user
interface FreebuffUser {
sub: string // always; stable for this visitor in this project
email?: string // `email` scope, when present
email_verified?: boolean
name?: string // `profile` scope, when set
picture?: string // https URL
}- Key your data on
sub. It is the same across your project's development and production clients, and different in every other project, so sites cannot correlate visitors by it. It never changes. - Email is profile data, not identity. It can change, may be absent, and may be unverified. Never merge or look up accounts by email.
- Ask for less with
scopes: ['openid'](justsub) or['openid', 'email'].
Sessions and security
- No secrets anywhere in your project. Freebuff signs the tokens; your server verifies them with Freebuff's public keys (cached; no per-request network call).
- The session is one
__Host-freebuff_sessioncookie:HttpOnly,Secure,SameSite=Lax, host-only. It holds a one-hour access token and a refresh token. Tokens never reach page JavaScript. - Refresh tokens rotate on every use. A stolen, already-used refresh token ends the whole session. Parallel requests are safe: they receive the same successor.
- Sessions last up to 30 days of inactivity and at most 90 days, then the visitor signs in again (usually one click: Freebuff remembers consent).
- Sign-out is
POST, same-origin only; it revokes the session at Freebuff. - If a Freebuff account is banned, or the project is disabled, its sessions stop within an hour (the access-token lifetime) and cannot be renewed.
- Sign-in uses PKCE,
stateandnonce, pinned issuer, exact callback URLs, and RFC 9207 issuer checks. Token requests are refused from browsers.
getUser only refreshes inside wrap, the Next.js proxy, or the
/auth/freebuff/session route, because a refreshed session must reach the
browser or its next refresh looks like theft. Elsewhere it verifies the
current access token and returns null once it expires.
Local development
Use your project's development client ID and
allowInsecureLocalhost: true with a http://localhost:<port> or
http://127.0.0.1:<port> baseUrl. Development clients accept any port
on the registered loopback host. Never use a development client or
allowInsecureLocalhost in production.
Options
| Option | Default | |
| --- | --- | --- |
| clientId | required | Public, from provisioning |
| baseUrl | required | Canonical origin; never derived from requests |
| issuer | https://freebuff.com/oidc | Only for a non-production Freebuff |
| scopes | ['openid', 'email', 'profile'] | openid is always included |
| allowInsecureLocalhost | false | Development only |
| fetch | global fetch | Server-side calls to Freebuff |
For AI coding agents integrating this package, read AGENTS.md.
