HonkIO Auth
Démarrage rapide Next.js
Ajoutez HonkIO Auth à une application Next.js (App Router) avec openid-client : connexion, rappel, renouvellement selon la règle de panne, et déconnexion.
Installation
Installez openid-client pour OpenID Connect et iron-session pour une session chiffrée dans un témoin. Les autres clients OIDC fonctionnent de la même façon : Auth.js et le module OIDC générique de Better Auth peuvent aussi utiliser HonkIO comme fournisseur.
npm install openid-client iron-sessionCréez une application de test dont l’URI de redirection est http://localhost:3000/auth/callback et l’URI de redirection après déconnexion http://localhost:3000/, puis définissez :
# .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-stringLe module d’authentification
Un module réservé au serveur contient le client, la session et la règle de renouvellement. La découverte lit le /.well-known/openid-configuration de l’émetteur. openid-client vérifie ensuite le iss (le nom d’hôte de votre application) et le aud (votre identifiant client) de chaque jeton d’identité : un jeton d’une autre application n’est jamais accepté.
// 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 : '/'
}Routes de connexion
La connexion commence à /auth/login, qui envoie PKCE (S256), state et nonce. HonkIO exige les trois, même d’un client confidentiel.
// 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)
}Le rappel échange le code contre des jetons. Un code ne fonctionne qu’une fois, pendant 60 secondes.
// 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))
}Les pages réservées aux personnes connectées appellent 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>
}Renouvellement et règle de panne
Les jetons d’accès durent 10 minutes. Quand l’un expire, requireUser fait passer la requête par /auth/refresh, qui échange le jeton de renouvellement contre de nouveaux jetons. Les jetons de renouvellement changent à chaque usage : chacun ne sert qu’une fois, et réutiliser un ancien jeton met fin à toute la session.
La règle de panne. Si HonkIO est injoignable (erreur réseau, délai dépassé ou erreur 5xx), gardez la session, mais seulement jusqu’à 24 heures après l’expiration du jeton d’accès. Si HonkIO répond et refuse le renouvellement avec invalid_grant, mettez fin à la session immédiatement : elle a été révoquée, ou l’utilisateur a été bloqué, supprimé ou retiré de l’organisation.
// 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))
}Ainsi, les personnes connectées le restent pendant une panne de HonkIO, sans qu’une session révoquée survive. Les nouvelles connexions exigent que HonkIO fonctionne. Voir Sessions et pannes.
Déconnexion
La déconnexion met fin à la session de votre application, puis envoie le navigateur au point de fin de session de HonkIO avec id_token_hint. HonkIO met fin à sa propre session et revient à un URI de redirection après déconnexion inscrit. Déclenchez-la par un formulaire POST depuis vos propres pages, pour qu’un lien sur un autre site ne puisse déconnecter personne.
// 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