@devcoffee/nuxt-core 1.4.3 → 1.5.1

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 CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.5.1
4
+
5
+ [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.5.0...v1.5.1)
6
+
7
+ ### 🩹 Fixes
8
+
9
+ - Implement auth request bypass logic for improved session handling ([8b4299c](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/8b4299c))
10
+ - Add ignore rule for .gitnexus/run.cjs in ESLint configuration ([5c563ff](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/5c563ff))
11
+
12
+ ### ❤️ Contributors
13
+
14
+ - Hieu Nguyen <hieu.nguyen@devcoffee.tech>
15
+
16
+ ## v1.5.0
17
+
18
+ [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.4.2...v1.5.0)
19
+
20
+ ### 🚀 Enhancements
21
+
22
+ - Add .gitnexusrc configuration file for project settings ([b3f8d23](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/b3f8d23))
23
+ - Restructure documentation by adding AGENTS.md and moving GUIDELINE.md to docs folder ([ff6ca21](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/ff6ca21))
24
+ - Enhance session management by validating HMAC format, clearing tokenSet on auth reset, and updating related tests ([ae2b6fb](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/ae2b6fb))
25
+ - Enhance session management by adding lock release functionality and improving token refresh logic ([506f3c5](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/506f3c5))
26
+
27
+ ### 🩹 Fixes
28
+
29
+ - Enhance refreshTokenIfNeeded to support sessionCreateOptions for improved session handling ([609a4a4](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/609a4a4))
30
+ - Update release script to include changelogen for better version management ([853cfbb](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/853cfbb))
31
+
32
+ ### 🏡 Chore
33
+
34
+ - **release:** V1.4.3 ([0b430bd](https://github.com/coolkg1412/devcoffee-nuxt-core/commit/0b430bd))
35
+
36
+ ### ❤️ Contributors
37
+
38
+ - Hieu Nguyen <hieu.nguyen@devcoffee.tech>
39
+
3
40
  ## v1.4.3
4
41
 
5
42
  [compare changes](https://github.com/coolkg1412/devcoffee-nuxt-core/compare/v1.4.2...v1.4.3)
package/README.md CHANGED
@@ -9,7 +9,7 @@ Full OpenID Connect / OAuth 2.0 authorization code grant with PKCE for DevCoffee
9
9
  Provides server-side session management via Nitro, client-side auth state via Vue composables, and universal route protection middleware.
10
10
 
11
11
  - [Release Notes](/CHANGELOG.md)
12
- - [Contributor Guide](/GUIDELINE.md)
12
+ - [Contributor Guide](/docs/GUIDELINE.md)
13
13
 
14
14
  ## Features
15
15
 
@@ -389,7 +389,7 @@ See [CHANGELOG.md](/CHANGELOG.md) for the full release history.
389
389
 
390
390
  ## Contributing
391
391
 
392
- See [GUIDELINE.md](/GUIDELINE.md) for contribution guidelines, local development setup, and release instructions.
392
+ See [GUIDELINE.md](/docs/GUIDELINE.md) for contribution guidelines, local development setup, and release instructions.
393
393
 
394
394
  <!-- Badges -->
395
395
  [npm-version-src]: https://img.shields.io/npm/v/@devcoffee/nuxt-core/latest.svg?style=flat&colorA=020420&colorB=00DC82
package/dist/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-core",
3
- "version": "1.4.3",
3
+ "version": "1.5.1",
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, addTemplate, addServerImports, addServerImportsDir, addServerPlugin, addImportsDir, addPlugin, addRouteMiddleware, addServerHandler } from '@nuxt/kit';
3
3
  import { deepMerge, pick } from '../dist/runtime/utils.js';
4
4
 
5
- const version = "1.4.3";
5
+ const version = "1.5.1";
6
6
 
7
7
  const defaultLocale = "vi-VN";
8
8
  const defaultLanguage = "vi";
@@ -21,6 +21,9 @@ export function verifySessionId(cookieValue, secret) {
21
21
  }
22
22
  const sessionId = cookieValue.slice(0, 64);
23
23
  const providedSig = cookieValue.slice(65);
24
+ if (!/^[0-9a-f]{64}$/i.test(providedSig)) {
25
+ return null;
26
+ }
24
27
  const expected = createHmac("sha256", secret).update(sessionId).digest("hex");
25
28
  const valid = timingSafeEqual(Buffer.from(providedSig, "hex"), Buffer.from(expected, "hex"));
26
29
  return valid ? sessionId : null;
@@ -201,6 +201,7 @@ export declare function refreshTokenIfNeeded(session: SessionContext, opts: {
201
201
  sessionCreateOptions?: {
202
202
  storageName: string;
203
203
  storagePrefix: string;
204
+ expiresIn?: number;
204
205
  secret: string;
205
206
  };
206
207
  }): Promise<any>;
@@ -30,7 +30,7 @@ import {
30
30
  signSessionId,
31
31
  verifySessionId
32
32
  } from "./crypto.js";
33
- import { tryAcquireLock } from "./mutex.js";
33
+ import { releaseLock, tryAcquireLock } from "./mutex.js";
34
34
  function getAnonymousUser(extras) {
35
35
  const anonymous = useRuntimeConfig().nuxtCore.authts.auth.anonymousUser;
36
36
  const { defaultLocale, defaultTimeZone, defaultLanguage } = useRuntimeConfig().nuxtCore;
@@ -160,6 +160,9 @@ export async function updateSession(sessionId, input, opts) {
160
160
  });
161
161
  }
162
162
  session = deepMerge({}, session, normalizedInput);
163
+ if (normalizedInput.auth && Object.prototype.hasOwnProperty.call(normalizedInput.auth, "tokenSet") && normalizedInput.auth.tokenSet === void 0) {
164
+ delete session.auth.tokenSet;
165
+ }
163
166
  session.expiresAt = now + opts.expiresIn * 1e3;
164
167
  const sessionToStore = { ...session };
165
168
  if (opts.secret && sessionToStore.auth?.tokenSet) {
@@ -216,7 +219,7 @@ export async function discoveryOpendId(wellKnownUrl, opts) {
216
219
  server: config.serverMetadata(),
217
220
  client: config.clientMetadata()
218
221
  };
219
- await storage.setItem(cacheKey, meta, { ttl: expires / 1e3 | 0 });
222
+ await storage.setItem(cacheKey, meta, { ttl: expires });
220
223
  logger.info('Fetching OpenID Connect metadata from "%s"', wellKnownUrl);
221
224
  }
222
225
  return meta;
@@ -267,7 +270,7 @@ export async function buildAuthorizationUrl(session, opt) {
267
270
  }
268
271
  await updateSession(
269
272
  session.id,
270
- { auth: { status: "unauthenticated" } },
273
+ { auth: { status: "unauthenticated", tokenSet: void 0 } },
271
274
  {
272
275
  storageName: sessionStorageName,
273
276
  storagePrefix: sessionStoragePrefix,
@@ -324,7 +327,7 @@ export function constructTokenSet(input) {
324
327
  }
325
328
  export async function refreshTokenIfNeeded(session, opts) {
326
329
  const logger = useServerLogger({ tag: "authts-helper" });
327
- let updateSession2 = {
330
+ let sessionUpdate = {
328
331
  auth: { status: "unauthenticated", tokenSet: void 0 },
329
332
  user: getAnonymousUser()
330
333
  };
@@ -332,7 +335,7 @@ export async function refreshTokenIfNeeded(session, opts) {
332
335
  const { accessToken, refreshToken, expiresAt } = session.auth.tokenSet;
333
336
  const accessExpired = Boolean(accessToken && expiresAt - opts.tokenRefreshBufferMs < Date.now());
334
337
  if (!accessExpired) {
335
- updateSession2 = {
338
+ sessionUpdate = {
336
339
  auth: {
337
340
  status: session.auth.status,
338
341
  tokenSet: session.auth.tokenSet
@@ -342,56 +345,88 @@ export async function refreshTokenIfNeeded(session, opts) {
342
345
  } else if (accessExpired && refreshToken) {
343
346
  const lockStorage = useStorage("cache");
344
347
  const lockKey = `${opts.cache.prefix}:refresh-lock:${session.id}`;
345
- const acquired = await tryAcquireLock(lockStorage, lockKey, 10, opts.distributedLock ?? false);
346
- if (!acquired) {
347
- logger.debug('[refreshTokenIfNeeded] refresh lock held for session "%s" \u2014 waiting...', session.id);
348
- if (opts.sessionCreateOptions) {
349
- const { storageName, storagePrefix, secret } = opts.sessionCreateOptions;
350
- let attempts = 0;
351
- const maxAttempts = 50;
352
- while (attempts < maxAttempts) {
353
- await new Promise((resolve) => setTimeout(resolve, 100));
354
- const lockExists = await lockStorage.hasItem(lockKey);
355
- if (!lockExists) {
356
- const updatedSession = await getSession(session.id, { storageName, storagePrefix, secret });
357
- if (updatedSession?.auth?.tokenSet) {
358
- const newExpiresAt = updatedSession.auth.tokenSet.expiresAt;
359
- const isStillExpired = Boolean(newExpiresAt - opts.tokenRefreshBufferMs < Date.now());
360
- if (!isStillExpired) {
361
- logger.debug("[refreshTokenIfNeeded] session refreshed by another request, proceeding.");
362
- return {
363
- auth: { status: updatedSession.auth.status, tokenSet: updatedSession.auth.tokenSet },
364
- user: updatedSession.user
365
- };
366
- }
348
+ const waitDeadline = Date.now() + 5e3;
349
+ let lease = await tryAcquireLock(lockStorage, lockKey, 10, opts.distributedLock ?? false);
350
+ const sessionCreateOptions = opts.sessionCreateOptions;
351
+ if (!lease) {
352
+ if (!sessionCreateOptions) {
353
+ logger.warn("[refreshTokenIfNeeded] sessionCreateOptions not provided, cannot wait for concurrent refresh");
354
+ return {};
355
+ }
356
+ while (!lease && Date.now() < waitDeadline) {
357
+ logger.debug('[refreshTokenIfNeeded] refresh lock held for session "%s" \u2014 waiting...', session.id);
358
+ const { storageName, storagePrefix, secret } = sessionCreateOptions;
359
+ const updatedSession = await getSession(session.id, { storageName, storagePrefix, secret });
360
+ if (updatedSession?.auth?.tokenSet) {
361
+ const newExpiresAt = updatedSession.auth.tokenSet.expiresAt;
362
+ const isStillExpired = Boolean(newExpiresAt - opts.tokenRefreshBufferMs < Date.now());
363
+ if (!isStillExpired) {
364
+ logger.debug("[refreshTokenIfNeeded] session refreshed by another request, proceeding.");
365
+ return {
366
+ auth: { status: updatedSession.auth.status, tokenSet: updatedSession.auth.tokenSet },
367
+ user: updatedSession.user
368
+ };
369
+ }
370
+ }
371
+ await new Promise((resolve) => setTimeout(resolve, 100));
372
+ if (!await lockStorage.hasItem(lockKey)) {
373
+ const updatedSessionAfterRelease = await getSession(session.id, { storageName, storagePrefix, secret });
374
+ if (updatedSessionAfterRelease?.auth?.tokenSet) {
375
+ const newExpiresAt = updatedSessionAfterRelease.auth.tokenSet.expiresAt;
376
+ const isStillExpired = Boolean(newExpiresAt - opts.tokenRefreshBufferMs < Date.now());
377
+ if (!isStillExpired) {
378
+ logger.debug("[refreshTokenIfNeeded] session refreshed by another request after lock release.");
379
+ return {
380
+ auth: {
381
+ status: updatedSessionAfterRelease.auth.status,
382
+ tokenSet: updatedSessionAfterRelease.auth.tokenSet
383
+ },
384
+ user: updatedSessionAfterRelease.user
385
+ };
367
386
  }
368
387
  }
369
- attempts++;
388
+ lease = await tryAcquireLock(lockStorage, lockKey, 10, opts.distributedLock ?? false);
370
389
  }
371
- logger.warn('[refreshTokenIfNeeded] Timeout waiting for refresh lock for session "%s"', session.id);
372
- } else {
373
- logger.warn("[refreshTokenIfNeeded] sessionCreateOptions not provided, cannot wait for concurrent refresh");
374
390
  }
375
- return { auth: { status: session.auth.status, tokenSet: session.auth.tokenSet }, user: session.user };
376
391
  }
377
- logger.info('Refreshing access token for session "%s"', session.id);
378
- const tokenSet = await refreshTokenGrant(refreshToken, {
379
- wellKnownUrl: opts.wellKnownUrl,
380
- cache: opts.cache,
381
- clientId: opts.clientId,
382
- clientSecret: opts.clientSecret
383
- });
384
- await lockStorage.removeItem(`${opts.cache.prefix}:userinfo:${session.id}`);
385
- updateSession2 = {
386
- auth: {
387
- status: session.auth.status,
388
- tokenSet: constructTokenSet(tokenSet)
389
- },
390
- user: session.user
391
- };
392
+ if (!lease) {
393
+ logger.warn('[refreshTokenIfNeeded] Timeout waiting for refresh lock for session "%s"', session.id);
394
+ return {};
395
+ }
396
+ try {
397
+ logger.info('Refreshing access token for session "%s"', session.id);
398
+ const tokenSet = await refreshTokenGrant(refreshToken, {
399
+ wellKnownUrl: opts.wellKnownUrl,
400
+ cache: opts.cache,
401
+ clientId: opts.clientId,
402
+ clientSecret: opts.clientSecret
403
+ });
404
+ await lockStorage.removeItem(`${opts.cache.prefix}:userinfo:${session.id}`);
405
+ sessionUpdate = {
406
+ auth: {
407
+ status: session.auth.status,
408
+ tokenSet: constructTokenSet(tokenSet)
409
+ },
410
+ user: session.user
411
+ };
412
+ if (opts.sessionCreateOptions?.expiresIn) {
413
+ const { storageName, storagePrefix, expiresIn, secret } = opts.sessionCreateOptions;
414
+ await updateSession(session.id, sessionUpdate, { storageName, storagePrefix, expiresIn, secret });
415
+ }
416
+ } finally {
417
+ try {
418
+ await releaseLock(lockStorage, lease);
419
+ } catch (err) {
420
+ logger.warn(
421
+ '[refreshTokenIfNeeded] failed to release refresh lock for session "%s": %s',
422
+ session.id,
423
+ String(err)
424
+ );
425
+ }
426
+ }
392
427
  }
393
428
  }
394
- return updateSession2;
429
+ return sessionUpdate;
395
430
  }
396
431
  export async function fetchUserInfo(accessToken, sub, opts) {
397
432
  const { wellKnownUrl, cache, clientId, clientSecret } = opts;
@@ -1,4 +1,15 @@
1
1
  import type { Storage } from 'unstorage';
2
+ type RedisLockClient = {
3
+ set: (...args: unknown[]) => Promise<string | null>;
4
+ eval?: (...args: unknown[]) => Promise<unknown>;
5
+ };
6
+ export type LockLease = {
7
+ key: string;
8
+ owner: string;
9
+ atomic: boolean;
10
+ prefixedKey?: string;
11
+ client?: RedisLockClient;
12
+ };
2
13
  /**
3
14
  * Attempts to acquire a distributed or optimistic mutex lock in Nitro cache storage.
4
15
  *
@@ -11,9 +22,11 @@ import type { Storage } from 'unstorage';
11
22
  *
12
23
  * @param storage - Prefixed cache storage (`useStorage('cache')`). Used for the optimistic path.
13
24
  * @param lockKey - The lock key (without the cache prefix). Must be unique per session.
14
- * @param ttlSeconds - Lock TTL in seconds. Lock expires automatically; no explicit release needed.
25
+ * @param ttlSeconds - Lock TTL in seconds. Lock expires automatically if release is skipped.
15
26
  * @param useAtomic - When `true`, attempt atomic NX acquisition via Redis native client.
16
- * @returns `true` if the lock was acquired by this caller, `false` if the lock was already held.
27
+ * @returns A lock lease if acquired by this caller, otherwise `null` if the lock was already held.
17
28
  * @since 1.0.0
18
29
  */
19
- export declare function tryAcquireLock(storage: Storage, lockKey: string, ttlSeconds: number, useAtomic: boolean): Promise<boolean>;
30
+ export declare function tryAcquireLock(storage: Storage, lockKey: string, ttlSeconds: number, useAtomic: boolean, owner?: string): Promise<LockLease | null>;
31
+ export declare function releaseLock(storage: Storage, lease: LockLease): Promise<void>;
32
+ export {};
@@ -1,6 +1,7 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import useServerLogger from "#devcoffee-core/server/composables/useServerLogger";
2
3
  import { useStorage } from "nitropack/runtime";
3
- export async function tryAcquireLock(storage, lockKey, ttlSeconds, useAtomic) {
4
+ export async function tryAcquireLock(storage, lockKey, ttlSeconds, useAtomic, owner = randomUUID()) {
4
5
  const logger = useServerLogger({ tag: "authts-mutex" });
5
6
  if (useAtomic) {
6
7
  try {
@@ -9,19 +10,14 @@ export async function tryAcquireLock(storage, lockKey, ttlSeconds, useAtomic) {
9
10
  const client = driver.getInstance?.();
10
11
  if (client) {
11
12
  const prefixedKey = base + lockKey;
12
- const result = await client.set(
13
- prefixedKey,
14
- "1",
15
- "NX",
16
- "EX",
17
- ttlSeconds
18
- );
13
+ const redisClient = client;
14
+ const result = await redisClient.set(prefixedKey, owner, "NX", "EX", ttlSeconds);
19
15
  if (result === "OK") {
20
16
  logger.debug('[mutex] atomic lock acquired for key "%s"', lockKey);
21
- return true;
17
+ return { key: lockKey, owner, atomic: true, prefixedKey, client: redisClient };
22
18
  }
23
19
  logger.debug('[mutex] atomic lock held for key "%s" \u2014 skipping', lockKey);
24
- return false;
20
+ return null;
25
21
  }
26
22
  logger.warn("[mutex] atomic lock unavailable (getInstance not present on driver) \u2014 falling back to optimistic");
27
23
  } catch (err) {
@@ -32,8 +28,26 @@ export async function tryAcquireLock(storage, lockKey, ttlSeconds, useAtomic) {
32
28
  const lockExists = await storage.hasItem(lockKey);
33
29
  if (lockExists) {
34
30
  logger.debug('[mutex] optimistic lock held for key "%s" \u2014 skipping', lockKey);
35
- return false;
31
+ return null;
36
32
  }
37
- await storage.setItem(lockKey, true, { ttl: ttlSeconds });
38
- return true;
33
+ await storage.setItem(lockKey, owner, { ttl: ttlSeconds });
34
+ return { key: lockKey, owner, atomic: false };
35
+ }
36
+ export async function releaseLock(storage, lease) {
37
+ const logger = useServerLogger({ tag: "authts-mutex" });
38
+ if (lease.atomic && lease.client?.eval && lease.prefixedKey) {
39
+ await lease.client.eval(
40
+ "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
41
+ 1,
42
+ lease.prefixedKey,
43
+ lease.owner
44
+ );
45
+ return;
46
+ }
47
+ const currentOwner = await storage.getItem(lease.key);
48
+ if (currentOwner === lease.owner) {
49
+ await storage.removeItem(lease.key);
50
+ return;
51
+ }
52
+ logger.debug('[mutex] skip releasing lock "%s" because owner changed', lease.key);
39
53
  }
@@ -133,6 +133,7 @@ export default function NuxtAuthtsHandler(options) {
133
133
  clientSecret: openid.clientSecret,
134
134
  redirectUri: openid.redirectUri,
135
135
  scopes: openid.scopes,
136
+ sessionSecret: sessionConfig.secret || "",
136
137
  sessionStorageName: sessionConfig.storage.name,
137
138
  sessionStoragePrefix: sessionConfig.storage.prefix
138
139
  });
@@ -1,4 +1,4 @@
1
- import { defineNitroPlugin, getCookie, useRuntimeConfig } from "#devcoffee-core/server/adapters/http";
1
+ import { defineNitroPlugin, getCookie, getRequestHeaders, useRuntimeConfig } from "#devcoffee-core/server/adapters/http";
2
2
  import useServerLogger from "#devcoffee-core/server/composables/useServerLogger";
3
3
  import {
4
4
  refreshTokenIfNeeded,
@@ -7,8 +7,21 @@ import {
7
7
  writeSessionCookie
8
8
  } from "#devcoffee-core/server/core/helpers";
9
9
  import { useNitroApp, useStorage } from "nitropack/runtime";
10
+ function getAuthRequestBypass(event) {
11
+ const path = event.path || "";
12
+ const i18nHeader = getRequestHeaders(event)["x-nuxt-i18n"];
13
+ if (i18nHeader?.toLowerCase() === "internal" || path.startsWith("/_i18n/") || path.startsWith("/_nuxt/")) {
14
+ return "hard";
15
+ }
16
+ if (path.startsWith("/__nuxt")) {
17
+ return "soft";
18
+ }
19
+ return "none";
20
+ }
10
21
  export default defineNitroPlugin((nitroApp) => {
11
22
  nitroApp.hooks.hook("request", async (event) => {
23
+ const authRequestBypass = getAuthRequestBypass(event);
24
+ if (authRequestBypass === "hard") return;
12
25
  const {
13
26
  enabled: authtsEnabled,
14
27
  openid: {
@@ -37,7 +50,8 @@ export default defineNitroPlugin((nitroApp) => {
37
50
  secret
38
51
  });
39
52
  const { status = "unauthenticated", tokenSet } = session.auth || {};
40
- if (authtsEnabled && status === "authenticated" && tokenSet) {
53
+ const shouldRunAuthSideEffects = authRequestBypass === "none";
54
+ if (shouldRunAuthSideEffects && authtsEnabled && status === "authenticated" && tokenSet) {
41
55
  const sessionUpdate = await refreshTokenIfNeeded(session, {
42
56
  wellKnownUrl,
43
57
  cache,
@@ -48,6 +62,7 @@ export default defineNitroPlugin((nitroApp) => {
48
62
  sessionCreateOptions: {
49
63
  storageName,
50
64
  storagePrefix,
65
+ expiresIn,
51
66
  secret
52
67
  }
53
68
  });
@@ -78,6 +93,7 @@ export default defineNitroPlugin((nitroApp) => {
78
93
  event.context.session = session;
79
94
  });
80
95
  nitroApp.hooks.hook("beforeResponse", async (event) => {
96
+ if (getAuthRequestBypass(event) === "hard") return;
81
97
  if (event.node.res.headersSent) return;
82
98
  const session = event.context.session;
83
99
  if (session) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devcoffee/nuxt-core",
3
- "version": "1.4.3",
3
+ "version": "1.5.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -41,8 +41,7 @@
41
41
  },
42
42
  "files": [
43
43
  "dist",
44
- "CHANGELOG.md",
45
- "GUIDELINE.md"
44
+ "CHANGELOG.md"
46
45
  ],
47
46
  "nuxt": {
48
47
  "module": "src/module.ts"
package/GUIDELINE.md DELETED
@@ -1,351 +0,0 @@
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`