Skip to content

Migrate to @auth0/auth0-nuxt#309

Open
irees wants to merge 27 commits intomainfrom
auth-sdk
Open

Migrate to @auth0/auth0-nuxt#309
irees wants to merge 27 commits intomainfrom
auth-sdk

Conversation

@irees
Copy link
Copy Markdown
Contributor

@irees irees commented Mar 28, 2026

Summary

Replaces client-side auth0-spa-js with server-side @auth0/auth0-nuxt. JWTs are managed entirely server-side via HTTP-only session cookies. Auth headers (JWT, API key, CSRF) are injected globally by two fetch-wrapping plugins, eliminating all per-callsite auth wiring. Works with both ssr: true and ssr: false apps.

Auth architecture

  • Authentication handled by @auth0/auth0-nuxt with server-side sessions (encrypted HTTP-only cookies)
  • Login/logout are server-side routes (/auth/login, /auth/logout) — JWTs never touch client-side JavaScript
  • Two global plugins inject headers automatically:
    • csrf.client (browser): wraps $fetch and fetch to add CSRF header
    • auth.server (SSR): wraps $fetch and fetch to add JWT + API key, scoped to configured proxyBase origins
  • Apollo plugin is auth-unaware — just endpoint config and cache management
  • Removed old SPA auth code (auth0-spa-js, auth.client.ts, useAuthHeaders, client-side token management)

Proxy

  • Proxy extracts JWT from auth0-nuxt session via getAccessToken() and forwards to backend
  • Anonymous users (no session) get API key only — backend decides what to serve
  • CSRF protection on all proxy requests prevents cross-site abuse
  • Removed separate auth middleware — proxy handles both authenticated and anonymous cases

User enrichment

  • New /api/auth/session endpoint returns auth0 claims enriched with roles from GraphQL me query
  • Client plugin fetches session endpoint on first navigation to populate user state
  • Works with ssr: false (fetches endpoint) and ssr: true (reads SSR-populated state, then enriches)
  • Roles cached client-side for 10 minutes

Cleanup

  • Removed authMode/AuthMode option and all SPA auth code
  • Removed useAuthHeaders composable — no longer needed
  • Removed apiBase from module options — client always uses /api/v2 proxy
  • Removed clearUser() — logout is a full page navigation
  • Removed @auth0/auth0-spa-js dependency
  • Added docs/auth-design.md documenting the architecture

Test plan

  • Verify login flow: unauthenticated user is redirected to /auth/login, returns with session
  • Verify logout flow: navigates to /auth/logout, session is cleared
  • Verify authenticated GraphQL queries return user-scoped data
  • Verify anonymous access: public data is served without a session via API key
  • Verify CSRF: requests without a valid CSRF token are rejected
  • Verify admin API calls work without manual header injection
  • Verify user enrichment: name, email, roles populate from session endpoint
  • Verify token refresh: leave session idle past token expiry, confirm requests still work

@irees
Copy link
Copy Markdown
Contributor Author

irees commented Mar 31, 2026

Testing in consuming apps before merging, since it's a breaking change in several ways (see auth-design.md)

@irees irees marked this pull request as ready for review April 1, 2026 11:01
Copilot AI review requested due to automatic review settings April 1, 2026 11:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR migrates the module from client-side @auth0/auth0-spa-js to server-side @auth0/auth0-nuxt sessions, with CSRF/JWT/API-key headers injected globally via fetch-wrapping plugins and a new /api/proxy/{backend}/... proxy pattern.

Changes:

  • Replaced SPA Auth0 token management with server-managed sessions (@auth0/auth0-nuxt) and a session-enrichment endpoint (/api/auth/session).
  • Added global header injection via csrf.client (browser) and auth.server (SSR) plugins; removed per-callsite useAuthHeaders.
  • Reworked proxy routing to /api/proxy/{backend}/... with pure routing utilities + tests.

Reviewed changes

