@oauth-spa-kit/nuxt
| Install | |
|---|---|
npm install @oauth-spa-kit/nuxt |
|
| Latest Version: | 2.5.1 |
| Vue: | 3 |
| License: | MIT |
| Last Updated: | Aug 8, 2026 |
| Links: | GitHub · npm |
oauth-spa-kit
Framework-agnostic OAuth 2.1 / OIDC for JS SPAs -- one core, thin adapters
for React, Vue/Nuxt, or anything else that can call fetch. Built to the
FAPI 2.0 Security Profile
baseline (PAR, PKCE, DPoP, private_key_jwt -- no shared secrets) using
only FIPS 140-approved algorithms.
Why this shape
Two existing libraries got studied for this design, and each contributed a different half:
oidc-client-ts-- the reference for the protocol pieces: PKCE, OIDC discovery, expiry-aware token modeling, refresh-token-first renewal, structured events. ItsSilentRenewService/AccessTokenEventspair (expiry timers driving automaticsigninSilent()) is the right idea for expiry handling.nuxt-auth-utils-- the reference for the session ergonomics: a sealed HttpOnly cookie holding server-side state, and a dead-simpleuseUserSession()-style client (user,loggedIn,ready,fetch/login/logout) that never touches a token directly.
Neither is reused wholesale: oidc-client-ts is browser-only and assumes
tokens live in the SPA; nuxt-auth-utils is Nuxt/Nitro-only and has no
OAuth token refresh at all (only session-cookie CRUD).
Why BFF-only, not a pluggable token store
Current guidance (OWASP, Auth0, the OAuth 2.1 draft's public-client
guidance) has converged on: don't put access or refresh tokens in
JS-reachable storage at all if you can avoid it. localStorage is
readable by any injected script; even in-memory tokens are readable by an
XSS payload for as long as the tab lives. A Backend-for-Frontend removes
the token from the browser's trust boundary entirely -- the SPA only ever
holds a HttpOnly; Secure; SameSite=Lax session cookie it cannot read or
exfiltrate via JS, and calls same-origin endpoints for everything.
The trade-off is explicit: this requires a server component, even for an otherwise-static React SPA. That's accepted here rather than hedged with a pluggable in-memory mode, because a dual-mode design tends to make the insecure option one config flag away, and the whole point is to make the secure default the only path.
Why no client_secret
This kit supports exactly one client authentication method:
private_key_jwt (RFC 7523). A
shared client_secret is never accepted, in either the core token-exchange
functions or the config types -- there's no flag to turn it on. Two
independent reasons converge on the same answer:
- FAPI 2.0 requires either
private_key_jwtor mTLS for confidential client authentication; shared secrets aren't in the baseline profile. - FIPS 140 compliance is about approved algorithms, not really about
secrets vs. keys, but a
client_secretis a long-lived bearer credential that has to be generated, stored, and rotated as a shared value -- an asymmetric keypair replaces that with a private key that never leaves this server and a public key registered once with the AS.
All signing in this kit (client assertions, DPoP proofs) uses PS256
(RSA-PSS) or ES256 (ECDSA P-256) only -- see the comment on JwtAlgorithm
in packages/core/src/jwt.ts. RS256 is deliberately excluded: FAPI 2.0
requires PS256 or ES256 for asymmetric signing (RS256's PKCS#1 v1.5 padding
is legacy), and both remaining algorithms are FIPS 140-approved (FIPS
186-4/186-5) given approved parameters (RSA >=2048-bit, P-256).
Layout
packages/
core/ Pure OAuth/OIDC primitives -- PKCE, discovery, JWT sign/verify,
DPoP proofs, private_key_jwt assertions, PAR, token exchange --
plus the browser-side session client. Zero DOM/Node-only APIs
(only Web Crypto + fetch), so this same code runs in the
browser AND inside the server package below.
server/ BFF: sealed-cookie session + createLoginHandler /
createCallbackHandler / createSessionHandler /
createLogoutHandler / createLivenessHandler /
getAuthorizationHeader / getUserInfo.
Handlers are plain `(Request) => Promise<Response>` -- deploy on Nitro,
Next.js route handlers, Cloudflare Workers, or Node via a small
adapter. Also exposes an OpenID AuthZEN Authorization API
client (evaluateAccess / evaluateAccessBatch / searchResources /
searchSubjects / searchActions) for externalized authorization
decisions, authenticated with the same session access token
getAuthorizationHeader uses.
react/ <AuthProvider> + useAuth() (useSyncExternalStore-based).
nuxt/ Nuxt module: wires server/'s handlers as Nitro routes, exposes
a useAuth() composable with the same shape as react/'s.
examples/
react-spa/ Wiring sketch for a Vite React app + a standalone Node/edge
server hosting the auth routes.
nuxt-app/ Wiring sketch using the Nuxt module.
A plain Vue app (no Nuxt) can use @oauth-spa-kit/core's
createSessionClient() directly the same way react/'s useAuth does --
there's no Vue-specific package because there's nothing left to adapt once
you're not inside Nitro.
Request flow
GET /auth/login[?returnTo=/foo]-- generates PKCE verifier/challenge,state/nonce, and (unlessdpop: false) a DPoP key pair whose JWK thumbprint is sent asdpop_jkt(RFC 9449 section 10), binding the authorization code itself to that key before any token exists. Unlesspar: false, these are pushed to the AS's PAR endpoint (RFC 9126) rather than placed on the/authorizequery string, so none of it sits in browser history or theRefererheader; the browser redirect then only carriesclient_id+ the resulting single-userequest_uri. All of this -- verifier, state, nonce, the DPoP key pair -- is stored in a separate, 5-minute, signed cookie (not server memory, so the BFF stays stateless across instances).GET /auth/callback?code=...&state=...-- verifiesstate, exchangescodefor tokens using the same DPoP key from step 1 (authorization_codegrant, PKCE-verified,private_key_jwt- authenticated), verifies theid_token's signature against the AS's JWKS and itsiss/aud/nonce/expclaims (OIDC Core section 3.1.3.7) before trusting anything in it, parses a friendly device label ("Chrome on macOS") from the request'sUser-Agent, seals{ tokens, user, dpopKeyPair, device }into the session cookie, redirects toreturnTo.GET /auth/session-- returns{ user, device }(never tokens). If the access token is withinrefreshThresholdSeconds(default 60s, same defaultoidc-client-tsuses) of expiry, refreshes server-side first via therefresh_tokengrant -- reusing the same DPoP key the tokens were bound to, since a DPoP-bound refresh token must be renewed with the key that obtained it -- and re-seals the cookie. If refresh fails (token revoked, rotated-and-reused, IdP down), returns 401 and clears the cookie -- no silently-stale "authenticated" state.POST /auth/logout-- clears the cookie, optionally redirects through the IdP'send_session_endpoint(RP-Initiated Logout) if configured.- App API routes that need to call an upstream resource server call
getAuthorizationHeader(request, config)to get a guaranteed-freshAuthorizationheader, refreshing under the hood exactly like step 3. For the AS's ownuserinfo_endpointspecifically,getUserInfo(request, config)wraps that same call plus the DPoP proof and nonce-retry it needs, returning the raw claims -- the current profile can change between login and any later request, so this hitsuserinfo_endpointrather than trusting theid_token's point-in-time snapshot. GET /auth/liveness(optional) -- a lightweight{ alive: boolean }check backed bygetUserInfo, so it also makes a real round trip to the AS rather than just inspecting the locally stored token expiry. Meant to be polled in the background (createSessionClient'slivenessCheckIntervalMs) so a session that's been revoked, or an account that's been deactivated, ends the SPA's session promptly instead of only on the user's next navigation -- token-expiry alone is already handled reactively by step 3 and doesn't need this.
No hidden iframe / prompt=none silent-renew path exists here, unlike
oidc-client-ts's iframe fallback -- third-party cookie restrictions
(Safari ITP, Chrome's phase-out) make that increasingly unreliable, and in
the BFF pattern there's no need for it: renewal is a same-origin
refresh_token exchange the browser never has to participate in.
Recommended practices this bakes in
- PKCE always, no exceptions -- OAuth 2.1 makes it mandatory for every authorization-code client, not just public ones.
- PAR by default (RFC 9126,
par: trueunless explicitly disabled) -- required by FAPI 2.0; keeps every authorization request parameter off the browser-visible URL. private_key_jwtonly, noclient_secret-- see "Why no client_secret" above.- DPoP by default (RFC 9449,
dpop: trueunless explicitly disabled) -- sender-constrains tokens to a key pair generated server-side, bound to the authorization code viadpop_jktat request time, reused for every subsequent refresh, and retried transparently against ause_dpop_noncechallenge (RFC 9449 section 8). Pairs with a resource-server-side verifier like this workspace's ownapisix/custom-plugins/apisix/plugins/dpop-verify.lua. - Refresh token rotation expected:
exchangeRefreshTokencarries the old refresh token forward only if the server omits a new one; if your IdP rotates and detects reuse of an old token, that's a signal of theft -- the session handler treats any refresh failure as "log the user out", not "retry with the stale token". id_tokensignature verified against the AS's JWKS before any claim in it is trusted (issuer, audience, nonce, expiry all checked too) -- seeverifyIdTokeninpackages/server/src/handlers.ts.- Cross-tab logout sync via
BroadcastChannel, not thelocalStoragekey-and-storage-event trick.
Status
Published on npm under the @oauth-spa-kit
scope. The full pipeline has actually been run, not just eyeballed:
pnpm install, pnpm build/typecheck across all 4 packages
(nuxt via its real build tool, nuxt-module-build), and pnpm test --
78 passing tests across core and server (PKCE against the RFC 7636
test vector, JWT sign/verify for both algorithms, DPoP proof structure +
nonce retry + key export/import round-trips, PAR, client assertions, and a
full login -> PAR -> callback -> JWKS-verified session -> refresh -> logout
integration suite against a mocked authorization server in
packages/server/test/handlers.test.ts).
packages/nuxt/playground/ is a minimal Nuxt app (static discovery
document and an ephemeral key pair, so nuxi dev/prepare never need
network access by default) that exists for one reason: nuxi prepare playground generates playground/.nuxt/tsconfig*.json, which the nuxt
package's own tsconfig.json references instead of compiling src/
directly -- the same pattern nuxt-auth-utils itself uses, since #imports
is a virtual module Nuxt only generates inside a real app. With that in
place pnpm typecheck now covers nuxt/src/runtime/** too, #imports and
all -- run pnpm --filter=@oauth-spa-kit/nuxt run dev:prepare once after
cloning (CI does this automatically before typechecking).
The same playground can also be pointed at a real local IdP instead of the
offline stub, to click through useAuth().login() and confirm it actually
lands on that server's login UI -- set PLAYGROUND_OAUTH_AUTHORITY (plus
PLAYGROUND_OAUTH_CLIENT_ID and PLAYGROUND_OAUTH_PRIVATE_KEY_JWK for a
key already registered on that client) before running dev; see the
comment at the top of packages/nuxt/playground/nuxt.config.ts for the
full variable list and an example invocation.
Releasing
Versioning and publishing are automated with
semantic-release, driven by
Conventional Commits -- there's no
manual version bump or tag. .github/workflows/release.yml runs on every
push to master (install, build, typecheck, test, then pnpm run release); semantic-release reads the commits since the last release,
decides whether a release is needed and what type (fix: -> patch,
feat: -> minor, a BREAKING CHANGE: footer -> major), and if so:
- Stamps that version into all 4
packages/*/package.jsonin lockstep (scripts/set-versions.mjs, driven byrelease.config.js's@semantic-release/execstep) -- they always ship at the same version. - Publishes all 4 to npm via
pnpm run publish:packages(not@semantic-release/npm's own publish step, which shells out to plainnpm publishand wouldn't resolve pnpm'sworkspace:*protocol in the packages' internal deps). - Updates
CHANGELOG.md, commitschore(release): x.y.z [skip ci], tags, and pushes back tomaster, then creates a GitHub Release.
Publishing uses npm's Trusted Publishing
(OIDC) -- no NPM_TOKEN secret involved. Each of the 4 packages has this
repo's release.yml workflow registered as a trusted publisher on
npmjs.com; the workflow's id-token: write permission is what lets it mint
a short-lived OIDC token at publish time instead. GITHUB_TOKEN is the
default Actions token, already scoped by the permissions: block in the
workflow.
Commit messages are enforced locally via commitlint + husky
(.husky/commit-msg, commitlint.config.js) -- a non-conventional commit
message is rejected at commit time, before it can silently fail to trigger
the release it should.
Next step: a real deployment target for examples/react-spa's server half
(currently written against any Web-standard-Request runtime, untested
against a specific one).
Version History
| Version | Released | Vue | License |
|---|---|---|---|
| 2.5.1 | ^3.0 | MIT | |
| 2.5.0 | ^3.0 | MIT | |
| 2.4.0 | ^3.0 | MIT | |
| 2.3.1 | ^3.0 | MIT | |
| 2.3.0 | ^3.0 | MIT | |
| 2.2.1 | ^3.0 | MIT | |
| 2.2.0 | ^3.0 | MIT | |
| 2.1.0 | ^3.0 | MIT | |
| 2.0.6 | ^3.0 | MIT | |
| 2.0.5 | ^3.0 | MIT | |
| 2.0.4 | ^3.0 | MIT | |
| 2.0.3 | ^3.0 | MIT | |
| 2.0.2 | ^3.0 | MIT | |
| 2.0.1 | ^3.0 | MIT | |
| 2.0.0 | ^3.0 | MIT | |
| 1.2.0 | ^3.0 | MIT | |
| 1.1.0 | ^3.0 | MIT | |
| 1.0.2 | ^3.0 | MIT | |
| 1.0.0 | ^3.0 | MIT |