Skip to main content

Node.js — openid-client

Certified OIDC implementation. Full control over every step of the flow.

Install

npm install openid-client express express-session

Full Example

Handles login (with PKCE), callback, logout (RP-initiated), and automatic token refresh.

const express = require("express")
const session = require("express-session")
const { Issuer, generators } = require("openid-client")

const app = express()
app.use(express.json())
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
// In production, use a persistent session store (e.g. connect-redis)
cookie: { httpOnly: true, secure: true, sameSite: "lax", maxAge: 30 * 24 * 60 * 60 * 1000 },
}))

let client

async function initClient() {
const issuer = await Issuer.discover("https://v1.0account.com")
client = new issuer.Client({
client_id: process.env.ZERO_CLIENT_ID,
client_secret: process.env.ZERO_CLIENT_SECRET,
redirect_uris: ["https://yourapp.com/auth/callback"],
response_types: ["code"],
})
}

app.get("/auth/login", (req, res) => {
const state = generators.state()
const verifier = generators.codeVerifier()
const challenge = generators.codeChallenge(verifier)

req.session.oauthState = state
req.session.pkceVerifier = verifier

res.redirect(client.authorizationUrl({
scope: "openid profile email offline_access",
state,
code_challenge: challenge,
code_challenge_method: "S256",
}))
})

app.get("/auth/callback", async (req, res) => {
try {
const params = client.callbackParams(req)
const tokenSet = await client.callback(
"https://yourapp.com/auth/callback",
params,
{
state: req.session.oauthState,
code_verifier: req.session.pkceVerifier,
},
)

delete req.session.oauthState
delete req.session.pkceVerifier

const claims = tokenSet.claims()

// The ID token carries identity only — sub, aud, iss, nonce, sid. Profile
// data lives in userinfo, so fetch it.
const userinfo = await client.userinfo(tokenSet.access_token)
// userinfo.email, userinfo.given_name, userinfo.family_name
//
// Custom fields are under a namespaced claim. A field titled "Pet name"
// arrives as petName.
const fields = userinfo["https://0account.com/claims/fields"] ?? {}

// TODO: upsert user into your database by claims.sub

req.session.userId = claims.sub
req.session.email = userinfo.email
req.session.petName = fields.petName
req.session.idToken = tokenSet.id_token
req.session.accessToken = tokenSet.access_token
req.session.refreshToken = tokenSet.refresh_token
req.session.expiresAt = tokenSet.expires_at // Unix seconds

res.redirect("/dashboard")
} catch (err) {
console.error("callback error:", err)
res.status(401).send("Authentication failed")
}
})

app.get("/auth/logout", async (req, res) => {
const idToken = req.session.idToken
await new Promise(resolve => req.session.destroy(resolve))
if (idToken) {
// Server-to-server: terminate the session on 0account's side without a browser redirect.
await fetch("https://v1.0account.com/oauth/logout", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ id_token_hint: idToken }),
}).catch(() => {}) // best-effort; local session already destroyed
}
res.redirect("/")
})

// withAuth middleware — requires a valid session and proactively refreshes
// the access token when it is within 5 minutes of expiry.
async function withAuth(req, res, next) {
if (!req.session.userId) return res.status(401).json({ error: "not authenticated" })

const expiresIn = (req.session.expiresAt * 1000) - Date.now()
if (expiresIn < 5 * 60 * 1000 && req.session.refreshToken) {
try {
const tokenSet = await client.refresh(req.session.refreshToken)
req.session.accessToken = tokenSet.access_token
req.session.expiresAt = tokenSet.expires_at
if (tokenSet.refresh_token) {
req.session.refreshToken = tokenSet.refresh_token // accept rotated refresh token
}
} catch (err) {
return req.session.destroy(() => res.redirect("/auth/login"))
}
}
next()
}

// Protected route example
app.get("/dashboard", withAuth, (req, res) => {
res.json({ userId: req.session.userId })
})

initClient().then(() => app.listen(3000))

Environment Variables

ZERO_CLIENT_ID=your-0account-client-id
ZERO_CLIENT_SECRET=your-0account-client-secret
SESSION_SECRET=your-random-secret

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 openid-client documentation.