@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 +1 -1
- package/dist/module.mjs +15 -7
- package/dist/runtime/app/composables/useAuth.d.ts +4 -2
- package/dist/runtime/app/composables/useAuth.js +11 -8
- package/dist/runtime/app/plugins/api.client.js +79 -76
- package/dist/runtime/app/plugins/api.server.js +1 -1
- package/dist/runtime/app/plugins/ssr-state.server.js +12 -9
- package/dist/runtime/server/middleware/auth.js +32 -24
- package/dist/runtime/server/routes/refresh.post.js +24 -15
- package/dist/runtime/server/routes/update-claims.post.d.ts +33 -0
- package/dist/runtime/server/routes/update-claims.post.js +66 -0
- package/dist/runtime/server/utils/recomputeClaims.d.ts +20 -0
- package/dist/runtime/server/utils/recomputeClaims.js +50 -0
- package/dist/runtime/types/refresh.d.ts +6 -0
- package/dist/runtime/types/routes.d.ts +2 -2
- package/package.json +12 -12
package/dist/module.json
CHANGED
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
|
-
|
|
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
|
|
107
|
-
if (!encryptionKey) {
|
|
108
|
-
|
|
109
|
-
"[Nuxt Aegis] Encryption is enabled but no encryption key is configured. Please set tokenRefresh.encryption.key in nuxt.config.ts or
|
|
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: (
|
|
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(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,13 +1,16 @@
|
|
|
1
1
|
import { defineNuxtPlugin, useState, useRequestEvent } from "#app";
|
|
2
2
|
import { filterTimeSensitiveClaims } from "../utils/tokenUtils.js";
|
|
3
|
-
export default defineNuxtPlugin(
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
user
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
27
|
-
loggedOutRedirectTo
|
|
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.
|
|
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 &&
|
|
43
|
-
"dev:build": "
|
|
44
|
-
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare &&
|
|
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.
|
|
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.
|
|
59
|
+
"ufo": "^1.6.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@nuxt/devtools": "^3.1.1",
|
|
63
|
-
"@nuxt/eslint-config": "^1.
|
|
63
|
+
"@nuxt/eslint-config": "^1.13.0",
|
|
64
64
|
"@nuxt/module-builder": "^1.0.2",
|
|
65
|
-
"@nuxt/schema": "^4.
|
|
66
|
-
"@nuxt/test-utils": "^3.
|
|
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.
|
|
70
|
-
"nuxt": "^4.
|
|
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.
|
|
73
|
+
"vue-tsc": "^3.2.3"
|
|
74
74
|
},
|
|
75
75
|
"pnpm": {
|
|
76
76
|
"overrides": {
|