HonkIO Auth

Next.js quickstart

Add HonkIO Auth to a Next.js App Router app with openid-client: sign-in, the callback, refresh with the outage rule, and sign-out.

Install

Install openid-client for OpenID Connect and iron-session for an encrypted cookie session. Other OIDC clients work the same way: Auth.js and Better Auth’s generic OIDC plugin can use HonkIO as a provider too.

bash
npm install openid-client iron-session

Create a test app whose redirect URI is http://localhost:3000/auth/callback and whose post-logout redirect URI is http://localhost:3000/, then set:

.env.local
# .env.local
HONKIO_AUTH_ISSUER=https://login-dev.acme.ca
HONKIO_AUTH_CLIENT_ID=hka_xxxxxxxxxxxx
HONKIO_AUTH_CLIENT_SECRET=hka_cs_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_URL=http://localhost:3000
# 32+ random characters: openssl rand -base64 32
SESSION_SECRET=replace-with-a-long-random-string

The auth module

One server-only module holds the client, the session and the refresh rule. Discovery reads the issuer’s /.well-known/openid-configuration. openid-client then checks each ID token’s iss (your app’s hostname) and aud (your client ID), so a token from another app is never accepted.

lib/auth.ts
// lib/auth.ts
import 'server-only'
import * as client from 'openid-client'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getIronSession, type SessionOptions } from 'iron-session'

export type AuthSession = {
  sub?: string
  email?: string
  idToken?: string
  accessToken?: string
  refreshToken?: string
  /** When the access token expires (ms since epoch). */
  accessExpiresAt?: number
  /** While HonkIO can't be reached: don't try to refresh again before this. */
  retryRefreshAt?: number
  /** The PKCE verifier, state and nonce of a sign-in in progress. */
  pending?: { verifier: string; state: string; nonce: string; returnTo: string }
}

const sessionOptions: SessionOptions = {
  password: process.env.SESSION_SECRET!,
  cookieName: 'app_session',
  cookieOptions: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'lax' },
}

export async function getSession() {
  return getIronSession<AuthSession>(await cookies(), sessionOptions)
}

/** Discovery runs once per server process; a failure is retried on the next call. */
let configPromise: Promise<client.Configuration> | undefined
export function oidcConfig(): Promise<client.Configuration> {
  configPromise ??= client
    .discovery(new URL(process.env.HONKIO_AUTH_ISSUER!), process.env.HONKIO_AUTH_CLIENT_ID!, process.env.HONKIO_AUTH_CLIENT_SECRET!)
    .then((config) => {
      config.timeout = 5 // seconds: a hung request counts as "can't be reached"
      return config
    })
    .catch((err) => {
      configPromise = undefined
      throw err
    })
  return configPromise
}

export function saveTokens(session: AuthSession, tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers) {
  const claims = tokens.claims()
  if (claims) {
    session.sub = claims.sub
    session.email = claims.email as string | undefined
  }
  if (tokens.id_token) session.idToken = tokens.id_token
  session.accessToken = tokens.access_token
  // Refresh tokens rotate: always keep the newest one.
  if (tokens.refresh_token) session.refreshToken = tokens.refresh_token
  session.accessExpiresAt = Date.now() + (tokens.expiresIn() ?? 600) * 1000
  delete session.retryRefreshAt
}

// ── The outage rule ─────────────────────────────────────────────────────
// Keep a signed-in user signed in while HonkIO can't be reached, for at most
// 24 hours past the access token's expiry. A refresh HonkIO refuses ends the
// session immediately.
export const OUTAGE_GRACE_MS = 24 * 60 * 60 * 1000

/** True only for "HonkIO can't be reached": a network error, a timeout or a 5xx. */
export function isUnreachable(err: unknown): boolean {
  if (err instanceof client.ResponseBodyError) return err.status >= 500
  if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) return true
  if (err instanceof TypeError) return true // fetch() could not connect
  const cause = (err as { cause?: unknown } | null)?.cause
  return cause instanceof Response && cause.status >= 500
}

/** The signed-in user, refreshing the tokens first when they've expired. Use it in Server Components. */
export async function requireUser(returnTo: string) {
  const session = await getSession()
  if (!session.sub || !session.accessExpiresAt) redirect('/auth/login?returnTo=' + encodeURIComponent(returnTo))
  const now = Date.now()
  const expired = now >= session.accessExpiresAt - 30_000
  const waitingOutOutage = session.retryRefreshAt !== undefined && now < session.retryRefreshAt && now < session.accessExpiresAt + OUTAGE_GRACE_MS
  // Server Components can't write cookies, so the refresh runs in a route handler.
  if (expired && !waitingOutOutage) redirect('/auth/refresh?returnTo=' + encodeURIComponent(returnTo))
  return { sub: session.sub, email: session.email }
}

/** Only same-site paths, so returnTo can't send people elsewhere. */
export function safeReturnTo(value: string | null): string {
  return value && value.startsWith('/') && !value.startsWith('//') ? value : '/'
}

Sign-in routes

Sign-in starts at /auth/login, which sends PKCE (S256), state and nonce. HonkIO requires all three, even from a confidential client.

