Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
981ceda
Remove app code and app-only dependencies
drewda Mar 27, 2026
1362f41
Migrate to @auth0/auth0-nuxt
irees Mar 28, 2026
9dd2b9a
Drop vue from peerDependencies
drewda Mar 28, 2026
6fa61db
Selectable auth modules
irees Mar 28, 2026
c9f3362
Update lockfile after removing app-specific dependencies
drewda Mar 28, 2026
722401d
Move station data models to lib/pathways/
drewda Mar 28, 2026
bba4e5f
Remove lib/geom and lib/pathways — moved to @interline-io/pathways in…
drewda Mar 30, 2026
b11a567
move more
drewda Mar 30, 2026
197514a
Simlify
irees Mar 30, 2026
7366877
Fix typecheck: restore useToastNotification and lib/gtfs to tlv2-ui
drewda Mar 30, 2026
2f0a80b
playground/modal.vue: use #imports for useToastNotification
drewda Mar 30, 2026
a3870ec
Simplify
irees Mar 30, 2026
4396632
playground/modal.vue: use direct source import for useToastNotification
drewda Mar 30, 2026
197a5e3
More simplify
irees Mar 30, 2026
833c8c2
Remove useAuthHeaders
irees Mar 30, 2026
7710e27
Simplify
irees Mar 31, 2026
ab5cd7a
Remove SPA
irees Mar 31, 2026
c1e8006
Simplify
irees Mar 31, 2026
4db33a7
Simplify proxy
irees Mar 31, 2026
ab32691
Address comments
irees Mar 31, 2026
4e531e1
More comments
irees Mar 31, 2026
60f3e40
Fix fresh check
irees Mar 31, 2026
1d16778
Simplify and test
irees Mar 31, 2026
d0b05b2
Clean up
irees Mar 31, 2026
9abe8f7
Dont require login
irees Mar 31, 2026
cf68b75
Rename src/runtime/auth/server
irees Mar 31, 2026
c1266f0
Tests
irees Mar 31, 2026
025df94
chore: pin @vueuse/core 14.2.1, align vue-tsc to 3.2.6
drewda Mar 31, 2026
f7fb0ae
Restrict csrf tokens to same origin
irees Apr 1, 2026
50956a4
Fix
irees Apr 1, 2026
6f217dd
Review
irees Apr 1, 2026
33a21f7
Simplify
irees Apr 1, 2026
56b7856
Tidy up
irees Apr 1, 2026
742ef7d
Sigh
irees Apr 1, 2026
2ed772c
Avoid dynamic imports
irees Apr 1, 2026
2a58e26
FIX
irees Apr 1, 2026
bb20a47
Fixes
irees Apr 1, 2026
dfa6ae0
merge: incorporate auth-sdk changes into dep-ownership
drewda Apr 1, 2026
28800f2
chore: loosen nuxt/vue version pins for easier upgrades
drewda Apr 1, 2026
0f69de0
chore: loosen h3 to ^1.15.0 and add 7-day minimumReleaseAge
drewda Apr 1, 2026
b7e1a51
chore: remove h3, defu, destr, unstorage from direct dependencies
drewda Apr 1, 2026
2c7e3c6
fix: declare defu and h3 as peerDependencies
drewda Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions docs/auth-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Auth Design

## Overview

Two plugins handle all auth/CSRF injection globally — no per-callsite wiring needed.

| Plugin | Runs on | Does what |
|---|---|---|
| `csrf.client` | Browser | Wraps `$fetch` and `fetch` to add CSRF header to same-origin requests |
| `auth.server` | SSR | Wraps `$fetch` and `fetch` to add JWT + API key to backend requests |

Both plugins wrap `globalThis.$fetch` (ofetch) and `globalThis.fetch` (native), so all request mechanisms — `useFetch`, `$fetch`, and Apollo's `createUploadLink` — get headers injected automatically.

## Request Flows

### Browser → proxy → backend

1. Browser sends session cookie automatically (`credentials: 'same-origin'`)
2. CSRF header added globally by `csrf.client`
3. Request hits `/api/proxy/{backend}/...` on the same origin (e.g., `/api/proxy/default/query`)
4. `nuxt-csurf` validates the CSRF token
5. Proxy extracts backend name from path, looks up `proxyBase` config
6. Proxy extracts JWT from session cookie via `useAuth0Session(event)`
7. If logged in: forwards JWT + API key to backend
8. If anonymous: forwards API key only