Copilot reviewed 34 out of 36 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/runtime/types.d.ts Updates runtime config typings for Auth0 server config and removes SPA-era public Auth0 fields/composables.
src/runtime/server/useSession.ts Adds useAuth0Session() helper to read auth session/token from event.context.
src/runtime/server/middleware/auth0.ts Adds middleware to populate event.context.auth0Session from auth0-nuxt session + access token.
src/runtime/server/api/auth/session.get.ts Adds /api/auth/session endpoint that returns Auth0 claims enriched with GraphQL me roles.
src/runtime/plugins/proxy.ts Updates proxy handler to parse /api/proxy/{backend}/... and forward with session-derived JWT.
src/runtime/plugins/csrf.client.ts Adds client plugin to inject CSRF headers into same-origin $fetch/fetch.
src/runtime/plugins/auth.server.ts Adds SSR plugin to inject JWT + API key into backend-bound $fetch/fetch.
src/runtime/plugins/auth.client.ts Removes legacy SPA Auth0 plugin and route middleware.
src/runtime/plugins/apollo.ts Removes Apollo auth link; relies on global fetch wrappers for auth/CSRF.
src/runtime/lib/util/proxy.ts Refactors proxy handler to use pure route/header builders and accept token/path override.
src/runtime/lib/util/proxy-route.ts Adds pure functions to parse proxy routes, resolve backend base, build target URL, build headers.
src/runtime/lib/util/proxy-route.spec.ts Adds unit tests for new proxy-route utilities.
src/runtime/lib/auth/user.ts Re-exports useUser and TlUser type from new auth implementation; removes User class/clearUser.
src/runtime/lib/auth/index.ts Stops exporting removed Auth0 SPA helpers.
src/runtime/lib/auth/auth0.ts Removes Auth0 SPA client implementation.
src/runtime/composables/useUser.ts Redirects composable export to new auth useUser.
src/runtime/composables/useLogout.ts Switches logout flow to server route (/auth/logout) via new auth composable.
src/runtime/composables/useLogin.ts Switches login flow to server route (/auth/login?returnTo=...) via new auth composable.
src/runtime/composables/useAuthHeaders.ts Removes composable that injected SPA JWT + CSRF headers per callsite.
src/runtime/composables/useApiEndpoint.ts Changes client URLs to go through /api/proxy/{backend} and server URLs to use proxyBase.
src/runtime/auth/useUser.ts Adds new useUser() reading auth0-nuxt state + enriched roles/id state.
src/runtime/auth/useLogout.ts Adds /auth/logout navigation composable.
src/runtime/auth/useLogin.ts Adds /auth/login navigation composable with returnTo.
src/runtime/auth/types.ts Adds TlUser interface for the new user shape.
src/runtime/auth/plugin.client.ts Adds client-side route middleware to fetch /api/auth/session and populate roles/id state (with caching).
src/runtime/auth/enrich.ts Adds pure enrichment helper to merge GraphQL me into Auth0 claims.
src/runtime/auth/enrich.spec.ts Adds unit tests for enrichment behavior.
src/runtime/apps/stations/gtfs-export-download.vue Removes per-callsite useAuthHeaders() usage; relies on global CSRF injection.
src/runtime/apps/admin/useAdminApi.ts Removes per-request useAuthHeaders() injection; uses same-origin credentials.
src/module.ts Installs nuxt-csurf always, conditionally installs @auth0/auth0-nuxt, registers new plugins/handlers and new proxy route.
pnpm-lock.yaml Updates lockfile for @auth0/auth0-nuxt and dependency graph changes.
playground/nuxt.config.ts Updates playground config to new runtimeConfig/auth expectations and removes useProxy option usage.
playground/app/pages/auth.vue Adds an auth debug page for login/logout and user state inspection.
playground/app/pages/apps/graphql-test.vue Expands GraphQL test page to run both me and feeds queries with updated UI.
package.json Replaces @auth0/auth0-spa-js with @auth0/auth0-nuxt; updates nuxt-csurf semver range.
docs/auth-design.md Adds documentation describing the new auth/proxy/CSRF architecture and migration notes.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +20 to +25
globalThis.$fetch = globalThis.$fetch.create({
onRequest ({ request, options }) {
if (!options.baseURL && isSameOrigin(request || '/')) {
options.headers = new Headers(options.headers || {})
options.headers.append(headerName, csrf)
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the $fetch onRequest hook, CSRF injection is skipped whenever options.baseURL is set. Several call sites (e.g., useAdminFetch) use baseURL for same-origin proxy requests, so those requests will miss the CSRF header and be rejected by nuxt-csurf. Consider resolving the final request URL (request + baseURL) and injecting the CSRF header whenever the resolved URL is same-origin, regardless of whether baseURL is set.

Copilot uses AI. Check for mistakes.
Comment on lines +47 to +76
// Override $fetch (ofetch) — covers useFetch, $fetch, fetchAdmin, etc.
globalThis.$fetch = globalThis.$fetch.create({
onRequest ({ request, options }) {
const url = typeof request === 'string' ? request : (request as Request).url || ''
if (!isBackendRequest(url)) { return }
const authHeaders = getAuthHeaders()
const headers = new Headers(options.headers || {})
for (const [key, value] of Object.entries(authHeaders)) {
headers.append(key, value)
}
options.headers = headers
}
})

// Wrap globalThis.fetch — covers Apollo's createUploadLink
const originalFetch = globalThis.fetch
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input)
if (!isBackendRequest(url)) {
return originalFetch(input, init)
}
const authHeaders = getAuthHeaders()
init = init || {}
const headers = new Headers(init.headers || {})
for (const [key, value] of Object.entries(authHeaders)) {
headers.append(key, value)
}
init.headers = headers
return originalFetch(input, init)
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This plugin mutates globalThis.$fetch and globalThis.fetch on the server and closes over nuxtApp/ssrContext. Because globalThis is shared across concurrent SSR requests, this can cause cross-request credential leakage (wrong Authorization/apikey), race conditions, and memory retention of prior nuxtApp instances. Prefer request-scoped fetch instances (e.g., provide a per-request $fetch via nuxtApp.provide / useRequestFetch, or wrap fetch via Nitro event hooks) rather than overriding global globals.

Copilot uses AI. Check for mistakes.
Comment on lines 30 to +35
return proxyHandler(
event,
String(config.tlv2.proxyBase?.default),
String(config.tlv2.graphqlApikey)
proxyBase,
String(config.tlv2.graphqlApikey),
auth.accessToken,
strippedPath
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

proxyHandler is called with String(config.tlv2.graphqlApikey). If graphqlApikey is undefined, this becomes the literal string "undefined" and will be forwarded as the apikey header. Use a safe default (e.g., config.tlv2?.graphqlApikey || '') and let buildProxyHeaders omit/empty it appropriately.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +17
const auth0 = useAuth0(event)
const session = await auth0.getSession()
if (!session?.user) {
return
}
const tokenSet = await auth0.getAccessToken()
event.context.auth0Session = {
user: session.user,
accessToken: tokenSet.accessToken || ''
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth0.getAccessToken() can throw (e.g., token refresh failure / misconfiguration). Right now an exception would bubble up and fail the entire request. Consider wrapping getAccessToken() in try/catch and either (a) leave event.context.auth0Session unset / anonymous, or (b) attach user with an empty accessToken, depending on desired behavior.

Copilot uses AI. Check for mistakes.
Comment thread docs/auth-design.md Outdated
- Server-side (SSR): returns the direct `proxyBase` URL
- Client-side: returns `/api/proxy/{clientName}` to route through the proxy

If a backend name is not found in the config, it falls back to `proxyBase.default`.
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs say an unknown backend name falls back to proxyBase.default, but the implementation currently returns null for unknown backends and the proxy handler responds 404. Either update the docs to match the 404 behavior or implement the documented fallback to default to avoid confusion/regressions.

Suggested change
If a backend name is not found in the config, it falls back to `proxyBase.default`.
If a backend name is not found in the config, the proxy does not fall back to `proxyBase.default`; instead, the lookup fails and the proxy responds with a 404.

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +20
// Resolve the backend name to a proxyBase URL, or null if unknown.
export function resolveProxyBase (
backendName: string,
proxyBases: Record<string, string>
): string | null {
return proxyBases[backendName] || null
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveProxyBase currently returns null for unknown backends, which conflicts with the newly added docs stating it should fall back to proxyBase.default. Consider implementing fallback (e.g., proxyBases[backendName] || proxyBases.default) or ensuring all callers/docs consistently treat unknown backends as an error.

Suggested change
// Resolve the backend name to a proxyBase URL, or null if unknown.
export function resolveProxyBase (
backendName: string,
proxyBases: Record<string, string>
): string | null {
return proxyBases[backendName] || null
// Resolve the backend name to a proxyBase URL. Falls back to proxyBases.default if the
// backend is unknown, or null if neither is configured.
export function resolveProxyBase (
backendName: string,
proxyBases: Record<string, string>
): string | null {
return proxyBases[backendName] ?? proxyBases.default ?? null

Copilot uses AI. Check for mistakes.
Comment thread playground/nuxt.config.ts
Comment on lines 10 to +18
runtimeConfig: {
// Auth0 server-side config (use NUXT_AUTH0_* env vars) — only for authMode: 'server'
auth0: {
domain: '',
clientId: '',
clientSecret: '',
sessionSecret: '',
appBaseUrl: '',
audience: '',
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment mentions "only for authMode: 'server'", but authMode was removed in this PR. Updating this comment will prevent confusion for consumers configuring runtimeConfig.auth0.

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +27
name: auth0User.value?.name || auth0User.value?.tlv2_name || '',
email: auth0User.value?.email || auth0User.value?.tlv2_email || '',
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useUser currently prefers the raw Auth0 OIDC name/email over the enriched tlv2_name/tlv2_email fields (because auth0User.value?.name / .email will usually be set). If the intent of /api/auth/session enrichment is to override identity fields from GraphQL me, consider preferring tlv2_name/tlv2_email first (or stop enriching those fields to avoid dead/unused data).

Suggested change
name: auth0User.value?.name || auth0User.value?.tlv2_name || '',
email: auth0User.value?.email || auth0User.value?.tlv2_email || '',
name: auth0User.value?.tlv2_name || auth0User.value?.name || '',
email: auth0User.value?.tlv2_email || auth0User.value?.email || '',

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +28
if (!response || !response.ok) {
if (response) {
console.warn('[tlv2-auth] /api/auth/session: GraphQL me query returned', response.status)
}
return null
}
const json = await response.json()
return json?.data?.me ?? null
}
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchMeData() intends to be best-effort and return null on backend errors, but response.json() can throw (e.g., non-JSON error body), which would currently bubble up and fail /api/auth/session. Consider wrapping the JSON parse in try/catch and returning null on parse errors as well.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants