@peterbud/nuxt-aegis 1.1.0-alpha.2 → 1.1.0-alpha.4

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/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-aegis",
3
3
  "configKey": "nuxtAegis",
4
- "version": "1.1.0-alpha.2",
4
+ "version": "1.1.0-alpha.4",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -21,6 +21,8 @@ const module$1 = defineNuxtModule({
21
21
  tokenRefresh: {
22
22
  enabled: true,
23
23
  automaticRefresh: true,
24
+ rotationEnabled: true,
25
+ // Enable refresh token rotation by default for security
24
26
  cookie: {
25
27
  cookieName: "nuxt-aegis-refresh",
26
28
  maxAge: 60 * 60 * 24 * 7,
@@ -95,18 +97,18 @@ const module$1 = defineNuxtModule({
95
97
  nuxtAegis: options
96
98
  });
97
99
  const runtimeConfig = nuxt.options.runtimeConfig;
100
+ const logger = useLogger("nuxt-aegis");
98
101
  if (options.enableSSR && nuxt.options.ssr === false) {
99
- const logger2 = useLogger("nuxt-aegis");
100
- logger2.warn(
102
+ logger.warn(
101
103
  "nuxtAegis.enableSSR is true but Nuxt SSR is disabled. SSR authentication will not work. Set ssr: true in nuxt.config.ts or disable enableSSR."
102
104
  );
103
105
  }
104
106
  const resolver = createResolver(import.meta.url);
105
107
  if (options.tokenRefresh?.encryption?.enabled) {
106
- const encryptionKey = options.tokenRefresh.encryption.key || process.env.NUXT_AEGIS_ENCRYPTION_KEY;
107
- if (!encryptionKey) {
108
- throw new Error(
109
- "[Nuxt Aegis] Encryption is enabled but no encryption key is configured. Please set tokenRefresh.encryption.key in nuxt.config.ts or NUXT_AEGIS_ENCRYPTION_KEY environment variable."
108
+ const encryptionKey = options.tokenRefresh.encryption.key;
109
+ if (!encryptionKey && nuxt.options._prepare !== true) {
110
+ logger.warn(
111
+ "[Nuxt Aegis] Encryption is enabled but no encryption key is configured. The application will fail at runtime if encryption is attempted. Please set tokenRefresh.encryption.key in nuxt.config.ts or in the appropriate environment variable."
110
112
  );
111
113
  }
112
114
  if (!runtimeConfig.nuxtAegis) {
@@ -145,6 +147,8 @@ const module$1 = defineNuxtModule({
145
147
  { name: "revokeRefreshToken", from: resolver.resolve("./runtime/server/utils/refreshToken") },
146
148
  { name: "deleteUserRefreshTokens", from: resolver.resolve("./runtime/server/utils/refreshToken") },
147
149
  { name: "hashRefreshToken", from: resolver.resolve("./runtime/server/utils/refreshToken") },
150
+ // Claims recomputation utilities (from recomputeClaims.ts)
151
+ { name: "recomputeCustomClaims", from: resolver.resolve("./runtime/server/utils/recomputeClaims") },
148
152
  // Cookie utilities (from cookies.ts)
149
153
  { name: "setRefreshTokenCookie", from: resolver.resolve("./runtime/server/utils/cookies") },
150
154
  // Handler utilities (from handler.ts)
@@ -171,6 +175,11 @@ const module$1 = defineNuxtModule({
171
175
  handler: resolver.resolve("./runtime/server/routes/refresh.post"),
172
176
  method: "post"
173
177
  });
178
+ addServerHandler({
179
+ route: `${runtimeConfig.public.nuxtAegis.authPath}/update-claims`,
180
+ handler: resolver.resolve("./runtime/server/routes/update-claims.post"),
181
+ method: "post"
182
+ });
174
183
  if (options.impersonation?.enabled) {
175
184
  addServerHandler({
176
185
  route: `${runtimeConfig.public.nuxtAegis.authPath}/impersonate`,
@@ -211,7 +220,6 @@ const module$1 = defineNuxtModule({
211
220
  addPlugin(resolver.resolve("./runtime/app/plugins/api.server"));
212
221
  addPlugin(resolver.resolve("./runtime/app/plugins/ssr-state.server"));
213
222
  }
214
- const logger = useLogger("nuxt-aegis");
215
223
  if (options.clientMiddleware?.enabled) {
216
224
  const cm = options.clientMiddleware;
217
225
  if (cm.global) {
@@ -26,7 +26,9 @@ interface UseAuthReturn<T extends BaseTokenClaims = BaseTokenClaims> {
26
26
  /** Method to end the user session */
27
27
  logout: (redirectTo?: string) => Promise<void>;
28
28
  /** Method to refresh the authentication state */
29
- refresh: () => Promise<void>;
29
+ refresh: (options?: {
30
+ updateClaims?: boolean;
31
+ }) => Promise<void>;
30
32
  /** Method to impersonate another user (admin only) */
31
33
  impersonate: (targetUserId: string, reason?: string) => Promise<void>;
32
34
  /** Method to stop impersonation and restore original session */
@@ -58,7 +60,7 @@ interface UseAuthReturn<T extends BaseTokenClaims = BaseTokenClaims> {
58
60
  * Methods:
59
61
  * - login(provider) - Initiate OAuth flow
60
62
  * - logout() - End user session
61
- * - refresh() - Restore authentication state
63
+ * - refresh(options?) - Restore authentication state, optionally with updated claims
62
64
  *
63
65
  * @template T - Custom token payload type extending BaseTokenClaims
64
66
  * @returns {UseAuthReturn<T>} Authentication state and methods
@@ -1,5 +1,5 @@
1
1
  import { useRuntimeConfig, navigateTo, useState, computed } from "#imports";
2
- import { clearAccessToken } from "../utils/tokenStore.js";
2
+ import { clearAccessToken, setAccessToken, getAccessToken } from "../utils/tokenStore.js";
3
3
  import { createLogger } from "../utils/logger.js";
4
4
  import { validateRedirectPath } from "../utils/redirectValidation.js";
5
5
  import { filterTimeSensitiveClaims } from "../utils/tokenUtils.js";
@@ -30,16 +30,22 @@ export function useAuth() {
30
30
  const loginPath = publicConfig.nuxtAegis?.loginPath || authPath;
31
31
  const logoutPath = publicConfig.nuxtAegis?.logoutPath;
32
32
  const refreshPath = publicConfig.nuxtAegis?.refreshPath;
33
- async function refresh() {
33
+ async function refresh(options) {
34
34
  authState.value.isLoading = true;
35
35
  authState.value.error = null;
36
- logger.debug("Refreshing authentication state...");
36
+ logger.debug("Refreshing authentication state...", { updateClaims: options?.updateClaims });
37
37
  try {
38
+ if (options?.updateClaims) {
39
+ logger.debug("Updating custom claims before refresh...");
40
+ await $fetch(`${authPath}/update-claims`, {
41
+ method: "POST"
42
+ });
43
+ logger.debug("Claims updated successfully in storage");
44
+ }
38
45
  const response = await $fetch(`${refreshPath}`, {
39
46
  method: "POST"
40
47
  });
41
48
  if (response?.accessToken) {
42
- const { setAccessToken } = await import("../utils/tokenStore.js");
43
49
  setAccessToken(response.accessToken);
44
50
  const tokenParts = response.accessToken.split(".");
45
51
  if (tokenParts[1]) {
@@ -57,6 +63,7 @@ export function useAuth() {
57
63
  authState.value.error = "Failed to refresh authentication";
58
64
  clearAccessToken();
59
65
  logger.error("Auth refresh failed:", error);
66
+ throw error;
60
67
  } finally {
61
68
  authState.value.isLoading = false;
62
69
  }
@@ -95,7 +102,6 @@ export function useAuth() {
95
102
  authState.value.error = null;
96
103
  authState.value.isLoading = true;
97
104
  logger.debug("Starting impersonation...", { targetUserId });
98
- const { getAccessToken } = await import("../utils/tokenStore.js");
99
105
  const currentToken = getAccessToken();
100
106
  if (!currentToken) {
101
107
  throw new Error("Must be authenticated to impersonate");
@@ -111,7 +117,6 @@ export function useAuth() {
111
117
  }
112
118
  });
113
119
  if (response?.accessToken) {
114
- const { setAccessToken } = await import("../utils/tokenStore.js");
115
120
  setAccessToken(response.accessToken);
116
121
  const tokenParts = response.accessToken.split(".");
117
122
  if (tokenParts[1]) {
@@ -138,7 +143,6 @@ export function useAuth() {
138
143
  authState.value.error = null;
139
144
  authState.value.isLoading = true;
140
145
  logger.debug("Stopping impersonation...");
141
- const { getAccessToken } = await import("../utils/tokenStore.js");
142
146
  const currentToken = getAccessToken();
143
147
  if (!currentToken) {
144
148
  throw new Error("No active session");
@@ -150,7 +154,6 @@ export function useAuth() {
150
154
  }
151
155
  });
152
156
  if (response?.accessToken) {
153
- const { setAccessToken } = await import("../utils/tokenStore.js");
154
157
  setAccessToken(response.accessToken);
155
158
  const tokenParts = response.accessToken.split(".");
156
159
  if (tokenParts[1]) {
@@ -5,88 +5,91 @@ import { isRouteMatch } from "../utils/routeMatching.js";
5
5
  import { createLogger } from "../utils/logger.js";
6
6
  import { validateRedirectPath } from "../utils/redirectValidation.js";
7
7
  const logger = createLogger("API:Client");
8
- export default defineNuxtPlugin(async (nuxtApp) => {
9
- let isRefreshing = false;
10
- let refreshPromise = null;
11
- let isInitialized = false;
12
- const autoRefreshEnabled = nuxtApp.$config.public.nuxtAegis.tokenRefresh.automaticRefresh ?? true;
13
- async function attemptTokenRefresh() {
14
- if (isRefreshing) return refreshPromise;
15
- logger.debug("Attempting token refresh...");
16
- isRefreshing = true;
17
- refreshPromise = nuxtApp.runWithContext(async () => {
18
- const auth = useAuth();
19
- try {
20
- await auth.refresh();
21
- return getAccessToken();
22
- } catch (error) {
23
- logger.error("Token refresh failed:", error);
24
- return null;
25
- } finally {
26
- isRefreshing = false;
27
- refreshPromise = null;
28
- }
29
- });
30
- return refreshPromise;
31
- }
32
- const api = $fetch.create({
33
- onRequest({ options }) {
34
- const token = getAccessToken();
35
- if (token) {
36
- options.headers.set("Authorization", `Bearer ${token}`);
37
- }
38
- },
39
- async onResponseError({ options, response }) {
40
- if (response.status === 401 && autoRefreshEnabled) {
41
- const newToken = await attemptTokenRefresh();
42
- if (newToken) {
43
- options.headers.set("Authorization", `Bearer ${newToken}`);
44
- return;
8
+ export default defineNuxtPlugin({
9
+ name: "nuxt-aegis-api-client",
10
+ async setup(nuxtApp) {
11
+ let isRefreshing = false;
12
+ let refreshPromise = null;
13
+ let isInitialized = false;
14
+ const autoRefreshEnabled = nuxtApp.$config.public.nuxtAegis.tokenRefresh.automaticRefresh ?? true;
15
+ async function attemptTokenRefresh() {
16
+ if (isRefreshing) return refreshPromise;
17
+ logger.debug("Attempting token refresh...");
18
+ isRefreshing = true;
19
+ refreshPromise = nuxtApp.runWithContext(async () => {
20
+ const auth = useAuth();
21
+ try {
22
+ await auth.refresh();
23
+ return getAccessToken();
24
+ } catch (error) {
25
+ logger.error("Token refresh failed:", error);
26
+ return null;
27
+ } finally {
28
+ isRefreshing = false;
29
+ refreshPromise = null;
45
30
  }
46
- clearAccessToken();
47
- const config = useRuntimeConfig();
48
- const errorUrl = validateRedirectPath(config.public.nuxtAegis.redirect?.error || "/");
49
- await nuxtApp.runWithContext(() => navigateTo(`${errorUrl}?error=token_refresh_failed&error_description=${encodeURIComponent("Session expired. Please log in again.")}`));
50
- }
51
- },
52
- retry: autoRefreshEnabled ? 1 : 0,
53
- retryStatusCodes: [401]
54
- });
55
- const result = {
56
- provide: {
57
- api
31
+ });
32
+ return refreshPromise;
58
33
  }
59
- };
60
- if (!isInitialized && autoRefreshEnabled) {
61
- isInitialized = true;
62
- logger.debug("Initializing auth state on startup...");
63
- nuxtApp.hook("app:mounted", async () => {
64
- await nuxtApp.runWithContext(async () => {
65
- const callbackPath = nuxtApp.$config.public.nuxtAegis.callbackPath;
66
- if (typeof window !== "undefined" && window.location.pathname === callbackPath) {
67
- logger.debug("On auth callback page, skipping refresh");
68
- return;
34
+ const api = $fetch.create({
35
+ onRequest({ options }) {
36
+ const token = getAccessToken();
37
+ if (token) {
38
+ options.headers.set("Authorization", `Bearer ${token}`);
69
39
  }
70
- if (typeof window !== "undefined") {
71
- const publicRoutes = nuxtApp.$config.public.nuxtAegis?.clientMiddleware?.publicRoutes || [];
72
- const currentPath = window.location.pathname;
73
- if (isRouteMatch(currentPath, publicRoutes)) {
74
- logger.debug("On public route, skipping refresh on startup");
40
+ },
41
+ async onResponseError({ options, response }) {
42
+ if (response.status === 401 && autoRefreshEnabled) {
43
+ const newToken = await attemptTokenRefresh();
44
+ if (newToken) {
45
+ options.headers.set("Authorization", `Bearer ${newToken}`);
75
46
  return;
76
47
  }
48
+ clearAccessToken();
49
+ const config = useRuntimeConfig();
50
+ const errorUrl = validateRedirectPath(config.public.nuxtAegis.redirect?.error || "/");
51
+ await nuxtApp.runWithContext(() => navigateTo(`${errorUrl}?error=token_refresh_failed&error_description=${encodeURIComponent("Session expired. Please log in again.")}`));
77
52
  }
78
- const currentToken = getAccessToken();
79
- if (currentToken) {
80
- logger.debug("Access token already present, skipping refresh on startup");
81
- return;
82
- }
83
- try {
84
- await useAuth().refresh();
85
- } catch {
86
- logger.debug("No valid refresh token found on startup");
87
- }
88
- });
53
+ },
54
+ retry: autoRefreshEnabled ? 1 : 0,
55
+ retryStatusCodes: [401]
89
56
  });
57
+ const result = {
58
+ provide: {
59
+ api
60
+ }
61
+ };
62
+ if (!isInitialized && autoRefreshEnabled) {
63
+ isInitialized = true;
64
+ logger.debug("Initializing auth state on startup...");
65
+ nuxtApp.hook("app:mounted", async () => {
66
+ await nuxtApp.runWithContext(async () => {
67
+ const callbackPath = nuxtApp.$config.public.nuxtAegis.callbackPath;
68
+ if (typeof window !== "undefined" && window.location.pathname === callbackPath) {
69
+ logger.debug("On auth callback page, skipping refresh");
70
+ return;
71
+ }
72
+ if (typeof window !== "undefined") {
73
+ const publicRoutes = nuxtApp.$config.public.nuxtAegis?.clientMiddleware?.publicRoutes || [];
74
+ const currentPath = window.location.pathname;
75
+ if (isRouteMatch(currentPath, publicRoutes)) {
76
+ logger.debug("On public route, skipping refresh on startup");
77
+ return;
78
+ }
79
+ }
80
+ const currentToken = getAccessToken();
81
+ if (currentToken) {
82
+ logger.debug("Access token already present, skipping refresh on startup");
83
+ return;
84
+ }
85
+ try {
86
+ await useAuth().refresh();
87
+ } catch {
88
+ logger.debug("No valid refresh token found on startup");
89
+ }
90
+ });
91
+ });
92
+ }
93
+ return result;
90
94
  }
91
- return result;
92
95
  });
@@ -1,6 +1,6 @@
1
1
  import { defineNuxtPlugin, useRequestEvent } from "#app";
2
2
  export default defineNuxtPlugin({
3
- name: "api-server",
3
+ name: "nuxt-aegis-api-server",
4
4
  enforce: "pre",
5
5
  async setup() {
6
6
  const api = $fetch.create({
@@ -1,13 +1,16 @@
1
1
  import { defineNuxtPlugin, useState, useRequestEvent } from "#app";
2
2
  import { filterTimeSensitiveClaims } from "../utils/tokenUtils.js";
3
- export default defineNuxtPlugin(() => {
4
- const event = useRequestEvent();
5
- if (event?.context.user) {
6
- const user = event.context.user;
7
- useState("auth-state", () => ({
8
- user: filterTimeSensitiveClaims(user),
9
- isLoading: false,
10
- error: null
11
- }));
3
+ export default defineNuxtPlugin({
4
+ name: "nuxt-aegis-ssr-state",
5
+ async setup() {
6
+ const event = useRequestEvent();
7
+ if (event?.context.user) {
8
+ const user = event.context.user;
9
+ useState("auth-state", () => ({
10
+ user: filterTimeSensitiveClaims(user),
11
+ isLoading: false,
12
+ error: null
13
+ }));
14
+ }
12
15
  }
13
16
  });
@@ -18,21 +18,20 @@ export default defineEventHandler(async (event) => {
18
18
  const routeRules = await getRouteRules(event);
19
19
  const authConfig = routeRules.nuxtAegis?.auth;
20
20
  const shouldProtect = authConfig === true || authConfig === "required" || authConfig === "protected";
21
- const shouldSkip = authConfig === false || authConfig === "public" || authConfig === "skip";
22
- if (!shouldProtect || shouldSkip) {
23
- return;
24
- }
25
21
  let token;
26
22
  const authHeader = getHeader(event, "authorization");
27
23
  if (authHeader?.startsWith("Bearer ")) {
28
24
  token = authHeader.substring(7);
29
25
  }
30
26
  if (!token) {
31
- throw createError({
32
- statusCode: 401,
33
- statusMessage: "Unauthorized",
34
- message: "Authentication required"
35
- });
27
+ if (shouldProtect) {
28
+ throw createError({
29
+ statusCode: 401,
30
+ statusMessage: "Unauthorized",
31
+ message: "Authentication required"
32
+ });
33
+ }
34
+ return;
36
35
  }
37
36
  if (!tokenConfig || !tokenConfig.secret) {
38
37
  logger.error("Token configuration is missing");
@@ -45,29 +44,38 @@ export default defineEventHandler(async (event) => {
45
44
  const payload = await verifyToken(token, tokenConfig.secret);
46
45
  if (!payload) {
47
46
  logger.debug("Token verification failed for path:", requestURL.pathname);
48
- throw createError({
49
- statusCode: 401,
50
- statusMessage: "Unauthorized",
51
- message: "Invalid or expired token"
52
- });
47
+ if (shouldProtect) {
48
+ throw createError({
49
+ statusCode: 401,
50
+ statusMessage: "Unauthorized",
51
+ message: "Invalid or expired token"
52
+ });
53
+ }
54
+ return;
53
55
  }
54
56
  if (tokenConfig.issuer && payload.iss !== tokenConfig.issuer) {
55
57
  logger.debug("Token issuer mismatch. Expected:", tokenConfig.issuer, "Got:", payload.iss);
56
- throw createError({
57
- statusCode: 401,
58
- statusMessage: "Unauthorized",
59
- message: "Invalid token issuer"
60
- });
58
+ if (shouldProtect) {
59
+ throw createError({
60
+ statusCode: 401,
61
+ statusMessage: "Unauthorized",
62
+ message: "Invalid token issuer"
63
+ });
64
+ }
65
+ return;
61
66
  }
62
67
  if (tokenConfig.audience && payload.aud) {
63
68
  const audienceMatch = Array.isArray(payload.aud) ? payload.aud.includes(tokenConfig.audience) : payload.aud === tokenConfig.audience;
64
69
  if (!audienceMatch) {
65
70
  logger.debug("Token audience mismatch. Expected:", tokenConfig.audience, "Got:", payload.aud);
66
- throw createError({
67
- statusCode: 401,
68
- statusMessage: "Unauthorized",
69
- message: "Invalid token audience"
70
- });
71
+ if (shouldProtect) {
72
+ throw createError({
73
+ statusCode: 401,
74
+ statusMessage: "Unauthorized",
75
+ message: "Invalid token audience"
76
+ });
77
+ }
78
+ return;
71
79
  }
72
80
  }
73
81
  const { iat, exp, iss, aud, ...userData } = payload;
@@ -8,6 +8,8 @@ import {
8
8
  } from "../utils/refreshToken.js";
9
9
  import { setRefreshTokenCookie } from "../utils/cookies.js";
10
10
  import { useRuntimeConfig } from "#imports";
11
+ import { createLogger } from "../utils/logger.js";
12
+ const logger = createLogger("Refresh");
11
13
  export default defineEventHandler(async (event) => {
12
14
  const config = useRuntimeConfig(event);
13
15
  const cookieConfig = config.nuxtAegis?.tokenRefresh?.cookie;
@@ -65,22 +67,29 @@ export default defineEventHandler(async (event) => {
65
67
  // Include provider name in JWT payload
66
68
  };
67
69
  const newToken = await generateToken(payload, tokenConfig, customClaims);
68
- const newRefreshToken = await generateAndStoreRefreshToken(
69
- providerUserInfo,
70
- // RS-2: Store complete OAuth provider user data
71
- provider,
72
- // Store provider name
73
- tokenRefreshConfig,
74
- hashedRefreshToken,
75
- // Pass previous token hash for rotation tracking
76
- customClaims,
77
- // Preserve custom claims in new refresh token
78
- event
79
- );
80
- if (newRefreshToken) {
81
- setRefreshTokenCookie(event, newRefreshToken, cookieConfig);
70
+ const rotationEnabled = tokenRefreshConfig?.rotationEnabled ?? true;
71
+ if (rotationEnabled) {
72
+ logger.debug("Rotating refresh token for user:", payload.sub);
73
+ const newRefreshToken = await generateAndStoreRefreshToken(
74
+ providerUserInfo,
75
+ // RS-2: Store complete OAuth provider user data
76
+ provider,
77
+ // Store provider name
78
+ tokenRefreshConfig,
79
+ hashedRefreshToken,
80
+ // Pass previous token hash for rotation tracking
81
+ customClaims,
82
+ // Preserve custom claims in new refresh token
83
+ event
84
+ );
85
+ if (newRefreshToken) {
86
+ setRefreshTokenCookie(event, newRefreshToken, cookieConfig);
87
+ }
88
+ await revokeRefreshToken(hashedRefreshToken, event);
89
+ } else {
90
+ logger.debug("Reusing existing refresh token for user:", payload.sub);
91
+ setRefreshTokenCookie(event, refreshToken, cookieConfig);
82
92
  }
83
- await revokeRefreshToken(hashedRefreshToken, event);
84
93
  return {
85
94
  success: true,
86
95
  message: "Token refreshed successfully",
@@ -0,0 +1,33 @@
1
+ /**
2
+ * POST /auth/update-claims
3
+ *
4
+ * Recomputes custom JWT claims based on current user data.
5
+ * Useful when user data changes (role, permissions, etc.) and claims need updating
6
+ * without requiring the user to logout and login again.
7
+ *
8
+ * Process:
9
+ * 1. Validates refresh token from cookie
10
+ * 2. Verifies user owns the refresh token (authorization check)
11
+ * 3. Re-executes global handler's customClaims callback
12
+ * 4. Optionally re-executes onUserPersist for fresh DB data (if configured)
13
+ * 5. Updates stored refresh token data with new claims
14
+ * 6. User must call refresh() afterward to get a new JWT with updated claims
15
+ *
16
+ * Configuration:
17
+ * - Requires: tokenRefresh.enableClaimsUpdate = true (default)
18
+ * - Optional: tokenRefresh.recomputeOnUserPersist = true (fetch fresh DB data)
19
+ *
20
+ * Security:
21
+ * - Requires valid refresh token cookie
22
+ * - Users can only update their own claims
23
+ * - Claims are recomputed via the same handler used during initial auth
24
+ *
25
+ * @returns Success response with message
26
+ * @throws 401 if no refresh token or invalid token
27
+ * @throws 403 if feature is disabled
28
+ */
29
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
30
+ success: boolean;
31
+ message: string;
32
+ }>>;
33
+ export default _default;
@@ -0,0 +1,66 @@
1
+ import { defineEventHandler, getCookie, createError } from "h3";
2
+ import { hashRefreshToken, getRefreshTokenData, storeRefreshTokenData } from "../utils/refreshToken.js";
3
+ import { useRuntimeConfig } from "#imports";
4
+ import { createLogger } from "../utils/logger.js";
5
+ import { recomputeCustomClaims } from "../utils/recomputeClaims.js";
6
+ const logger = createLogger("UpdateClaims");
7
+ export default defineEventHandler(async (event) => {
8
+ const config = useRuntimeConfig(event);
9
+ const cookieConfig = config.nuxtAegis?.tokenRefresh?.cookie;
10
+ const tokenRefreshConfig = config.nuxtAegis?.tokenRefresh;
11
+ const enableClaimsUpdate = tokenRefreshConfig?.enableClaimsUpdate ?? true;
12
+ if (!enableClaimsUpdate) {
13
+ throw createError({
14
+ statusCode: 403,
15
+ statusMessage: "Forbidden",
16
+ message: "Claims update feature is disabled"
17
+ });
18
+ }
19
+ const cookieName = cookieConfig?.cookieName || "nuxt-aegis-refresh";
20
+ const refreshToken = getCookie(event, cookieName);
21
+ if (!refreshToken) {
22
+ throw createError({
23
+ statusCode: 401,
24
+ statusMessage: "Unauthorized",
25
+ message: "No refresh token found"
26
+ });
27
+ }
28
+ try {
29
+ const hashedRefreshToken = hashRefreshToken(refreshToken);
30
+ const storedRefreshToken = await getRefreshTokenData(hashedRefreshToken, event);
31
+ const isRevoked = storedRefreshToken?.isRevoked || false;
32
+ const isExpired = storedRefreshToken?.expiresAt ? Date.now() > storedRefreshToken.expiresAt : true;
33
+ if (!storedRefreshToken || isRevoked || isExpired) {
34
+ throw createError({
35
+ statusCode: 401,
36
+ statusMessage: "Unauthorized",
37
+ message: "Invalid or expired refresh token"
38
+ });
39
+ }
40
+ logger.debug(`Updating claims for user: ${storedRefreshToken.sub}`);
41
+ const newCustomClaims = await recomputeCustomClaims(storedRefreshToken, event);
42
+ await storeRefreshTokenData(
43
+ hashedRefreshToken,
44
+ {
45
+ ...storedRefreshToken,
46
+ customClaims: newCustomClaims
47
+ },
48
+ event
49
+ );
50
+ logger.debug("Claims updated successfully. User should call refresh() to get new JWT.");
51
+ return {
52
+ success: true,
53
+ message: "Claims updated successfully. Call refresh() to receive a new access token with updated claims."
54
+ };
55
+ } catch (error) {
56
+ if (error && typeof error === "object" && "statusCode" in error) {
57
+ throw error;
58
+ }
59
+ logger.error("Claims update failed:", error);
60
+ throw createError({
61
+ statusCode: 500,
62
+ statusMessage: "Internal Server Error",
63
+ message: "Failed to update claims"
64
+ });
65
+ }
66
+ });
@@ -0,0 +1,20 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { RefreshTokenData } from '../../types/index.js';
3
+ /**
4
+ * Recompute custom claims for a user based on current data
5
+ *
6
+ * Uses the global handler's customClaims callback to generate fresh claims.
7
+ * Optionally re-executes onUserPersist to fetch fresh database data.
8
+ *
9
+ * @param refreshTokenData - Stored refresh token data containing user info
10
+ * @param event - H3 event for context
11
+ * @returns Updated custom claims object
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const storedData = await getRefreshTokenData(hashedToken, event)
16
+ * const newClaims = await recomputeCustomClaims(storedData, event)
17
+ * // newClaims now contains fresh data from database
18
+ * ```
19
+ */
20
+ export declare function recomputeCustomClaims(refreshTokenData: RefreshTokenData, event: H3Event): Promise<Record<string, unknown>>;
@@ -0,0 +1,50 @@
1
+ import { useRuntimeConfig } from "#imports";
2
+ import { useAegisHandler } from "./handler.js";
3
+ import { processCustomClaims } from "./customClaims.js";
4
+ import { createLogger } from "./logger.js";
5
+ const logger = createLogger("RecomputeClaims");
6
+ export async function recomputeCustomClaims(refreshTokenData, event) {
7
+ const config = useRuntimeConfig(event);
8
+ const tokenRefreshConfig = config.nuxtAegis?.tokenRefresh;
9
+ const handler = useAegisHandler();
10
+ if (!handler) {
11
+ logger.warn("No Aegis handler registered. Cannot recompute custom claims.");
12
+ return refreshTokenData.customClaims || {};
13
+ }
14
+ let userData = refreshTokenData.providerUserInfo;
15
+ const provider = refreshTokenData.provider;
16
+ if (tokenRefreshConfig?.recomputeOnUserPersist && handler.onUserPersist) {
17
+ logger.debug(`Re-executing onUserPersist for provider: ${provider}`);
18
+ const persistContext = {
19
+ provider,
20
+ event
21
+ };
22
+ try {
23
+ const enrichedData = await handler.onUserPersist(userData, persistContext);
24
+ if (enrichedData) {
25
+ userData = {
26
+ ...userData,
27
+ ...enrichedData
28
+ };
29
+ }
30
+ } catch (error) {
31
+ logger.error("Error re-executing onUserPersist:", error);
32
+ }
33
+ }
34
+ if (!handler.customClaims) {
35
+ logger.debug("No customClaims callback defined in handler. Returning existing claims.");
36
+ return refreshTokenData.customClaims || {};
37
+ }
38
+ try {
39
+ logger.debug(`Recomputing custom claims for user: ${refreshTokenData.sub}`);
40
+ const newClaims = await processCustomClaims(
41
+ userData,
42
+ handler.customClaims
43
+ );
44
+ logger.debug("Custom claims recomputed successfully");
45
+ return newClaims;
46
+ } catch (error) {
47
+ logger.error("Error recomputing custom claims:", error);
48
+ return refreshTokenData.customClaims || {};
49
+ }
50
+ }
@@ -50,6 +50,12 @@ export interface TokenRefreshConfig {
50
50
  enabled?: boolean;
51
51
  /** Automatically refresh tokens in the background (default: true) */
52
52
  automaticRefresh?: boolean;
53
+ /** Enable refresh token rotation on every refresh (default: true) */
54
+ rotationEnabled?: boolean;
55
+ /** Enable claims update endpoint and functionality (default: true) */
56
+ enableClaimsUpdate?: boolean;
57
+ /** Re-execute onUserPersist hook when updating claims for fresh database data (default: false) */
58
+ recomputeOnUserPersist?: boolean;
53
59
  /** Refresh token cookie configuration */
54
60
  cookie?: CookieConfig;
55
61
  /** Encryption configuration for stored user data */
@@ -23,8 +23,8 @@ export interface ClientMiddlewareConfig {
23
23
  global?: boolean;
24
24
  /** Redirect destination for unauthenticated users (required when enabled) */
25
25
  redirectTo: string;
26
- /** Redirect destination for authenticated users on logged-out pages (required when enabled) */
27
- loggedOutRedirectTo: string;
26
+ /** Redirect destination for authenticated users on logged-out pages */
27
+ loggedOutRedirectTo?: string;
28
28
  /** Array of route patterns excluded from authentication (glob patterns supported) */
29
29
  publicRoutes?: string[];
30
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peterbud/nuxt-aegis",
3
- "version": "1.1.0-alpha.2",
3
+ "version": "1.1.0-alpha.4",
4
4
  "description": "Nuxt module for authentication with JWT token generation and session management.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -39,9 +39,9 @@
39
39
  "scripts": {
40
40
  "build": "nuxt-module-build prepare && nuxt-module-build build",
41
41
  "prepack": "nuxt-module-build build",
42
- "dev": "pnpm run dev:prepare && nuxi dev playground",
43
- "dev:build": "nuxi build playground",
44
- "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
42
+ "dev": "pnpm run dev:prepare && nuxt dev playground",
43
+ "dev:build": "nuxt build playground",
44
+ "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
45
45
  "docs:dev": "pnpm --filter docs docs:dev",
46
46
  "docs:build": "pnpm --filter docs docs:build",
47
47
  "release": "pnpm run lint && pnpm run test && pnpm run prepack && changelogen --release && npm publish && git push --follow-tags",
@@ -52,25 +52,25 @@
52
52
  "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
53
53
  },
54
54
  "dependencies": {
55
- "@nuxt/kit": "^4.2.1",
55
+ "@nuxt/kit": "^4.3.0",
56
56
  "consola": "^3.4.2",
57
57
  "defu": "^6.1.4",
58
58
  "jose": "^6.1.3",
59
- "ufo": "^1.6.1"
59
+ "ufo": "^1.6.3"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@nuxt/devtools": "^3.1.1",
63
- "@nuxt/eslint-config": "^1.11.0",
63
+ "@nuxt/eslint-config": "^1.13.0",
64
64
  "@nuxt/module-builder": "^1.0.2",
65
- "@nuxt/schema": "^4.2.1",
66
- "@nuxt/test-utils": "^3.21.0",
65
+ "@nuxt/schema": "^4.3.0",
66
+ "@nuxt/test-utils": "^3.23.0",
67
67
  "@types/node": "latest",
68
68
  "changelogen": "^0.6.2",
69
- "eslint": "^9.39.1",
70
- "nuxt": "^4.2.1",
69
+ "eslint": "^9.39.2",
70
+ "nuxt": "^4.3.0",
71
71
  "typescript": "~5.9.3",
72
72
  "vitest": "^3.2.4",
73
- "vue-tsc": "^3.1.5"
73
+ "vue-tsc": "^3.2.3"
74
74
  },
75
75
  "pnpm": {
76
76
  "overrides": {