HonkIO Auth

Démarrage rapide Express

HonkIO Auth dans une application Express avec openid-client et express-session, en un seul fichier.

Installation

Installez Express, express-session et openid-client. Créez une application de test avec l’URI de redirection http://localhost:3000/auth/callback et l’URI de redirection après déconnexion http://localhost:3000/, et définissez HONKIO_AUTH_ISSUER, HONKIO_AUTH_CLIENT_ID, HONKIO_AUTH_CLIENT_SECRET, APP_URL et SESSION_SECRET.

bash
npm install express express-session openid-client

L’application

Toute l’application, en un fichier. La connexion envoie PKCE, state et nonce; le rappel les vérifie, ainsi que le iss et le aud du jeton d’identité, puis ouvre une nouvelle 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)

Renouvellement et règle de panne

Le middleware requireUser renouvelle les jetons expirés. Si HonkIO est injoignable (erreur réseau, délai dépassé ou erreur 5xx), il garde la session jusqu’à 24 heures après l’expiration du jeton d’accès et réessaie chaque minute. Si HonkIO refuse le renouvellement (invalid_grant), il met fin à la session immédiatement. Voir Sessions et pannes.