app/auth/login/route.ts
// app/auth/login/route.ts
import * as client from 'openid-client'
import { NextResponse, type NextRequest } from 'next/server'
import { getSession, oidcConfig, safeReturnTo } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const config = await oidcConfig()
  const verifier = client.randomPKCECodeVerifier()
  const state = client.randomState()
  const nonce = client.randomNonce()

  const session = await getSession()
  session.pending = { verifier, state, nonce, returnTo: safeReturnTo(request.nextUrl.searchParams.get('returnTo')) }
  await session.save()

  const url = client.buildAuthorizationUrl(config, {
    redirect_uri: process.env.APP_URL + '/auth/callback',
    scope: 'openid email',
    code_challenge: await client.calculatePKCECodeChallenge(verifier),
    code_challenge_method: 'S256',
    state,
    nonce,
  })
  return NextResponse.redirect(url)
}

The callback exchanges the code for tokens. A code works once, for 60 seconds.

app/auth/callback/route.ts
// app/auth/callback/route.ts
import * as client from 'openid-client'
import { NextResponse, type NextRequest } from 'next/server'
import { getSession, oidcConfig, saveTokens } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const session = await getSession()
  const pending = session.pending
  if (!pending) return NextResponse.redirect(new URL('/auth/login', process.env.APP_URL))

  // Checks state, nonce, PKCE, and that iss is your app's hostname and aud your client id.
  const callbackUrl = new URL(request.nextUrl.pathname + request.nextUrl.search, process.env.APP_URL)
  const tokens = await client.authorizationCodeGrant(await oidcConfig(), callbackUrl, {
    pkceCodeVerifier: pending.verifier,
    expectedState: pending.state,
    expectedNonce: pending.nonce,
    idTokenExpected: true,
  })

  delete session.pending
  saveTokens(session, tokens)
  await session.save()
  return NextResponse.redirect(new URL(pending.returnTo, process.env.APP_URL))
}

Signed-in pages call requireUser:

app/dashboard/page.tsx
// app/dashboard/page.tsx
import { requireUser } from '@/lib/auth'

export default async function Dashboard() {
  const user = await requireUser('/dashboard')
  return <p>Signed in as {user.email ?? user.sub}</p>
}

Refresh and the outage rule

Access tokens last 10 minutes. When one expires, requireUser sends the request through /auth/refresh, which trades the refresh token for new tokens. Refresh tokens rotate: each one works once, and reusing an old one ends the whole session.

The outage rule. If HonkIO can’t be reached (a network error, a timeout or a 5xx), keep the session, but only until 24 hours past the access token’s expiry. If HonkIO answers and refuses the refresh with invalid_grant, end the session at once: it was revoked, or the user was blocked, deleted or removed from the organization.

app/auth/refresh/route.ts
// app/auth/refresh/route.ts
import * as client from 'openid-client'
import { NextResponse, type NextRequest } from 'next/server'
import { getSession, isUnreachable, oidcConfig, OUTAGE_GRACE_MS, safeReturnTo, saveTokens } from '@/lib/auth'

export async function GET(request: NextRequest) {
  const returnTo = safeReturnTo(request.nextUrl.searchParams.get('returnTo'))
  const session = await getSession()
  if (!session.refreshToken || !session.accessExpiresAt) {
    session.destroy()
    return NextResponse.redirect(new URL('/auth/login?returnTo=' + encodeURIComponent(returnTo), process.env.APP_URL))
  }

  try {
    const tokens = await client.refreshTokenGrant(await oidcConfig(), session.refreshToken)
    saveTokens(session, tokens)
    await session.save()
  } catch (err) {
    const withinGrace = Date.now() < session.accessExpiresAt + OUTAGE_GRACE_MS
    if (isUnreachable(err) && withinGrace) {
      // HonkIO is down or unreachable: stay signed in, and try again in a minute.
      session.retryRefreshAt = Date.now() + 60_000
      await session.save()
    } else {
      // invalid_grant (session revoked, user blocked or deleted, removed from
      // the organization), or the outage outlasted the 24-hour limit: sign out now.
      session.destroy()
      return NextResponse.redirect(new URL('/auth/login?returnTo=' + encodeURIComponent(returnTo), process.env.APP_URL))
    }
  }
  return NextResponse.redirect(new URL(returnTo, process.env.APP_URL))
}

This keeps signed-in users signed in through a HonkIO outage without letting a revoked session live on. New sign-ins need HonkIO to be up. See Sessions and outages.

Sign-out

Sign-out ends your app’s session, then sends the browser to HonkIO’s end-session endpoint with id_token_hint. HonkIO ends its own session and returns to a registered post-logout redirect URI. Start it with a form POST from your own pages, so a link on another site can’t sign people out.

app/auth/logout/route.ts
// app/auth/logout/route.ts
import * as client from 'openid-client'
import { NextResponse } from 'next/server'
import { getSession, oidcConfig } from '@/lib/auth'

// A form POST from your own pages, so a link on another site can't sign people out.
export async function POST() {
  const session = await getSession()
  const idToken = session.idToken
  session.destroy()
  try {
    const url = client.buildEndSessionUrl(await oidcConfig(), {
      post_logout_redirect_uri: process.env.APP_URL + '/',
      ...(idToken ? { id_token_hint: idToken } : {}),
    })
    return NextResponse.redirect(url, 303)
  } catch {
    // HonkIO unreachable: your app's session is already gone.
    return NextResponse.redirect(new URL('/', process.env.APP_URL), 303)
  }
}