@devcoffee/nuxt-core 1.1.0 β†’ 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,115 @@
1
+ # Changelog
2
+
3
+ ## v1.2.0
4
+
5
+ [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.1.1...v1.2.0)
6
+
7
+ ### πŸš€ Enhancements
8
+
9
+ - **quick-260328-uf7:** Create admin-board test fixture ([45dae18](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/45dae18))
10
+ - **quick-260328-uf7:** Add admin-board OIDC e2e test ([20bf504](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/20bf504))
11
+
12
+ ### 🩹 Fixes
13
+
14
+ - **types:** Explicitly type module export as NuxtModule for better type inference ([33e140e](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/33e140e))
15
+ - **types:** Resolve TypeScript errors in middleware and plugins ([df23475](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/df23475))
16
+ - Resolve #devcoffee-core subpath import warnings in build ([#19](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/19))
17
+ - Eliminate duplicate Redis sessions on SSR first load and refactor event.context.session ([#20](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/20))
18
+ - Rotate session on logout and remove console.log debug statement ([fbe129f](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/fbe129f))
19
+ - Resolve autoFetchUser SSR divergence on first render ([1f15ac7](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/1f15ac7))
20
+ - **lint:** Remove unused destructure aliases in SSR session mapping ([cfe7884](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/cfe7884))
21
+
22
+ ### πŸ“– Documentation
23
+
24
+ - **quick:** Create plan for admin-board OIDC e2e test ([ce8c611](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/ce8c611))
25
+ - **quick-260328-uf7:** Complete admin-board OIDC e2e plan ([23c0945](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/23c0945))
26
+
27
+ ### βœ… Tests
28
+
29
+ - Update deleteSession tests to reflect renewSession LOGOUT behavior ([b444baa](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/b444baa))
30
+
31
+ ### ❀️ Contributors
32
+
33
+ - Hieu Nguyen <hieu.nguyen@devcoffee.tech>
34
+ - HiαΊΏu Nguyα»…n ([@coolkg1412](https://github.com/coolkg1412))
35
+
36
+ ## v1.1.1
37
+
38
+ [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.0...v1.1.1)
39
+
40
+ ### πŸ’… Refactors
41
+
42
+ - Sort storage adapter imports in helpers.ts; remove unused moduleText in forward handler test ([20f3496](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/20f3496))
43
+
44
+ ### 🏑 Chore
45
+
46
+ - Complete v1.0 milestone β€” archive roadmap, requirements, retrospective ([2cb5067](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/2cb5067))
47
+ - Delete REQUIREMENTS.md β€” archived to milestones/v1.0-REQUIREMENTS.md ([48ce751](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/48ce751))
48
+ - Archive phase directories from v1.0 milestone ([1f3ced8](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/1f3ced8))
49
+
50
+ ### ❀️ Contributors
51
+
52
+ - Hieu Nguyen <hieu.nguyen@devcoffee.tech>
53
+
54
+ ## v1.1.0
55
+
56
+ [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.0.2...v1.1.0)
57
+
58
+ ### πŸš€ Enhancements
59
+
60
+ - Add support for ignored authentication path regex patterns ([#5](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/5))
61
+ - **04:** Adapter foundation β€” http/utils/storage/oidc barrel adapters (ADPT-01–04) ([3c44a78](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/3c44a78))
62
+ - **09:** Add E2E test coverage using Playwright ([#14](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/14))
63
+ - **docs:** Phase 10 β€” README.md and GUIDELINE.md for consumers and contributors ([#15](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/15))
64
+ - **11:** Distributed token refresh mutex β€” atomic Redis NX lock for multi-instance deployments ([f7e2d72](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/f7e2d72))
65
+
66
+ ### 🩹 Fixes
67
+
68
+ - TypeScript ([#4](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/4))
69
+ - Update index page test to check for non-null response ([aba39bf](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/aba39bf))
70
+ - **lint:** Auto-fix prettier formatting and replace dynamic delete in omit() ([89d987a](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/89d987a))
71
+ - **test:** Update SEC-03 source-text assertion to match prettier-formatted multiline call ([55f92d7](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/55f92d7))
72
+ - **types:** Resolve TypeScript strict-mode errors across app layer and server core ([b089812](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/b089812))
73
+ - **types:** Cast cookieOpts input before omit to resolve TS key narrowing error ([93dbb50](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/93dbb50))
74
+ - **types:** Include global.d.ts in Nitro server tsconfig to resolve DeepPartial on server layer ([24700e2](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/24700e2))
75
+
76
+ ### πŸ’… Refactors
77
+
78
+ - Flatten utils paths (utils/utils.ts β†’ utils.ts) ([45d8997](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/45d8997))
79
+ - **test:** Migrate test imports to @/ alias and fix tsconfig/vitest paths ([1f73e73](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/1f73e73))
80
+ - **utils:** Consolidate utility exports and fix module import paths ([d9e2fdc](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/d9e2fdc))
81
+ - **module:** Remove redundant typescript tsConfig includes covered by prepare:types hook ([9ced2c5](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/9ced2c5))
82
+
83
+ ### πŸ“– Documentation
84
+
85
+ - **10:** Research phase for README.md and GUIDELINE.md documentation ([31d5ab0](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/31d5ab0))
86
+ - Resolve debug eslint-ts-type-errors ([0653761](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/0653761))
87
+ - Update debug knowledge base with eslint-ts-type-errors ([2dc0cc1](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/2dc0cc1))
88
+
89
+ ### 🏑 Chore
90
+
91
+ - Authorize for devecoffee ([#2](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/2))
92
+ - The base UI and common Components ([#3](https://github.com/coolkg1412/devcoffee-nuxt-core/pull/3))
93
+ - Update docs ([b708e93](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/b708e93))
94
+ - Suppress DEP0155 deprecation warning in test scripts ([6fdb696](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/6fdb696))
95
+ - **test:** Split test scripts and enable headed E2E mode ([207c3d5](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/207c3d5))
96
+ - Bump `compatibilityDate` ([2654eb2](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/2654eb2))
97
+
98
+ ### βœ… Tests
99
+
100
+ - **api:** Mark BUG-03 deprecation tests as todo until implemented ([69cd623](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/69cd623))
101
+ - Update import aliases and trim stale todo tests for BUG-03 ([f7c5cff](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/f7c5cff))
102
+
103
+ ### ❀️ Contributors
104
+
105
+ - Hieu Nguyen <hieu.nguyen@devcoffee.tech>
106
+ - HiαΊΏu Nguyα»…n ([@coolkg1412](https://github.com/coolkg1412))
107
+
108
+ ## v1.0.2
109
+
110
+ [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.0.1...v1.0.2)
111
+
112
+ ## v1.0.1
113
+
114
+ [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.0.0...v1.0.1)
115
+
package/GUIDELINE.md ADDED
@@ -0,0 +1,351 @@
1
+ # @devcoffee/nuxt-core β€” Contributor Guide
2
+
3
+ This guide is for developers working on the module itself. If you are integrating the module into your app, see [README.md](./README.md).
4
+
5
+ ## Quick Reference
6
+
7
+ | Topic | Section |
8
+ |---|---|
9
+ | Module layers and responsibilities | [Architecture](#architecture) |
10
+ | Annotated file tree | [Directory Reference](#directory-reference) |
11
+ | First-time setup | [Development Setup](#development-setup) |
12
+ | Test commands | [Test Infrastructure](#test-infrastructure) |
13
+ | Adding external dependencies | [Adapter Pattern](#adapter-pattern) |
14
+ | Per-request auth flow | [Request Lifecycle](#request-lifecycle) |
15
+ | Guarded design choices | [Key Architectural Decisions](#key-architectural-decisions) |
16
+ | Publishing a new version | [Release Pipeline](#release-pipeline) |
17
+ | Planning workflow | [GSD Workflow](#gsd-workflow) |
18
+ | Style and naming rules | [Coding Conventions](#coding-conventions) |
19
+
20
+ ## Architecture
21
+
22
+ The module is organized into five layers. Each layer has a single responsibility and strict import rules.
23
+
24
+ ### Layer 1: Module Definition (`src/module.ts`)
25
+
26
+ Entry point. Configures and registers all runtime components into Nuxt and Nitro during build:
27
+
28
+ - Registers server plugins, server composables, and server handler imports via `@nuxt/kit`
29
+ - Registers app plugins, app composables, and route middleware
30
+ - Injects runtime configuration from `nuxtCore` options
31
+ - Sets up Nitro storage and DevTools routes
32
+
33
+ ### Layer 2: Server / Nitro (`src/runtime/server/`)
34
+
35
+ All server-side auth logic. Runs in the Nitro runtime (not the browser):
36
+
37
+ - `plugins/authts.ts` β€” global Nitro plugin; validates and refreshes sessions on every HTTP request
38
+ - `core/helpers.ts` β€” PKCE, state, token exchange, session CRUD, token refresh mutex
39
+ - `core/nuxtAuthtsHandler.ts` β€” `NuxtAuthtsHandler` export; handles GET_SESSION, TOKEN, LOGOUT, AUTHORIZE_URL actions
40
+ - `core/nuxtForwardHandler.ts` β€” `NuxtForwardRequestHandler` export; authenticated API proxy
41
+ - `adapters/` β€” external dependency wrappers (see [Adapter Pattern](#adapter-pattern))
42
+ - `composables/useServerLogger.ts` β€” server-side logging composable
43
+
44
+ ### Layer 3: Client / App (`src/runtime/app/`)
45
+
46
+ Client-side auth state, UI interactions, and route protection. Runs in Vue/Nuxt:
47
+
48
+ - `plugins/authts.ts` β€” app plugin; initializes session state, provides `$sessionContext`, `$sessionReady`, fires hooks
49
+ - `middleware/authts.ts` β€” global route middleware; enforces `definePageMeta` auth requirements
50
+ - `composables/useAuthContext.ts` β€” reactive auth state and actions
51
+ - `composables/useSessionContext.ts` β€” low-level session accessor
52
+ - `composables/useLogger.ts` β€” client-side logging composable
53
+ - `pages/authorize.vue` β€” OIDC callback page (auto-registered at `openid.redirectUri`)
54
+
55
+ ### Layer 4: Types (`src/types/`)
56
+
57
+ All TypeScript interfaces and augmentations:
58
+
59
+ - `types/authts.d.ts` β€” auth types (`SessionContext`, `AuthorizedUser`, `ModuleOptions`, etc.)
60
+ - `types/logging.d.ts` β€” logging types
61
+ - `types/index.d.ts` β€” central re-export point; consumers import from `@devcoffee/nuxt-core`
62
+
63
+ ### Layer 5: Module Helpers (`src/helpers.ts`)
64
+
65
+ Option normalization and public config sanitization:
66
+
67
+ - `normalizedModuleOptions()` β€” merges user config with defaults
68
+ - `normalizePublicRuntimeConfig()` β€” strips private fields before injecting into runtime config
69
+
70
+ ## Directory Reference
71
+
72
+ ```
73
+ src/
74
+ β”œβ”€β”€ module.ts # Layer 1: Module Definition
75
+ β”œβ”€β”€ helpers.ts # Layer 5: Option normalization + defaults
76
+ β”œβ”€β”€ utils.ts # Utility relay (re-exports from runtime/utils)
77
+ β”œβ”€β”€ types/
78
+ β”‚ β”œβ”€β”€ index.d.ts # Central type re-export for consumers
79
+ β”‚ β”œβ”€β”€ authts.d.ts # Auth types (SessionContext, AuthorizedUser, etc.)
80
+ β”‚ └── logging.d.ts # Logging types
81
+ └── runtime/
82
+ β”œβ”€β”€ server/ # Layer 2: Server / Nitro
83
+ β”‚ β”œβ”€β”€ adapters/ # External dependency wrappers
84
+ β”‚ β”‚ β”œβ”€β”€ http.ts # h3 cookie, request, response, error functions
85
+ β”‚ β”‚ β”œβ”€β”€ oidc.ts # openid-client wrappers with module-owned types
86
+ β”‚ β”‚ β”œβ”€β”€ storage.ts # Nitro useStorage session CRUD
87
+ β”‚ β”‚ └── utils.ts # deepMerge, omit, pick (native, no lodash)
88
+ β”‚ β”œβ”€β”€ core/ # Business logic β€” imports ONLY from adapters/
89
+ β”‚ β”‚ β”œβ”€β”€ helpers.ts
90
+ β”‚ β”‚ β”œβ”€β”€ nuxtAuthtsHandler.ts
91
+ β”‚ β”‚ └── nuxtForwardHandler.ts
92
+ β”‚ β”œβ”€β”€ composables/
93
+ β”‚ β”‚ └── useServerLogger.ts
94
+ β”‚ β”œβ”€β”€ plugins/
95
+ β”‚ β”‚ └── authts.ts # Per-request session validation entry point
96
+ β”‚ └── dev/ # DevTools route handler
97
+ └── app/ # Layer 3: Client / App
98
+ β”œβ”€β”€ composables/
99
+ β”‚ β”œβ”€β”€ useAuthContext.ts
100
+ β”‚ β”œβ”€β”€ useSessionContext.ts
101
+ β”‚ └── useLogger.ts
102
+ β”œβ”€β”€ middleware/
103
+ β”‚ └── authts.ts # Global route protection middleware
104
+ β”œβ”€β”€ pages/
105
+ β”‚ └── authorize.vue # OIDC callback page (auto-registered)
106
+ β”œβ”€β”€ plugins/
107
+ β”‚ β”œβ”€β”€ authts.ts # Session init, hooks, $sessionReady
108
+ β”‚ β”œβ”€β”€ logging.ts
109
+ β”‚ β”œβ”€β”€ formatters.ts
110
+ β”‚ └── locale.ts
111
+ └── utils/
112
+ └── utils.ts # Utility relay
113
+
114
+ test/
115
+ β”œβ”€β”€ unit/ # Vitest unit tests
116
+ └── e2e/ # Playwright + Vitest E2E tests
117
+ β”œβ”€β”€ fixture/ # Nuxt test app with mock OIDC server
118
+ └── *.test.ts
119
+ ```
120
+
121
+ ## Development Setup
122
+
123
+ ### Prerequisites
124
+
125
+ - Node.js LTS (18+)
126
+ - npm 10+
127
+ - A `.certs/devcoffee.ca.pem` file β€” required for TLS in development (internal CA). Place the DevCoffee CA certificate at this path.
128
+
129
+ ### First-time setup
130
+
131
+ ```bash
132
+ # 1. Clone and install
133
+ git clone <repo-url>
134
+ cd devcoffee-nuxt-core
135
+ npm install
136
+
137
+ # 2. Generate type stubs (required before dev or test)
138
+ npm run dev:prepare
139
+
140
+ # 3. Start the playground dev server
141
+ npm run dev
142
+ ```
143
+
144
+ The playground runs at `http://localhost:3000`. The custom CA certificate is injected via `NODE_EXTRA_CA_CERTS` in the `dev` script.
145
+
146
+ ### Clean rebuild
147
+
148
+ ```bash
149
+ npm run cleanup # Remove .nuxt and playground build artifacts
150
+ npm run dev:prepare
151
+ npm run dev
152
+ ```
153
+
154
+ ## Test Infrastructure
155
+
156
+ | Command | What it runs | When to use |
157
+ |---|---|---|
158
+ | `npm run test` | Vitest unit suite (once) | Before committing |
159
+ | `npm run test:watch` | Vitest unit suite (watch mode) | During development |
160
+ | `npm run test:types` | `vue-tsc --noEmit` type check | Before committing |
161
+ | `npm run test:e2e` | Playwright + Vitest E2E tests (headless) | After changing auth flow, middleware, or session handling |
162
+ | `npm run test:e2e:ui` | E2E tests in headed Chromium (via `PLAYWRIGHT_HEADLESS=false`) | Debugging browser-mode E2E failures |
163
+ | `npm run test:all` | All unit + E2E tests combined | Before opening a PR or releasing |
164
+
165
+ ### Unit tests (`test/unit/`)
166
+
167
+ Written with Vitest. Use `@nuxt/test-utils` for composable and middleware tests. Mock Nitro virtual modules where needed (e.g., `vi.mock('nitropack/runtime')`).
168
+
169
+ Tests follow source-text assertions for structural code presence and behavior assertions for logic. TDD was used for SEC-*, PERF-*, MDLW-*, and SSR-* requirements β€” new security or behavior requirements should follow the same pattern.
170
+
171
+ ### E2E tests (`test/e2e/`)
172
+
173
+ Run in Vitest but use `@playwright/test` for browser automation and `createNuxtDevServer` from `@nuxt/test-utils` to spin up the fixture Nuxt app. A mock OIDC server is embedded as a Nitro route handler in the fixture.
174
+
175
+ Key files:
176
+
177
+ - `test/e2e/fixture/` β€” test Nuxt app (standalone, not the playground)
178
+ - `test/e2e/fixture/server/routes/` β€” mock OIDC discovery, token, and userinfo endpoints
179
+ - `test/e2e/middleware.test.ts` β€” E2E-01 (required redirect) and E2E-02 (unauthenticatedOnly)
180
+ - `test/e2e/session.test.ts` β€” E2E-03 (session creation) and E2E-07 (token refresh)
181
+ - `test/e2e/auth-flow.test.ts` β€” E2E-04 (valid TOKEN flow), E2E-05 (invalid state), E2E-06 (LOGOUT)
182
+ - `test/e2e/auth-flow-browser.test.ts` β€” E2E-08 (browser-mode Chromium tests)
183
+
184
+ **Important:** Browser tests (`auth-flow-browser.test.ts`) are isolated in their own file to prevent `EADDRINUSE` port conflicts when two Nuxt dev servers start in the same process.
185
+
186
+ ## Adapter Pattern
187
+
188
+ ### Why adapters exist
189
+
190
+ Business logic in `src/runtime/server/core/` must not import directly from `h3`, `openid-client`, or any external npm package. Instead, all external calls go through adapter barrel files in `src/runtime/server/adapters/`.
191
+
192
+ **Rationale:** Adapters create a stable internal boundary. If `h3` or `openid-client` upgrades break their API, only the adapter needs to change β€” not every call site in the business logic. Adapters also own the type bridge between external library types and module-owned types (e.g., `OidcUserInfo`).
193
+
194
+ This rule is enforced by grep assertions in the test suite:
195
+
196
+ ```bash
197
+ # These must return zero matches:
198
+ grep -r "from 'h3'" src/runtime/server/core/
199
+ grep -r "from 'openid-client'" src/runtime/server/core/
200
+ ```
201
+
202
+ ### How to add a new external dependency
203
+
204
+ 1. Install the package as a dependency
205
+ 2. Create (or update) the appropriate adapter file in `src/runtime/server/adapters/`
206
+ 3. Write module-owned types for any types the adapter exposes
207
+ 4. Export only what business logic needs β€” keep the adapter surface minimal
208
+ 5. Import from the adapter in `core/` files, never directly from the package
209
+
210
+ Example: adding a hypothetical `jose` utility to the http adapter:
211
+
212
+ ```typescript
213
+ // src/runtime/server/adapters/http.ts
214
+ import { decodeJwt as _decodeJwt } from 'jose'
215
+ import type { JWTPayload } from './types' // module-owned type, not jose's type
216
+
217
+ export function decodeJwt(token: string): JWTPayload {
218
+ return _decodeJwt(token) as JWTPayload
219
+ }
220
+ ```
221
+
222
+ ## Request Lifecycle
223
+
224
+ Every HTTP request to the Nuxt app passes through this sequence:
225
+
226
+ ```
227
+ HTTP request
228
+ β”‚
229
+ β–Ό
230
+ Nitro server plugin (src/runtime/server/plugins/authts.ts)
231
+ β”‚ Reads session cookie β†’ validateSession()
232
+ β”‚ If authenticated + near-expiry β†’ refreshTokenIfNeeded() [mutex-protected per session]
233
+ β”‚ Sets event.context.sessionId
234
+ β”‚
235
+ β–Ό
236
+ Route handler (src/runtime/server/core/nuxtAuthtsHandler.ts)
237
+ β”‚ Reads action from URL path: GET_SESSION | TOKEN | LOGOUT | AUTHORIZE_URL
238
+ β”‚ Calls getSession(event.context.sessionId) β†’ reads + decrypts session from storage
239
+ β”‚ Dispatches to action handler
240
+ β”‚
241
+ β–Ό
242
+ Client (src/runtime/app/plugins/authts.ts)
243
+ β”‚ useAsyncData('authts:session') fetches /api/_auth/session on SSR
244
+ β”‚ Sets $sessionReady promise β€” resolved before route middleware runs
245
+ β”‚
246
+ β–Ό
247
+ Route middleware (src/runtime/app/middleware/authts.ts)
248
+ β”‚ Awaits $sessionReady
249
+ β”‚ Reads definePageMeta({ authts: { required, unauthenticatedOnly } })
250
+ β”‚ Redirects or aborts navigation based on auth state
251
+ ```
252
+
253
+ ### Authorization code flow (login)
254
+
255
+ ```
256
+ User clicks login
257
+ β”‚
258
+ β–Ό
259
+ useAuthContext.login(redirectTo)
260
+ β”‚ GET /api/_auth/authorize-url β†’ returns OIDC authorization URL
261
+ β”‚ Stores redirectTo in session cookie (auths.redirect)
262
+ β”‚
263
+ β–Ό
264
+ Browser redirects to OIDC provider
265
+ β”‚
266
+ β–Ό
267
+ Provider redirects to /authorize?code=...&state=...
268
+ β”‚
269
+ β–Ό
270
+ authorize.vue (auto-registered OIDC callback page)
271
+ β”‚ useAuthContext.authorize(code, state)
272
+ β”‚ POST /api/_auth/token β†’ validates state, exchanges code for tokens
273
+ β”‚ If autoFetchUser: fetches userinfo from provider
274
+ β”‚ Stores session in Nitro storage (tokenSet encrypted if secret is set)
275
+ β”‚ Fires user:loggedIn hook
276
+ β”‚
277
+ β–Ό
278
+ Browser redirects to intended destination
279
+ ```
280
+
281
+ ## Key Architectural Decisions
282
+
283
+ These decisions are enforced by tests or type constraints. Changing them requires updating the tests that guard them.
284
+
285
+ | Decision | What it means |
286
+ |---|---|
287
+ | `sessions.secret` empty = no signing | Backward compatible β€” existing deployments without a secret continue working. Insecure for new deployments. |
288
+ | HKDF-SHA256 derives AES key from `sessions.secret` | Domain-separated with `'devcoffee-authts-tokenset-v1'` info string |
289
+ | Cookie names are `auths.ssid`, `auths.state`, `auths.pkce`, `auths.redirect` | Configured in `sessions.names` defaults in `src/helpers.ts` |
290
+ | `deleteSession()` is separate from `renewSession()` | Logout deletes the session and does not create a replacement. Prevents zombie sessions. |
291
+ | Token refresh mutex per session | Lock is placed inside `accessExpired && refreshToken` branch only β€” no storage overhead for non-expiring tokens |
292
+ | Adapter import enforcement | Verified by grep in test suite. Zero direct `h3` or `openid-client` imports allowed in `core/`. |
293
+ | `usePkce: false` in E2E fixture | PKCE threading through test requires additional cookie management. PKCE is fully covered at the unit layer. |
294
+
295
+ Full decision log: see `## Decisions` in `.planning/STATE.md`.
296
+
297
+ ## Release Pipeline
298
+
299
+ ```bash
300
+ npm run release
301
+ ```
302
+
303
+ This runs in sequence: `lint` β†’ `test:all` β†’ `prepack` β†’ `changelogen` β†’ `npm publish` β†’ `git push --follow-tags`.
304
+
305
+ Individual steps:
306
+
307
+ ```bash
308
+ npm run lint # ESLint check (with --fix)
309
+ npm run test # Vitest unit suite
310
+ npm run test:types # vue-tsc type check
311
+ npm run test:all # All unit + E2E tests combined
312
+ npm run prepack # Build module dist (ES module + .d.ts types via @nuxt/module-builder)
313
+ npm run release # Full pipeline (lint + test:all + prepack + changelogen + publish + push)
314
+ ```
315
+
316
+ The published package is `@devcoffee/nuxt-core`. The `dist/` directory is generated by `@nuxt/module-builder` and is what consumers install.
317
+
318
+ ## GSD Workflow
319
+
320
+ This project uses a phase-based planning system. All code changes must go through a GSD command to keep planning artifacts in sync.
321
+
322
+ | Task type | Entry point |
323
+ |---|---|
324
+ | Small fix or ad-hoc change | `/gsd:quick` |
325
+ | Bug investigation | `/gsd:debug` |
326
+ | Planned phase work | `/gsd:execute-phase` |
327
+
328
+ Planning artifacts live in `.planning/`:
329
+
330
+ - `ROADMAP.md` β€” all phases and their requirements
331
+ - `STATE.md` β€” current position, accumulated decisions, blockers
332
+ - `phases/NN-slug/` β€” per-phase RESEARCH.md, PLAN.md files, SUMMARY.md files
333
+
334
+ Do not make direct file edits outside a GSD workflow unless explicitly bypassing it.
335
+
336
+ ## Coding Conventions
337
+
338
+ Key rules (full reference: `CLAUDE.md`):
339
+
340
+ - **No semicolons** β€” Prettier enforces this
341
+ - **Single quotes** for strings
342
+ - **2-space indent**, 120-char line length
343
+ - **Trailing commas:** es5 style (objects and arrays, not function parameters)
344
+ - **TypeScript strict** β€” no `any` in public API surface
345
+ - **Composables:** `use[Name].ts` file and function name
346
+ - **Server handlers:** `Nuxt[Name]Handler` (PascalCase with `Nuxt` prefix)
347
+ - **Getters:** `get[Name]`, **Setters:** `set[Name]`, **Validators:** `validate[Name]`
348
+ - **Logging:** Use `useLogger` on client, `useServerLogger` on server. Always include `tag` and `level`.
349
+ - **Error handling:** `createError()` from h3 for server errors, `abortNavigation(createError())` for middleware
350
+ - **Imports:** Prefer named imports. Use `#imports` for Nuxt auto-imports.
351
+ - **JSDoc:** All public functions and composables must have JSDoc with `@param`, `@returns`, `@example`, `@since 1.0.0`
package/README.md CHANGED
@@ -252,8 +252,6 @@ export default NuxtForwardRequestHandler({
252
252
  })
253
253
  ```
254
254
 
255
- > **Note:** `NuxtForwardRequestHandller` (double-l) is a deprecated alias kept for backward compatibility. Use `NuxtForwardRequestHandler` in new code.
256
-
257
255
  ## Route Protection
258
256
 
259
257
  The module registers a global Nuxt middleware that runs on every navigation. Control access per page with `definePageMeta`.
package/dist/module.d.mts CHANGED
@@ -1,6 +1,7 @@
1
- import { CookieSerializeOptions } from '#devcoffee-core/runtime/server/adapters/http';
2
- import { OidcUserInfo } from '#devcoffee-core/runtime/server/adapters/oidc';
3
- import { ConsolaInstance, LogLevel as LogLevel$1, ConsolaOptions } from 'consola';
1
+ import { NuxtModule } from '@nuxt/schema';
2
+ import { CookieSerializeOptions } from '../dist/runtime/server/adapters/http.js';
3
+ import { OidcUserInfo } from '../dist/runtime/server/adapters/oidc.js';
4
+ import { LogLevel as LogLevel$1, ConsolaOptions, ConsolaInstance } from 'consola';
4
5
 
5
6
  interface AuthorizedUser {
6
7
  id: string
@@ -294,7 +295,7 @@ type ModulePublicRuntimeConfig = Pick<ModuleOptions, 'defaultLocale' | 'defaultT
294
295
 
295
296
  type InputModuleOptions = DeepPartial<ModuleOptions>
296
297
 
297
- declare const _default: any;
298
+ declare const _module: NuxtModule<InputModuleOptions>;
298
299
 
299
- export { _default as default };
300
+ export { _module as default };
300
301
  export type { AuthData, AuthorizedUser, AuthtsMiddlewareMeta, AuthtsModuleOptions, CoreLogInstance, CoreLogLevel, InputModuleOptions, LoggingModuleOptions, LoggingOptions, ModuleOptions, ModulePublicRuntimeConfig, NuxtAuthOptions, NuxtCoreLogging, NuxtSessionContext, NuxtSessionUpdateContext, SessionContext };
package/dist/module.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { CookieSerializeOptions } from '#devcoffee-core/runtime/server/adapters/http';
2
- import { OidcUserInfo } from '#devcoffee-core/runtime/server/adapters/oidc';
3
- import { ConsolaInstance, LogLevel as LogLevel$1, ConsolaOptions } from 'consola';
1
+ import { NuxtModule } from '@nuxt/schema';
2
+ import { CookieSerializeOptions } from '../dist/runtime/server/adapters/http.js';
3
+ import { OidcUserInfo } from '../dist/runtime/server/adapters/oidc.js';
4
+ import { LogLevel as LogLevel$1, ConsolaOptions, ConsolaInstance } from 'consola';
4
5
 
5
6
  interface AuthorizedUser {
6
7
  id: string
@@ -294,7 +295,7 @@ type ModulePublicRuntimeConfig = Pick<ModuleOptions, 'defaultLocale' | 'defaultT
294
295
 
295
296
  type InputModuleOptions = DeepPartial<ModuleOptions>
296
297
 
297
- declare const _default: any;
298
+ declare const _module: NuxtModule<InputModuleOptions>;
298
299
 
299
- export { _default as default };
300
+ export { _module as default };
300
301
  export type { AuthData, AuthorizedUser, AuthtsMiddlewareMeta, AuthtsModuleOptions, CoreLogInstance, CoreLogLevel, InputModuleOptions, LoggingModuleOptions, LoggingOptions, ModuleOptions, ModulePublicRuntimeConfig, NuxtAuthOptions, NuxtCoreLogging, NuxtSessionContext, NuxtSessionUpdateContext, SessionContext };
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "configKey": "nuxtCore",
5
5
  "compatibility": {
6
6
  "nuxt": "^4.0.0"
package/dist/module.mjs CHANGED
@@ -2,7 +2,7 @@ import { addCustomTab } from '@nuxt/devtools-kit';
2
2
  import { defineNuxtModule, useLogger, createResolver, addServerImports, addServerImportsDir, addServerPlugin, addImportsDir, addPlugin, addRouteMiddleware, addTemplate, addServerHandler } from '@nuxt/kit';
3
3
  import { deepMerge, pick } from '../dist/runtime/utils.js';
4
4
 
5
- const version = "1.1.0";
5
+ const version = "1.2.0";
6
6
 
7
7
  const defaultLocale = "vi-VN";
8
8
  const defaultLanguage = "vi";
@@ -113,7 +113,7 @@ function normalizePublicRuntimeConfig(inputOpts) {
113
113
 
114
114
  const moduleName = "nuxt-core";
115
115
  const configKey = "nuxtCore";
116
- const module = defineNuxtModule({
116
+ const _module = defineNuxtModule({
117
117
  meta: {
118
118
  name: moduleName,
119
119
  version,
@@ -228,4 +228,4 @@ const module = defineNuxtModule({
228
228
  }
229
229
  });
230
230
 
231
- export { module as default };
231
+ export { _module as default };
@@ -1,4 +1,4 @@
1
- import { useRequestURL } from "#app";
1
+ import { useRequestEvent, useRequestURL, useRuntimeConfig } from "#app";
2
2
  import {
3
3
  computed,
4
4
  createError,
@@ -9,6 +9,7 @@ import {
9
9
  useRequestFetch,
10
10
  useSessionContext
11
11
  } from "#imports";
12
+ import { deleteCookie, setCookie } from "h3";
12
13
  export function useAuthContext(initiator) {
13
14
  const { callHook, runWithContext } = useNuxtApp();
14
15
  const { getValue } = useSessionContext();
@@ -58,7 +59,15 @@ export function useAuthContext(initiator) {
58
59
  processing.value = true;
59
60
  }
60
61
  }).then(
61
- async ({ redirectUrl }) => runWithContext(async () => await navigateTo(redirectUrl, { external: true, replace: true }))
62
+ async ({ redirectUrl, cookies }) => runWithContext(async () => {
63
+ if (import.meta.server) {
64
+ const event = useRequestEvent();
65
+ if (event) {
66
+ cookies.forEach(([n, v, o]) => setCookie(event, n, v, o));
67
+ }
68
+ }
69
+ return await navigateTo(redirectUrl, { external: true, replace: true });
70
+ })
62
71
  ).catch((ex) => {
63
72
  logger.log(`[login] failed error:${ex}`, ex);
64
73
  throw sanitizeError(ex);
@@ -76,7 +85,18 @@ export function useAuthContext(initiator) {
76
85
  await runWithContext(async () => await callHook("user:loggedIn"));
77
86
  return response;
78
87
  }).then(
79
- async ({ redirectUrl }) => runWithContext(async () => await navigateTo(redirectUrl))
88
+ async ({ redirectUrl }) => runWithContext(async () => {
89
+ if (import.meta.server) {
90
+ const event = useRequestEvent();
91
+ if (event) {
92
+ const { cookieOpts, names: cookienames } = useRuntimeConfig(event).nuxtCore.authts.sessions;
93
+ Array.of(cookienames.state, cookienames.pkce, cookienames.redirectUrl).forEach(
94
+ (cookie) => deleteCookie(event, cookie, cookieOpts)
95
+ );
96
+ }
97
+ }
98
+ return await navigateTo(redirectUrl);
99
+ })
80
100
  ).catch((ex) => {
81
101
  logger.log(`[authorize] failed error:${ex}`, ex);
82
102
  throw sanitizeError(ex);
@@ -90,7 +110,16 @@ export function useAuthContext(initiator) {
90
110
  processing.value = true;
91
111
  }
92
112
  }).then(async (response) => {
93
- await runWithContext(async () => await callHook("user:loggedOut"));
113
+ await runWithContext(async () => {
114
+ if (import.meta.server) {
115
+ const event = useRequestEvent();
116
+ if (event) {
117
+ const { cookieOpts, names: cookienames } = useRuntimeConfig(event).nuxtCore.authts.sessions;
118
+ deleteCookie(event, cookienames.sessionId, cookieOpts);
119
+ }
120
+ }
121
+ return await callHook("user:loggedOut");
122
+ });
94
123
  return response;
95
124
  }).then(
96
125
  async ({ redirectUrl }) => runWithContext(async () => await navigateTo(redirectUrl))
@@ -3,7 +3,6 @@ import {
3
3
  createError,
4
4
  defineNuxtRouteMiddleware,
5
5
  navigateTo,
6
- refreshNuxtData,
7
6
  useCookie,
8
7
  useNuxtApp,
9
8
  useRequestEvent,
@@ -68,7 +67,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
68
67
  logger.debug(`Bypassing auth checks for ignored path: ${normalizedPath}`);
69
68
  return;
70
69
  }
71
- if (import.meta.env.DEV && ignoreRegexPatternsDev.length && ignoreRegexPatternsDev.some((pattern) => pattern.test(normalizedPath))) {
70
+ if (import.meta.dev && ignoreRegexPatternsDev.length && ignoreRegexPatternsDev.some((pattern) => pattern.test(normalizedPath))) {
72
71
  logger.debug(`Bypassing auth checks for ignored path (dev): ${normalizedPath}`);
73
72
  return;
74
73
  }
@@ -83,7 +82,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
83
82
  );
84
83
  }
85
84
  if (import.meta.client) {
86
- await refreshNuxtData("authts:session");
85
+ await nuxtApp.$sessionContext.refetch();
87
86
  }
88
87
  const { isAuthenticated } = useAuthContext("core.app.authts.middleware");
89
88
  const loginPath = loginUri.toLowerCase();
@@ -2,10 +2,10 @@ import { watch } from "vue";
2
2
  import {
3
3
  createError,
4
4
  defineNuxtPlugin,
5
- refreshNuxtData,
6
5
  useAsyncData,
7
6
  useCookie,
8
7
  useNuxtApp,
8
+ useRequestEvent,
9
9
  useRequestFetch,
10
10
  useRuntimeConfig,
11
11
  useState
@@ -21,8 +21,25 @@ export default defineNuxtPlugin(async (_nuxtApp) => {
21
21
  }
22
22
  );
23
23
  async function __getServerSession(initiator) {
24
+ logger.debug(`[__getServerSession] Called with initiator: '${initiator}'`);
25
+ if (import.meta.server) {
26
+ const event = useRequestEvent();
27
+ const session = event?.context?.session ?? null;
28
+ if (!session) {
29
+ throw createError({
30
+ status: 500,
31
+ fatal: true,
32
+ statusMessage: "Failed to fetch session",
33
+ message: "Server session not found on event context"
34
+ });
35
+ }
36
+ const { auth, ...rest } = session;
37
+ return {
38
+ ...rest,
39
+ isAuthenticated: auth?.status === "authenticated" && Boolean(auth?.tokenSet && session.user?.id)
40
+ };
41
+ }
24
42
  try {
25
- logger.debug(`[__getServerSession] Called with initiator: '${initiator}'`);
26
43
  return await fetchRequest("/api/_auth/session");
27
44
  } catch (ex) {
28
45
  throw createError({
@@ -41,36 +58,17 @@ export default defineNuxtPlugin(async (_nuxtApp) => {
41
58
  _nuxtApp.provide("sessionReady", sessionReady);
42
59
  const sessionId = useCookie(useRuntimeConfig().public.nuxtCore.authts.sessionCookie);
43
60
  logger.debug(`Initial session fetch on plugin init with sessionId: ${sessionId.value}`);
44
- if (sessionId.value) {
45
- const { data } = await useAsyncData("authts:session", () => __getServerSession("plugin initialization"));
46
- if (data.value) {
47
- context.value = data.value;
48
- }
49
- watch(data, (newData) => {
61
+ const { data, refresh } = await useAsyncData("authts:session", () => __getServerSession("plugin initialization"));
62
+ watch(
63
+ data,
64
+ (newData) => {
50
65
  if (newData) context.value = newData;
51
- });
52
- } else {
53
- context.value = {
54
- id: "",
55
- isAuthenticated: false,
56
- user: {
57
- id: "",
58
- sub: "",
59
- email: "",
60
- firstName: "Anonymous",
61
- lastName: "User",
62
- locale: "",
63
- language: "",
64
- timezone: ""
65
- },
66
- data: {}
67
- };
68
- }
69
- resolveReady();
66
+ },
67
+ { immediate: true }
68
+ );
70
69
  _nuxtApp.hooks.addHooks({
71
- // session:fetch calls refreshNuxtData to stay coherent with useAsyncData cache (D-04)
72
70
  "session:fetch": async (_initiator) => {
73
- await refreshNuxtData("authts:session");
71
+ await refresh({ dedupe: "cancel" });
74
72
  },
75
73
  "user:loggedIn": async () => {
76
74
  await _nuxtApp.callHook("session:fetch", "user:loggedIn");
@@ -88,4 +86,5 @@ export default defineNuxtPlugin(async (_nuxtApp) => {
88
86
  await _nuxtApp.callHook("session:fetch");
89
87
  }
90
88
  _nuxtApp.provide("sessionContext", { getValue, refetch });
89
+ resolveReady();
91
90
  });
@@ -1,5 +1,5 @@
1
1
  export { createError, deleteCookie, eventHandler, getCookie, getQuery, getRequestHeaders, getRequestURL, proxyRequest, readBody, readFormData, sendNoContent, setCookie, } from 'h3';
2
2
  export type { EventHandlerRequest, H3Error, H3Event, ProxyOptions, RequestHeaders } from 'h3';
3
3
  export { defineNitroPlugin, useRuntimeConfig } from 'nitropack/runtime';
4
- export type { CoreLogLevel, NuxtAuthOptions } from 'nitropack/runtime';
4
+ export type { CoreLogLevel, NuxtAuthOptions } from '@devcoffee/nuxt-core';
5
5
  export type { CookieSerializeOptions } from 'cookie-es';
@@ -10,8 +10,8 @@ type SessionCreateOptions = {
10
10
  /**
11
11
  * Check whether a candidate redirect URL is same-origin with the request URL.
12
12
  *
13
- * Uses the WHATWG URL constructor β€” malformed URLs and relative paths return false
14
- * instead of throwing, guarding against attacker-controlled input.
13
+ * Uses the WHATWG URL constructor β€” relative paths are considered same-origin.
14
+ * Malformed URLs return false, guarding against attacker-controlled input.
15
15
  *
16
16
  * @param redirectUrl - The candidate redirect URL string (from query param or cookie).
17
17
  * @param requestUrl - The trusted request URL from the h3 event.
@@ -12,10 +12,16 @@ import {
12
12
  refreshTokenGrant as refreshTokenGrantFromOidc,
13
13
  revokeToken
14
14
  } from "#devcoffee-core/server/adapters/oidc";
15
+ import {
16
+ getSessionData,
17
+ hasSessionData,
18
+ removeSessionData,
19
+ setSessionData
20
+ } from "#devcoffee-core/server/adapters/storage";
15
21
  import { deepMerge, omit } from "#devcoffee-core/server/adapters/utils";
16
22
  import useServerLogger from "#devcoffee-core/server/composables/useServerLogger";
17
23
  import { useRuntimeConfig } from "#imports";
18
- import { useStorage } from "nitropack/runtime/internal/storage";
24
+ import { useStorage } from "nitropack/runtime";
19
25
  import { decryptTokenSet, encryptTokenSet, generateSessionId, isValidSessionId, verifySessionId } from "./crypto.js";
20
26
  import { tryAcquireLock } from "./mutex.js";
21
27
  function getAnonymousUser(extras) {
@@ -41,17 +47,19 @@ function getSessionStorageKey(storagePrefix, sessionId) {
41
47
  return storagePrefix ? `${storagePrefix}:${sessionId}` : sessionId;
42
48
  }
43
49
  export function isSameOrigin(redirectUrl, requestUrl) {
50
+ if (redirectUrl.includes(" ")) {
51
+ return false;
52
+ }
44
53
  try {
45
- return new URL(redirectUrl).origin === requestUrl.origin;
54
+ return new URL(redirectUrl, requestUrl.href).origin === requestUrl.origin;
46
55
  } catch {
47
56
  return false;
48
57
  }
49
58
  }
50
59
  export async function getSession(sessionId, opts) {
51
- const storage = useStorage(opts.storageName);
52
60
  const sessingKey = getSessionStorageKey(opts.storagePrefix, sessionId);
53
- if (!await storage.hasItem(sessingKey)) return null;
54
- const session = await storage.getItem(sessingKey);
61
+ if (!await hasSessionData(opts.storageName, sessingKey)) return null;
62
+ const session = await getSessionData(opts.storageName, sessingKey);
55
63
  if (!session) return null;
56
64
  if (session.auth?.tokenSet && session.auth.tokenSet.encrypted === true) {
57
65
  if (opts.secret) {
@@ -78,7 +86,6 @@ function newSession(sessionId, expiresAt) {
78
86
  };
79
87
  }
80
88
  export async function validateSession(sessionCookieId, opts) {
81
- const storage = useStorage(opts.storageName);
82
89
  const now = Date.now();
83
90
  const expiresAt = now + opts.expiresIn;
84
91
  let sessionId = void 0;
@@ -91,8 +98,8 @@ export async function validateSession(sessionCookieId, opts) {
91
98
  }
92
99
  if (sessionId) {
93
100
  sessionKey = getSessionStorageKey(opts.storagePrefix, sessionId);
94
- if (await storage.hasItem(sessionKey)) {
95
- session = await storage.getItem(sessionKey);
101
+ if (await hasSessionData(opts.storageName, sessionKey)) {
102
+ session = await getSessionData(opts.storageName, sessionKey);
96
103
  }
97
104
  if (!session || session.expiresAt <= now) {
98
105
  deleteSessionKey = session ? sessionKey : void 0;
@@ -105,22 +112,19 @@ export async function validateSession(sessionCookieId, opts) {
105
112
  session = session || newSession(generateSessionId(), expiresAt);
106
113
  sessionKey = sessionKey || getSessionStorageKey(opts.storagePrefix, session.id);
107
114
  if (deleteSessionKey && deleteSessionKey !== sessionKey) {
108
- await storage.removeItem(deleteSessionKey);
115
+ await removeSessionData(opts.storageName, deleteSessionKey);
109
116
  }
110
- await storage.setItem(sessionKey, session, {
111
- ttl: opts.expiresIn / 1e3 | 0
112
- });
117
+ await setSessionData(opts.storageName, sessionKey, session, opts.expiresIn / 1e3 | 0);
113
118
  return session;
114
119
  }
115
120
  export async function updateSession(sessionId, input, opts) {
116
121
  const now = Date.now();
117
- const storage = useStorage(opts.storageName);
118
122
  const serverKey = `${opts.storagePrefix}:${sessionId}`;
119
123
  const normalizedInput = omit(
120
124
  input,
121
125
  ["id", "issuedAt", "expiresAt"]
122
126
  );
123
- let session = await storage.getItem(serverKey);
127
+ let session = await getSessionData(opts.storageName, serverKey);
124
128
  if (!session) {
125
129
  throw createError({
126
130
  status: 500,
@@ -140,29 +144,23 @@ export async function updateSession(sessionId, input, opts) {
140
144
  )
141
145
  };
142
146
  }
143
- await storage.setItem(serverKey, sessionToStore, {
144
- ttl: opts.expiresIn / 1e3 | 0
145
- });
147
+ await setSessionData(opts.storageName, serverKey, sessionToStore, opts.expiresIn / 1e3 | 0);
146
148
  return session;
147
149
  }
148
150
  export async function renewSession(sessionId, opts) {
149
- const storage = useStorage(opts.storageName);
150
151
  let sessionKey = getSessionStorageKey(opts.storagePrefix, sessionId);
151
- if (await storage.hasItem(sessionKey)) {
152
- await storage.removeItem(sessionKey);
152
+ if (await hasSessionData(opts.storageName, sessionKey)) {
153
+ await removeSessionData(opts.storageName, sessionKey);
153
154
  }
154
155
  const session = newSession(generateSessionId(), Date.now() + opts.expiresIn);
155
156
  sessionKey = getSessionStorageKey(opts.storagePrefix, session.id);
156
- await storage.setItem(sessionKey, session, {
157
- ttl: opts.expiresIn / 1e3 | 0
158
- });
157
+ await setSessionData(opts.storageName, sessionKey, session, opts.expiresIn / 1e3 | 0);
159
158
  return session;
160
159
  }
161
160
  export async function deleteSession(sessionId, opts) {
162
- const storage = useStorage(opts.storageName);
163
161
  const sessionKey = getSessionStorageKey(opts.storagePrefix, sessionId);
164
- if (await storage.hasItem(sessionKey)) {
165
- await storage.removeItem(sessionKey);
162
+ if (await hasSessionData(opts.storageName, sessionKey)) {
163
+ await removeSessionData(opts.storageName, sessionKey);
166
164
  }
167
165
  }
168
166
  export async function discoveryOpendId(wellKnownUrl, opts) {
@@ -1,5 +1,5 @@
1
1
  import useServerLogger from "#devcoffee-core/server/composables/useServerLogger";
2
- import { useStorage } from "nitropack/runtime/internal/storage";
2
+ import { useStorage } from "nitropack/runtime";
3
3
  const logger = useServerLogger({ tag: "authts-mutex", level: 3 });
4
4
  export async function tryAcquireLock(storage, lockKey, ttlSeconds, useAtomic) {
5
5
  if (useAtomic) {
@@ -1,4 +1,10 @@
1
1
  import type { NuxtAuthOptions } from '#devcoffee-core/server/adapters/http';
2
+ declare module 'nitropack' {
3
+ interface NitroApp {
4
+ /** Registered userInfo callback from NuxtAuthtsHandler β€” used by server plugin for SSR autoFetchUser enrichment. */
5
+ _sessionUserInfo?: NuxtAuthOptions['userInfo'];
6
+ }
7
+ }
2
8
  /**
3
9
  * Creates a universal authentication handler for Nuxt, integrating with OpenID Connect.
4
10
  *
@@ -10,15 +10,14 @@ import {
10
10
  useRuntimeConfig
11
11
  } from "#devcoffee-core/server/adapters/http";
12
12
  import { deepMerge, omit } from "#devcoffee-core/server/adapters/utils";
13
- import { useStorage } from "nitropack/runtime/internal/storage";
13
+ import { useNitroApp, useStorage } from "nitropack/runtime";
14
14
  import {
15
15
  authorizationCodeGrant,
16
16
  buildAuthorizationUrl,
17
17
  constructTokenSet,
18
- deleteSession,
19
18
  fetchUserInfo,
20
- getSession,
21
19
  isSameOrigin,
20
+ renewSession,
22
21
  revokeTokens,
23
22
  updateSession
24
23
  } from "./helpers.js";
@@ -67,20 +66,17 @@ const defaultNuxtAuthOptions = {
67
66
  export default function NuxtAuthtsHandler(options) {
68
67
  const { enabled: authEnabled, sessions: sessionConfig, openid, auth } = useRuntimeConfig().nuxtCore.authts;
69
68
  const nuxtAuthOptions = deepMerge({ ...defaultNuxtAuthOptions }, options || {});
69
+ useNitroApp()._sessionUserInfo = nuxtAuthOptions.userInfo;
70
70
  return eventHandler(async (event) => {
71
71
  const requestUrl = getRequestURL(event);
72
72
  const queryParams = getQuery(event);
73
73
  const authAction = getAuthAction(requestUrl);
74
- let session = await getSession(event.context.sessionId, {
75
- storageName: sessionConfig.storage.name,
76
- storagePrefix: sessionConfig.storage.prefix,
77
- secret: sessionConfig.secret || ""
78
- });
74
+ let session = event.context.session;
79
75
  if (!session) {
80
76
  throw createError({
81
77
  status: 500,
82
78
  fatal: true,
83
- message: `session '${event.context.sessionId}' was not found!`
79
+ message: `session was not found on event context!`
84
80
  });
85
81
  }
86
82
  if (!authEnabled && !["session" /* GET_SESSION */].includes(authAction)) {
@@ -114,7 +110,7 @@ export default function NuxtAuthtsHandler(options) {
114
110
  secret: sessionConfig.secret || ""
115
111
  });
116
112
  }
117
- event.context.sessionId = session.id;
113
+ event.context.session = session;
118
114
  return nuxtAuthOptions.session(omit(session, ["auth"]), session.auth);
119
115
  case "authorize-url" /* AUTHORIZE_URL */:
120
116
  const { authorizeUrl, state, pkceCodeVerifier } = await buildAuthorizationUrl(session, {
@@ -131,8 +127,10 @@ export default function NuxtAuthtsHandler(options) {
131
127
  sessionStorageName: sessionConfig.storage.name,
132
128
  sessionStoragePrefix: sessionConfig.storage.prefix
133
129
  });
130
+ const result = { redirectUrl: authorizeUrl, cookies: [] };
134
131
  if (pkceCodeVerifier) {
135
132
  setCookie(event, sessionConfig.names.pkce, pkceCodeVerifier, authCookieOpts);
133
+ result.cookies.push([sessionConfig.names.pkce, pkceCodeVerifier, authCookieOpts]);
136
134
  }
137
135
  if (queryParams.redirectUrl) {
138
136
  const candidateUrl = String(queryParams.redirectUrl);
@@ -144,10 +142,12 @@ export default function NuxtAuthtsHandler(options) {
144
142
  });
145
143
  }
146
144
  setCookie(event, sessionConfig.names.redirectUrl, candidateUrl, authCookieOpts);
145
+ result.cookies.push([sessionConfig.names.redirectUrl, candidateUrl, authCookieOpts]);
147
146
  }
148
147
  setCookie(event, sessionConfig.names.state, state, authCookieOpts);
149
- event.context.sessionId = session.id;
150
- return { redirectUrl: authorizeUrl };
148
+ result.cookies.push([sessionConfig.names.state, state, authCookieOpts]);
149
+ event.context.session = session;
150
+ return result;
151
151
  case "token" /* TOKEN */:
152
152
  const formData = await readFormData(event);
153
153
  const openIdTokenSet = await authorizationCodeGrant(
@@ -198,13 +198,13 @@ export default function NuxtAuthtsHandler(options) {
198
198
  });
199
199
  sessionData.user = await nuxtAuthOptions.userInfo(session.user, { openidUser, tokenSet });
200
200
  }
201
- await updateSession(session.id, sessionData, {
201
+ session = await updateSession(session.id, sessionData, {
202
202
  storageName: sessionConfig.storage.name,
203
203
  storagePrefix: sessionConfig.storage.prefix,
204
204
  expiresIn: sessionConfig.expiresIn,
205
205
  secret: sessionConfig.secret || ""
206
206
  });
207
- event.context.sessionId = session.id;
207
+ event.context.session = session;
208
208
  return { redirectUrl };
209
209
  case "logout" /* LOGOUT */:
210
210
  redirectUrl = auth.defaultLogoutRedirectUri;
@@ -217,14 +217,15 @@ export default function NuxtAuthtsHandler(options) {
217
217
  wellKnownUrl: openid.wellKnownUrl
218
218
  });
219
219
  }
220
- await deleteSession(session.id, {
220
+ const newSession = await renewSession(session.id, {
221
221
  storageName: sessionConfig.storage.name,
222
- storagePrefix: sessionConfig.storage.prefix
222
+ storagePrefix: sessionConfig.storage.prefix,
223
+ expiresIn: sessionConfig.expiresIn,
224
+ secret: sessionConfig.secret || ""
223
225
  });
224
226
  const cacheStorage = useStorage("cache");
225
227
  await cacheStorage.removeItem(`${openid.cache.prefix}:userinfo:${session.id}`);
226
- deleteCookie(event, sessionConfig.names.sessionId, authCookieOpts);
227
- event.context.sessionId = "";
228
+ event.context.session = newSession;
228
229
  return { redirectUrl };
229
230
  default:
230
231
  break;
@@ -7,7 +7,7 @@ import {
7
7
  } from "#devcoffee-core/server/adapters/http";
8
8
  import { deepMerge } from "#devcoffee-core/server/adapters/utils";
9
9
  import useServerLogger from "#devcoffee-core/server/composables/useServerLogger";
10
- import { getOpenIdConfiguration, getSession } from "./helpers.js";
10
+ import { getOpenIdConfiguration } from "./helpers.js";
11
11
  const defaultOpts = {
12
12
  logLevel: 2,
13
13
  proxyPrefix: ""
@@ -34,15 +34,11 @@ export default function NuxtForwardRequestHandler(opts) {
34
34
  });
35
35
  }
36
36
  const {
37
- openid: { wellKnownUrl, cache, clientId, clientSecret },
38
- sessions: {
39
- secret = "",
40
- storage: { name: storageName, prefix: storagePrefix }
41
- }
37
+ openid: { wellKnownUrl, cache, clientId, clientSecret }
42
38
  } = useRuntimeConfig().nuxtCore.authts;
43
39
  return eventHandler(async (event) => {
44
40
  const oidConfig = await getOpenIdConfiguration(wellKnownUrl, { cache, clientId, clientSecret });
45
- const session = await getSession(event.context.sessionId, { storageName, storagePrefix, secret });
41
+ const session = event.context.session;
46
42
  const headers = getRequestHeaders(event);
47
43
  if (session?.auth?.status === "authenticated" && session.auth.tokenSet?.accessToken) {
48
44
  headers.Authorization = `${session.auth.tokenSet.tokenType} ${session.auth.tokenSet?.accessToken}`;
@@ -1,2 +1,2 @@
1
- declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<any>>;
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, any>;
2
2
  export default _default;
@@ -1,8 +1,4 @@
1
1
  import { defineEventHandler } from "h3";
2
- import { useRuntimeConfig } from "nitropack/runtime";
3
- import { getSession } from "../../core/helpers.js";
4
- export default defineEventHandler(async (event) => {
5
- const { name: storageName, prefix: storagePrefix } = useRuntimeConfig(event).nuxtCore.authts.sessions.storage;
6
- const { secret = "" } = useRuntimeConfig(event).nuxtCore.authts.sessions;
7
- return await getSession(event.context.sessionId, { storageName, storagePrefix, secret });
2
+ export default defineEventHandler((event) => {
3
+ return event.context.session;
8
4
  });
@@ -2,10 +2,10 @@
2
2
  * πŸ” Nitro plugin for session validation and cookie management.
3
3
  *
4
4
  * This plugin automatically validates the session ID from the incoming request cookie.
5
- * If the session is invalid or expired, it will create or refresh the session ID and
6
- * reassign it back to the request context and cookie.
5
+ * If the session is invalid or expired, it will create or refresh the session and
6
+ * store the full session object on event.context.session for downstream handlers.
7
7
  *
8
8
  * @since 1.0.0
9
9
  */
10
- declare const _default: import("nitropack").NitroAppPlugin;
10
+ declare const _default: any;
11
11
  export default _default;
@@ -1,12 +1,21 @@
1
+ import { defineNitroPlugin, getCookie, setCookie, useRuntimeConfig } from "#devcoffee-core/server/adapters/http";
1
2
  import { signSessionId } from "#devcoffee-core/server/core/crypto";
2
- import { getSession, refreshTokenIfNeeded, updateSession, validateSession } from "#devcoffee-core/server/core/helpers";
3
- import { getCookie, setCookie } from "h3";
4
- import { defineNitroPlugin, useRuntimeConfig } from "nitropack/runtime";
3
+ import { refreshTokenIfNeeded, updateSession, validateSession } from "#devcoffee-core/server/core/helpers";
4
+ import { useNitroApp, useStorage } from "nitropack/runtime";
5
5
  export default defineNitroPlugin((nitroApp) => {
6
6
  nitroApp.hooks.hook("request", async (event) => {
7
7
  const {
8
8
  enabled: authtsEnabled,
9
- openid: { wellKnownUrl, cache, clientId, clientSecret, tokenRefreshBufferMs },
9
+ openid: {
10
+ wellKnownUrl,
11
+ cache,
12
+ clientId,
13
+ clientSecret,
14
+ tokenRefreshBufferMs,
15
+ distributedLock,
16
+ autoFetchUser,
17
+ autoFetchUserTtl
18
+ },
10
19
  sessions: {
11
20
  expiresIn,
12
21
  secret = "",
@@ -14,7 +23,7 @@ export default defineNitroPlugin((nitroApp) => {
14
23
  names: { sessionId: cookieName }
15
24
  }
16
25
  } = useRuntimeConfig(event).nuxtCore.authts;
17
- const session = await validateSession(getCookie(event, cookieName), {
26
+ let session = await validateSession(getCookie(event, cookieName), {
18
27
  storageName,
19
28
  storagePrefix,
20
29
  expiresIn,
@@ -27,25 +36,39 @@ export default defineNitroPlugin((nitroApp) => {
27
36
  cache,
28
37
  clientId,
29
38
  clientSecret,
30
- tokenRefreshBufferMs
39
+ tokenRefreshBufferMs,
40
+ distributedLock
31
41
  });
32
42
  if (Object.keys(sessionUpdate).length > 0) {
33
- await updateSession(session.id, sessionUpdate, { storageName, storagePrefix, expiresIn, secret });
43
+ session = await updateSession(session.id, sessionUpdate, { storageName, storagePrefix, expiresIn, secret });
44
+ }
45
+ if (autoFetchUser) {
46
+ const cacheStorage = useStorage("cache");
47
+ const userInfoCacheKey = `${cache.prefix}:userinfo:${session.id}`;
48
+ let cachedUser = await cacheStorage.getItem(userInfoCacheKey);
49
+ if (!cachedUser) {
50
+ const userInfoFn = useNitroApp()._sessionUserInfo;
51
+ if (userInfoFn && session.auth.tokenSet) {
52
+ cachedUser = await userInfoFn(session.user, { tokenSet: session.auth.tokenSet });
53
+ await cacheStorage.setItem(userInfoCacheKey, cachedUser, { ttl: autoFetchUserTtl });
54
+ }
55
+ }
56
+ if (cachedUser) {
57
+ session = { ...session, user: cachedUser };
58
+ }
34
59
  }
35
60
  }
36
- event.context.sessionId = session.id;
61
+ event.context.session = session;
37
62
  });
38
63
  nitroApp.hooks.hook("beforeResponse", async (event) => {
39
64
  const {
40
65
  cookieOpts,
41
66
  secret = "",
42
- storage: { name: storageName, prefix: storagePrefix },
43
67
  names: { sessionId: cookieName }
44
68
  } = useRuntimeConfig(event).nuxtCore.authts.sessions;
45
- const session = await getSession(event.context.sessionId, { storageName, storagePrefix, secret });
69
+ const session = event.context.session;
46
70
  if (session) {
47
71
  const cookieValue = secret ? signSessionId(session.id, secret) : session.id;
48
- event.context.sessionId = session.id;
49
72
  setCookie(event, cookieName, cookieValue, {
50
73
  ...cookieOpts,
51
74
  expires: new Date(session.expiresAt)
@@ -1,9 +1,9 @@
1
- import type { CoreLogInstance, CoreLogLevel, NuxtAuthOptions } from '@devcoffee/nuxt-core'
1
+ import type { CoreLogInstance, CoreLogLevel, NuxtAuthOptions, SessionContext } from '@devcoffee/nuxt-core'
2
2
 
3
3
  declare module 'h3' {
4
4
  interface H3EventContext {
5
5
  logger: CoreLogInstance
6
- sessionId: string
6
+ session: SessionContext | null
7
7
  }
8
8
  }
9
9
 
package/package.json CHANGED
@@ -1,8 +1,25 @@
1
1
  {
2
2
  "name": "@devcoffee/nuxt-core",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "author": "Hieu Nguyen <hieunguyen@devcoffee.tech>",
5
- "description": "devcoffee core module for Nuxt",
5
+ "description": "Nuxt 4 module providing OpenID Connect / OAuth 2.0 authorization code grant with PKCE, server-side session management via Nitro, client-side auth state composables, and universal route protection middleware.",
6
+ "keywords": [
7
+ "nuxt",
8
+ "nuxt-module",
9
+ "nuxt4",
10
+ "oidc",
11
+ "oauth2",
12
+ "pkce",
13
+ "openid-connect",
14
+ "authentication",
15
+ "session",
16
+ "jwt",
17
+ "nitro",
18
+ "vue3",
19
+ "auth",
20
+ "middleware",
21
+ "composables"
22
+ ],
6
23
  "license": "MIT",
7
24
  "type": "module",
8
25
  "exports": {
@@ -20,7 +37,9 @@
20
37
  }
21
38
  },
22
39
  "files": [
23
- "dist"
40
+ "dist",
41
+ "CHANGELOG.md",
42
+ "GUIDELINE.md"
24
43
  ],
25
44
  "nuxt": {
26
45
  "module": "src/module.ts"
@@ -32,13 +51,14 @@
32
51
  "dev:build": "nuxi build playground",
33
52
  "prepack": "nuxt-module-build build",
34
53
  "release": "npm run lint && npm run test:all && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
35
- "lint": "eslint --fix src test",
54
+ "lint": "eslint",
55
+ "lint:fix": "eslint --fix",
36
56
  "test": "cross-env NODE_OPTIONS=--no-deprecation vitest run test/unit",
37
57
  "test:e2e": "cross-env NODE_OPTIONS=--no-deprecation vitest run test/e2e",
38
58
  "test:e2e:ui": "cross-env NODE_OPTIONS=--no-deprecation PLAYWRIGHT_HEADLESS=false vitest run test/e2e",
39
59
  "test:all": "cross-env NODE_OPTIONS=--no-deprecation vitest run",
40
60
  "test:watch": "cross-env NODE_OPTIONS=--no-deprecation vitest watch test/unit",
41
- "test:types": "vue-tsc --noEmit src --skipLibCheck --strict"
61
+ "test:types": "vue-tsc --noEmit"
42
62
  },
43
63
  "dependencies": {
44
64
  "@nuxt/kit": "^4.1.3",
@@ -71,4 +91,4 @@
71
91
  "vitest": "^3.2.4",
72
92
  "vue-tsc": "^3.1.0"
73
93
  }
74
- }
94
+ }
@@ -1,3 +0,0 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
- declare const _default: typeof __VLS_export;
3
- export default _default;
@@ -1,32 +0,0 @@
1
- <script setup>
2
- import { computed, onMounted } from "vue";
3
- import { createError, useRoute } from "#app";
4
- import { useAuthContext } from "#imports";
5
- const { query } = useRoute();
6
- const { authorize } = useAuthContext("core.app.pages.authorize");
7
- const searchParms = computed(() => {
8
- const params = new URLSearchParams();
9
- if (query.code) {
10
- params.set("code", query.code);
11
- }
12
- if (query.state) {
13
- params.set("state", query.state);
14
- }
15
- return params;
16
- });
17
- if (!searchParms.value.has("code")) {
18
- throw createError({
19
- status: 400,
20
- message: "Invalid code params"
21
- });
22
- }
23
- onMounted(async () => {
24
- if (searchParms.value.has("code")) {
25
- await authorize(searchParms.value);
26
- }
27
- });
28
- </script>
29
-
30
- <template>
31
- <div>Nuxt module auth callback!</div>
32
- </template>
@@ -1,3 +0,0 @@
1
- declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
- declare const _default: typeof __VLS_export;
3
- export default _default;