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.
npm install openid-client iron-sessionCreate 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
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-stringThe 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
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
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
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
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
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
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)
}
}
HonkIO