@meistrari/auth-nuxt 1.0.6 → 2.0.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/README.md +2 -2
- package/dist/module.d.mts +14 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +38 -2
- package/dist/runtime/components/tela-role.d.vue.ts +23 -0
- package/dist/runtime/components/tela-role.vue +15 -0
- package/dist/runtime/components/tela-role.vue.d.ts +23 -0
- package/dist/runtime/composables/application-auth.d.ts +41 -0
- package/dist/runtime/composables/application-auth.js +171 -0
- package/dist/runtime/composables/state.d.ts +8 -57
- package/dist/runtime/composables/state.js +8 -0
- package/dist/runtime/pages/callback.d.vue.ts +2 -0
- package/dist/runtime/pages/callback.vue +27 -0
- package/dist/runtime/pages/callback.vue.d.ts +2 -0
- package/dist/runtime/plugins/application-token-refresh.js +134 -0
- package/dist/runtime/plugins/auth-guard.d.ts +22 -0
- package/dist/runtime/plugins/auth-guard.js +39 -0
- package/dist/runtime/plugins/directives.d.ts +20 -0
- package/dist/runtime/plugins/directives.js +63 -0
- package/dist/runtime/plugins/handshake.d.ts +2 -0
- package/dist/runtime/{plugin.js → plugins/handshake.js} +3 -3
- package/dist/runtime/server/middleware/application-auth.d.ts +4 -0
- package/dist/runtime/server/middleware/application-auth.js +49 -0
- package/dist/runtime/server/types/h3.d.ts +2 -0
- package/dist/runtime/server/utils/require-auth.d.ts +27 -0
- package/dist/runtime/server/utils/require-auth.js +69 -0
- package/dist/runtime/shared.d.ts +1 -2
- package/dist/runtime/shared.js +10 -1
- package/dist/runtime/types/page-meta.d.ts +15 -0
- package/package.json +51 -49
- /package/dist/runtime/{plugin.d.ts → plugins/application-token-refresh.d.ts} +0 -0
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ await signInWithEmailAndPassword({
|
|
|
99
99
|
```typescript
|
|
100
100
|
await signInWithSocialProvider({
|
|
101
101
|
provider: 'google', // or 'microsoft'
|
|
102
|
-
callbackURL: '/
|
|
102
|
+
callbackURL: '/',
|
|
103
103
|
errorCallbackURL: '/login?error=true'
|
|
104
104
|
})
|
|
105
105
|
```
|
|
@@ -108,7 +108,7 @@ await signInWithSocialProvider({
|
|
|
108
108
|
```typescript
|
|
109
109
|
await signInWithSaml({
|
|
110
110
|
email: 'user@example.com',
|
|
111
|
-
callbackURL: '/
|
|
111
|
+
callbackURL: '/',
|
|
112
112
|
errorCallbackURL: '/login?error=true'
|
|
113
113
|
})
|
|
114
114
|
```
|
package/dist/module.d.mts
CHANGED
|
@@ -7,6 +7,20 @@ interface ModuleOptions {
|
|
|
7
7
|
jwtCookieName: string;
|
|
8
8
|
/** Skip default server middleware */
|
|
9
9
|
skipServerMiddleware: boolean;
|
|
10
|
+
application?: {
|
|
11
|
+
/** Whether to enable application authentication */
|
|
12
|
+
enabled: boolean;
|
|
13
|
+
/** Auth Dashboard URL */
|
|
14
|
+
dashboardUrl: string;
|
|
15
|
+
/** The ID of the application to authenticate with */
|
|
16
|
+
applicationId: string;
|
|
17
|
+
/** The redirect URI to redirect to after authentication. Must be registered in the application's settings. */
|
|
18
|
+
redirectUri: string;
|
|
19
|
+
/** Path to redirect to when authentication is required (default: '/login') */
|
|
20
|
+
loginPath?: string;
|
|
21
|
+
/** Path to redirect to when user lacks required role (default: '/unauthorized') */
|
|
22
|
+
unauthorizedPath?: string;
|
|
23
|
+
};
|
|
10
24
|
}
|
|
11
25
|
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
12
26
|
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineNuxtModule, createResolver,
|
|
1
|
+
import { defineNuxtModule, createResolver, addImports, addPlugin, extendPages, addComponent, addServerImportsDir, addServerHandler } from '@nuxt/kit';
|
|
2
2
|
|
|
3
3
|
const module$1 = defineNuxtModule({
|
|
4
4
|
meta: {
|
|
@@ -12,6 +12,42 @@ const module$1 = defineNuxtModule({
|
|
|
12
12
|
setup(options, nuxt) {
|
|
13
13
|
const resolver = createResolver(import.meta.url);
|
|
14
14
|
nuxt.options.runtimeConfig.public.telaAuth = options;
|
|
15
|
+
if (options.application?.enabled) {
|
|
16
|
+
addImports({
|
|
17
|
+
name: "useTelaApplicationAuth",
|
|
18
|
+
as: "useTelaApplicationAuth",
|
|
19
|
+
from: resolver.resolve("runtime/composables/application-auth")
|
|
20
|
+
});
|
|
21
|
+
addPlugin(resolver.resolve("./runtime/plugins/application-token-refresh"));
|
|
22
|
+
extendPages((pages) => {
|
|
23
|
+
pages.unshift({
|
|
24
|
+
name: "auth-callback",
|
|
25
|
+
path: "/auth/callback",
|
|
26
|
+
file: resolver.resolve("./runtime/pages/callback.vue")
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
addComponent({
|
|
30
|
+
name: "TelaRole",
|
|
31
|
+
filePath: resolver.resolve("./runtime/components/tela-role.vue"),
|
|
32
|
+
global: true,
|
|
33
|
+
export: "default"
|
|
34
|
+
});
|
|
35
|
+
nuxt.hook("prepare:types", ({ references }) => {
|
|
36
|
+
references.push({
|
|
37
|
+
path: resolver.resolve("./runtime/types/page-meta.d.ts")
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
addPlugin(resolver.resolve("./runtime/plugins/auth-guard"));
|
|
41
|
+
addPlugin(resolver.resolve("./runtime/plugins/directives"));
|
|
42
|
+
addServerImportsDir(resolver.resolve("./runtime/server/utils"));
|
|
43
|
+
if (!options.skipServerMiddleware) {
|
|
44
|
+
addServerHandler({
|
|
45
|
+
route: "",
|
|
46
|
+
handler: resolver.resolve("./runtime/server/middleware/application-auth")
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
15
51
|
if (!options.skipServerMiddleware) {
|
|
16
52
|
addServerHandler({
|
|
17
53
|
route: "",
|
|
@@ -33,7 +69,7 @@ const module$1 = defineNuxtModule({
|
|
|
33
69
|
as: "useTelaApiKey",
|
|
34
70
|
from: resolver.resolve("runtime/composables/api-key")
|
|
35
71
|
});
|
|
36
|
-
addPlugin(resolver.resolve("./runtime/
|
|
72
|
+
addPlugin(resolver.resolve("./runtime/plugins/handshake"));
|
|
37
73
|
}
|
|
38
74
|
});
|
|
39
75
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
allowedRoles: string[];
|
|
3
|
+
};
|
|
4
|
+
declare var __VLS_1: {
|
|
5
|
+
role: any;
|
|
6
|
+
}, __VLS_3: {
|
|
7
|
+
role: any;
|
|
8
|
+
}, __VLS_5: {};
|
|
9
|
+
type __VLS_Slots = {} & {
|
|
10
|
+
default?: (props: typeof __VLS_1) => any;
|
|
11
|
+
} & {
|
|
12
|
+
'not-authorized'?: (props: typeof __VLS_3) => any;
|
|
13
|
+
} & {
|
|
14
|
+
'logged-out'?: (props: typeof __VLS_5) => any;
|
|
15
|
+
};
|
|
16
|
+
declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
|
|
18
|
+
export default _default;
|
|
19
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
20
|
+
new (): {
|
|
21
|
+
$slots: S;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { useTelaApplicationAuth } from "../composables/application-auth";
|
|
3
|
+
const { user } = useTelaApplicationAuth();
|
|
4
|
+
defineProps({
|
|
5
|
+
allowedRoles: { type: Array, required: true }
|
|
6
|
+
});
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<template v-if="user">
|
|
11
|
+
<slot v-if="user.role && allowedRoles.includes(user.role)" :role="user.role" />
|
|
12
|
+
<slot v-else name="not-authorized" :role="user.role" />
|
|
13
|
+
</template>
|
|
14
|
+
<slot v-else name="logged-out" />
|
|
15
|
+
</template>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
type __VLS_Props = {
|
|
2
|
+
allowedRoles: string[];
|
|
3
|
+
};
|
|
4
|
+
declare var __VLS_1: {
|
|
5
|
+
role: any;
|
|
6
|
+
}, __VLS_3: {
|
|
7
|
+
role: any;
|
|
8
|
+
}, __VLS_5: {};
|
|
9
|
+
type __VLS_Slots = {} & {
|
|
10
|
+
default?: (props: typeof __VLS_1) => any;
|
|
11
|
+
} & {
|
|
12
|
+
'not-authorized'?: (props: typeof __VLS_3) => any;
|
|
13
|
+
} & {
|
|
14
|
+
'logged-out'?: (props: typeof __VLS_5) => any;
|
|
15
|
+
};
|
|
16
|
+
declare const __VLS_component: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
17
|
+
declare const _default: __VLS_WithSlots<typeof __VLS_component, __VLS_Slots>;
|
|
18
|
+
export default _default;
|
|
19
|
+
type __VLS_WithSlots<T, S> = T & {
|
|
20
|
+
new (): {
|
|
21
|
+
$slots: S;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable for managing Tela application authentication with OAuth 2.0 PKCE flow
|
|
3
|
+
*
|
|
4
|
+
* Provides methods to:
|
|
5
|
+
* - Initiate OAuth login flow with PKCE
|
|
6
|
+
* - Exchange authorization codes for tokens
|
|
7
|
+
* - Automatically refresh access tokens
|
|
8
|
+
* - Manage user session state
|
|
9
|
+
* - Handle logout
|
|
10
|
+
*
|
|
11
|
+
* @returns An object containing authentication state and methods
|
|
12
|
+
* @throws {Error} If auth dashboard URL is not configured
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```ts
|
|
16
|
+
* const auth = useTelaApplicationAuth()
|
|
17
|
+
*
|
|
18
|
+
* // Initiate login
|
|
19
|
+
* await auth.login({
|
|
20
|
+
* applicationId: 'app-123',
|
|
21
|
+
* redirectUri: 'https://example.com/callback'
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* // Initialize session (handles OAuth callback and token refresh)
|
|
25
|
+
* await auth.initSession()
|
|
26
|
+
*
|
|
27
|
+
* // Access user state
|
|
28
|
+
* console.log(auth.user.value)
|
|
29
|
+
* console.log(auth.activeOrganization.value)
|
|
30
|
+
*
|
|
31
|
+
* // Logout
|
|
32
|
+
* await auth.logout()
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export declare function useTelaApplicationAuth(): {
|
|
36
|
+
login: () => Promise<void>;
|
|
37
|
+
logout: () => Promise<void>;
|
|
38
|
+
initSession: () => Promise<void>;
|
|
39
|
+
user: import("vue").Ref<any, any>;
|
|
40
|
+
activeOrganization: import("vue").Ref<Omit<FullOrganization, "members" | "invitations" | "teams"> | null, Omit<FullOrganization, "members" | "invitations" | "teams"> | null>;
|
|
41
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { navigateTo, useCookie, useRoute, useRuntimeConfig } from "#app";
|
|
2
|
+
import { createNuxtAuthClient } from "../shared.js";
|
|
3
|
+
import { useApplicationSessionState } from "./state.js";
|
|
4
|
+
import { AuthorizationFlowError, isTokenExpired, RefreshTokenExpiredError } from "@meistrari/auth-core";
|
|
5
|
+
const SEVEN_DAYS = 60 * 60 * 24 * 7;
|
|
6
|
+
const FIFTEEN_MINUTES = 60 * 15;
|
|
7
|
+
const ONE_MINUTE = 60 * 1e3;
|
|
8
|
+
function verifier(stateKey) {
|
|
9
|
+
return `code_verifier_${stateKey}`;
|
|
10
|
+
}
|
|
11
|
+
function hexEncode(bytes) {
|
|
12
|
+
return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
13
|
+
}
|
|
14
|
+
function generateStateKey(query) {
|
|
15
|
+
if (query.state && typeof query.state === "string") {
|
|
16
|
+
return query.state;
|
|
17
|
+
}
|
|
18
|
+
const array = new Uint8Array(8);
|
|
19
|
+
crypto.getRandomValues(array);
|
|
20
|
+
return hexEncode(array);
|
|
21
|
+
}
|
|
22
|
+
function generateCodeVerifier() {
|
|
23
|
+
const array = new Uint8Array(32);
|
|
24
|
+
crypto.getRandomValues(array);
|
|
25
|
+
return base64UrlEncode(array);
|
|
26
|
+
}
|
|
27
|
+
async function generateCodeChallenge(verifier2) {
|
|
28
|
+
const encoder = new TextEncoder();
|
|
29
|
+
const data = encoder.encode(verifier2);
|
|
30
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
31
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
32
|
+
}
|
|
33
|
+
function base64UrlEncode(bytes) {
|
|
34
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
35
|
+
}
|
|
36
|
+
function mapOrganization(organization) {
|
|
37
|
+
return {
|
|
38
|
+
name: organization.title,
|
|
39
|
+
id: organization.id,
|
|
40
|
+
createdAt: organization.createdAt,
|
|
41
|
+
logo: organization.avatarUrl,
|
|
42
|
+
metadata: organization.metadata,
|
|
43
|
+
slug: organization.slug ?? ""
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function useTelaApplicationAuth() {
|
|
47
|
+
const appConfig = useRuntimeConfig().public.telaAuth;
|
|
48
|
+
const query = useRoute().query;
|
|
49
|
+
const accessTokenCookie = useCookie("tela-access-token", {
|
|
50
|
+
secure: true,
|
|
51
|
+
sameSite: "lax",
|
|
52
|
+
maxAge: FIFTEEN_MINUTES
|
|
53
|
+
});
|
|
54
|
+
const refreshTokenCookie = useCookie("tela-refresh-token", {
|
|
55
|
+
secure: true,
|
|
56
|
+
sameSite: "lax",
|
|
57
|
+
maxAge: SEVEN_DAYS
|
|
58
|
+
});
|
|
59
|
+
const authClient = createNuxtAuthClient(appConfig.apiUrl, () => null, () => refreshTokenCookie.value ?? null);
|
|
60
|
+
const state = useApplicationSessionState();
|
|
61
|
+
if (!appConfig.application?.dashboardUrl) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
"[Tela Auth SDK] Auth dashboard URL is not configured, but it is required to use application authentication."
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!appConfig.application?.applicationId) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"[Tela Auth SDK] Application ID is not configured, but it is required to use application authentication."
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (!appConfig.application?.redirectUri) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"[Tela Auth SDK] Redirect URI is not configured, but it is required to use application authentication."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const { applicationId, redirectUri } = appConfig.application;
|
|
77
|
+
async function login() {
|
|
78
|
+
if (import.meta.server) {
|
|
79
|
+
throw new AuthorizationFlowError("The login function can only be called on the client side.");
|
|
80
|
+
}
|
|
81
|
+
if (typeof localStorage === "undefined") {
|
|
82
|
+
throw new AuthorizationFlowError("localStorage is not available. The login function must be called on the client side.");
|
|
83
|
+
}
|
|
84
|
+
const codeVerifier = generateCodeVerifier();
|
|
85
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
86
|
+
const stateKey = generateStateKey(query);
|
|
87
|
+
localStorage.setItem(verifier(stateKey), codeVerifier);
|
|
88
|
+
const url = new URL("/applications/login", appConfig.application?.dashboardUrl);
|
|
89
|
+
url.searchParams.set("application_id", applicationId);
|
|
90
|
+
url.searchParams.set("code_challenge", codeChallenge);
|
|
91
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
92
|
+
url.searchParams.set("state", stateKey);
|
|
93
|
+
navigateTo(url.toString(), { external: true });
|
|
94
|
+
}
|
|
95
|
+
async function logout() {
|
|
96
|
+
accessTokenCookie.value = null;
|
|
97
|
+
refreshTokenCookie.value = null;
|
|
98
|
+
state.user.value = null;
|
|
99
|
+
state.activeOrganization.value = null;
|
|
100
|
+
if (typeof localStorage !== "undefined") {
|
|
101
|
+
for (const key in localStorage) {
|
|
102
|
+
if (key.startsWith("code_verifier_")) {
|
|
103
|
+
localStorage.removeItem(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function exchangeCodeForToken() {
|
|
109
|
+
if (import.meta.server) {
|
|
110
|
+
throw new AuthorizationFlowError("The exchangeCodeForToken function can only be called on the client side.");
|
|
111
|
+
}
|
|
112
|
+
const code = query.code;
|
|
113
|
+
const stateKey = query.state;
|
|
114
|
+
if (!code) {
|
|
115
|
+
throw new AuthorizationFlowError("Authorization code not found in query parameters");
|
|
116
|
+
}
|
|
117
|
+
if (!stateKey) {
|
|
118
|
+
throw new AuthorizationFlowError("State parameter not found in query parameters");
|
|
119
|
+
}
|
|
120
|
+
if (typeof localStorage === "undefined") {
|
|
121
|
+
throw new AuthorizationFlowError("localStorage is not available");
|
|
122
|
+
}
|
|
123
|
+
const codeVerifierKey = verifier(stateKey);
|
|
124
|
+
const codeVerifier = localStorage.getItem(codeVerifierKey);
|
|
125
|
+
if (!codeVerifier) {
|
|
126
|
+
throw new AuthorizationFlowError("Code verifier not found. This may indicate a CSRF attack or expired session.");
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const { accessToken, refreshToken: refreshToken2, user, organization } = await authClient.application.completeAuthorizationFlow(code, codeVerifier);
|
|
130
|
+
accessTokenCookie.value = accessToken;
|
|
131
|
+
refreshTokenCookie.value = refreshToken2;
|
|
132
|
+
state.user.value = user;
|
|
133
|
+
state.activeOrganization.value = mapOrganization(organization);
|
|
134
|
+
} finally {
|
|
135
|
+
localStorage.removeItem(codeVerifierKey);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function refreshToken() {
|
|
139
|
+
if (!refreshTokenCookie.value) {
|
|
140
|
+
throw new RefreshTokenExpiredError();
|
|
141
|
+
}
|
|
142
|
+
const { accessToken, refreshToken: refreshToken2, user, organization } = await authClient.application.refreshAccessToken();
|
|
143
|
+
accessTokenCookie.value = accessToken;
|
|
144
|
+
refreshTokenCookie.value = refreshToken2;
|
|
145
|
+
state.user.value = user;
|
|
146
|
+
state.activeOrganization.value = mapOrganization(organization);
|
|
147
|
+
}
|
|
148
|
+
async function initSession() {
|
|
149
|
+
const code = query.code;
|
|
150
|
+
if (code) {
|
|
151
|
+
await exchangeCodeForToken();
|
|
152
|
+
}
|
|
153
|
+
if (!accessTokenCookie.value && !refreshTokenCookie.value) {
|
|
154
|
+
throw new RefreshTokenExpiredError();
|
|
155
|
+
}
|
|
156
|
+
const isExpiredOrClose = accessTokenCookie.value ? isTokenExpired(accessTokenCookie.value, ONE_MINUTE) : true;
|
|
157
|
+
if (isExpiredOrClose && !refreshTokenCookie.value) {
|
|
158
|
+
await logout();
|
|
159
|
+
throw new RefreshTokenExpiredError();
|
|
160
|
+
}
|
|
161
|
+
if (isExpiredOrClose && refreshTokenCookie.value) {
|
|
162
|
+
await refreshToken();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
...state,
|
|
167
|
+
login,
|
|
168
|
+
logout,
|
|
169
|
+
initSession
|
|
170
|
+
};
|
|
171
|
+
}
|
|
@@ -1,69 +1,20 @@
|
|
|
1
|
-
import type { FullOrganization, Member } from '@meistrari/auth-core';
|
|
2
1
|
/**
|
|
3
2
|
* Shared state for session management.
|
|
4
3
|
* This module provides access to session-related state without creating circular dependencies.
|
|
5
4
|
*/
|
|
6
5
|
export declare function useSessionState(): {
|
|
7
|
-
user: import("vue").Ref<
|
|
8
|
-
|
|
9
|
-
createdAt: Date;
|
|
10
|
-
updatedAt: Date;
|
|
11
|
-
email: string;
|
|
12
|
-
emailVerified: boolean;
|
|
13
|
-
name: string;
|
|
14
|
-
image?: string | null | undefined;
|
|
15
|
-
twoFactorEnabled: boolean | null | undefined;
|
|
16
|
-
banned: boolean | null | undefined;
|
|
17
|
-
role?: string | null | undefined;
|
|
18
|
-
banReason?: string | null | undefined;
|
|
19
|
-
banExpires?: Date | null | undefined;
|
|
20
|
-
lastActiveAt?: Date | null | undefined;
|
|
21
|
-
} | null, {
|
|
22
|
-
id: string;
|
|
23
|
-
createdAt: Date;
|
|
24
|
-
updatedAt: Date;
|
|
25
|
-
email: string;
|
|
26
|
-
emailVerified: boolean;
|
|
27
|
-
name: string;
|
|
28
|
-
image?: string | null | undefined;
|
|
29
|
-
twoFactorEnabled: boolean | null | undefined;
|
|
30
|
-
banned: boolean | null | undefined;
|
|
31
|
-
role?: string | null | undefined;
|
|
32
|
-
banReason?: string | null | undefined;
|
|
33
|
-
banExpires?: Date | null | undefined;
|
|
34
|
-
lastActiveAt?: Date | null | undefined;
|
|
35
|
-
} | null>;
|
|
36
|
-
session: import("vue").Ref<{
|
|
37
|
-
id: string;
|
|
38
|
-
createdAt: Date;
|
|
39
|
-
updatedAt: Date;
|
|
40
|
-
userId: string;
|
|
41
|
-
expiresAt: Date;
|
|
42
|
-
token: string;
|
|
43
|
-
ipAddress?: string | null | undefined;
|
|
44
|
-
userAgent?: string | null | undefined;
|
|
45
|
-
activeOrganizationId?: string | null | undefined;
|
|
46
|
-
activeTeamId?: string | null | undefined;
|
|
47
|
-
impersonatedBy?: string | null | undefined;
|
|
48
|
-
} | null, {
|
|
49
|
-
id: string;
|
|
50
|
-
createdAt: Date;
|
|
51
|
-
updatedAt: Date;
|
|
52
|
-
userId: string;
|
|
53
|
-
expiresAt: Date;
|
|
54
|
-
token: string;
|
|
55
|
-
ipAddress?: string | null | undefined;
|
|
56
|
-
userAgent?: string | null | undefined;
|
|
57
|
-
activeOrganizationId?: string | null | undefined;
|
|
58
|
-
activeTeamId?: string | null | undefined;
|
|
59
|
-
impersonatedBy?: string | null | undefined;
|
|
60
|
-
} | null>;
|
|
6
|
+
user: import("vue").Ref<any, any>;
|
|
7
|
+
session: import("vue").Ref<any, any>;
|
|
61
8
|
};
|
|
62
9
|
/**
|
|
63
10
|
* Shared state for organization management.
|
|
64
11
|
* This module provides access to organization-related state without creating circular dependencies.
|
|
65
12
|
*/
|
|
66
13
|
export declare function useOrganizationState(): {
|
|
67
|
-
activeOrganization: import("vue").Ref<
|
|
68
|
-
activeMember: import("vue").Ref<
|
|
14
|
+
activeOrganization: import("vue").Ref<any, any>;
|
|
15
|
+
activeMember: import("vue").Ref<any, any>;
|
|
16
|
+
};
|
|
17
|
+
export declare function useApplicationSessionState(): {
|
|
18
|
+
user: import("vue").Ref<any, any>;
|
|
19
|
+
activeOrganization: import("vue").Ref<Omit<FullOrganization, "members" | "invitations" | "teams"> | null, Omit<FullOrganization, "members" | "invitations" | "teams"> | null>;
|
|
69
20
|
};
|
|
@@ -15,3 +15,11 @@ export function useOrganizationState() {
|
|
|
15
15
|
activeMember
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
+
export function useApplicationSessionState() {
|
|
19
|
+
const user = useState("user", () => null);
|
|
20
|
+
const activeOrganization = useState("activeOrganization", () => null);
|
|
21
|
+
return {
|
|
22
|
+
user,
|
|
23
|
+
activeOrganization
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { navigateTo, useRuntimeConfig } from "#app";
|
|
3
|
+
import { onMounted } from "vue";
|
|
4
|
+
import { useTelaApplicationAuth } from "../composables/application-auth";
|
|
5
|
+
const { initSession } = useTelaApplicationAuth();
|
|
6
|
+
const config = useRuntimeConfig();
|
|
7
|
+
const authConfig = config.public.telaAuth;
|
|
8
|
+
const loginPath = authConfig.application?.loginPath ?? "/login";
|
|
9
|
+
onMounted(async () => {
|
|
10
|
+
try {
|
|
11
|
+
await initSession();
|
|
12
|
+
navigateTo("/");
|
|
13
|
+
} catch (error) {
|
|
14
|
+
console.error("Session initialization failed:", error);
|
|
15
|
+
navigateTo({
|
|
16
|
+
path: loginPath,
|
|
17
|
+
query: { error: "session_failed" }
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="flex items-center justify-center min-h-screen">
|
|
25
|
+
<h1>Loading...</h1>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
2
|
+
export default _default;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useCookie, useRuntimeConfig } from "#app";
|
|
2
|
+
import { isTokenExpired } from "@meistrari/auth-core";
|
|
3
|
+
import { useApplicationSessionState } from "../composables/state.js";
|
|
4
|
+
import { createNuxtAuthClient } from "../shared.js";
|
|
5
|
+
import { watch } from "vue";
|
|
6
|
+
const SEVEN_DAYS = 60 * 60 * 24 * 7;
|
|
7
|
+
const FIFTEEN_MINUTES = 60 * 15;
|
|
8
|
+
const TWO_MINUTES = 2 * 60 * 1e3;
|
|
9
|
+
function parseTokenExpiry(token) {
|
|
10
|
+
try {
|
|
11
|
+
const tokenParts = token.split(".");
|
|
12
|
+
const payloadPart = tokenParts[1];
|
|
13
|
+
if (!payloadPart) return null;
|
|
14
|
+
const payload = JSON.parse(atob(payloadPart));
|
|
15
|
+
if (!payload.exp) return null;
|
|
16
|
+
return payload.exp * 1e3;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function mapOrganization(organization) {
|
|
22
|
+
return {
|
|
23
|
+
name: organization.title,
|
|
24
|
+
id: organization.id,
|
|
25
|
+
createdAt: organization.createdAt,
|
|
26
|
+
logo: organization.avatarUrl,
|
|
27
|
+
metadata: organization.metadata,
|
|
28
|
+
slug: organization.slug ?? ""
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export default defineNuxtPlugin({
|
|
32
|
+
name: "tela-application-token-refresh",
|
|
33
|
+
enforce: "post",
|
|
34
|
+
env: {
|
|
35
|
+
islands: false
|
|
36
|
+
},
|
|
37
|
+
async setup() {
|
|
38
|
+
const appConfig = useRuntimeConfig().public.telaAuth;
|
|
39
|
+
const state = useApplicationSessionState();
|
|
40
|
+
const accessTokenCookie = useCookie("tela-access-token", {
|
|
41
|
+
secure: true,
|
|
42
|
+
sameSite: "lax",
|
|
43
|
+
maxAge: FIFTEEN_MINUTES
|
|
44
|
+
});
|
|
45
|
+
const refreshTokenCookie = useCookie("tela-refresh-token", {
|
|
46
|
+
secure: true,
|
|
47
|
+
sameSite: "lax",
|
|
48
|
+
maxAge: SEVEN_DAYS
|
|
49
|
+
});
|
|
50
|
+
const authClient = createNuxtAuthClient(appConfig.apiUrl, () => null, () => refreshTokenCookie.value ?? null);
|
|
51
|
+
let tokenRefreshInterval = null;
|
|
52
|
+
let isRefreshing = false;
|
|
53
|
+
let retryCount = 0;
|
|
54
|
+
const MAX_RETRIES = 3;
|
|
55
|
+
async function refreshToken() {
|
|
56
|
+
if (isRefreshing) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
isRefreshing = true;
|
|
60
|
+
try {
|
|
61
|
+
const { accessToken, refreshToken: refreshToken2, user, organization } = await authClient.application.refreshAccessToken();
|
|
62
|
+
accessTokenCookie.value = accessToken;
|
|
63
|
+
refreshTokenCookie.value = refreshToken2;
|
|
64
|
+
state.user.value = user;
|
|
65
|
+
state.activeOrganization.value = mapOrganization(organization);
|
|
66
|
+
} finally {
|
|
67
|
+
isRefreshing = false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function logout() {
|
|
71
|
+
accessTokenCookie.value = null;
|
|
72
|
+
refreshTokenCookie.value = null;
|
|
73
|
+
state.user.value = null;
|
|
74
|
+
state.activeOrganization.value = null;
|
|
75
|
+
}
|
|
76
|
+
async function scheduleTokenRefresh() {
|
|
77
|
+
if (tokenRefreshInterval) {
|
|
78
|
+
clearTimeout(tokenRefreshInterval);
|
|
79
|
+
tokenRefreshInterval = null;
|
|
80
|
+
}
|
|
81
|
+
if (!accessTokenCookie.value) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (isTokenExpired(accessTokenCookie.value, TWO_MINUTES)) {
|
|
85
|
+
await refreshToken();
|
|
86
|
+
}
|
|
87
|
+
const expiry = parseTokenExpiry(accessTokenCookie.value);
|
|
88
|
+
if (!expiry) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const nextRefresh = Math.max(expiry - TWO_MINUTES - Date.now(), 0);
|
|
92
|
+
tokenRefreshInterval = window.setTimeout(refreshToken, nextRefresh);
|
|
93
|
+
}
|
|
94
|
+
if (import.meta.server) {
|
|
95
|
+
if (accessTokenCookie.value) {
|
|
96
|
+
try {
|
|
97
|
+
const data = await authClient.application.whoAmI(accessTokenCookie.value);
|
|
98
|
+
state.user.value = data.user;
|
|
99
|
+
state.activeOrganization.value = mapOrganization(data.organization);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(`[Tela Auth SDK] Failed to get user and organization:`, error.message);
|
|
102
|
+
if (!refreshTokenCookie.value) {
|
|
103
|
+
console.error(`[Tela Auth SDK] Missing refresh token, logging out...`);
|
|
104
|
+
logout();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const refreshTokenValue = refreshTokenCookie.value;
|
|
108
|
+
try {
|
|
109
|
+
await refreshToken();
|
|
110
|
+
} catch (error2) {
|
|
111
|
+
console.error(`[Tela Auth SDK] Failed to refresh token ${refreshTokenValue}...:`, error2.message);
|
|
112
|
+
logout();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (!accessTokenCookie.value && refreshTokenCookie.value) {
|
|
117
|
+
try {
|
|
118
|
+
await refreshToken();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
console.error(`[Tela Auth SDK] Failed to refresh token ${refreshTokenCookie.value}...:`, error.message);
|
|
121
|
+
logout();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (import.meta.client) {
|
|
127
|
+
watch(refreshTokenCookie, async (newVal) => {
|
|
128
|
+
if (newVal) {
|
|
129
|
+
await scheduleTokenRefresh();
|
|
130
|
+
}
|
|
131
|
+
}, { immediate: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side authentication guard plugin
|
|
3
|
+
*
|
|
4
|
+
* This plugin provides UX-level route protection by checking JWT tokens client-side
|
|
5
|
+
* and redirecting users before they navigate to protected routes. This creates a smooth
|
|
6
|
+
* user experience by avoiding unnecessary page loads.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY NOTE: This is NOT the primary security layer. The client-side JWT decoding
|
|
9
|
+
* is for UX purposes only. Actual cryptographic verification happens server-side in:
|
|
10
|
+
* - application-auth.ts: Server middleware that verifies JWTs for all /api routes
|
|
11
|
+
* - require-auth.ts: Server utility for protecting individual API route handlers
|
|
12
|
+
*
|
|
13
|
+
* An attacker could bypass this client-side check, but they cannot bypass the server-side
|
|
14
|
+
* verification which cryptographically validates the JWT signature using JWKS.
|
|
15
|
+
*
|
|
16
|
+
* This follows a defense-in-depth approach:
|
|
17
|
+
* 1. Client-side (this file): Fast UX-level routing decisions
|
|
18
|
+
* 2. Server middleware: Automatic JWT verification for all API routes
|
|
19
|
+
* 3. Route handlers: Explicit role checks in sensitive endpoints
|
|
20
|
+
*/
|
|
21
|
+
declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
|
|
22
|
+
export default _default;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { addRouteMiddleware, defineNuxtPlugin, navigateTo, useCookie, useRuntimeConfig } from "#app";
|
|
2
|
+
import { decodeJwt } from "jose";
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const config = useRuntimeConfig();
|
|
5
|
+
const authConfig = config.public.telaAuth;
|
|
6
|
+
const loginPath = authConfig.application?.loginPath ?? "/login";
|
|
7
|
+
const unauthorizedPath = authConfig.application?.unauthorizedPath ?? "/unauthorized";
|
|
8
|
+
addRouteMiddleware("auth-guard", (to) => {
|
|
9
|
+
const authMeta = to.meta?.auth;
|
|
10
|
+
if (!authMeta || authMeta.required !== true) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const token = useCookie("tela-access-token");
|
|
14
|
+
if (!token.value) {
|
|
15
|
+
return navigateTo({
|
|
16
|
+
path: loginPath,
|
|
17
|
+
query: { redirect: to.fullPath }
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
if (authMeta.roles && authMeta.roles.length > 0) {
|
|
21
|
+
try {
|
|
22
|
+
const payload = decodeJwt(token.value);
|
|
23
|
+
const userRole = payload.user?.role;
|
|
24
|
+
if (!userRole || !authMeta.roles.includes(userRole)) {
|
|
25
|
+
return navigateTo({
|
|
26
|
+
path: unauthorizedPath,
|
|
27
|
+
query: { redirect: to.fullPath }
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
console.error("Failed to decode token:", error);
|
|
32
|
+
return navigateTo({
|
|
33
|
+
path: loginPath,
|
|
34
|
+
query: { redirect: to.fullPath }
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}, { global: true });
|
|
39
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin to register custom role-based directives
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - v-if-role: Conditionally renders element (like v-if) based on user role
|
|
6
|
+
* - v-show-role: Conditionally shows element (like v-show) based on user role
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```vue
|
|
10
|
+
* <!-- Single role -->
|
|
11
|
+
* <button v-if-role="'admin'">Admin Only</button>
|
|
12
|
+
* <div v-show-role="'moderator'">Moderator Panel</div>
|
|
13
|
+
*
|
|
14
|
+
* <!-- Multiple roles -->
|
|
15
|
+
* <button v-if-role="['admin', 'moderator']">Admin or Moderator</button>
|
|
16
|
+
* <div v-show-role="['editor', 'writer']">Content Panel</div>
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
declare const _default: import("#app").Plugin<Record<string, unknown>> & import("#app").ObjectPlugin<Record<string, unknown>>;
|
|
20
|
+
export default _default;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineNuxtPlugin } from "#app";
|
|
2
|
+
import { useApplicationSessionState } from "../composables/state.js";
|
|
3
|
+
function hasRole(allowedRoles) {
|
|
4
|
+
const { user } = useApplicationSessionState();
|
|
5
|
+
if (!user.value?.role) {
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
const roles = Array.isArray(allowedRoles) ? allowedRoles : [allowedRoles];
|
|
9
|
+
return roles.includes(user.value.role);
|
|
10
|
+
}
|
|
11
|
+
export default defineNuxtPlugin((nuxtApp) => {
|
|
12
|
+
nuxtApp.vueApp.directive("if-role", {
|
|
13
|
+
mounted(el, binding) {
|
|
14
|
+
const shouldRender = hasRole(binding.value);
|
|
15
|
+
if (!shouldRender) {
|
|
16
|
+
const comment = document.createComment("v-if-role");
|
|
17
|
+
el.parentNode?.replaceChild(comment, el);
|
|
18
|
+
comment.__vIfRoleElement = el;
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
updated(el, binding) {
|
|
22
|
+
const shouldRender = hasRole(binding.value);
|
|
23
|
+
const isComment = el.nodeType === 8;
|
|
24
|
+
if (shouldRender && isComment) {
|
|
25
|
+
const originalElement = el.__vIfRoleElement;
|
|
26
|
+
if (originalElement && originalElement.parentNode === null) {
|
|
27
|
+
el.parentNode?.replaceChild(originalElement, el);
|
|
28
|
+
}
|
|
29
|
+
} else if (!shouldRender && !isComment) {
|
|
30
|
+
const comment = document.createComment("v-if-role");
|
|
31
|
+
el.parentNode?.replaceChild(comment, el);
|
|
32
|
+
comment.__vIfRoleElement = el;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
unmounted(el) {
|
|
36
|
+
if (el.nodeType === 8) {
|
|
37
|
+
delete el.__vIfRoleElement;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
nuxtApp.vueApp.directive("show-role", {
|
|
42
|
+
mounted(el, binding) {
|
|
43
|
+
const shouldShow = hasRole(binding.value);
|
|
44
|
+
if (!shouldShow) {
|
|
45
|
+
;
|
|
46
|
+
el.__vOriginalDisplay = el.style.display;
|
|
47
|
+
el.style.display = "none";
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
updated(el, binding) {
|
|
51
|
+
const shouldShow = hasRole(binding.value);
|
|
52
|
+
if (shouldShow) {
|
|
53
|
+
el.style.display = el.__vOriginalDisplay || "";
|
|
54
|
+
} else {
|
|
55
|
+
if (!el.__vOriginalDisplay && el.style.display !== "none") {
|
|
56
|
+
;
|
|
57
|
+
el.__vOriginalDisplay = el.style.display;
|
|
58
|
+
}
|
|
59
|
+
el.style.display = "none";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { defineNuxtPlugin, useCookie, useRuntimeConfig, useRequestURL } from "#app";
|
|
2
2
|
import { watch } from "vue";
|
|
3
|
-
import { useTelaSession } from "
|
|
4
|
-
import { createNuxtAuthClient } from "
|
|
3
|
+
import { useTelaSession } from "../composables/session.js";
|
|
4
|
+
import { createNuxtAuthClient } from "../shared.js";
|
|
5
5
|
import { useRoute, navigateTo } from "#imports";
|
|
6
6
|
export default defineNuxtPlugin({
|
|
7
|
-
name: "tela-auth",
|
|
7
|
+
name: "tela-auth-handshake",
|
|
8
8
|
enforce: "pre",
|
|
9
9
|
env: {
|
|
10
10
|
islands: false
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AuthenticatedH3Event } from '../types/h3.js';
|
|
2
|
+
export declare function meistrariApplicationAuthMiddleware(callback: (event: AuthenticatedH3Event) => void | Promise<void>): import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
3
|
+
declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
4
|
+
export default _default;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useRuntimeConfig } from "#build/types/nitro-imports";
|
|
2
|
+
import { defineEventHandler, getCookie } from "h3";
|
|
3
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
|
+
async function setApplicationAuthContext(event) {
|
|
5
|
+
event.context.auth = {
|
|
6
|
+
user: null,
|
|
7
|
+
workspace: null,
|
|
8
|
+
token: null
|
|
9
|
+
};
|
|
10
|
+
const token = getCookie(event, "tela-access-token");
|
|
11
|
+
if (!token) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const authConfig = useRuntimeConfig(event).public.telaAuth;
|
|
15
|
+
const { apiUrl, application } = authConfig;
|
|
16
|
+
const { applicationId } = application ?? {};
|
|
17
|
+
if (!applicationId || !apiUrl) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const payload = await jwtVerify(
|
|
22
|
+
token,
|
|
23
|
+
createRemoteJWKSet(new URL("/.well-known/jwks.json", apiUrl)),
|
|
24
|
+
{
|
|
25
|
+
issuer: apiUrl,
|
|
26
|
+
audience: applicationId,
|
|
27
|
+
algorithms: ["RS256"]
|
|
28
|
+
}
|
|
29
|
+
).then(({ payload: payload2 }) => payload2);
|
|
30
|
+
event.context.auth = {
|
|
31
|
+
user: { ...payload.user, email: payload.email },
|
|
32
|
+
workspace: payload.workspace,
|
|
33
|
+
token
|
|
34
|
+
};
|
|
35
|
+
} catch {
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function meistrariApplicationAuthMiddleware(callback) {
|
|
39
|
+
return defineEventHandler(async (event) => {
|
|
40
|
+
await setApplicationAuthContext(event);
|
|
41
|
+
await callback(event);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export default defineEventHandler(async (event) => {
|
|
45
|
+
if (!event.path.startsWith("/api")) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await setApplicationAuthContext(event);
|
|
49
|
+
});
|
|
@@ -6,6 +6,7 @@ declare module 'h3' {
|
|
|
6
6
|
auth?: {
|
|
7
7
|
user: { email: string } & JWTTokenPayload['user'] | null
|
|
8
8
|
workspace: JWTTokenPayload['workspace'] | null
|
|
9
|
+
token?: string
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
}
|
|
@@ -14,6 +15,7 @@ export interface AuthenticatedH3EventContext extends H3EventContext {
|
|
|
14
15
|
auth: {
|
|
15
16
|
user: { email: string } & JWTTokenPayload['user'] | null
|
|
16
17
|
workspace: JWTTokenPayload['workspace'] | null
|
|
18
|
+
token?: string
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { EventHandlerRequest } from 'h3';
|
|
2
|
+
import type { AuthenticatedH3Event } from '../types/h3.js';
|
|
3
|
+
/**
|
|
4
|
+
* Wraps an event handler to require authentication.
|
|
5
|
+
* Throws 401 if no valid token is present, 403 if user lacks required role.
|
|
6
|
+
*
|
|
7
|
+
* This utility works in two modes:
|
|
8
|
+
* 1. If application-auth middleware has already verified the token, reuses that context
|
|
9
|
+
* 2. If middleware is skipped (skipServerMiddleware: true), performs JWT verification
|
|
10
|
+
*
|
|
11
|
+
* Use this when you need explicit role-based access control on specific API routes,
|
|
12
|
+
* or when you've disabled the global server middleware for performance reasons.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* export default requireAuth(async (event) => {
|
|
16
|
+
* const token = event.context.auth.token
|
|
17
|
+
* return { data: 'protected' }
|
|
18
|
+
* })
|
|
19
|
+
*
|
|
20
|
+
* @example With roles
|
|
21
|
+
* export default requireAuth(async (event) => {
|
|
22
|
+
* return { data: 'admin only' }
|
|
23
|
+
* }, { roles: ['admin'] })
|
|
24
|
+
*/
|
|
25
|
+
export declare function requireAuth<T>(handler: (event: AuthenticatedH3Event) => T | Promise<T>, options?: {
|
|
26
|
+
roles?: string[];
|
|
27
|
+
}): import("h3").EventHandler<EventHandlerRequest, any>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useRuntimeConfig } from "#build/types/nitro-imports";
|
|
2
|
+
import { createError, defineEventHandler, getCookie } from "h3";
|
|
3
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
|
+
export function requireAuth(handler, options) {
|
|
5
|
+
return defineEventHandler(async (event) => {
|
|
6
|
+
if (event.context.auth?.user && event.context.auth?.token) {
|
|
7
|
+
const user = event.context.auth.user;
|
|
8
|
+
if (options?.roles && !options.roles.includes(user.role ?? "")) {
|
|
9
|
+
throw createError({
|
|
10
|
+
statusCode: 403,
|
|
11
|
+
statusMessage: "Forbidden",
|
|
12
|
+
message: "User is not authorized to access this resource"
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
return handler(event);
|
|
16
|
+
}
|
|
17
|
+
const token = getCookie(event, "tela-access-token");
|
|
18
|
+
const authConfig = useRuntimeConfig(event).public.telaAuth;
|
|
19
|
+
const { apiUrl, application } = authConfig;
|
|
20
|
+
const { applicationId } = application ?? {};
|
|
21
|
+
if (!applicationId) {
|
|
22
|
+
throw createError({
|
|
23
|
+
statusCode: 500,
|
|
24
|
+
statusMessage: "Internal Server Error",
|
|
25
|
+
message: "Application ID is not configured"
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (!apiUrl) {
|
|
29
|
+
throw createError({
|
|
30
|
+
statusCode: 500,
|
|
31
|
+
statusMessage: "Internal Server Error",
|
|
32
|
+
message: "API URL is not configured"
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!token) {
|
|
36
|
+
throw createError({
|
|
37
|
+
statusCode: 401,
|
|
38
|
+
statusMessage: "Unauthorized",
|
|
39
|
+
message: "Authentication required"
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const payload = await jwtVerify(token, createRemoteJWKSet(new URL("/.well-known/jwks.json", apiUrl)), {
|
|
44
|
+
issuer: apiUrl,
|
|
45
|
+
audience: applicationId,
|
|
46
|
+
algorithms: ["RS256"]
|
|
47
|
+
}).then(({ payload: payload2 }) => payload2);
|
|
48
|
+
const user = payload.user;
|
|
49
|
+
if (options?.roles && !options.roles.includes(user.role ?? "")) {
|
|
50
|
+
throw createError({
|
|
51
|
+
statusCode: 403,
|
|
52
|
+
statusMessage: "Forbidden",
|
|
53
|
+
message: "User is not authorized to access this resource"
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
event.context.auth = { user: { ...payload.user, email: payload.email }, workspace: payload.workspace, token };
|
|
57
|
+
return handler(event);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error && typeof error === "object" && "statusCode" in error) {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
throw createError({
|
|
63
|
+
statusCode: 401,
|
|
64
|
+
statusMessage: "Unauthorized",
|
|
65
|
+
message: "Invalid or expired token"
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
package/dist/runtime/shared.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
|
|
2
|
-
export declare function createNuxtAuthClient(apiUrl: string, getAuthToken: () => string | null): AuthClient;
|
|
1
|
+
export declare function createNuxtAuthClient(apiUrl: string, getAuthToken: () => string | null, getRefreshToken?: () => string | null): any;
|
package/dist/runtime/shared.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AuthClient } from "@meistrari/auth-core";
|
|
2
2
|
import { version } from "../../package.json";
|
|
3
|
-
export function createNuxtAuthClient(apiUrl, getAuthToken) {
|
|
3
|
+
export function createNuxtAuthClient(apiUrl, getAuthToken, getRefreshToken = () => null) {
|
|
4
4
|
const serviceName = typeof process !== "undefined" ? process.env.SERVICE_NAME : "";
|
|
5
5
|
const userAgent = `auth-sdk:nuxt:${version}${serviceName ? `@${serviceName}` : ""}`;
|
|
6
6
|
return new AuthClient(apiUrl, {
|
|
@@ -14,6 +14,15 @@ export function createNuxtAuthClient(apiUrl, getAuthToken) {
|
|
|
14
14
|
if (token && !isTokenRequest) {
|
|
15
15
|
context.headers.set("Authorization", `Bearer ${token}`);
|
|
16
16
|
}
|
|
17
|
+
const isRefreshTokenRequest = requestUrl.pathname.endsWith("/api/auth/applications/token/refresh");
|
|
18
|
+
if (isRefreshTokenRequest) {
|
|
19
|
+
const refreshToken = getRefreshToken();
|
|
20
|
+
if (refreshToken) {
|
|
21
|
+
const cookie = context.headers.get("Cookie");
|
|
22
|
+
const newCookie = cookie ? `${cookie}; tela-refresh-token=${refreshToken}` : `tela-refresh-token=${refreshToken}`;
|
|
23
|
+
context.headers.set("Cookie", newCookie);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
17
26
|
}
|
|
18
27
|
});
|
|
19
28
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
declare type Roles = 'meistrari:admin' | (string & {})
|
|
2
|
+
declare module '#app' {
|
|
3
|
+
interface PageMeta {
|
|
4
|
+
auth?: {
|
|
5
|
+
required: false
|
|
6
|
+
} | {
|
|
7
|
+
required: true
|
|
8
|
+
roles?: Roles[]
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// It is always important to ensure you import/export something when augmenting a type
|
|
14
|
+
export { }
|
|
15
|
+
|
package/package.json
CHANGED
|
@@ -1,54 +1,56 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
2
|
+
"name": "@meistrari/auth-nuxt",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": {
|
|
7
|
+
"types": "./dist/types.d.mts",
|
|
8
|
+
"import": "./dist/module.mjs"
|
|
9
|
+
},
|
|
10
|
+
"./server/middleware/auth": {
|
|
11
|
+
"types": "./dist/runtime/server/middleware/auth.d.ts",
|
|
12
|
+
"import": "./dist/runtime/server/middleware/auth.js"
|
|
13
|
+
},
|
|
14
|
+
"./core": {
|
|
15
|
+
"types": "./dist/core.d.mts",
|
|
16
|
+
"import": "./dist/core.mjs"
|
|
17
|
+
}
|
|
9
18
|
},
|
|
10
|
-
"./
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
"main": "./dist/module.mjs",
|
|
20
|
+
"typesVersions": {
|
|
21
|
+
"*": {
|
|
22
|
+
".": [
|
|
23
|
+
"./dist/types.d.mts"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
13
26
|
},
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt-module-build build"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@meistrari/auth-core": "workspace:*",
|
|
35
|
+
"jose": "6.1.3"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"nuxt": "^3.0.0 || ^4.0.0",
|
|
39
|
+
"vue": "^3.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@nuxt/devtools": "2.6.3",
|
|
43
|
+
"@nuxt/eslint-config": "1.9.0",
|
|
44
|
+
"@nuxt/kit": "4.0.3",
|
|
45
|
+
"@nuxt/module-builder": "1.0.2",
|
|
46
|
+
"@nuxt/schema": "4.0.3",
|
|
47
|
+
"@nuxt/test-utils": "3.19.2",
|
|
48
|
+
"@types/node": "latest",
|
|
49
|
+
"changelogen": "0.6.2",
|
|
50
|
+
"nuxt": "4.0.3",
|
|
51
|
+
"typescript": "5.9.2",
|
|
52
|
+
"unbuild": "3.6.1",
|
|
53
|
+
"vitest": "3.2.4",
|
|
54
|
+
"vue-tsc": "3.0.6"
|
|
25
55
|
}
|
|
26
|
-
},
|
|
27
|
-
"files": [
|
|
28
|
-
"dist"
|
|
29
|
-
],
|
|
30
|
-
"scripts": {
|
|
31
|
-
"build": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt-module-build build"
|
|
32
|
-
},
|
|
33
|
-
"dependencies": {
|
|
34
|
-
"@meistrari/auth-core": "1.5.2"
|
|
35
|
-
},
|
|
36
|
-
"peerDependencies": {
|
|
37
|
-
"nuxt": "^3.0.0 || ^4.0.0"
|
|
38
|
-
},
|
|
39
|
-
"devDependencies": {
|
|
40
|
-
"@nuxt/devtools": "2.6.3",
|
|
41
|
-
"@nuxt/eslint-config": "1.9.0",
|
|
42
|
-
"@nuxt/kit": "4.0.3",
|
|
43
|
-
"@nuxt/module-builder": "1.0.2",
|
|
44
|
-
"@nuxt/schema": "4.0.3",
|
|
45
|
-
"@nuxt/test-utils": "3.19.2",
|
|
46
|
-
"@types/node": "latest",
|
|
47
|
-
"changelogen": "0.6.2",
|
|
48
|
-
"nuxt": "4.0.3",
|
|
49
|
-
"typescript": "5.9.2",
|
|
50
|
-
"unbuild": "3.6.1",
|
|
51
|
-
"vitest": "3.2.4",
|
|
52
|
-
"vue-tsc": "3.0.6"
|
|
53
|
-
}
|
|
54
56
|
}
|
|
File without changes
|