@icecat-studio/nuxt-auth 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +644 -0
- package/dist/module.d.mts +7 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +131 -0
- package/dist/runtime/composables/useAuth.d.ts +54 -0
- package/dist/runtime/composables/useAuth.js +242 -0
- package/dist/runtime/composables/useAuthFetch.d.ts +13 -0
- package/dist/runtime/composables/useAuthFetch.js +57 -0
- package/dist/runtime/composables/useTokenManager.d.ts +16 -0
- package/dist/runtime/composables/useTokenManager.js +103 -0
- package/dist/runtime/middleware/auth.d.ts +2 -0
- package/dist/runtime/middleware/auth.js +31 -0
- package/dist/runtime/plugins/00.session-init.d.ts +2 -0
- package/dist/runtime/plugins/00.session-init.js +131 -0
- package/dist/runtime/plugins/01.token-refresh.client.d.ts +2 -0
- package/dist/runtime/plugins/01.token-refresh.client.js +102 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/types.d.ts +201 -0
- package/dist/runtime/types.js +0 -0
- package/dist/runtime/utils/helpers.d.ts +33 -0
- package/dist/runtime/utils/helpers.js +37 -0
- package/dist/runtime/utils/redirect.d.ts +7 -0
- package/dist/runtime/utils/redirect.js +6 -0
- package/dist/types.d.mts +3 -0
- package/package.json +79 -0
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addPlugin, addRouteMiddleware, addImportsDir, addTypeTemplate } from '@nuxt/kit';
|
|
2
|
+
import { defu } from 'defu';
|
|
3
|
+
|
|
4
|
+
const module = defineNuxtModule({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "@icecat-studio/nuxt-auth",
|
|
7
|
+
configKey: "auth",
|
|
8
|
+
compatibility: {
|
|
9
|
+
nuxt: ">=3.0.0"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
// Default configuration options of the Nuxt module
|
|
13
|
+
defaults: {
|
|
14
|
+
baseUrl: "/api/auth",
|
|
15
|
+
endpoints: {
|
|
16
|
+
login: { path: "/login", method: "post", fetchOptions: {} },
|
|
17
|
+
logout: { path: "/logout", method: "post", fetchOptions: {} },
|
|
18
|
+
register: { path: "/register", method: "post", fetchOptions: {} },
|
|
19
|
+
refresh: { path: "/refresh", method: "post", fetchOptions: {} },
|
|
20
|
+
user: { path: "/user", method: "get", fetchOptions: {} }
|
|
21
|
+
},
|
|
22
|
+
accessToken: {
|
|
23
|
+
property: "accessToken",
|
|
24
|
+
cookieName: "auth.access_token",
|
|
25
|
+
httpOnly: false,
|
|
26
|
+
secure: true,
|
|
27
|
+
sameSite: "lax",
|
|
28
|
+
path: "/",
|
|
29
|
+
maxAge: 60 * 15,
|
|
30
|
+
// 15 minutes
|
|
31
|
+
type: "Bearer",
|
|
32
|
+
headerName: "Authorization"
|
|
33
|
+
},
|
|
34
|
+
refreshToken: {
|
|
35
|
+
property: "refreshToken",
|
|
36
|
+
cookieName: "auth.refresh_token",
|
|
37
|
+
httpOnly: false,
|
|
38
|
+
secure: true,
|
|
39
|
+
sameSite: "lax",
|
|
40
|
+
path: "/",
|
|
41
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
42
|
+
// 7 days
|
|
43
|
+
serverManaged: false,
|
|
44
|
+
bodyProperty: "refreshToken"
|
|
45
|
+
},
|
|
46
|
+
autoRefresh: {
|
|
47
|
+
enabled: true,
|
|
48
|
+
pauseOnInactive: true,
|
|
49
|
+
enableTabCoordination: true,
|
|
50
|
+
coordinationCookieName: "auth.last_refresh",
|
|
51
|
+
coordinationThreshold: 5
|
|
52
|
+
// 5 seconds
|
|
53
|
+
},
|
|
54
|
+
user: {
|
|
55
|
+
property: void 0,
|
|
56
|
+
autoFetch: true
|
|
57
|
+
},
|
|
58
|
+
redirect: {
|
|
59
|
+
login: "/login",
|
|
60
|
+
logout: "/",
|
|
61
|
+
home: "/"
|
|
62
|
+
},
|
|
63
|
+
globalMiddleware: false
|
|
64
|
+
},
|
|
65
|
+
setup(options, nuxt) {
|
|
66
|
+
const resolver = createResolver(import.meta.url);
|
|
67
|
+
const resolvedOptions = defu(options, nuxt.options.auth || {});
|
|
68
|
+
if (resolvedOptions.autoRefresh?.interval === void 0) {
|
|
69
|
+
const maxAge = resolvedOptions.accessToken?.maxAge ?? 60 * 15;
|
|
70
|
+
resolvedOptions.autoRefresh = resolvedOptions.autoRefresh || {};
|
|
71
|
+
resolvedOptions.autoRefresh.interval = Math.round(maxAge * 0.75);
|
|
72
|
+
}
|
|
73
|
+
nuxt.options.runtimeConfig.public.auth = resolvedOptions;
|
|
74
|
+
addPlugin(resolver.resolve("./runtime/plugins/00.session-init"));
|
|
75
|
+
if (resolvedOptions.autoRefresh?.enabled !== false && resolvedOptions.endpoints?.refresh !== false) {
|
|
76
|
+
addPlugin(resolver.resolve("./runtime/plugins/01.token-refresh.client"));
|
|
77
|
+
}
|
|
78
|
+
addRouteMiddleware({
|
|
79
|
+
name: "auth",
|
|
80
|
+
path: resolver.resolve("./runtime/middleware/auth"),
|
|
81
|
+
global: true
|
|
82
|
+
});
|
|
83
|
+
addImportsDir(resolver.resolve("./runtime/composables"));
|
|
84
|
+
addTypeTemplate({
|
|
85
|
+
filename: "types/nuxt-auth.d.ts",
|
|
86
|
+
getContents: () => `
|
|
87
|
+
declare module '#app' {
|
|
88
|
+
interface NuxtApp {
|
|
89
|
+
$auth: import('${resolver.resolve("./runtime/composables/useAuth")}').Auth
|
|
90
|
+
$refreshManager?: {
|
|
91
|
+
start: () => void
|
|
92
|
+
stop: () => void
|
|
93
|
+
refresh: () => Promise<void>
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
declare module 'nuxt/schema' {
|
|
99
|
+
interface PublicRuntimeConfig {
|
|
100
|
+
auth: import('${resolver.resolve("./runtime/types")}').ModuleOptions
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
declare module '@nuxt/schema' {
|
|
105
|
+
interface NuxtConfig {
|
|
106
|
+
auth?: import('${resolver.resolve("./runtime/types")}').ModuleOptions
|
|
107
|
+
}
|
|
108
|
+
interface NuxtOptions {
|
|
109
|
+
auth?: import('${resolver.resolve("./runtime/types")}').ModuleOptions
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
declare module 'vue-router' {
|
|
114
|
+
interface RouteMeta {
|
|
115
|
+
auth?: boolean | 'guest' | 'guestOnly'
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
declare module '@icecat-studio/nuxt-auth' {
|
|
120
|
+
interface User {
|
|
121
|
+
[key: string]: unknown
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export {}
|
|
126
|
+
`
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
export { module as default };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ComputedRef, Ref } from 'vue';
|
|
2
|
+
import type { LoginOptions } from '../types.js';
|
|
3
|
+
export type AuthStatus = 'idle' | 'loading' | 'refreshing' | 'authenticated' | 'unauthenticated';
|
|
4
|
+
/**
|
|
5
|
+
* Default user interface - can be augmented via module declaration
|
|
6
|
+
* @example
|
|
7
|
+
* // In your project: types/auth.d.ts
|
|
8
|
+
* declare module '@icecat-studio/nuxt-auth' {
|
|
9
|
+
* interface User {
|
|
10
|
+
* id: number
|
|
11
|
+
* email: string
|
|
12
|
+
* name: string
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
export interface User {
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export interface LoginCredentials {
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface RegisterData {
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Auth composable return type
|
|
27
|
+
* @template TUser - User type, defaults to User interface
|
|
28
|
+
*/
|
|
29
|
+
export interface Auth<TUser = User> {
|
|
30
|
+
user: Ref<TUser | null>;
|
|
31
|
+
status: Ref<AuthStatus>;
|
|
32
|
+
loggedIn: ComputedRef<boolean>;
|
|
33
|
+
accessToken: Ref<string | null>;
|
|
34
|
+
refreshToken: Ref<string | null>;
|
|
35
|
+
canRefresh: ComputedRef<boolean>;
|
|
36
|
+
login: (credentials: LoginCredentials, options?: LoginOptions) => Promise<void>;
|
|
37
|
+
logout: () => Promise<void>;
|
|
38
|
+
register: (data: RegisterData) => Promise<void>;
|
|
39
|
+
refresh: () => Promise<void>;
|
|
40
|
+
fetchUser: () => Promise<void>;
|
|
41
|
+
getAuthHeaders: () => Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Auth composable for managing authentication state
|
|
45
|
+
* @template TUser - User type, defaults to User interface (can be augmented globally)
|
|
46
|
+
* @example
|
|
47
|
+
* // Use with default User type (augmented globally)
|
|
48
|
+
* const auth = useAuth()
|
|
49
|
+
*
|
|
50
|
+
* // Or override with specific type
|
|
51
|
+
* interface CustomUser { id: number; email: string }
|
|
52
|
+
* const auth = useAuth<CustomUser>()
|
|
53
|
+
*/
|
|
54
|
+
export declare function useAuth<TUser = User>(): Auth<TUser>;
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { computed, useNuxtApp, useRuntimeConfig, useState } from "#imports";
|
|
2
|
+
import { getNestedProperty, isObject } from "../utils/helpers.js";
|
|
3
|
+
import { handleRedirect } from "../utils/redirect.js";
|
|
4
|
+
import { useTokenManager } from "./useTokenManager.js";
|
|
5
|
+
export function useAuth() {
|
|
6
|
+
const nuxtApp = useNuxtApp();
|
|
7
|
+
if (nuxtApp.$auth) {
|
|
8
|
+
return nuxtApp.$auth;
|
|
9
|
+
}
|
|
10
|
+
const config = useRuntimeConfig().public.auth;
|
|
11
|
+
const tokenManager = useTokenManager();
|
|
12
|
+
const user = useState("auth:user", () => null);
|
|
13
|
+
const status = useState("auth:status", () => "idle");
|
|
14
|
+
const refreshEnabled = config.endpoints.refresh !== false;
|
|
15
|
+
const canRefresh = computed(() => {
|
|
16
|
+
return refreshEnabled && (config.refreshToken.serverManaged || !!tokenManager.refreshToken.value);
|
|
17
|
+
});
|
|
18
|
+
const loggedIn = computed(() => {
|
|
19
|
+
if (canRefresh.value) {
|
|
20
|
+
return status.value === "authenticated" || status.value === "refreshing";
|
|
21
|
+
}
|
|
22
|
+
return status.value === "authenticated" && !!tokenManager.accessToken.value;
|
|
23
|
+
});
|
|
24
|
+
const setUser = (newUser) => {
|
|
25
|
+
user.value = newUser;
|
|
26
|
+
status.value = newUser ? "authenticated" : "unauthenticated";
|
|
27
|
+
};
|
|
28
|
+
const handleUserResponse = async (data) => {
|
|
29
|
+
if (config.user?.property) {
|
|
30
|
+
const userData = getNestedProperty(data, config.user.property);
|
|
31
|
+
if (userData && isObject(userData)) {
|
|
32
|
+
setUser(userData);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (config.user?.autoFetch !== false && config.endpoints?.user) {
|
|
37
|
+
await fetchUser();
|
|
38
|
+
} else {
|
|
39
|
+
status.value = "authenticated";
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const login = async (credentials, options) => {
|
|
43
|
+
if (!config.endpoints?.login) {
|
|
44
|
+
throw new Error("Login endpoint is not configured");
|
|
45
|
+
}
|
|
46
|
+
status.value = "loading";
|
|
47
|
+
try {
|
|
48
|
+
const data = await $fetch(`${config.baseUrl}${config.endpoints.login.path}`, {
|
|
49
|
+
...config.endpoints.login.fetchOptions,
|
|
50
|
+
method: config.endpoints.login.method,
|
|
51
|
+
body: credentials
|
|
52
|
+
});
|
|
53
|
+
tokenManager.setTokensFromResponse(data);
|
|
54
|
+
tokenManager.updateCoordinationCookie();
|
|
55
|
+
await handleUserResponse(data);
|
|
56
|
+
if (options?.redirect === false) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (options?.redirect) {
|
|
60
|
+
handleRedirect(options.redirect);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (config.redirect?.home) {
|
|
64
|
+
handleRedirect(config.redirect.home);
|
|
65
|
+
}
|
|
66
|
+
} catch (error) {
|
|
67
|
+
status.value = "unauthenticated";
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const logout = async () => {
|
|
72
|
+
if (config.endpoints?.logout) {
|
|
73
|
+
try {
|
|
74
|
+
await $fetch(`${config.baseUrl}${config.endpoints.logout.path}`, {
|
|
75
|
+
...config.endpoints.logout.fetchOptions,
|
|
76
|
+
method: config.endpoints.logout.method,
|
|
77
|
+
headers: tokenManager.getAuthHeaders()
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error("[Auth] Logout request failed:", error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
tokenManager.clearTokens();
|
|
84
|
+
setUser(null);
|
|
85
|
+
tokenManager.clearCoordinationCookie();
|
|
86
|
+
if (import.meta.client && "$refreshManager" in nuxtApp) {
|
|
87
|
+
const refreshManager = nuxtApp.$refreshManager;
|
|
88
|
+
refreshManager?.stop();
|
|
89
|
+
}
|
|
90
|
+
handleRedirect(config.redirect?.logout);
|
|
91
|
+
};
|
|
92
|
+
const register = async (data) => {
|
|
93
|
+
if (!config.endpoints?.register) {
|
|
94
|
+
throw new Error("Register endpoint is not configured");
|
|
95
|
+
}
|
|
96
|
+
status.value = "loading";
|
|
97
|
+
try {
|
|
98
|
+
const response = await $fetch(`${config.baseUrl}${config.endpoints.register.path}`, {
|
|
99
|
+
...config.endpoints.register.fetchOptions,
|
|
100
|
+
method: config.endpoints.register.method,
|
|
101
|
+
body: data
|
|
102
|
+
});
|
|
103
|
+
const accessTokenValue = config.accessToken?.property ? getNestedProperty(response, config.accessToken.property) : null;
|
|
104
|
+
if (accessTokenValue && typeof accessTokenValue === "string") {
|
|
105
|
+
tokenManager.setTokensFromResponse(response);
|
|
106
|
+
tokenManager.updateCoordinationCookie();
|
|
107
|
+
await handleUserResponse(response);
|
|
108
|
+
handleRedirect(config.redirect?.home);
|
|
109
|
+
} else {
|
|
110
|
+
status.value = "unauthenticated";
|
|
111
|
+
handleRedirect(config.redirect?.login);
|
|
112
|
+
}
|
|
113
|
+
} catch (error) {
|
|
114
|
+
status.value = "unauthenticated";
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
let refreshPromise = null;
|
|
119
|
+
const refresh = async () => {
|
|
120
|
+
if (refreshPromise) {
|
|
121
|
+
if (import.meta.dev) {
|
|
122
|
+
console.debug("[Auth] Refresh already in progress, reusing existing request");
|
|
123
|
+
}
|
|
124
|
+
return refreshPromise;
|
|
125
|
+
}
|
|
126
|
+
refreshPromise = doRefresh();
|
|
127
|
+
try {
|
|
128
|
+
return await refreshPromise;
|
|
129
|
+
} finally {
|
|
130
|
+
refreshPromise = null;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const doRefresh = async () => {
|
|
134
|
+
if (!config.endpoints?.refresh) {
|
|
135
|
+
throw new Error("Refresh endpoint is not configured or disabled");
|
|
136
|
+
}
|
|
137
|
+
if (!config.refreshToken.serverManaged && !tokenManager.refreshToken.value) {
|
|
138
|
+
throw new Error("No refresh token available");
|
|
139
|
+
}
|
|
140
|
+
if (import.meta.dev) {
|
|
141
|
+
console.log("[Auth] Starting token refresh:", {
|
|
142
|
+
isServer: import.meta.server,
|
|
143
|
+
serverManaged: config.refreshToken.serverManaged,
|
|
144
|
+
endpoint: `${config.baseUrl}${config.endpoints.refresh.path}`
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
status.value = "refreshing";
|
|
148
|
+
try {
|
|
149
|
+
const body = config.refreshToken.serverManaged ? void 0 : { [config.refreshToken.bodyProperty]: tokenManager.refreshToken.value };
|
|
150
|
+
const headers = {};
|
|
151
|
+
if (import.meta.server) {
|
|
152
|
+
const event = nuxtApp.ssrContext?.event;
|
|
153
|
+
if (event) {
|
|
154
|
+
const cookieHeader = event.node.req.headers.cookie;
|
|
155
|
+
if (cookieHeader) {
|
|
156
|
+
headers.cookie = cookieHeader;
|
|
157
|
+
if (import.meta.dev) {
|
|
158
|
+
console.log("[Auth] Forwarding cookies on SSR:", cookieHeader);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const data = await $fetch(`${config.baseUrl}${config.endpoints.refresh.path}`, {
|
|
164
|
+
...config.endpoints.refresh.fetchOptions,
|
|
165
|
+
method: config.endpoints.refresh.method,
|
|
166
|
+
body,
|
|
167
|
+
headers: {
|
|
168
|
+
...config.endpoints.refresh.fetchOptions?.headers,
|
|
169
|
+
...headers
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
tokenManager.setTokensFromResponse(data);
|
|
173
|
+
tokenManager.updateCoordinationCookie();
|
|
174
|
+
status.value = "authenticated";
|
|
175
|
+
if (import.meta.dev) {
|
|
176
|
+
console.log("[Auth] Token refresh successful:", {
|
|
177
|
+
isServer: import.meta.server,
|
|
178
|
+
hasAccessToken: !!tokenManager.accessToken.value
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (import.meta.dev) {
|
|
183
|
+
console.error("[Auth] Token refresh failed:", {
|
|
184
|
+
isServer: import.meta.server,
|
|
185
|
+
error
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
const responseStatus = error?.response?.status;
|
|
189
|
+
const isServerRejection = responseStatus !== void 0 && responseStatus >= 400 && responseStatus < 500;
|
|
190
|
+
if (isServerRejection) {
|
|
191
|
+
tokenManager.clearTokens();
|
|
192
|
+
tokenManager.clearCoordinationCookie();
|
|
193
|
+
}
|
|
194
|
+
status.value = isServerRejection ? "unauthenticated" : "authenticated";
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
const fetchUser = async () => {
|
|
199
|
+
if (!config.endpoints?.user) {
|
|
200
|
+
throw new Error("User endpoint is not configured");
|
|
201
|
+
}
|
|
202
|
+
if (!tokenManager.accessToken.value) {
|
|
203
|
+
if (!canRefresh.value) {
|
|
204
|
+
status.value = "unauthenticated";
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (status.value !== "authenticated") {
|
|
209
|
+
status.value = "loading";
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const data = await $fetch(`${config.baseUrl}${config.endpoints.user.path}`, {
|
|
213
|
+
...config.endpoints.user.fetchOptions,
|
|
214
|
+
method: config.endpoints.user.method,
|
|
215
|
+
headers: tokenManager.getAuthHeaders()
|
|
216
|
+
});
|
|
217
|
+
const userData = config.user?.property ? getNestedProperty(data, config.user.property) : data;
|
|
218
|
+
if (isObject(userData)) {
|
|
219
|
+
setUser(userData);
|
|
220
|
+
}
|
|
221
|
+
} catch (error) {
|
|
222
|
+
status.value = "unauthenticated";
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const auth = {
|
|
227
|
+
user,
|
|
228
|
+
status,
|
|
229
|
+
loggedIn,
|
|
230
|
+
canRefresh,
|
|
231
|
+
accessToken: tokenManager.accessToken,
|
|
232
|
+
refreshToken: tokenManager.refreshToken,
|
|
233
|
+
login,
|
|
234
|
+
logout,
|
|
235
|
+
register,
|
|
236
|
+
refresh,
|
|
237
|
+
fetchUser,
|
|
238
|
+
getAuthHeaders: tokenManager.getAuthHeaders
|
|
239
|
+
};
|
|
240
|
+
nuxtApp.provide("auth", auth);
|
|
241
|
+
return auth;
|
|
242
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AvailableRouterMethod, NitroFetchOptions, NitroFetchRequest } from 'nitropack';
|
|
2
|
+
/**
|
|
3
|
+
* Returns a `$fetch`-like function that automatically:
|
|
4
|
+
* - Adds Authorization headers
|
|
5
|
+
* - Forwards cookies on SSR (for httpOnly tokens)
|
|
6
|
+
* - On 401: refreshes token and retries the request (client-only)
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const authFetch = useAuthFetch()
|
|
10
|
+
* const data = await authFetch('/api/orders')
|
|
11
|
+
* const user = await authFetch<User>('/api/me')
|
|
12
|
+
*/
|
|
13
|
+
export declare function useAuthFetch(): <ResT = undefined, ReqT extends NitroFetchRequest = NitroFetchRequest, Method extends AvailableRouterMethod<ReqT> = ResT extends undefined ? "get" extends AvailableRouterMethod<ReqT> ? "get" : AvailableRouterMethod<ReqT> : AvailableRouterMethod<ReqT>>(request: ReqT, opts?: NitroFetchOptions<ReqT, Method>) => Promise<import("nitropack").TypedInternalResponse<ReqT, ResT, "get">>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useNuxtApp, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { useAuth } from "./useAuth.js";
|
|
3
|
+
export function useAuthFetch() {
|
|
4
|
+
const auth = useAuth();
|
|
5
|
+
const nuxtApp = useNuxtApp();
|
|
6
|
+
const config = useRuntimeConfig().public.auth;
|
|
7
|
+
async function authFetch(request, opts) {
|
|
8
|
+
if (!auth.accessToken.value && auth.canRefresh.value) {
|
|
9
|
+
await auth.refresh();
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return await $fetch(request, withAuthHeaders(opts));
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (import.meta.client && isFetchError(error, 401)) {
|
|
15
|
+
try {
|
|
16
|
+
await auth.refresh();
|
|
17
|
+
return await $fetch(request, withAuthHeaders(opts));
|
|
18
|
+
} catch {
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function withAuthHeaders(opts) {
|
|
26
|
+
const headers = {
|
|
27
|
+
...toPlainHeaders(opts?.headers),
|
|
28
|
+
...auth.getAuthHeaders()
|
|
29
|
+
};
|
|
30
|
+
if (import.meta.server) {
|
|
31
|
+
const event = nuxtApp.ssrContext?.event;
|
|
32
|
+
const cookieHeader = event?.node.req.headers.cookie;
|
|
33
|
+
if (cookieHeader) {
|
|
34
|
+
headers.cookie = cookieHeader;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { ...opts, headers };
|
|
38
|
+
}
|
|
39
|
+
return authFetch;
|
|
40
|
+
}
|
|
41
|
+
function isFetchError(error, status) {
|
|
42
|
+
return typeof error === "object" && error !== null && "response" in error && error.response?.status === status;
|
|
43
|
+
}
|
|
44
|
+
function toPlainHeaders(headers) {
|
|
45
|
+
if (!headers) return {};
|
|
46
|
+
if (headers instanceof Headers) {
|
|
47
|
+
const plain = {};
|
|
48
|
+
headers.forEach((value, key) => {
|
|
49
|
+
plain[key] = value;
|
|
50
|
+
});
|
|
51
|
+
return plain;
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(headers)) {
|
|
54
|
+
return Object.fromEntries(headers);
|
|
55
|
+
}
|
|
56
|
+
return { ...headers };
|
|
57
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
export interface TokenManager {
|
|
3
|
+
accessToken: Ref<string | null>;
|
|
4
|
+
refreshToken: Ref<string | null>;
|
|
5
|
+
coordinationTimestamp: Readonly<Ref<number | null>>;
|
|
6
|
+
getAuthHeaders: () => Record<string, string>;
|
|
7
|
+
setTokensFromResponse: (data: unknown) => void;
|
|
8
|
+
clearTokens: () => void;
|
|
9
|
+
hasTokens: () => {
|
|
10
|
+
access: boolean;
|
|
11
|
+
refresh: boolean;
|
|
12
|
+
};
|
|
13
|
+
updateCoordinationCookie: () => void;
|
|
14
|
+
clearCoordinationCookie: () => void;
|
|
15
|
+
}
|
|
16
|
+
export declare function useTokenManager(): TokenManager;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useCookie, useRuntimeConfig, ref } from "#imports";
|
|
2
|
+
import { getNestedProperty } from "../utils/helpers.js";
|
|
3
|
+
export function useTokenManager() {
|
|
4
|
+
const config = useRuntimeConfig().public.auth;
|
|
5
|
+
const accessToken = useCookie(config.accessToken.cookieName, {
|
|
6
|
+
httpOnly: config.accessToken.httpOnly,
|
|
7
|
+
secure: config.accessToken.secure,
|
|
8
|
+
sameSite: config.accessToken.sameSite,
|
|
9
|
+
path: config.accessToken.path,
|
|
10
|
+
domain: config.accessToken.domain,
|
|
11
|
+
maxAge: config.accessToken.maxAge,
|
|
12
|
+
default: () => null
|
|
13
|
+
});
|
|
14
|
+
const refreshToken = config.endpoints?.refresh !== false && !config.refreshToken.serverManaged ? useCookie(config.refreshToken.cookieName, {
|
|
15
|
+
httpOnly: config.refreshToken.httpOnly,
|
|
16
|
+
secure: config.refreshToken.secure,
|
|
17
|
+
sameSite: config.refreshToken.sameSite,
|
|
18
|
+
path: config.refreshToken.path,
|
|
19
|
+
domain: config.refreshToken.domain,
|
|
20
|
+
maxAge: config.refreshToken.maxAge,
|
|
21
|
+
default: () => null
|
|
22
|
+
}) : ref(null);
|
|
23
|
+
const coordinationCookie = config.autoRefresh?.enableTabCoordination ? useCookie(config.autoRefresh.coordinationCookieName, {
|
|
24
|
+
maxAge: config.autoRefresh.interval * 2,
|
|
25
|
+
// 2x refresh interval (already in seconds)
|
|
26
|
+
sameSite: "lax",
|
|
27
|
+
default: () => null
|
|
28
|
+
}) : null;
|
|
29
|
+
const getAuthHeaders = () => {
|
|
30
|
+
const token = accessToken.value;
|
|
31
|
+
if (!token) return {};
|
|
32
|
+
const headerName = config.accessToken.headerName;
|
|
33
|
+
const tokenType = config.accessToken.type;
|
|
34
|
+
return {
|
|
35
|
+
[headerName]: `${tokenType} ${token}`
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const setTokensFromResponse = (data) => {
|
|
39
|
+
if (config.accessToken.property) {
|
|
40
|
+
const token = getNestedProperty(data, config.accessToken.property);
|
|
41
|
+
if (token && typeof token === "string") {
|
|
42
|
+
accessToken.value = token;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (config.endpoints?.refresh !== false && !config.refreshToken.serverManaged && config.refreshToken.property) {
|
|
46
|
+
const token = getNestedProperty(data, config.refreshToken.property);
|
|
47
|
+
if (token && typeof token === "string") {
|
|
48
|
+
refreshToken.value = token;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const clearTokens = () => {
|
|
53
|
+
accessToken.value = null;
|
|
54
|
+
if (config.endpoints?.refresh !== false && !config.refreshToken.serverManaged) {
|
|
55
|
+
refreshToken.value = null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const hasTokens = () => {
|
|
59
|
+
return {
|
|
60
|
+
access: !!accessToken.value,
|
|
61
|
+
refresh: !!refreshToken.value
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
const updateCoordinationCookie = () => {
|
|
65
|
+
if (coordinationCookie) {
|
|
66
|
+
const previousValue = coordinationCookie.value;
|
|
67
|
+
coordinationCookie.value = Date.now();
|
|
68
|
+
if (import.meta.dev) {
|
|
69
|
+
console.log("[TokenManager] Updated coordination cookie:", {
|
|
70
|
+
previousTimestamp: previousValue,
|
|
71
|
+
newTimestamp: coordinationCookie.value,
|
|
72
|
+
timeSinceLast: previousValue ? Date.now() - previousValue : "N/A",
|
|
73
|
+
isServer: import.meta.server
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
} else if (import.meta.dev) {
|
|
77
|
+
console.log("[TokenManager] Coordination cookie not enabled");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const clearCoordinationCookie = () => {
|
|
81
|
+
if (coordinationCookie) {
|
|
82
|
+
if (import.meta.dev) {
|
|
83
|
+
console.log("[TokenManager] Clearing coordination cookie:", {
|
|
84
|
+
previousTimestamp: coordinationCookie.value,
|
|
85
|
+
isServer: import.meta.server
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
coordinationCookie.value = null;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const coordinationTimestamp = coordinationCookie ?? ref(null);
|
|
92
|
+
return {
|
|
93
|
+
accessToken,
|
|
94
|
+
refreshToken,
|
|
95
|
+
coordinationTimestamp,
|
|
96
|
+
getAuthHeaders,
|
|
97
|
+
setTokensFromResponse,
|
|
98
|
+
clearTokens,
|
|
99
|
+
hasTokens,
|
|
100
|
+
updateCoordinationCookie,
|
|
101
|
+
clearCoordinationCookie
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineNuxtRouteMiddleware, useAuth, useRuntimeConfig } from "#imports";
|
|
2
|
+
import { handleRedirect } from "../utils/redirect.js";
|
|
3
|
+
export default defineNuxtRouteMiddleware(async (to) => {
|
|
4
|
+
const auth = useAuth();
|
|
5
|
+
const config = useRuntimeConfig().public.auth;
|
|
6
|
+
const authMeta = to.meta.auth;
|
|
7
|
+
if (authMeta === false) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if ((authMeta === "guest" || authMeta === "guestOnly") && auth.loggedIn.value) {
|
|
11
|
+
return handleRedirect(config.redirect.home);
|
|
12
|
+
}
|
|
13
|
+
const requiresAuth = authMeta === true || authMeta === void 0 && config.globalMiddleware;
|
|
14
|
+
if (requiresAuth && !auth.loggedIn.value) {
|
|
15
|
+
if (auth.canRefresh.value) {
|
|
16
|
+
try {
|
|
17
|
+
await auth.refresh();
|
|
18
|
+
} catch {
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (!auth.loggedIn.value) {
|
|
22
|
+
return handleRedirect(config.redirect.login);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (auth.loggedIn.value && !auth.accessToken.value && auth.canRefresh.value) {
|
|
26
|
+
try {
|
|
27
|
+
await auth.refresh();
|
|
28
|
+
} catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|