Node.js — Auth.js
The least code of any option. Auth.js (formerly NextAuth) handles sessions, cookies, PKCE, token refresh, and CSRF with a single config object.
Works with Next.js, SvelteKit, Nuxt, and plain Express.
Install (Next.js App Router)
npm install next-auth
npx auth secret # generates AUTH_SECRET
Setup — 2 Files
// auth.ts (project root)
import NextAuth from "next-auth"
refreshAccessToken — only needed if you asked for offline_access
Access tokens last an hour. This exchanges the refresh token for a new pair when one expires. Skip it entirely if your app only needs to know who signed in.
async function refreshAccessToken(token: Record<string, unknown>) {
const response = await fetch("https://v1.0account.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: token.refreshToken as string,
client_id: process.env.ZERO_CLIENT_ID!,
client_secret: process.env.ZERO_CLIENT_SECRET!,
}),
})
const tokens = await response.json()
if (!response.ok) throw tokens
return {
...token,
accessToken: tokens.access_token,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in as number),
// Use the new refresh token if the server rotated it
refreshToken: (tokens.refresh_token as string) ?? token.refreshToken,
}
}
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
{
id: "0account",
name: "0account",
type: "oidc",
issuer: "https://v1.0account.com",
clientId: process.env.ZERO_CLIENT_ID,
clientSecret: process.env.ZERO_CLIENT_SECRET,
// offline_access requests a refresh token
authorization: { params: { scope: "openid profile email offline_access" } },
// Required. Auth.js's default mapping looks for `name`, which we do not
// send — userinfo returns given_name and family_name separately, so
// without this session.user.name is undefined.
profile(profile) {
return {
id: profile.sub,
name: [profile.given_name, profile.family_name].filter(Boolean).join(" "),
email: profile.email,
image: profile.picture,
}
},
},
],
events: {
async signOut(message) {
// Server-to-server: terminate the session on 0account's side without a browser redirect.
if ("token" in message && message.token?.idToken) {
await fetch("https://v1.0account.com/oauth/logout", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ id_token_hint: message.token.idToken as string }),
}).catch(() => {})
}
},
},
callbacks: {
async jwt({ token, account }) {
// Persist tokens from the initial sign-in
if (account) {
return {
...token,
accessToken: account.access_token,
idToken: account.id_token,
expiresAt: account.expires_at,
refreshToken: account.refresh_token,
}
}
// Return token if it has not expired yet
if (Date.now() < (token.expiresAt as number) * 1000 - 60_000) return token
// Refresh the access token
try {
return await refreshAccessToken(token)
} catch {
return { ...token, error: "RefreshAccessTokenError" }
}
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
if (token.error) session.error = token.error as string
return session
},
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers
Using the Session
// app/page.tsx
import { auth, signIn, signOut } from "@/auth"
export default async function Page() {
const session = await auth()
if (!session) {
return (
<form action={async () => { "use server"; await signIn("0account") }}>
<button type="submit">Sign in with 0account</button>
</form>
)
}
// Show a warning if the token could not be refreshed
if (session.error === "RefreshAccessTokenError") {
return <form action={async () => { "use server"; await signIn("0account") }}>
<button type="submit">Session expired — sign in again</button>
</form>
}
return (
<div>
<p>Welcome, {session.user?.name}</p>
<form action={async () => { "use server"; await signOut() }}>
<button type="submit">Sign out</button>
</form>
</div>
)
}
Auth.js handles the redirect, callback, state, PKCE, and session automatically. The signOut()
call triggers RP-Initiated Logout via the end_session_endpoint from the discovery document.
Reading custom fields
session.user carries what the profile() mapping above returns. Everything you
configured on your app — including fields with no OIDC equivalent — arrives under
a namespaced claim on the raw profile, so pass it through the callbacks.
// auth.ts — add to the callbacks you already have
const FIELDS = "https://0account.com/claims/fields"
callbacks: {
async jwt({ token, profile }) {
// profile is present only on the first call, right after sign-in
if (profile) token.fields = profile[FIELDS] ?? {}
return token
},
async session({ session, token }) {
session.fields = token.fields as Record<string, unknown>
return session
},
}
const session = await auth()
const petName = session?.fields?.petName
The key is whatever you named the field, in camelCase: a field titled
Pet name arrives as petName. Standard fields are in there too, so you can
read everything from one place if you prefer.
Environment Variables
ZERO_CLIENT_ID=your-0account-client-id
ZERO_CLIENT_SECRET=your-0account-client-secret
AUTH_SECRET=your-random-secret # run: npx auth secret
Using with Express
import { ExpressAuth } from "@auth/express"
app.use("/auth/*", ExpressAuth({
providers: [{
id: "0account",
name: "0account",
type: "oidc",
issuer: "https://v1.0account.com",
clientId: process.env.ZERO_CLIENT_ID,
clientSecret: process.env.ZERO_CLIENT_SECRET,
authorization: { params: { scope: "openid profile email offline_access" } },
}],
}))
Going further
This page covers sign-in with 0account and nothing else. For everything the library itself does — middleware, adapters, session storage, multiple providers — see the Auth.js documentation.