@zephkelly/nuxt-authkit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -0
- package/dist/module.d.mts +6 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +80 -0
- package/dist/runtime/composables/useAuthkitToken.d.ts +25 -0
- package/dist/runtime/composables/useAuthkitToken.js +31 -0
- package/dist/runtime/helpers/request-middleware.d.ts +22 -0
- package/dist/runtime/helpers/request-middleware.js +82 -0
- package/dist/runtime/index.d.ts +8 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/plugins/auth-fetch.client.d.ts +13 -0
- package/dist/runtime/plugins/auth-fetch.client.js +91 -0
- package/dist/runtime/plugins/internal/request-target.d.ts +16 -0
- package/dist/runtime/plugins/internal/request-target.js +17 -0
- package/dist/runtime/roles/has-role.d.ts +21 -0
- package/dist/runtime/roles/has-role.js +51 -0
- package/dist/runtime/server/api/_auth/refresh-handler.d.ts +6 -0
- package/dist/runtime/server/api/_auth/refresh-handler.js +44 -0
- package/dist/runtime/server/api/_auth/refresh.post.d.ts +6 -0
- package/dist/runtime/server/api/_auth/refresh.post.js +5 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/service/index.d.ts +119 -0
- package/dist/runtime/service/index.js +170 -0
- package/dist/runtime/service/new-index.d.ts +24 -0
- package/dist/runtime/service/new-index.js +0 -0
- package/dist/runtime/service/types/async-service.d.ts +59 -0
- package/dist/runtime/service/types/async-service.js +0 -0
- package/dist/runtime/service/types/sync-service.d.ts +28 -0
- package/dist/runtime/service/types/sync-service.js +0 -0
- package/dist/runtime/service/utils/getJwtAsyncService.d.ts +11 -0
- package/dist/runtime/service/utils/getJwtAsyncService.js +16 -0
- package/dist/runtime/storage/nitro.d.ts +61 -0
- package/dist/runtime/storage/nitro.js +124 -0
- package/dist/runtime/storage/types.d.ts +27 -0
- package/dist/runtime/storage/types.js +0 -0
- package/dist/runtime/strategy/base.d.ts +40 -0
- package/dist/runtime/strategy/base.js +88 -0
- package/dist/runtime/strategy/jwt/callback.d.ts +60 -0
- package/dist/runtime/strategy/jwt/callback.js +0 -0
- package/dist/runtime/strategy/jwt/index.d.ts +137 -0
- package/dist/runtime/strategy/jwt/index.js +748 -0
- package/dist/runtime/strategy/jwt/jwt-crypto.d.ts +122 -0
- package/dist/runtime/strategy/jwt/jwt-crypto.js +298 -0
- package/dist/runtime/strategy/jwt/types.d.ts +68 -0
- package/dist/runtime/strategy/jwt/types.js +0 -0
- package/dist/runtime/strategy/types.d.ts +68 -0
- package/dist/runtime/strategy/types.js +0 -0
- package/dist/runtime/types/2fac.d.ts +32 -0
- package/dist/runtime/types/2fac.js +0 -0
- package/dist/runtime/types/authkit-types.d.ts +6 -0
- package/dist/runtime/types/authkit-types.js +0 -0
- package/dist/runtime/types/callbacks.d.ts +133 -0
- package/dist/runtime/types/callbacks.js +0 -0
- package/dist/runtime/types/cookie.d.ts +47 -0
- package/dist/runtime/types/cookie.js +0 -0
- package/dist/runtime/types/expand.d.ts +3 -0
- package/dist/runtime/types/expand.js +0 -0
- package/dist/runtime/types/index.d.ts +52 -0
- package/dist/runtime/types/index.js +0 -0
- package/dist/runtime/types/module-options.d.ts +33 -0
- package/dist/runtime/types/module-options.js +0 -0
- package/dist/runtime/types/runtime-config.d.ts +2 -0
- package/dist/runtime/types/runtime-config.js +47 -0
- package/dist/runtime/types/token.d.ts +42 -0
- package/dist/runtime/types/token.js +12 -0
- package/dist/runtime/utils/context-helpers.d.ts +4 -0
- package/dist/runtime/utils/context-helpers.js +10 -0
- package/dist/runtime/utils/get-module-options.d.ts +2 -0
- package/dist/runtime/utils/get-module-options.js +18 -0
- package/dist/runtime/utils/password.d.ts +23 -0
- package/dist/runtime/utils/password.js +18 -0
- package/dist/runtime/utils/uuid.d.ts +187 -0
- package/dist/runtime/utils/uuid.js +345 -0
- package/dist/types.d.mts +7 -0
- package/package.json +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# @zephkelly/nuxt-authkit
|
|
2
|
+
|
|
3
|
+
An authentication toolkit for Nuxt applications. JWT access/refresh tokens with
|
|
4
|
+
rotating refresh tokens, HttpOnly cookie storage, role guards, and scrypt password
|
|
5
|
+
hashing.
|
|
6
|
+
|
|
7
|
+
> **Upgrading from 0.0.x?** Read [MIGRATION.md](./MIGRATION.md) first. This release
|
|
8
|
+
> **logs every user out once** — tokens minted by 0.0.x carry no `typ` claim and are
|
|
9
|
+
> now rejected — and three changes need action in consuming apps.
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
// nuxt.config.ts
|
|
15
|
+
export default defineNuxtConfig({
|
|
16
|
+
modules: ['@zephkelly/nuxt-authkit'],
|
|
17
|
+
|
|
18
|
+
nuxtAuthkit: {
|
|
19
|
+
strategies: ['jwt'],
|
|
20
|
+
strategy: {
|
|
21
|
+
name: 'jwt',
|
|
22
|
+
tokens: {
|
|
23
|
+
access: { expiresIn: 900 }, // 15 minutes
|
|
24
|
+
refresh: {
|
|
25
|
+
expiresIn: 604800, // 7 days
|
|
26
|
+
rotate: true, // rotate on every refresh (default)
|
|
27
|
+
cookie: {
|
|
28
|
+
name: 'refreshToken',
|
|
29
|
+
secure: true,
|
|
30
|
+
httpOnly: true, // forced on regardless
|
|
31
|
+
sameSite: 'strict'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
jwt: {
|
|
36
|
+
algorithm: 'RS256',
|
|
37
|
+
privateKey: process.env.JWT_PRIVATE_KEY!,
|
|
38
|
+
publicKey: process.env.JWT_PUBLIC_KEY!
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Generate an RSA key pair:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out private.pem
|
|
49
|
+
openssl rsa -pubout -in private.pem -out public.pem
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Keep the private key in `JWT_PRIVATE_KEY` (or `NUXT_NUXTAUTHKIT_STRATEGY_JWT_PRIVATE_KEY`)
|
|
53
|
+
— it belongs in the environment, never in a committed config or a published build
|
|
54
|
+
output.
|
|
55
|
+
|
|
56
|
+
## Wiring it up
|
|
57
|
+
|
|
58
|
+
Register the strategy in a Nitro plugin. The callbacks are where your app plugs in
|
|
59
|
+
its own storage and user directory.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// server/plugins/auth.ts
|
|
63
|
+
export default defineNitroPlugin(() => {
|
|
64
|
+
createNuxtAuthkit(
|
|
65
|
+
createJWTStrategy({
|
|
66
|
+
// Required: verify credentials.
|
|
67
|
+
async onAuthenticate({ email, password }) {
|
|
68
|
+
const user = await db.users.findByEmail(email);
|
|
69
|
+
if (!user || !await verifyPassword(user.passwordHash, password)) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
return { id: user.id, roles: user.roles };
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Required: persist a refresh token. Store a HASH, not the token —
|
|
76
|
+
// then a leaked database snapshot is not a set of live sessions.
|
|
77
|
+
async onStoreRefreshToken(token, userId) {
|
|
78
|
+
await db.refreshTokens.insert({ userId, hash: sha256(token) });
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
// Required: look one up.
|
|
82
|
+
async onGetRefreshToken(token, userId) {
|
|
83
|
+
const record = await db.refreshTokens.find(sha256(token));
|
|
84
|
+
if (!record) return { valid: false };
|
|
85
|
+
|
|
86
|
+
// Rotated away already but presented again → the token was stolen.
|
|
87
|
+
// authkit responds by revoking the whole family via onRevokeTokenFamily.
|
|
88
|
+
// Allow a short grace period, or concurrent refreshes log users out.
|
|
89
|
+
if (record.consumedAt) {
|
|
90
|
+
const age = Date.now() - record.consumedAt;
|
|
91
|
+
return age < 10_000 ? { valid: true } : { valid: false, reused: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Roles read fresh from the DB, so a ban applies on the next refresh
|
|
95
|
+
// rather than whenever the user next logs in.
|
|
96
|
+
return { valid: true, roles: await db.users.rolesOf(userId) };
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Strongly recommended: without this, logout cannot terminate a session
|
|
100
|
+
// server-side and rotation never invalidates the old token.
|
|
101
|
+
async onRemoveRefreshToken(token) {
|
|
102
|
+
await db.refreshTokens.markConsumed(sha256(token));
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// Recommended: theft response.
|
|
106
|
+
async onRevokeTokenFamily(family) {
|
|
107
|
+
await db.refreshTokens.revokeFamily(family);
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Recommended: admin force-logout. revokeServer() throws without it.
|
|
111
|
+
async onRevokeServer({ userId }) {
|
|
112
|
+
await db.refreshTokens.deleteAllForUser(userId);
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The library warns at startup about any recommended callback you have not
|
|
120
|
+
implemented, and what the consequence is.
|
|
121
|
+
|
|
122
|
+
## Protecting routes
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
export default defineEventHandler({
|
|
126
|
+
onRequest: [
|
|
127
|
+
() => hasAuthkitSession(), // 401 without a valid session
|
|
128
|
+
() => hasAuthkitRole('admin'), // 401 unauthenticated, 403 without the role
|
|
129
|
+
() => rejectAuthkitRole('banned') // deny-filter; also 401 unauthenticated
|
|
130
|
+
],
|
|
131
|
+
handler: async () => { /* ... */ }
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`rejectAuthkitRole` is a deny-filter, **not** an authentication gate. It requires a
|
|
136
|
+
valid session: proving a caller does *not* hold a role first requires knowing who
|
|
137
|
+
they are.
|
|
138
|
+
|
|
139
|
+
## Tokens
|
|
140
|
+
|
|
141
|
+
`authenticate()` sets the refresh token as an HttpOnly cookie and returns the
|
|
142
|
+
access token. **Never put the refresh token in a response body** — from there it
|
|
143
|
+
reaches JavaScript, and it is a multi-day credential.
|
|
144
|
+
|
|
145
|
+
Access and refresh tokens carry a signed `typ` claim, so a refresh token cannot be
|
|
146
|
+
replayed as a Bearer access token.
|
|
147
|
+
|
|
148
|
+
On the client, the shipped plugin attaches the access token to same-origin requests
|
|
149
|
+
and transparently refreshes + retries once on a 401:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
const { accessToken, setAccessToken, clearAccessToken } = useAuthkitToken();
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Passwords
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const hash = await hashPassword(plain);
|
|
159
|
+
const ok = await verifyPassword(hash, plain);
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
scrypt via `@adonisjs/hash`. Cost parameters are configurable under
|
|
163
|
+
`nuxtAuthkit.scrypt`; parameters are embedded in each stored hash, so raising them
|
|
164
|
+
later does not invalidate existing passwords.
|
|
165
|
+
|
|
166
|
+
## Security
|
|
167
|
+
|
|
168
|
+
See [SECURITY-AUDIT-JWT.md](./SECURITY-AUDIT-JWT.md) for the audit this library's
|
|
169
|
+
current design responds to, and [MIGRATION.md](./MIGRATION.md) for the resulting
|
|
170
|
+
changes.
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, updateRuntimeConfig, addServerImports, addServerHandler, addPlugin } from '@nuxt/kit';
|
|
2
|
+
import { mergeRuntimeConfig } from '../dist/runtime/utils/get-module-options.js';
|
|
3
|
+
|
|
4
|
+
const module = defineNuxtModule({
|
|
5
|
+
meta: {
|
|
6
|
+
name: "nuxt-authkit",
|
|
7
|
+
configKey: "nuxtAuthkit"
|
|
8
|
+
},
|
|
9
|
+
defaults: {
|
|
10
|
+
strategies: ["jwt"],
|
|
11
|
+
scrypt: {},
|
|
12
|
+
clientPlugin: true
|
|
13
|
+
},
|
|
14
|
+
setup(_options, _nuxt) {
|
|
15
|
+
const resolver = createResolver(import.meta.url);
|
|
16
|
+
_nuxt.options.alias = _nuxt.options.alias || {};
|
|
17
|
+
_nuxt.options.alias["#nuxt-authkit"] = resolver.resolve("./runtime/index");
|
|
18
|
+
_nuxt.hook("nitro:config", async (nitroConfig) => {
|
|
19
|
+
nitroConfig.experimental = nitroConfig.experimental || {};
|
|
20
|
+
nitroConfig.experimental.asyncContext = true;
|
|
21
|
+
});
|
|
22
|
+
const mergedRuntimeConfig = mergeRuntimeConfig(_options);
|
|
23
|
+
updateRuntimeConfig(mergedRuntimeConfig);
|
|
24
|
+
let getAuthkitUtil = null;
|
|
25
|
+
if (mergedRuntimeConfig?.nuxtAuthkit.strategies.includes("jwt")) {
|
|
26
|
+
getAuthkitUtil = {
|
|
27
|
+
name: "getAuthkit",
|
|
28
|
+
from: resolver.resolve("./runtime/service/utils/getJwtAsyncService")
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
addServerImports([
|
|
32
|
+
{
|
|
33
|
+
name: "createNuxtAuthkit",
|
|
34
|
+
from: resolver.resolve("./runtime/index")
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: "createJWTStrategy",
|
|
38
|
+
from: resolver.resolve("./runtime/strategy/jwt")
|
|
39
|
+
},
|
|
40
|
+
...getAuthkitUtil ? [getAuthkitUtil] : []
|
|
41
|
+
]);
|
|
42
|
+
addServerImports([
|
|
43
|
+
{
|
|
44
|
+
name: "hasAuthkitRole",
|
|
45
|
+
from: resolver.resolve("./runtime/helpers/request-middleware")
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "hasAuthkitSession",
|
|
49
|
+
from: resolver.resolve("./runtime/helpers/request-middleware")
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "rejectAuthkitRole",
|
|
53
|
+
from: resolver.resolve("./runtime/helpers/request-middleware")
|
|
54
|
+
}
|
|
55
|
+
]);
|
|
56
|
+
addServerImports([
|
|
57
|
+
{
|
|
58
|
+
name: "hashPassword",
|
|
59
|
+
from: resolver.resolve("./runtime/utils/password")
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
name: "verifyPassword",
|
|
63
|
+
from: resolver.resolve("./runtime/utils/password")
|
|
64
|
+
}
|
|
65
|
+
]);
|
|
66
|
+
addServerHandler({
|
|
67
|
+
route: "/api/_authkit/refresh",
|
|
68
|
+
method: "post",
|
|
69
|
+
handler: resolver.resolve("./runtime/server/api/_auth/refresh.post")
|
|
70
|
+
});
|
|
71
|
+
if (_options.clientPlugin !== false) {
|
|
72
|
+
addPlugin({
|
|
73
|
+
src: resolver.resolve("./runtime/plugins/auth-fetch.client"),
|
|
74
|
+
mode: "client"
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export { module as default };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access to the current access token.
|
|
3
|
+
*
|
|
4
|
+
* The refresh token deliberately has no presence here. It lives in an HttpOnly
|
|
5
|
+
* cookie, unreadable from JavaScript, and the server reads it from there and
|
|
6
|
+
* nowhere else. Mirroring it into `useState` would put a multi-day credential
|
|
7
|
+
* within reach of any XSS on the page (and serialise it into `window.__NUXT__`
|
|
8
|
+
* during SSR) in exchange for nothing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function useAuthkitToken(): {
|
|
11
|
+
accessToken: Readonly<import("vue").Ref<string | null, string | null>>;
|
|
12
|
+
setAccessToken: (token: string | null) => void;
|
|
13
|
+
clearAccessToken: () => void;
|
|
14
|
+
getAuthHeader: () => {
|
|
15
|
+
Authorization: string;
|
|
16
|
+
} | {
|
|
17
|
+
Authorization?: undefined;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated The refresh token is HttpOnly and is never held in client
|
|
21
|
+
* state. Clearing the access token is what "log out locally" means here.
|
|
22
|
+
* This alias exists so 0.0.x callers keep compiling; it will be removed.
|
|
23
|
+
*/
|
|
24
|
+
clearRefreshToken: () => void;
|
|
25
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useState } from "#app";
|
|
2
|
+
import { readonly } from "vue";
|
|
3
|
+
export function useAuthkitToken() {
|
|
4
|
+
const accessToken = useState("authkit-access-token", () => null);
|
|
5
|
+
function setAccessToken(token) {
|
|
6
|
+
accessToken.value = token;
|
|
7
|
+
}
|
|
8
|
+
function clearAccessToken() {
|
|
9
|
+
accessToken.value = null;
|
|
10
|
+
}
|
|
11
|
+
function getAuthHeader() {
|
|
12
|
+
if (accessToken.value) {
|
|
13
|
+
return {
|
|
14
|
+
Authorization: `Bearer ${accessToken.value}`
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
accessToken: readonly(accessToken),
|
|
21
|
+
setAccessToken,
|
|
22
|
+
clearAccessToken,
|
|
23
|
+
getAuthHeader,
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated The refresh token is HttpOnly and is never held in client
|
|
26
|
+
* state. Clearing the access token is what "log out locally" means here.
|
|
27
|
+
* This alias exists so 0.0.x callers keep compiling; it will be removed.
|
|
28
|
+
*/
|
|
29
|
+
clearRefreshToken: clearAccessToken
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Require a valid session. Throws 401 otherwise.
|
|
3
|
+
*/
|
|
4
|
+
export declare function hasAuthkitSession(): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Require the caller to hold at least one of `roles` (or all of them when
|
|
7
|
+
* `requireAll`). Throws 401 without a valid session, 403 without the role.
|
|
8
|
+
*/
|
|
9
|
+
export declare function hasAuthkitRole(roles: string | string[], requireAll?: boolean): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Deny-filter: reject callers who hold any of `roles` (or all of them when
|
|
12
|
+
* `requireAll`).
|
|
13
|
+
*
|
|
14
|
+
* This is NOT an authentication gate — it is the inverse of one, and it requires
|
|
15
|
+
* a valid session to mean anything. Without the session check below, an
|
|
16
|
+
* unauthenticated request has no roles, "has no roles" reads as "does not hold
|
|
17
|
+
* the rejected role", and the guard waves it straight through: a banned user
|
|
18
|
+
* evades `rejectAuthkitRole('banned')` by simply dropping their Authorization
|
|
19
|
+
* header. Proving a caller does *not* hold a role first requires knowing who
|
|
20
|
+
* they are.
|
|
21
|
+
*/
|
|
22
|
+
export declare function rejectAuthkitRole(roles: string | string[], requireAll?: boolean): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
import { getAuthkit } from "../service/utils/getJwtAsyncService.js";
|
|
3
|
+
import { hasRole } from "../roles/has-role.js";
|
|
4
|
+
function isHttpError(error) {
|
|
5
|
+
return !!error && typeof error === "object" && "statusCode" in error;
|
|
6
|
+
}
|
|
7
|
+
export async function hasAuthkitSession() {
|
|
8
|
+
try {
|
|
9
|
+
const authkit = getAuthkit();
|
|
10
|
+
const tokenSet = await authkit.verify();
|
|
11
|
+
if (!tokenSet.valid) {
|
|
12
|
+
throw createError({
|
|
13
|
+
statusCode: 401,
|
|
14
|
+
message: "Unauthorized"
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (isHttpError(error)) {
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
throw createError({
|
|
22
|
+
statusCode: 401,
|
|
23
|
+
message: "Unauthorized"
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export async function hasAuthkitRole(roles, requireAll = false) {
|
|
28
|
+
try {
|
|
29
|
+
const authkit = getAuthkit();
|
|
30
|
+
const verificationResult = await authkit.verify();
|
|
31
|
+
if (!verificationResult.valid) {
|
|
32
|
+
throw createError({
|
|
33
|
+
statusCode: 401,
|
|
34
|
+
message: "Unauthorized"
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const payloadRoles = verificationResult.payload?.roles || [];
|
|
38
|
+
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
|
39
|
+
if (!hasRole(payloadRoles, allowedRoles, requireAll)) {
|
|
40
|
+
throw createError({
|
|
41
|
+
statusCode: 403,
|
|
42
|
+
message: "Forbidden"
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (isHttpError(error)) {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
throw createError({
|
|
50
|
+
statusCode: 403,
|
|
51
|
+
message: "Forbidden"
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function rejectAuthkitRole(roles, requireAll = false) {
|
|
56
|
+
try {
|
|
57
|
+
const authkit = getAuthkit();
|
|
58
|
+
const verificationResult = await authkit.verify();
|
|
59
|
+
if (!verificationResult.valid) {
|
|
60
|
+
throw createError({
|
|
61
|
+
statusCode: 401,
|
|
62
|
+
message: "Unauthorized"
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const payloadRoles = verificationResult.payload?.roles || [];
|
|
66
|
+
const rejectedRoles = Array.isArray(roles) ? roles : [roles];
|
|
67
|
+
if (hasRole(payloadRoles, rejectedRoles, requireAll)) {
|
|
68
|
+
throw createError({
|
|
69
|
+
statusCode: 403,
|
|
70
|
+
message: "Forbidden"
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (isHttpError(error)) {
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
throw createError({
|
|
78
|
+
statusCode: 403,
|
|
79
|
+
message: "Forbidden"
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createNuxtAuthkit } from './service/index.js';
|
|
2
|
+
export { createJWTStrategy } from './strategy/jwt/index.js';
|
|
3
|
+
export type { AuthCallbacks, RefreshTokenLookup, RefreshTokenLookupResult } from './types/callbacks.js';
|
|
4
|
+
export type { AuthkitCredentials, AuthkitUser } from './types/authkit-types.js';
|
|
5
|
+
export type { TokenPayload, TokenSet, TokenMetadata, TokenUse } from './types/token.js';
|
|
6
|
+
export type { AuthResult, VerifyAuthResult, AuthError, AuthErrorCode } from './types/index.js';
|
|
7
|
+
export type { JWTAuthCallbacks } from './strategy/jwt/callback.js';
|
|
8
|
+
export type { JwtStrategyConfig, JWTVerifyOptions, JWTRefreshOptions, JWTServerRevokeOptions } from './strategy/jwt/types.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-only. This plugin assigns `globalThis.$fetch`, and on the server that is
|
|
3
|
+
* a single process-wide object shared by every concurrent SSR request — one
|
|
4
|
+
* request's setup would overwrite another's, handing request A the token store
|
|
5
|
+
* closed over by request B. In the browser `globalThis` is the tab, so exactly
|
|
6
|
+
* one user's session is ever in scope.
|
|
7
|
+
*
|
|
8
|
+
* The `.client` suffix is what enforces that. Nuxt matches plugin mode on
|
|
9
|
+
* `/\.(server|client)(\.\w+)*$/` — a *dot*, not a hyphen. A file named
|
|
10
|
+
* `auth-fetch-client.ts` does not match, registers as mode 'all', and runs on SSR.
|
|
11
|
+
*/
|
|
12
|
+
declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
|
|
13
|
+
export default _default;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { defineNuxtPlugin } from "#app";
|
|
2
|
+
import { useAuthkitToken } from "../composables/useAuthkitToken.js";
|
|
3
|
+
import { REFRESH_ENDPOINT, isSameOrigin, isRefreshRequest } from "./internal/request-target.js";
|
|
4
|
+
export default defineNuxtPlugin(() => {
|
|
5
|
+
const tokenStore = useAuthkitToken();
|
|
6
|
+
let refreshPromise = null;
|
|
7
|
+
const origin = () => window.location.origin;
|
|
8
|
+
const performRefresh = async () => {
|
|
9
|
+
try {
|
|
10
|
+
const response = await $fetch.raw(REFRESH_ENDPOINT, {
|
|
11
|
+
method: "POST"
|
|
12
|
+
});
|
|
13
|
+
const newToken = response?._data?.data?.tokens?.accessToken;
|
|
14
|
+
if (newToken) {
|
|
15
|
+
tokenStore.setAccessToken(newToken);
|
|
16
|
+
return newToken;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
} catch (error) {
|
|
20
|
+
tokenStore.clearAccessToken();
|
|
21
|
+
throw error;
|
|
22
|
+
} finally {
|
|
23
|
+
refreshPromise = null;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
globalThis.$fetch = $fetch.create({
|
|
27
|
+
async onRequest({ options, request }) {
|
|
28
|
+
if (isRefreshRequest(request, origin())) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (refreshPromise) {
|
|
32
|
+
await refreshPromise.catch(() => {
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (!isSameOrigin(request, origin())) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const token = tokenStore.accessToken.value;
|
|
39
|
+
if (token) {
|
|
40
|
+
options.headers = {
|
|
41
|
+
...options.headers,
|
|
42
|
+
...tokenStore.getAuthHeader()
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
async onResponse({ response, request }) {
|
|
47
|
+
if (!isRefreshRequest(request, origin())) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const newToken = response._data?.data?.tokens?.accessToken;
|
|
51
|
+
if (newToken) {
|
|
52
|
+
tokenStore.setAccessToken(newToken);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
async onResponseError({ response, options, request }) {
|
|
56
|
+
if (isRefreshRequest(request, origin())) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (response.status !== 401) return;
|
|
60
|
+
if (options.headers?.["x-retried"]) {
|
|
61
|
+
tokenStore.clearAccessToken();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (response._data?.error?.code === "REFRESH_TOKEN_EXPIRED" || response._data?.error?.recoverable === false) {
|
|
65
|
+
tokenStore.clearAccessToken();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (!refreshPromise) {
|
|
69
|
+
refreshPromise = performRefresh();
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const newToken = await refreshPromise;
|
|
73
|
+
if (!newToken) {
|
|
74
|
+
tokenStore.clearAccessToken();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
return await $fetch(request, {
|
|
78
|
+
...options,
|
|
79
|
+
headers: {
|
|
80
|
+
...options.headers,
|
|
81
|
+
"Authorization": `Bearer ${newToken}`,
|
|
82
|
+
"x-retried": "true"
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
tokenStore.clearAccessToken();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare const REFRESH_ENDPOINT = "/api/_authkit/refresh";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a request is going to our own origin.
|
|
4
|
+
*
|
|
5
|
+
* The interceptor patches the global `$fetch`, so it sees every request the app
|
|
6
|
+
* makes — including third-party ones (payments, maps, analytics) and any URL an
|
|
7
|
+
* attacker managed to influence. Attaching the session token to those hands the
|
|
8
|
+
* user's credential to whoever is on the other end, and a permissive CORS policy
|
|
9
|
+
* on their side is all it takes for the call to succeed.
|
|
10
|
+
*/
|
|
11
|
+
export declare function isSameOrigin(request: unknown, origin: string): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Whether a request targets our own refresh endpoint — the only response allowed
|
|
14
|
+
* to hand us a new session token.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isRefreshRequest(request: unknown, origin: string): boolean;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const REFRESH_ENDPOINT = "/api/_authkit/refresh";
|
|
2
|
+
function toUrl(request, origin) {
|
|
3
|
+
const raw = typeof request === "string" ? request : typeof Request !== "undefined" && request instanceof Request ? request.url : String(request);
|
|
4
|
+
try {
|
|
5
|
+
return new URL(raw, origin);
|
|
6
|
+
} catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function isSameOrigin(request, origin) {
|
|
11
|
+
return toUrl(request, origin)?.origin === origin;
|
|
12
|
+
}
|
|
13
|
+
export function isRefreshRequest(request, origin) {
|
|
14
|
+
const url = toUrl(request, origin);
|
|
15
|
+
if (!url) return false;
|
|
16
|
+
return url.origin === origin && url.pathname === REFRESH_ENDPOINT;
|
|
17
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core function to check if a user has the specified role(s)
|
|
3
|
+
* Works with both arrays of roles and role objects with primary_role/roles properties
|
|
4
|
+
*
|
|
5
|
+
* @param {string[] | { primary_role?: string, roles?: string[] } | null | undefined} userRoles - User roles to check
|
|
6
|
+
* @param {string[]} requiredRoles - Role or array of roles to check against
|
|
7
|
+
* @param {boolean} requireAll - If true, user must have ALL specified roles; if false, user only needs ONE of the roles
|
|
8
|
+
* @returns {boolean} - True if user has the required role(s), false otherwise
|
|
9
|
+
*/
|
|
10
|
+
export declare function checkRole(userRoles: (string)[] | {
|
|
11
|
+
primary_role?: string;
|
|
12
|
+
roles?: (string)[];
|
|
13
|
+
} | null | undefined, requiredRoles: string | string[], requireAll?: boolean): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Server-side role checking function
|
|
16
|
+
*
|
|
17
|
+
* @param string[] payloadRoles - Roles from the token payload
|
|
18
|
+
* @param string[] roles - Role or array of roles to check against
|
|
19
|
+
* @returns {boolean} - True if user has the role, false otherwise
|
|
20
|
+
*/
|
|
21
|
+
export declare function hasRole(payloadRoles: string[] | undefined, roles: string[], requireAll?: boolean): boolean;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createError } from "h3";
|
|
2
|
+
import { consola } from "consola";
|
|
3
|
+
export function checkRole(userRoles, requiredRoles, requireAll = false) {
|
|
4
|
+
if (!userRoles) {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
if (!requiredRoles) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
const rolesToCheck = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];
|
|
11
|
+
if (rolesToCheck.length === 0) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(userRoles)) {
|
|
15
|
+
if (requireAll) {
|
|
16
|
+
return rolesToCheck.every((role) => userRoles.includes(role));
|
|
17
|
+
} else {
|
|
18
|
+
return rolesToCheck.some((role) => userRoles.includes(role));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const allUserRoles = [];
|
|
22
|
+
if (userRoles.primary_role) {
|
|
23
|
+
allUserRoles.push(userRoles.primary_role);
|
|
24
|
+
}
|
|
25
|
+
if (userRoles.roles && Array.isArray(userRoles.roles)) {
|
|
26
|
+
allUserRoles.push(...userRoles.roles);
|
|
27
|
+
}
|
|
28
|
+
if (requireAll) {
|
|
29
|
+
return rolesToCheck.every((role) => allUserRoles.includes(role));
|
|
30
|
+
} else {
|
|
31
|
+
return rolesToCheck.some((role) => allUserRoles.includes(role));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function hasRole(payloadRoles, roles, requireAll = false) {
|
|
35
|
+
if (!roles) {
|
|
36
|
+
if (import.meta.server) {
|
|
37
|
+
throw createError({
|
|
38
|
+
statusCode: 500,
|
|
39
|
+
message: "Validation rules not provided"
|
|
40
|
+
});
|
|
41
|
+
} else if (import.meta.client || import.meta.browser) {
|
|
42
|
+
consola.error("[\x1B[31m authkit \x1B[0m] hasRole called without roles to check against");
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
if (!payloadRoles) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return checkRole(payloadRoles, roles, requireAll);
|
|
51
|
+
}
|