HonkIO Auth
Express quickstart
HonkIO Auth in an Express app with openid-client and express-session, in one file.
Install
Install Express, express-session and openid-client. Create a test app with the redirect URI http://localhost:3000/auth/callback and the post-logout redirect URI http://localhost:3000/, and set HONKIO_AUTH_ISSUER, HONKIO_AUTH_CLIENT_ID, HONKIO_AUTH_CLIENT_SECRET, APP_URL and SESSION_SECRET.
npm install express express-session openid-clientThe app
The whole app, in one file. Sign-in sends PKCE, state and nonce; the callback checks them and the ID token’s iss and aud, then starts a fresh session.
// server.js (ES module)
import express from 'express'
import session from 'express-session'
import * as client from 'openid-client'
const ISSUER = process.env.HONKIO_AUTH_ISSUER // https://login-dev.acme.ca
const APP_URL = process.env.APP_URL // http://localhost:3000
const OUTAGE_GRACE_MS = 24 * 60 * 60 * 1000
const config = await client.discovery(new URL(ISSUER), process.env.HONKIO_AUTH_CLIENT_ID, process.env.HONKIO_AUTH_CLIENT_SECRET)
config.timeout = 5
const app = express()
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' },
// Use a persistent store (Redis, Postgres) in production.
}))
function saveTokens(req, tokens) {
const claims = tokens.claims()
if (claims) req.session.user = { sub: claims.sub, email: claims.email }
if (tokens.id_token) req.session.idToken = tokens.id_token
if (tokens.refresh_token) req.session.refreshToken = tokens.refresh_token
req.session.accessToken = tokens.access_token
req.session.accessExpiresAt = Date.now() + (tokens.expiresIn() ?? 600) * 1000
delete req.session.retryRefreshAt
}
app.get('/auth/login', async (req, res) => {
const verifier = client.randomPKCECodeVerifier()
const state = client.randomState()
const nonce = client.randomNonce()
req.session.pending = { verifier, state, nonce }
const url = client.buildAuthorizationUrl(config, {
redirect_uri: APP_URL + '/auth/callback',
scope: 'openid email',
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: 'S256',
state,
nonce,
})
res.redirect(url.href)
})
app.get('/auth/callback', async (req, res, next) => {
try {
const pending = req.session.pending
if (!pending) return res.redirect('/auth/login')
const tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, APP_URL), {
pkceCodeVerifier: pending.verifier,
expectedState: pending.state,
expectedNonce: pending.nonce,
idTokenExpected: true,
})
delete req.session.pending
req.session.regenerate((err) => {
if (err) return next(err)
saveTokens(req, tokens)
res.redirect('/')
})
} catch (err) {
next(err)
}
})
/** True only for "HonkIO can't be reached": a network error, a timeout or a 5xx. */
function isUnreachable(err) {
if (err instanceof client.ResponseBodyError) return err.status >= 500
if (err?.name === 'TimeoutError' || err?.name === 'AbortError') return true
if (err instanceof TypeError) return true
return err?.cause instanceof Response && err.cause.status >= 500
}
// Signed-in pages: refresh when the access token has expired, with the outage rule.
async function requireUser(req, res, next) {
const s = req.session
if (!s.user) return res.redirect('/auth/login')
const now = Date.now()
if (now < s.accessExpiresAt - 30_000) return next()
if (s.retryRefreshAt && now < s.retryRefreshAt && now < s.accessExpiresAt + OUTAGE_GRACE_MS) return next()
try {
saveTokens(req, await client.refreshTokenGrant(config, s.refreshToken))
return next()
} catch (err) {
if (isUnreachable(err) && now < s.accessExpiresAt + OUTAGE_GRACE_MS) {
// HonkIO can't be reached: keep the session, retry in a minute.
s.retryRefreshAt = now + 60_000
return next()
}
// invalid_grant, or the outage outlasted 24 hours past expiry: end the session now.
return s.destroy(() => res.redirect('/auth/login'))
}
}
app.get('/', requireUser, (req, res) => res.send('Signed in as ' + (req.session.user.email ?? req.session.user.sub)))
app.post('/auth/logout', (req, res) => {
const idToken = req.session.idToken
req.session.destroy(() => {
const url = client.buildEndSessionUrl(config, { post_logout_redirect_uri: APP_URL + '/', ...(idToken ? { id_token_hint: idToken } : {}) })
res.redirect(303, url.href)
})
})
app.listen(3000)Refresh and the outage rule
The requireUser middleware refreshes expired tokens. When HonkIO can’t be reached (a network error, a timeout or a 5xx), it keeps the session until 24 hours past the access token’s expiry and retries every minute. When HonkIO refuses the refresh (invalid_grant), it ends the session at once. See Sessions and outages.
HonkIO