Conversation
|
Testing in consuming apps before merging, since it's a breaking change in several ways (see auth-design.md) |
There was a problem hiding this comment.
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) andauth.server(SSR) plugins; removed per-callsiteuseAuthHeaders. - 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.
| globalThis.$fetch = globalThis.$fetch.create({ | ||
| onRequest ({ request, options }) { | ||
| if (!options.baseURL && isSameOrigin(request || '/')) { | ||
| options.headers = new Headers(options.headers || {}) | ||
| options.headers.append(headerName, csrf) | ||
| } |
There was a problem hiding this comment.
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.
| // 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) | ||
| } |
There was a problem hiding this comment.
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.
| return proxyHandler( | ||
| event, | ||
| String(config.tlv2.proxyBase?.default), | ||
| String(config.tlv2.graphqlApikey) | ||
| proxyBase, | ||
| String(config.tlv2.graphqlApikey), | ||
| auth.accessToken, | ||
| strippedPath |
There was a problem hiding this comment.
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.
| 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 || '' | ||
| } |
There was a problem hiding this comment.
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.
| - 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`. |
There was a problem hiding this comment.
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.
| 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. |
| // 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 |
There was a problem hiding this comment.
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.
| // 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 |
| runtimeConfig: { | ||
| // Auth0 server-side config (use NUXT_AUTH0_* env vars) — only for authMode: 'server' | ||
| auth0: { | ||
| domain: '', | ||
| clientId: '', | ||
| clientSecret: '', | ||
| sessionSecret: '', | ||
| appBaseUrl: '', | ||
| audience: '', |
There was a problem hiding this comment.
Comment mentions "only for authMode: 'server'", but authMode was removed in this PR. Updating this comment will prevent confusion for consumers configuring runtimeConfig.auth0.
| name: auth0User.value?.name || auth0User.value?.tlv2_name || '', | ||
| email: auth0User.value?.email || auth0User.value?.tlv2_email || '', |
There was a problem hiding this comment.
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).
| 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 || '', |
| 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 | ||
| } |
There was a problem hiding this comment.
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.
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: trueandssr: falseapps.Auth architecture
@auth0/auth0-nuxtwith server-side sessions (encrypted HTTP-only cookies)/auth/login,/auth/logout) — JWTs never touch client-side JavaScriptcsrf.client(browser): wraps$fetchandfetchto add CSRF headerauth.server(SSR): wraps$fetchandfetchto add JWT + API key, scoped to configuredproxyBaseoriginsauth0-spa-js,auth.client.ts,useAuthHeaders, client-side token management)Proxy
getAccessToken()and forwards to backendUser enrichment
/api/auth/sessionendpoint returns auth0 claims enriched with roles from GraphQLmequeryssr: false(fetches endpoint) andssr: true(reads SSR-populated state, then enriches)Cleanup
authMode/AuthModeoption and all SPA auth codeuseAuthHeaderscomposable — no longer neededapiBasefrom module options — client always uses/api/v2proxyclearUser()— logout is a full page navigation@auth0/auth0-spa-jsdependencydocs/auth-design.mddocumenting the architectureTest plan
/auth/login, returns with session/auth/logout, session is cleared