@cueplusplus/id-nextjs
v0.5.0
Published
CUE++ ID — Next.js integration (cookie adapter, auth handlers, middleware refresh).
Readme
@cueplusplus/id-nextjs
Next.js integration for CUE++ ID:
route handlers for the whole OAuth 2.1 / OIDC flow, a sealed httpOnly session
cookie, single-flight middleware refresh, and typed server-side session access.
Shaped after @supabase/ssr — the . entry is isomorphic, server-only APIs
live behind ./server.
Install
pnpm add @cueplusplus/id-nextjs josePeer dependencies: next ^16, react ^19, jose ^5.9 || ^6 (the crypto library
all token verification and cookie sealing runs through; pnpm ≥8 and npm ≥7
install it for you, Yarn PnP and auto-install-peers=false do not). Configure via env
(CUE_ID_ISSUER, CUE_ID_CLIENT_ID, CUE_ID_CLIENT_SECRET,
CUE_ID_REDIRECT_URI, CUE_ID_COOKIE_SECRET, optional CUE_ID_SCOPES,
CUE_ID_TIMEOUT_MS, CUE_ID_TOKEN_AUTH_METHOD) — it is read lazily, so
next build needs no secrets and createServerClient can be constructed at
module scope.
| Optional var | Default | Why you would change it |
| --- | --- | --- |
| CUE_ID_SCOPES | openid profile email | Spec §7.6 allows no others today. |
| CUE_ID_TIMEOUT_MS | 5000 | Total budget for one SDK operation; its (at most two) sequential network legs get half each. A rotation runs two operations, so its worst case is 1.5x — values from 6667 up are refused at boot, because that reaches GoTrue's 10s refresh-token reuse interval. |
| CUE_ID_TOKEN_AUTH_METHOD | client_secret_basic | Set to client_secret_post if the client is registered with that method (spec §7.3 registers Basic by default for confidential clients). |
| CUE_ID_DIAGNOSTIC_HEADERS | (off) | 1 to emit the optional diagnostic headers below. Behaviour is identical either way. |
CUE_ID_COOKIE_SECRET is 32 random bytes, in either base64 alphabet:
openssl rand -base64 32 # standard
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" # url-safeUsage
// app/api/auth/[...cueid]/route.ts — sign-in, callback and sign-out
import { createAuthHandlers } from '@cueplusplus/id-nextjs/server'
export const { GET, POST } = createAuthHandlers()// middleware.ts — refresh sessions that are near expiry
import { updateSession } from '@cueplusplus/id-nextjs/server'
import type { NextRequest } from 'next/server'
export default function proxy(req: NextRequest) {
return updateSession(req)
}The middleware is what keeps sessions alive — it is not optional, and its matcher must cover every authenticated route.
updateSessionis the only place that can refresh: a server component cannot set cookies, so it cannot renew anything. On the server read path (auth(),requireAuth(),getSession(),getAccessToken(),GET /session) an expired session is no session — those helpers returnnull(andrequireAuththrows) onceexpiresAthas passed, plus a 60s clock allowance. That number is not chosen here:expiresAtis the access token's own verifiedexp, so the allowance is the same tolerance thejoseverifier already applies, and the refresh fires a further 60s ahead of it (120s total) so the window is never reached under middleware coverage. A route the matcher misses therefore signs the user out roughly an hour after sign-in instead of authorizing them on a stale cookie.
// app/profile/page.tsx — server component
import { auth } from '@cueplusplus/id-nextjs/server'
export default async function Profile() {
const session = await auth() // AuthContext | null
if (!session) return <p>Not signed in.</p>
return <p>{session.user.email} — {session.user.role}</p>
}// lib/client.ts — client components
'use client'
import { createBrowserClient } from '@cueplusplus/id-nextjs'
export const cue = createBrowserClient() // pinned to /api/authSigning out
POST {baseURL}/sign-out clears the session cookie and attempts to revoke the
refresh token upstream, then answers in whichever idiom the caller used:
| Caller | Request | Response |
| --- | --- | --- |
| HTML form post / navigation | POST with an HTML Accept (or none) | 302 to redirectTo (default /) |
| Programmatic client (@cueplusplus/id-react) | POST with Accept: application/json | 204, no redirect |
The 204 exists because fetch follows redirects by default, which would make
res.ok report the status of the page you were redirected TO — a gated or
cross-origin / would look like a failed sign-out after the cookie was already
cleared. @cueplusplus/id-react also sends redirect: 'manual', so both
answers are read correctly.
createServerClient(cookieStore) binds the SDK to any getAll/setAll cookie
store — the same adapter contract as @supabase/ssr, so both SDKs can share one
Next.js cookies() jar. auth() and getSession() are thin wrappers over it
bound to Next's own jar.
Roles
requireAuth({ role }) is a hierarchy, not an exact match: admin ⊇
member ⊇ guest, so an admin satisfies a member-gated route.
user.role is derived from the user_role claim on the verified access
token (the Custom Access Token Hook is what injects it) and is re-derived on
every refresh, so a role change propagates within one refresh cycle. If the
claim is missing the role is guest — never a cached or invented value — and
the middleware marks that response x-cue-id-role-claim: absent so a
deployment whose hook is not firing is visible in monitoring rather than
silently demoting everyone.
The diagnostic headers below are opt-in: set CUE_ID_DIAGNOSTIC_HEADERS=1
to emit them. They are telemetry, not mechanism — every condition they describe
drives the same behaviour whether or not the headers are on — so leaving them
off costs diagnosis speed, never correctness. x-cue-id itself is always
emitted; spec §9.3 mandates that marker.
x-cue-id-role-claim distinguishes three different incidents — they have
different causes and different fixes, so they do not share a flag:
| Value | Meaning | What to do |
| --- | --- | --- |
| (absent) | The token verified and carried user_role. | Nothing. |
| absent | The token verified; there was no user_role claim. | The Custom Access Token Hook is not firing for this deployment. |
| unverified | The access token did not verify. | The signing key or the aud pin is wrong. Role falls back to guest. |
| stale | Verification could not be performed (e.g. JWKS unreachable). | The previous role is carried for at most one refresh cycle, and only from a verification less than an hour old. A repeated fault stops carrying and falls back to guest — see x-cue-id-warn: role-carry-expired. |
x-cue-id-warn carries non-role diagnostics as a comma-separated list:
cookie-overflow (the rotated session exceeded the browser's cookie limit and
the session was cleared rather than silently dropped), claims-unverified (an
id_token arrived on a refresh and failed verification; the session was kept
with its previous claims), role-carry-expired (a transient verification fault
recurred, so the previous role was not carried again and the user is now
guest), and subject-mismatch (a rotation returned a token for a different
subject; the session was cleared).
POST /sign-out clears the session cookie and sets x-cue-id-signout: local
(gated, like every other diagnostic).
Sign-out is local, and that is a provider fact rather than a shortcut. Supabase's OAuth server has no token-revocation endpoint:
{issuer}/oauth/revokereturns 404 with a body byte-identical to a path invented on the spot, and discovery advertises norevocation_endpoint(probed 2026-08-19). This handler used to POST there and reportx-cue-id-revoke: ok | failed | skipped; since the call could only ever fail, the header's failure value was the steady state — an alarm that is always on. Both the call and the header are gone.What that means for your users: the cookie is cleared, so they are signed out of your app. The refresh token stays valid at the provider until it expires. Ending the CUE++ ID session itself is a deliberate action on the account page at
id.cueplusplus.com(GoTrue'sPOST /auth/v1/logout, an endpoint that does exist). Ending every session for a user isadmin.users.revokeSessionsin@cueplusplus/id-admin. Grant revocation —DELETE {issuer}/user/oauth/grants?client_id=…, authenticated as the user and forcing re-consent — is a different operation and is not wired up.
x-cue-id-signout is gated because there is nothing to hide: clearing a cookie
in a response the caller receives cannot half-happen. The unconditional-emit
carve-out that x-cue-id-revoke: failed had existed because a failed revoke had
no other outlet; with no revoke, there is no such signal left.
Limits and failure modes
- The sealed cookie must stay under the browser's 4096-byte limit. Sign-in fails
with an explicit error above it rather than letting the browser drop the
cookie silently (which presents as an endless sign-in loop). Large
user_metadataor custom claims are what push it over. - Every SDK operation has a total deadline —
CUE_ID_TIMEOUT_MS, default 5000ms — and its at most two sequential legs get half each, so a stalled token endpoint degrades instead of holding the request open. - The session cookie's
Max-Ageand the seal's own expiry are both 14 days.
Entry points
| Subpath | Exports |
| ---------- | ------- |
| . | createBrowserClient; also createAuthHandlers and updateSession, kept for compatibility — import those from ./server |
| ./server | createAuthHandlers, updateSession, auth, requireAuth, getSession, getAccessToken, buildSignInUrl, createServerClient (server-only) |
Import createAuthHandlers and updateSession from ./server. The . entry also exports
createBrowserClient, whose module imports @cueplusplus/id-react, which calls React's
createContext as it loads. React's server build has none, so a route handler or a proxy that
imports from . cannot load at all.
| ./react | the full @cueplusplus/id-react surface |
A runnable consumer lives in
apps/example.
License
MIT — see LICENSE.
