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.

bash
npm install express express-session openid-client

The 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
// 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.