### SSR → backend (direct)

1. `auth.server` plugin injects JWT + API key into both `$fetch` and `fetch`
2. Requests go directly to backend (via `proxyBase` URL), bypassing the proxy
3. If logged in: JWT from the original request's session
4. If anonymous: API key only

## Composables

- `useUser` — reads auth0-nuxt session state + enriched roles from GraphQL
- `useLogin` — navigates to `/auth/login`
- `useLogout` — navigates to `/auth/logout`

## User Enrichment

The `/api/auth/session` endpoint returns the auth0-nuxt session claims enriched with roles and identity from the GraphQL `me` endpoint (if a backend is configured). This is a single server-side request that combines auth0 identity with application-level data.

`auth/plugin.client` is a global route middleware that:
1. Checks if `useState('auth0_user')` is populated (it will be during SSR via auth0-nuxt's server middleware)
2. If not (e.g., `ssr: false`), fetches `/api/auth/session` to populate it
3. Reads `tlv2_roles` and `tlv2_id` from the session response and stores them in `useState`
4. Caches for 10 minutes before re-checking

During SSR, roles are not yet available — only identity (name, email, sub) from the auth0-nuxt session is present. Role-gated UI renders after client-side hydration. A future improvement is to cache the `me` response in a server-side KV store so roles are available during SSR.

## CSRF

CSRF protection is provided by `nuxt-csurf` and applies to all requests, including anonymous ones. The CSRF token prevents other websites from using the proxy as an open relay — either with a logged-in user's session cookie (classic CSRF) or with the module's API key (anonymous abuse).

The `csrf.client` plugin wraps both `globalThis.$fetch` (ofetch) and `globalThis.fetch` (native) so that same-origin client-side requests automatically include the CSRF header. Cross-origin requests (e.g., map tiles) are excluded to avoid triggering CORS preflights. No per-callsite injection is needed.

## Anonymous Access

Anonymous users (no auth0 session) can access public data through the proxy. The proxy forwards these requests with the API key only (no JWT). The backend decides what data to serve based on the presence or absence of a JWT.

## Auth0 Integration

Authentication is handled entirely by `@auth0/auth0-nuxt`, which manages server-side sessions using HTTP-only encrypted cookies. The user's JWT never touches client-side JavaScript. Login and logout are server-side routes (`/auth/login`, `/auth/logout`) provided by auth0-nuxt.

## Key Files

| File | Purpose |
|---|---|
| `src/module.ts` | Installs `nuxt-csurf` and `@auth0/auth0-nuxt`, registers plugins |
| `src/runtime/plugins/csrf.client.ts` | Global CSRF header injection (browser) |
| `src/runtime/plugins/auth.server.ts` | Global JWT + API key injection (SSR) |
| `src/runtime/plugins/apollo.ts` | Apollo client setup (no auth awareness — handled by plugins) |
| `src/runtime/plugins/proxy.ts` | `/api/proxy/{backend}/**` handler, routes to configured proxyBase |
| `src/runtime/lib/util/proxy.ts` | Proxy implementation, forwards to backend with auth headers |
| `src/runtime/server/api/auth/session.get.ts` | Session endpoint, returns auth0 claims + enriched roles |
| `src/runtime/auth/plugin.client.ts` | Route middleware that populates user state from session endpoint |
| `src/runtime/auth/useUser.ts` | `useUser` composable (reads session + enriched roles) |
| `src/runtime/auth/useLogin.ts` | `useLogin` composable (redirects to `/auth/login`) |
| `src/runtime/auth/useLogout.ts` | `useLogout` composable (redirects to `/auth/logout`) |
| `src/runtime/auth/types.ts` | `TlUser` interface |
| `src/runtime/server/useSession.ts` | `useAuth0Session` — single entry point for all server-side auth0 calls |

## Migration Guide for Existing Consumers

### Auth0 Configuration

Consumer apps must switch from Auth0 SPA application credentials to a **Regular Web Application** in the Auth0 console.

1. Create or reconfigure an Auth0 application as "Regular Web Application"
2. Set Allowed Callback URLs to `https://<your-domain>/auth/callback`
3. Set Allowed Logout URLs to `https://<your-domain>`
4. Ensure the Auth0 API has "Allow Offline Access" enabled (for refresh tokens)
5. If using a custom Auth0 domain (e.g., `auth.example.com`), use that as the domain — not the raw `*.auth0.com` tenant domain — so the JWT `iss` claim matches what the backend expects

### Environment Variables

Replace the old SPA-style Auth0 config with server-side env vars:

```
# Remove these (no longer used)
# NUXT_PUBLIC_TLV2_AUTH0_CLIENT_ID
# NUXT_PUBLIC_TLV2_AUTH0_DOMAIN
# NUXT_PUBLIC_TLV2_AUTH0_AUDIENCE
# NUXT_PUBLIC_TLV2_AUTH0_SCOPE
# NUXT_PUBLIC_TLV2_AUTH0_REDIRECT_URI
# NUXT_PUBLIC_TLV2_AUTH0_LOGOUT_URI

# Add these (server-side only, never exposed to client)
NUXT_AUTH0_DOMAIN=auth.example.com
NUXT_AUTH0_CLIENT_ID=...
NUXT_AUTH0_CLIENT_SECRET=...
NUXT_AUTH0_SESSION_SECRET=<random 32+ character string>
NUXT_AUTH0_APP_BASE_URL=https://your-app.example.com
NUXT_AUTH0_AUDIENCE=https://api.transit.land
```

### Module Options

Remove these options from your `nuxt.config.ts` module config:

- `authMode` — no longer exists, server mode is the only mode
- `auth0ClientId`, `auth0Domain`, `auth0Audience`, `auth0Scope`, `auth0RedirectUri`, `auth0LogoutUri` — replaced by `NUXT_AUTH0_*` env vars
- `apiBase` — no longer used, client routes through `/api/proxy/{backend}` proxy
- `useProxy` — removed, proxy is always active

### Code Changes

- **Remove `useAuthHeaders()` calls.** CSRF and auth headers are injected globally. Delete any manual header injection in `onRequest` callbacks or fetch options.
- **`useUser()`, `useLogin()`, `useLogout()`** — API is unchanged, no code changes needed.
- **`clearUser()`** — removed. Logout navigates to `/auth/logout` which clears the session server-side.
- **Direct imports from `tlv2-ui/lib/auth`** — `useUser` and `TlUser` are still exported. `clearUser` and the `User` class are removed.

### User Impact

All existing user sessions will be invalidated on deployment. Users will need to log in again — the old SPA tokens (stored in browser memory/localStorage) are not compatible with the new server-side session cookies. Users who authenticated via Google or other social providers can log in again with one click. Users with email/password accounts can use Auth0's "Forgot Password" flow if needed.

## Proxy Routing

The proxy routes client-side requests to backend services based on the URL path:

```
/api/proxy/{backend}/... → proxyBase.{backend} + /...
```

For example, with this config:
```
proxyBase.default = https://api.transit.land/api/v2
proxyBase.stationEditor = https://station-api.example.com
```

- `/api/proxy/default/query` → `https://api.transit.land/api/v2/query`
- `/api/proxy/stationEditor/query` → `https://station-api.example.com/query`

`useApiEndpoint(path, clientName)` generates the correct URL for each context:
- 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, the proxy responds with a 404.

## Future Work

- **Server-side role caching.** Cache the GraphQL `me` response in a KV store so roles are available during SSR without a round-trip on every request.
72 changes: 16 additions & 56 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,37 +33,17 @@
"types": "./dist/runtime/lib/config/index.d.ts",
"import": "./dist/runtime/lib/config/index.js"
},
"./lib/geom": {
"types": "./dist/runtime/lib/geom/index.d.ts",
"import": "./dist/runtime/lib/geom/index.js"
"./lib/testutil": {
"types": "./dist/runtime/lib/testutil/index.d.ts",
"import": "./dist/runtime/lib/testutil/index.js"
},
"./lib/gtfs": {
"types": "./dist/runtime/lib/gtfs/index.d.ts",
"import": "./dist/runtime/lib/gtfs/index.js"
},
"./lib/pathways": {
"types": "./dist/runtime/lib/pathways/index.d.ts",
"import": "./dist/runtime/lib/pathways/index.js"
},
"./lib/testutil": {
"types": "./dist/runtime/lib/testutil/index.d.ts",
"import": "./dist/runtime/lib/testutil/index.js"
},
"./lib/util": {
"types": "./dist/runtime/lib/util/index.d.ts",
"import": "./dist/runtime/lib/util/index.js"
},
"./apps/admin": {
"types": "./dist/runtime/apps/admin/index.d.ts",
"import": "./dist/runtime/apps/admin/index.js"
},
"./apps/stations": {
"types": "./dist/runtime/apps/stations/index.d.ts",
"import": "./dist/runtime/apps/stations/index.js"
},
"./apps/transfers": {
"types": "./dist/runtime/apps/transfers/index.d.ts",
"import": "./dist/runtime/apps/transfers/index.js"
}
},
"main": "./dist/module.mjs",
Expand All @@ -75,29 +55,26 @@
"access": "restricted"
},
"peerDependencies": {
"nuxt": "4.2.0",
"vue": "3.5.22"
"defu": ">=6.0.0",
"h3": ">=1.0.0",
"nuxt": "^4.0.0"
},
"devDependencies": {
"@nuxt/cli": "3.30.0",
"@nuxt/devtools": "3.0.1",
"@nuxt/eslint": "1.10.0",
"@nuxt/eslint-config": "1.10.0",
"@nuxt/kit": "4.2.0",
"@nuxt/kit": "^4.2.0",
"@nuxt/module-builder": "1.0.2",
"@nuxt/schema": "4.2.0",
"@nuxt/schema": "^4.2.0",
"@nuxt/test-utils": "3.20.1",
"@nuxt/types": "2.18.1",
"@pollyjs/adapter-fetch": "6.0.7",
"@pollyjs/core": "6.0.6",
"@pollyjs/persister-fs": "6.0.6",
"@stylistic/eslint-plugin": "5.5.0",
"@types/cytoscape": "3.21.9",
"@types/cytoscape-fcose": "2.2.4",
"@types/d3-scale-chromatic": "3.1.0",
"@types/geojson": "7946.0.16",
"@types/jsdom": "21.1.7",
"@types/mapbox__mapbox-gl-draw": "1.4.8",
"@types/node": "latest",
"@types/pg": "8.15.4",
"@vitejs/plugin-vue": "6.0.1",
Expand All @@ -109,56 +86,39 @@
"happy-dom": "18.0.1",
"jsdom": "26.1.0",
"nuxi": "3.30.0",
"nuxt": "4.2.0",
"nuxt": "^4.2.0",
"playwright-core": "1.54.2",
"sass": "1.86.0",
"tsx": "4.20.6",
"typescript": "5.9.3",
"typescript-eslint": "8.46.3",
"vite": "7.2.2",
"vitest": "3.2.4",
"vue": "3.5.22",
"vue": "^3.5.0",
"vue-eslint-parser": "10.2.0",
"vue-tsc": "3.1.3"
"vue-tsc": "3.2.6"
},
"dependencies": {
"@apollo/client": "3.13.4",
"@auth0/auth0-spa-js": "2.1.3",
"@mapbox/mapbox-gl-draw": "1.4.3",
"@maplibre/maplibre-gl-geocoder": "1.9.0",
"@auth0/auth0-nuxt": "^1.0.1",
"@mdi/font": "7.4.47",
"@observablehq/plot": "0.6.17",
"@turf/centroid": "7.3.0",
"@vue/apollo-composable": "4.2.2",
"@vue/apollo-option": "4.2.2",
"@vueuse/core": "14.0.0",
"@vueuse/core": "14.2.1",
"apollo-upload-client": "18.0.1",
"bulma": "1.0.4",
"commander": "14.0.0",
"csv-stringify": "6.6.0",
"cytoscape-fcose": "2.2.0",
"cytoscape": "3.31.1",
"d3-scale-chromatic": "3.1.0",
"date-fns": "4.1.0",
"dayjs": "1.11.19",
"defu": "6.1.4",
"destr": "2.0.3",
"graphql-tag": "2.12.6",
"graphql": "16.10.0",
"h3": "1.15.1",
"maplibre-gl": "5.2.0",
"graphql-tag": "2.12.6",
"mixpanel-browser": "2.61.2",
"nuxt-csurf": "1.6.5",
"nuxt-csurf": "^1.6.5",
"protomaps-themes-base": "1.3.1",
"tiny-emitter": "2.1.0",
"unstorage": "1.15.0",
"vega-embed": "7.1.0",
"vega-lite": "6.4.1",
"vega": "6.2.0",
"vue-json-pretty": "2.6.0"
},
"packageManager": "pnpm@10.21.0+sha512.da3337267e400fdd3d479a6c68079ac6db01d8ca4f67572083e722775a796788a7a9956613749e000fac20d424b594f7a791a5f4e2e13581c5ef947f26968a40",
"pnpm": {
"minimumReleaseAge": 10080,
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild",
Expand Down
Loading
Loading