@synqauth/nextjs
v0.3.0
Published
Synq for Next.js: a backend-for-frontend session, so the browser never holds a Synq token.
Readme
@synqauth/nextjs
Synq for Next.js: a backend-for-frontend session, so the browser never holds a Synq token.
Synq is customer identity for businesses: an OpenID Connect issuer for each of your brands, with Solana wallet, social, email and passkey sign-in. The documentation is at docs.synq.build.
This package is at 0.x, so a minor release can change its API: see its CHANGELOG.md.
Quick start
Register the app in the Synq dashboard as a web app, with https://<your app>/api/auth/callback as its redirect URI and https://<your app>/ as its post-logout URI. Then set five environment variables:
SYNQ_ISSUER=https://<your brand>.auth.synq.build
SYNQ_CLIENT_ID=app_...
SYNQ_CLIENT_SECRET=...
SYNQ_APP_URL=https://app.example.com
SYNQ_SESSION_SECRET=$(openssl rand -base64 32)One configuration, which the server and the proxy share:
// synq.config.ts
import { defineSynqConfig } from '@synqauth/nextjs/config'
export const synqConfig = defineSynqConfig({})// lib/synq.ts
import { synqAuth } from '@synqauth/nextjs'
import { synqConfig } from '../synq.config'
export const synq = synqAuth(synqConfig)// app/api/auth/[...synq]/route.ts
import { synq } from '@/lib/synq'
export const { GET, POST, PATCH, DELETE } = synq.handlers// proxy.ts
import { synqProxy } from '@synqauth/nextjs/proxy'
import { synqConfig } from './synq.config'
export const proxy = synqProxy(synqConfig, { protect: ['/dashboard'] })
// Pages only: not Next's files, nor anything with an extension.
export const config = { matcher: ['/((?!_next/|.*\\..*).*)'] }To add a way to sign in to someone's account (Google, a wallet, a passkey, anything the brand offers), POST { method, returnTo } as JSON to /api/auth/link from your own pages and send the browser to the redirect it answers, or call useAuth().link('google') from @synqauth/react: Synq asks them to sign in again, then adds it.
To sign people in on your own page instead of the brand's (AERO-1411), turn on own-UI sign-in for the app in the dashboard and use <SignInForm> or useSignIn() from @synqauth/react. They post each step to /api/auth/sign-in/flow, which takes it to Synq as your app. Pass clientAddress to defineSynqConfig when your host doesn't set x-real-ip, so Synq can limit tries per address. Read a header your own proxy sets, never one the browser could send:
export const synqConfig = defineSynqConfig({
// Behind Cloudflare
clientAddress: (request) => request.headers.get('cf-connecting-ip') ?? undefined,
})In a server component, await synq.me() is the signed-in person or null, and await synq.getAccessToken() is a token for your API (resource). Link synq.signInPath({ returnTo: '/dashboard' }) to sign in, and sign out with a form that POSTs to synq.signOutPath.
When a sign-in doesn't finish
Synq's own dashboard can resume an unverified email address with
/auth/sign-in?intent=verify-email&returnTo=/onboard. The intent defaults to
prompt=select_account, so an existing GitHub-only account can prove its email
without signing in again or creating another account. An explicit prompt and
max_age are preserved: verification never replaces a request for fresh
authentication. A maximum-age request takes the normal sign-in flow; an
incompatible explicit prompt is refused rather than replaced. This verification
flow applies only where the issuer requires unproved email addresses to be
verified; tenants keep their existing behavior.
When Synq refuses a sign-in (the person cancelled, their account can't use the app, a token gate they went back from) or the callback fails, the route mount sends the browser back to returnTo with synq_error=<code>. The proxy never sends such a page to sign in again: with the same session at the brand, that would land on the same refusal, round and round. Without a session, it shows why instead, with "Try again" and "Use another account" (prompt=select_account). Give it a page of your own with signInError, which it never protects:
export const proxy = synqProxy(synqConfig, { protect: () => true, signInError: '/sign-in-error' })// app/sign-in-error/page.tsx
export default async function SignInError({ searchParams }: PageProps<'/sign-in-error'>) {
const failed = synq.signInError(await searchParams)
if (!failed) redirect('/')
return (
<main>
<h1>Sign-in didn’t finish</h1>
<p>{failed.message}</p>
<a href={failed.retryPath}>Try again</a> <a href={failed.anotherAccountPath}>Use another account</a>
</main>
)
}A page that isn't protected gets synq_error as it is: synq.signInError(await searchParams, '/where-to-come-back') reads it there too.
Next 16 runs proxy.ts on Node. The proxy imports nothing only Node has, so it works on the Edge runtime too.
More than one server process: use a store
By default a session lives in a sealed, HttpOnly cookie, and only one refresh runs at a time within one server process. Synq rotates refresh tokens and ends a session whose old one comes back, so an app on several processes (Vercel and other serverless hosts, replicas) must keep sessions in a store they all share, which gives each session one lock:
import { keyValueSessionStore } from '@synqauth/nextjs'
export const synqConfig = defineSynqConfig({
store: keyValueSessionStore({
get: (key) => redis.get(key),
set: async (key, value, { ttlMs, onlyIfAbsent }) =>
(await redis.set(key, value, { PX: ttlMs, NX: onlyIfAbsent })) === 'OK',
delete: async (key) => void (await redis.del(key)),
}),
})synqAuth() refuses cookie sessions where VERCEL, AWS_LAMBDA_FUNCTION_NAME, NETLIFY, K_SERVICE or KUBERNETES_SERVICE_HOST is set, unless allowSingleProcessCookieMode says the app truly runs one process. A store also lets your server end a session; a cookie can't be taken back.
To rotate SYNQ_SESSION_SECRET, move the old secret to SYNQ_SESSION_SECRET_PREVIOUS (comma-separated), which opens cookies but never seals them.
Several sites from one deployment
One app can sign people in on many hosts, each with its own app, at its own brand or a shared one: a site per chapter, say. Pass hosts, every host you serve (*. and a domain for any one label under it), and resolve, which answers each request's site from its host. It may be async and read the client secret from your own store:
export const synqConfig = defineSynqConfig({
hosts: ['*.acmeguild.com'],
async resolve({ host }) {
const chapter = await chapterByHost(host)
if (!chapter) return null
return {
issuer: chapter.issuer,
clientId: chapter.clientId,
clientSecret: await chapter.clientSecret(),
storePrefix: `chapter-${chapter.slug}:`,
}
},
})The handlers, the proxy, me(), getSession() and getAccessToken() then pick the request's site per request. The host comes from X-Forwarded-Host, else Host, and the site's app URL is https://<host>. A host off hosts, or one resolve answers null for, is refused with unknown_host: never given another site's configuration. Each site's core is kept, and its cookies are sealed for it alone, so sites never share a session. The guide is at docs.synq.build/guides/several-sites.
License
MIT
