@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
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { BaseStrategy } from "../base.js";
|
|
2
|
+
import { RESERVED_CLAIMS } from "../../types/token.js";
|
|
3
|
+
import { NitroStorage } from "../../storage/nitro.js";
|
|
4
|
+
import { useRuntimeConfig } from "#imports";
|
|
5
|
+
import { getCookie, setCookie, deleteCookie } from "h3";
|
|
6
|
+
import { timingSafeEqual } from "node:crypto";
|
|
7
|
+
import { consola } from "consola";
|
|
8
|
+
import { JWTCrypto, JWTCryptoError } from "./jwt-crypto.js";
|
|
9
|
+
import { uuidv7 } from "../../utils/uuid.js";
|
|
10
|
+
import { storeContextData, getContextData } from "../../utils/context-helpers.js";
|
|
11
|
+
const ACCESS_TOKEN_LIFETIME_WARN_THRESHOLD = 24 * 60 * 60;
|
|
12
|
+
export class JWTStrategy extends BaseStrategy {
|
|
13
|
+
jwtCrypto;
|
|
14
|
+
jwtConfig;
|
|
15
|
+
constructor(callbacks) {
|
|
16
|
+
const runtimeConfig = useRuntimeConfig();
|
|
17
|
+
const defaultConfig = runtimeConfig.nuxtAuthkit.strategy;
|
|
18
|
+
const fullConfig = {
|
|
19
|
+
...defaultConfig,
|
|
20
|
+
storage: new NitroStorage(
|
|
21
|
+
// @ts-expect-error
|
|
22
|
+
useStorage(
|
|
23
|
+
"nuxt-authkit"
|
|
24
|
+
)
|
|
25
|
+
)
|
|
26
|
+
};
|
|
27
|
+
super(fullConfig, callbacks);
|
|
28
|
+
this.jwtConfig = fullConfig;
|
|
29
|
+
this.assertNoReservedClaims(this.jwtConfig.jwt?.claims);
|
|
30
|
+
this.jwtCrypto = new JWTCrypto({
|
|
31
|
+
privateKey: this.jwtConfig.jwt?.privateKey,
|
|
32
|
+
publicKey: this.jwtConfig.jwt?.publicKey,
|
|
33
|
+
algorithm: this.jwtConfig.jwt?.algorithm || "RS256"
|
|
34
|
+
});
|
|
35
|
+
this.warnOnWeakConfiguration();
|
|
36
|
+
}
|
|
37
|
+
async authenticate(credentials, event, options) {
|
|
38
|
+
if (!event) {
|
|
39
|
+
throw this.createError(
|
|
40
|
+
"EVENT_UNAVAILABLE",
|
|
41
|
+
"Unable to access the current event context"
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const authenticationResult = this.getStructuredAuthenticateResult(await this.callbacks.onAuthenticate(
|
|
46
|
+
credentials,
|
|
47
|
+
event
|
|
48
|
+
));
|
|
49
|
+
const userIdentifier = authenticationResult?.id;
|
|
50
|
+
if (!userIdentifier) {
|
|
51
|
+
return {
|
|
52
|
+
success: false,
|
|
53
|
+
error: {
|
|
54
|
+
code: "INVALID_CREDENTIALS",
|
|
55
|
+
message: "Invalid credentials",
|
|
56
|
+
recoverable: true
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const tokens = await this.generateTokens(
|
|
61
|
+
userIdentifier,
|
|
62
|
+
true,
|
|
63
|
+
authenticationResult?.roles,
|
|
64
|
+
authenticationResult?.claims,
|
|
65
|
+
uuidv7(),
|
|
66
|
+
options
|
|
67
|
+
);
|
|
68
|
+
await this.callbacks.onStoreRefreshToken(tokens.refreshToken, userIdentifier, event);
|
|
69
|
+
if (this.jwtConfig.tokens.refresh?.cookie) {
|
|
70
|
+
if (!tokens.refreshToken) {
|
|
71
|
+
throw this.createError(
|
|
72
|
+
"UNKNOWN_ERROR",
|
|
73
|
+
"Refresh token creation failed"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
this.issueRefreshHttpOnlyCookie(
|
|
77
|
+
event,
|
|
78
|
+
tokens.refreshToken
|
|
79
|
+
);
|
|
80
|
+
const result2 = {
|
|
81
|
+
success: true,
|
|
82
|
+
tokens: {
|
|
83
|
+
...tokens,
|
|
84
|
+
refreshToken: void 0
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
await this.notifyAuthenticated(result2, event);
|
|
88
|
+
return result2;
|
|
89
|
+
}
|
|
90
|
+
const result = {
|
|
91
|
+
success: true,
|
|
92
|
+
tokens
|
|
93
|
+
};
|
|
94
|
+
await this.notifyAuthenticated(result, event);
|
|
95
|
+
return result;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return this.handleError(error, event);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
verify(event, options) {
|
|
101
|
+
try {
|
|
102
|
+
if (!event) {
|
|
103
|
+
throw this.createError(
|
|
104
|
+
"EVENT_UNAVAILABLE",
|
|
105
|
+
"Unable to access the current event context"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const isAmbientVerification = !options?.accessToken && !options?.ignoreExpiration;
|
|
109
|
+
if (isAmbientVerification) {
|
|
110
|
+
const cached = getContextData(event, "jwt-verify-result");
|
|
111
|
+
if (cached) {
|
|
112
|
+
return cached;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const accessToken = options?.accessToken ? options.accessToken : this.getAuthBearerToken(event);
|
|
116
|
+
if (!accessToken) {
|
|
117
|
+
return this.invalidToken("Invalid access token");
|
|
118
|
+
}
|
|
119
|
+
const accessPayload = this.decodeToken(accessToken);
|
|
120
|
+
if (!accessPayload || !accessPayload.sub) {
|
|
121
|
+
return this.invalidToken("Invalid access token");
|
|
122
|
+
}
|
|
123
|
+
const claimError = this.validateClaims(accessPayload, "access", {
|
|
124
|
+
ignoreExpiration: options?.ignoreExpiration
|
|
125
|
+
});
|
|
126
|
+
if (claimError) {
|
|
127
|
+
return { valid: false, error: claimError };
|
|
128
|
+
}
|
|
129
|
+
const shouldRefresh = this.shouldRefreshToken(accessPayload);
|
|
130
|
+
const response = {
|
|
131
|
+
valid: true,
|
|
132
|
+
payload: accessPayload,
|
|
133
|
+
shouldRefresh
|
|
134
|
+
};
|
|
135
|
+
if (isAmbientVerification) {
|
|
136
|
+
storeContextData(event, "jwt-verify-result", response);
|
|
137
|
+
}
|
|
138
|
+
return response;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return {
|
|
141
|
+
valid: false,
|
|
142
|
+
error: this.extractAuthError(error)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Verify a bare access token, with no request context.
|
|
148
|
+
*
|
|
149
|
+
* `verify()` resolves the token from the ambient H3 event, which does not
|
|
150
|
+
* exist for a WebSocket peer, a queue worker, or a signed asset URL carrying
|
|
151
|
+
* its own token. Those callers have a token and nothing else, and this is the
|
|
152
|
+
* entry point for them.
|
|
153
|
+
*
|
|
154
|
+
* Claim validation is identical to `verify()` — in particular `typ` must be
|
|
155
|
+
* `'access'`, so a refresh token cannot be replayed here to open a socket.
|
|
156
|
+
*/
|
|
157
|
+
verifyToken(accessToken) {
|
|
158
|
+
try {
|
|
159
|
+
if (!accessToken) {
|
|
160
|
+
return this.invalidToken("Invalid access token");
|
|
161
|
+
}
|
|
162
|
+
const payload = this.decodeToken(accessToken);
|
|
163
|
+
if (!payload || !payload.sub) {
|
|
164
|
+
return this.invalidToken("Invalid access token");
|
|
165
|
+
}
|
|
166
|
+
const claimError = this.validateClaims(payload, "access");
|
|
167
|
+
if (claimError) {
|
|
168
|
+
return { valid: false, error: claimError };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
valid: true,
|
|
172
|
+
payload,
|
|
173
|
+
shouldRefresh: this.shouldRefreshToken(payload)
|
|
174
|
+
};
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return {
|
|
177
|
+
valid: false,
|
|
178
|
+
error: this.extractAuthError(error)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Mint a token pair for a user directly, without presenting credentials.
|
|
184
|
+
*
|
|
185
|
+
* `authenticate()` is the credential path and always runs `onAuthenticate`.
|
|
186
|
+
* This is the path for the flows where the app has *already* established who
|
|
187
|
+
* the user is by some other means — an OAuth callback, an accepted invite, an
|
|
188
|
+
* admin impersonating a user, a device-linking handshake — and needs to hand
|
|
189
|
+
* out a session for a subject it chose itself.
|
|
190
|
+
*
|
|
191
|
+
* It is a session-minting primitive with no authentication of its own: the
|
|
192
|
+
* caller is asserting the identity. Never reach it from an unauthenticated
|
|
193
|
+
* route without first proving the subject some other way.
|
|
194
|
+
*
|
|
195
|
+
* Starts a fresh rotation family, so it is a real new session rather than a
|
|
196
|
+
* graft onto an existing one.
|
|
197
|
+
*/
|
|
198
|
+
async issueTokens(userId, event, options) {
|
|
199
|
+
if (!event) {
|
|
200
|
+
throw this.createError(
|
|
201
|
+
"EVENT_UNAVAILABLE",
|
|
202
|
+
"Unable to access the current event context"
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const tokens = await this.generateTokens(
|
|
207
|
+
userId,
|
|
208
|
+
true,
|
|
209
|
+
options?.roles,
|
|
210
|
+
options?.claims,
|
|
211
|
+
uuidv7(),
|
|
212
|
+
options
|
|
213
|
+
);
|
|
214
|
+
if (!tokens.refreshToken) {
|
|
215
|
+
throw this.createError(
|
|
216
|
+
"UNKNOWN_ERROR",
|
|
217
|
+
"Refresh token creation failed"
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
await this.callbacks.onStoreRefreshToken(tokens.refreshToken, userId, event);
|
|
221
|
+
if (this.jwtConfig.tokens.refresh?.cookie) {
|
|
222
|
+
this.issueRefreshHttpOnlyCookie(event, tokens.refreshToken);
|
|
223
|
+
const result2 = {
|
|
224
|
+
success: true,
|
|
225
|
+
tokens: {
|
|
226
|
+
...tokens,
|
|
227
|
+
refreshToken: void 0
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
await this.notifyAuthenticated(result2, event);
|
|
231
|
+
return result2;
|
|
232
|
+
}
|
|
233
|
+
const result = {
|
|
234
|
+
success: true,
|
|
235
|
+
tokens
|
|
236
|
+
};
|
|
237
|
+
await this.notifyAuthenticated(result, event);
|
|
238
|
+
return result;
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return this.handleError(error, event);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async refresh(event, options) {
|
|
244
|
+
try {
|
|
245
|
+
if (!event) {
|
|
246
|
+
throw this.createError(
|
|
247
|
+
"EVENT_UNAVAILABLE",
|
|
248
|
+
"Unable to access the current event context"
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
const requestedRefreshToken = this.readRefreshHttpOnlyCookie(event);
|
|
252
|
+
if (!requestedRefreshToken) {
|
|
253
|
+
return this.invalidRefresh("REFRESH_TOKEN_INVALID", "Invalid refresh token");
|
|
254
|
+
}
|
|
255
|
+
const payload = this.decodeToken(requestedRefreshToken);
|
|
256
|
+
if (!payload || !payload.sub) {
|
|
257
|
+
return this.invalidRefresh("REFRESH_TOKEN_INVALID", "Invalid refresh token");
|
|
258
|
+
}
|
|
259
|
+
const claimError = this.validateClaims(payload, "refresh");
|
|
260
|
+
if (claimError) {
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
error: {
|
|
264
|
+
code: claimError.code === "TOKEN_EXPIRED" ? "REFRESH_TOKEN_EXPIRED" : "REFRESH_TOKEN_INVALID",
|
|
265
|
+
message: claimError.code === "TOKEN_EXPIRED" ? "Refresh token has expired" : "Invalid refresh token",
|
|
266
|
+
recoverable: false
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const lookup = this.normaliseRefreshLookup(
|
|
271
|
+
await this.callbacks.onGetRefreshToken(requestedRefreshToken, payload.sub, event),
|
|
272
|
+
requestedRefreshToken
|
|
273
|
+
);
|
|
274
|
+
if (!lookup.valid) {
|
|
275
|
+
if (lookup.gracePeriodRace) {
|
|
276
|
+
return {
|
|
277
|
+
success: false,
|
|
278
|
+
error: {
|
|
279
|
+
code: "REFRESH_TOKEN_RACE",
|
|
280
|
+
message: "Refresh token was rotated by a concurrent request. Retry.",
|
|
281
|
+
recoverable: true
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
if (lookup.reused && payload.family && this.callbacks.onRevokeTokenFamily) {
|
|
286
|
+
consola.warn(
|
|
287
|
+
`[nuxt-authkit] Refresh token reuse detected for user ${payload.sub} (family ${payload.family}). Revoking family.`
|
|
288
|
+
);
|
|
289
|
+
await this.callbacks.onRevokeTokenFamily(payload.family, payload.sub, event);
|
|
290
|
+
}
|
|
291
|
+
if (this.jwtConfig.tokens.refresh?.cookie) {
|
|
292
|
+
this.revokeRefreshHttpOnlyCookie(event);
|
|
293
|
+
}
|
|
294
|
+
return this.invalidRefresh("REFRESH_TOKEN_INVALID", "Invalid refresh token");
|
|
295
|
+
}
|
|
296
|
+
const roles = await this.resolveRolesForRefresh(lookup, payload, event);
|
|
297
|
+
const claims = lookup.claims ?? this.extractCustomClaims(payload);
|
|
298
|
+
const shouldRotate = this.shouldRotate(options);
|
|
299
|
+
const family = payload.family || uuidv7();
|
|
300
|
+
const tokens = await this.generateTokens(
|
|
301
|
+
payload.sub,
|
|
302
|
+
shouldRotate,
|
|
303
|
+
roles,
|
|
304
|
+
claims,
|
|
305
|
+
family,
|
|
306
|
+
lookup.refreshExpiresIn ? { refreshExpiresIn: lookup.refreshExpiresIn } : void 0
|
|
307
|
+
);
|
|
308
|
+
if (shouldRotate && tokens.refreshToken) {
|
|
309
|
+
await this.callbacks.onStoreRefreshToken(tokens.refreshToken, payload.sub, event);
|
|
310
|
+
await this.removeStoredRefreshToken(requestedRefreshToken, payload.sub, event);
|
|
311
|
+
if (this.jwtConfig.tokens.refresh?.cookie) {
|
|
312
|
+
this.issueRefreshHttpOnlyCookie(event, tokens.refreshToken);
|
|
313
|
+
const result2 = {
|
|
314
|
+
success: true,
|
|
315
|
+
tokens: {
|
|
316
|
+
...tokens,
|
|
317
|
+
refreshToken: void 0
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
await this.notifyTokenRefreshed(result2, event);
|
|
321
|
+
return result2;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const result = {
|
|
325
|
+
success: true,
|
|
326
|
+
tokens
|
|
327
|
+
};
|
|
328
|
+
await this.notifyTokenRefreshed(result, event);
|
|
329
|
+
return result;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
return this.handleError(error, event);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async revoke(event) {
|
|
335
|
+
if (!event) {
|
|
336
|
+
throw this.createError(
|
|
337
|
+
"EVENT_UNAVAILABLE",
|
|
338
|
+
"Unable to access the current event context"
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
const requestedRefreshToken = this.readRefreshHttpOnlyCookie(event);
|
|
342
|
+
if (this.jwtConfig.tokens.refresh?.cookie) {
|
|
343
|
+
this.revokeRefreshHttpOnlyCookie(event);
|
|
344
|
+
}
|
|
345
|
+
if (!requestedRefreshToken) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const payload = this.decodeToken(requestedRefreshToken);
|
|
349
|
+
if (!payload || !payload.sub) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
await this.removeStoredRefreshToken(requestedRefreshToken, payload.sub, event);
|
|
353
|
+
await this.notifyLogout(payload.sub, event);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Server-initiated revocation (admin force-logout, scheduled cleanup...).
|
|
357
|
+
*
|
|
358
|
+
* Throws when no `onRevokeServer` callback is configured. Only the app knows
|
|
359
|
+
* where its tokens live, so there is nothing this can do on its own — and
|
|
360
|
+
* reporting success while doing nothing is worse than failing.
|
|
361
|
+
*/
|
|
362
|
+
async revokeServer(options, event) {
|
|
363
|
+
if (!this.callbacks.onRevokeServer) {
|
|
364
|
+
throw this.createError(
|
|
365
|
+
"UNKNOWN_ERROR",
|
|
366
|
+
"[nuxt-authkit] revokeServer() was called but no onRevokeServer callback is configured. Implement onRevokeServer in createJWTStrategy() to delete the relevant refresh tokens from your store.",
|
|
367
|
+
void 0,
|
|
368
|
+
false
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
await this.callbacks.onRevokeServer(options, event);
|
|
372
|
+
}
|
|
373
|
+
// ========== Claim validation ==========
|
|
374
|
+
/**
|
|
375
|
+
* Decide whether a payload is an access or a refresh token.
|
|
376
|
+
*
|
|
377
|
+
* `typ` is mandatory: a token that does not say what it is, is not usable as
|
|
378
|
+
* anything. Both kinds are signed with the same key, so this claim is the only
|
|
379
|
+
* thing preventing a refresh token from being replayed as a 7-day Bearer
|
|
380
|
+
* session. There is deliberately no fallback for tokens minted before the claim
|
|
381
|
+
* existed — they are rejected, which logs their holders out once.
|
|
382
|
+
*/
|
|
383
|
+
resolveTokenUse(payload) {
|
|
384
|
+
if (payload.typ === "access" || payload.typ === "refresh") {
|
|
385
|
+
return payload.typ;
|
|
386
|
+
}
|
|
387
|
+
return "unknown";
|
|
388
|
+
}
|
|
389
|
+
validateClaims(payload, expectedUse, options) {
|
|
390
|
+
if (this.resolveTokenUse(payload) !== expectedUse) {
|
|
391
|
+
return {
|
|
392
|
+
code: "TOKEN_INVALID",
|
|
393
|
+
message: `Invalid token: expected a ${expectedUse} token`,
|
|
394
|
+
recoverable: false
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
398
|
+
if (!options?.ignoreExpiration) {
|
|
399
|
+
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
|
|
400
|
+
return {
|
|
401
|
+
code: "TOKEN_EXPIRED",
|
|
402
|
+
message: "Token has no valid expiry",
|
|
403
|
+
recoverable: false
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
if (payload.exp < now) {
|
|
407
|
+
return {
|
|
408
|
+
code: "TOKEN_EXPIRED",
|
|
409
|
+
message: "Token has expired",
|
|
410
|
+
recoverable: false
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (typeof payload.nbf === "number" && Number.isFinite(payload.nbf) && payload.nbf > now) {
|
|
415
|
+
return {
|
|
416
|
+
code: "TOKEN_INVALID",
|
|
417
|
+
message: "Token is not yet valid",
|
|
418
|
+
recoverable: false
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const issuer = this.jwtConfig.jwt?.issuer;
|
|
422
|
+
if (issuer && payload.iss !== issuer) {
|
|
423
|
+
return {
|
|
424
|
+
code: "TOKEN_INVALID",
|
|
425
|
+
message: "Token issuer mismatch",
|
|
426
|
+
recoverable: false
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
const audience = this.jwtConfig.jwt?.audience;
|
|
430
|
+
if (audience && !this.audienceMatches(payload.aud, audience)) {
|
|
431
|
+
return {
|
|
432
|
+
code: "TOKEN_INVALID",
|
|
433
|
+
message: "Token audience mismatch",
|
|
434
|
+
recoverable: false
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
/** `aud` is `string | string[]` on both sides; they match if they intersect. */
|
|
440
|
+
audienceMatches(tokenAudience, configuredAudience) {
|
|
441
|
+
if (tokenAudience === void 0) {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
const tokenValues = Array.isArray(tokenAudience) ? tokenAudience : [tokenAudience];
|
|
445
|
+
const configuredValues = Array.isArray(configuredAudience) ? configuredAudience : [configuredAudience];
|
|
446
|
+
return tokenValues.some((value) => configuredValues.includes(value));
|
|
447
|
+
}
|
|
448
|
+
// ========== Refresh helpers ==========
|
|
449
|
+
shouldRotate(options) {
|
|
450
|
+
if (!this.jwtConfig.tokens.refresh) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
if (typeof options?.rotate === "boolean") {
|
|
454
|
+
return options.rotate;
|
|
455
|
+
}
|
|
456
|
+
if (typeof options?.refreshToken === "boolean") {
|
|
457
|
+
return options.refreshToken;
|
|
458
|
+
}
|
|
459
|
+
return this.jwtConfig.tokens.refresh.rotate !== false;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Accepts the legacy `string | null` return as well as the richer
|
|
463
|
+
* {@link RefreshTokenLookup}, so existing callbacks keep working unchanged.
|
|
464
|
+
*/
|
|
465
|
+
normaliseRefreshLookup(result, presentedToken) {
|
|
466
|
+
if (result === null || result === void 0) {
|
|
467
|
+
return { valid: false };
|
|
468
|
+
}
|
|
469
|
+
if (typeof result === "string") {
|
|
470
|
+
return { valid: this.constantTimeEquals(result, presentedToken) };
|
|
471
|
+
}
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
async resolveRolesForRefresh(lookup, payload, event) {
|
|
475
|
+
if (lookup.roles) {
|
|
476
|
+
return lookup.roles;
|
|
477
|
+
}
|
|
478
|
+
if (this.callbacks.onGetRoles && payload.sub) {
|
|
479
|
+
return await this.callbacks.onGetRoles(payload.sub, event);
|
|
480
|
+
}
|
|
481
|
+
return payload.roles;
|
|
482
|
+
}
|
|
483
|
+
async removeStoredRefreshToken(refreshToken, identifier, event) {
|
|
484
|
+
if (!this.callbacks.onRemoveRefreshToken) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
await this.callbacks.onRemoveRefreshToken(refreshToken, identifier, event);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
consola.error("[nuxt-authkit] Error in onRemoveRefreshToken callback:", error);
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
constantTimeEquals(a, b) {
|
|
495
|
+
const bufferA = Buffer.from(a, "utf8");
|
|
496
|
+
const bufferB = Buffer.from(b, "utf8");
|
|
497
|
+
if (bufferA.length !== bufferB.length) {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
return timingSafeEqual(bufferA, bufferB);
|
|
501
|
+
}
|
|
502
|
+
// ========== Token Management ==========
|
|
503
|
+
async generateTokens(userId, includeRefreshToken, roles, claims, family, options) {
|
|
504
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
505
|
+
const accessExpiresIn = options?.accessExpiresIn ? options.accessExpiresIn : this.jwtConfig.tokens.access.expiresIn;
|
|
506
|
+
if (accessExpiresIn > ACCESS_TOKEN_LIFETIME_WARN_THRESHOLD) {
|
|
507
|
+
consola.warn(
|
|
508
|
+
`[nuxt-authkit] Access token lifetime is ${accessExpiresIn}s (> 24h). Access tokens cannot be revoked before they expire \u2014 check this is seconds, not milliseconds.`
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
const accessPayload = {
|
|
512
|
+
...this.jwtConfig.jwt?.claims,
|
|
513
|
+
...this.stripReservedClaims(claims),
|
|
514
|
+
sub: userId,
|
|
515
|
+
typ: "access",
|
|
516
|
+
iat: now,
|
|
517
|
+
exp: now + accessExpiresIn,
|
|
518
|
+
...roles !== void 0 && { roles },
|
|
519
|
+
...this.jwtConfig.jwt?.issuer && {
|
|
520
|
+
iss: this.jwtConfig.jwt.issuer
|
|
521
|
+
},
|
|
522
|
+
...this.jwtConfig.jwt?.audience && {
|
|
523
|
+
aud: this.jwtConfig.jwt.audience
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
const newAccessToken = this.encodeToken(accessPayload);
|
|
527
|
+
if (!newAccessToken) {
|
|
528
|
+
throw this.createError(
|
|
529
|
+
"UNKNOWN_ERROR",
|
|
530
|
+
"Access token creation failed"
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
if (includeRefreshToken) {
|
|
534
|
+
const refreshExpiresIn = options?.refreshExpiresIn ? options.refreshExpiresIn : this.jwtConfig.tokens.refresh?.expiresIn ?? 0;
|
|
535
|
+
const refreshPayload = refreshExpiresIn ? {
|
|
536
|
+
sub: userId,
|
|
537
|
+
typ: "refresh",
|
|
538
|
+
iat: now,
|
|
539
|
+
exp: now + refreshExpiresIn,
|
|
540
|
+
jti: uuidv7(),
|
|
541
|
+
family: family || uuidv7(),
|
|
542
|
+
// Carried so roles survive rotation even without an onGetRoles callback.
|
|
543
|
+
...roles !== void 0 && { roles },
|
|
544
|
+
...this.jwtConfig.jwt?.issuer && {
|
|
545
|
+
iss: this.jwtConfig.jwt.issuer
|
|
546
|
+
},
|
|
547
|
+
...this.jwtConfig.jwt?.audience && {
|
|
548
|
+
aud: this.jwtConfig.jwt.audience
|
|
549
|
+
}
|
|
550
|
+
} : null;
|
|
551
|
+
const newRefreshToken = refreshPayload ? this.encodeToken(refreshPayload) : void 0;
|
|
552
|
+
if (!newRefreshToken) {
|
|
553
|
+
throw this.createError(
|
|
554
|
+
"UNKNOWN_ERROR",
|
|
555
|
+
"Refresh token creation failed"
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
accessToken: newAccessToken,
|
|
560
|
+
refreshToken: newRefreshToken,
|
|
561
|
+
expiresAt: (now + accessExpiresIn) * 1e3,
|
|
562
|
+
expiresIn: accessExpiresIn,
|
|
563
|
+
tokenType: "Bearer"
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
accessToken: newAccessToken,
|
|
568
|
+
expiresAt: (now + accessExpiresIn) * 1e3,
|
|
569
|
+
expiresIn: accessExpiresIn,
|
|
570
|
+
tokenType: "Bearer"
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Carry an existing token's custom claims through rotation, dropping the
|
|
575
|
+
* reserved ones (which are always recomputed).
|
|
576
|
+
*/
|
|
577
|
+
extractCustomClaims(payload) {
|
|
578
|
+
return this.stripReservedClaims(payload) ?? {};
|
|
579
|
+
}
|
|
580
|
+
stripReservedClaims(claims) {
|
|
581
|
+
if (!claims) {
|
|
582
|
+
return void 0;
|
|
583
|
+
}
|
|
584
|
+
const result = {};
|
|
585
|
+
for (const [key, value] of Object.entries(claims)) {
|
|
586
|
+
if (!RESERVED_CLAIMS.includes(key) && value !== void 0) {
|
|
587
|
+
result[key] = value;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Static `jwt.claims` that collide with a computed claim are a configuration
|
|
594
|
+
* error, not something to silently drop: a stray `exp` there would otherwise
|
|
595
|
+
* have minted never-expiring tokens app-wide.
|
|
596
|
+
*/
|
|
597
|
+
assertNoReservedClaims(claims) {
|
|
598
|
+
if (!claims) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const offending = Object.keys(claims).filter(
|
|
602
|
+
(key) => RESERVED_CLAIMS.includes(key)
|
|
603
|
+
);
|
|
604
|
+
if (offending.length > 0) {
|
|
605
|
+
throw new Error(
|
|
606
|
+
`[nuxt-authkit] jwt.claims may not contain the reserved claim(s): ${offending.join(", ")}. These are computed per request. Reserved claims: ${RESERVED_CLAIMS.join(", ")}.`
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
warnOnWeakConfiguration() {
|
|
611
|
+
if (!this.callbacks.onRemoveRefreshToken) {
|
|
612
|
+
consola.warn(
|
|
613
|
+
"[nuxt-authkit] No onRemoveRefreshToken callback configured. Refresh tokens cannot be deleted from your store, so logout and rotation will not invalidate them server-side \u2014 a leaked refresh token stays valid until it expires."
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
if (!this.callbacks.onGetRoles) {
|
|
617
|
+
consola.warn(
|
|
618
|
+
"[nuxt-authkit] No onGetRoles callback configured. Roles are carried forward from the refresh token, so a role change or a ban will not take effect until the user logs in again."
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
const cookie = this.jwtConfig.tokens.refresh?.cookie;
|
|
622
|
+
if (cookie && cookie.secure === false && process.env.NODE_ENV === "production") {
|
|
623
|
+
consola.warn(
|
|
624
|
+
"[nuxt-authkit] The refresh cookie is configured with secure:false in production. It will be sent over plaintext HTTP."
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
encodeToken(payload) {
|
|
629
|
+
return this.jwtCrypto.sign(payload);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Verify and decode a token.
|
|
633
|
+
*
|
|
634
|
+
* Returns `null` for any cryptographically-invalid token — malformed, bad
|
|
635
|
+
* signature, wrong algorithm. The distinction is logged server-side but never
|
|
636
|
+
* surfaced to the caller, so it cannot become an error oracle, and callers get
|
|
637
|
+
* a single unambiguous "invalid" they can fail closed on.
|
|
638
|
+
*/
|
|
639
|
+
decodeToken(token) {
|
|
640
|
+
try {
|
|
641
|
+
return this.jwtCrypto.verify(token);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
if (error instanceof JWTCryptoError) {
|
|
644
|
+
consola.debug(`[nuxt-authkit] Token rejected (${error.code}): ${error.message}`);
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
invalidToken(message) {
|
|
651
|
+
return {
|
|
652
|
+
valid: false,
|
|
653
|
+
error: {
|
|
654
|
+
code: "TOKEN_INVALID",
|
|
655
|
+
message,
|
|
656
|
+
recoverable: false
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
invalidRefresh(code, message) {
|
|
661
|
+
return {
|
|
662
|
+
success: false,
|
|
663
|
+
error: {
|
|
664
|
+
code,
|
|
665
|
+
message,
|
|
666
|
+
recoverable: false
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
shouldRefreshToken(payload) {
|
|
671
|
+
if (!this.jwtConfig.autoRefresh?.enabled || !payload.exp) {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
675
|
+
const beforeExpiry = this.jwtConfig.autoRefresh.beforeExpiry;
|
|
676
|
+
return payload.exp - now <= beforeExpiry;
|
|
677
|
+
}
|
|
678
|
+
async notifyTokenRefreshed(result, event) {
|
|
679
|
+
if (this.callbacks.onTokenRefreshed && result.tokens) {
|
|
680
|
+
try {
|
|
681
|
+
await this.callbacks.onTokenRefreshed(result.tokens, event);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
consola.error("[nuxt-authkit] Error in onTokenRefreshed callback:", error);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// ========== Cookie handling ==========
|
|
688
|
+
readRefreshHttpOnlyCookie(event) {
|
|
689
|
+
const cookieName = this.jwtConfig.tokens.refresh?.cookie?.name || "refreshToken";
|
|
690
|
+
return getCookie(event, cookieName) || null;
|
|
691
|
+
}
|
|
692
|
+
revokeRefreshHttpOnlyCookie(event) {
|
|
693
|
+
const cookieName = this.jwtConfig.tokens.refresh?.cookie?.name || "refreshToken";
|
|
694
|
+
deleteCookie(event, cookieName, this.resolveRefreshCookieOptions(event));
|
|
695
|
+
}
|
|
696
|
+
issueRefreshHttpOnlyCookie(event, refreshToken) {
|
|
697
|
+
const cookieName = this.jwtConfig.tokens.refresh?.cookie?.name || "refreshToken";
|
|
698
|
+
setCookie(event, cookieName, refreshToken, this.resolveRefreshCookieOptions(event));
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* `httpOnly` is forced on rather than merged in. These helpers are named for a
|
|
702
|
+
* guarantee, and a refresh token readable from JavaScript is exactly the
|
|
703
|
+
* credential this design exists to keep out of reach.
|
|
704
|
+
*
|
|
705
|
+
* The domain is resolved per request when `onResolveCookieDomain` is supplied:
|
|
706
|
+
* one API can serve clients on several registrable domains, and a static
|
|
707
|
+
* `cookie.domain` can only ever satisfy one of them. Deletion resolves the
|
|
708
|
+
* domain the same way — a cookie set on one domain cannot be cleared by a
|
|
709
|
+
* Set-Cookie scoped to another.
|
|
710
|
+
*/
|
|
711
|
+
resolveRefreshCookieOptions(event) {
|
|
712
|
+
const options = {
|
|
713
|
+
...this.jwtConfig.tokens.refresh?.cookie,
|
|
714
|
+
name: void 0,
|
|
715
|
+
httpOnly: true
|
|
716
|
+
};
|
|
717
|
+
if (event && this.callbacks.onResolveCookieDomain) {
|
|
718
|
+
const resolvedDomain = this.callbacks.onResolveCookieDomain(event);
|
|
719
|
+
if (resolvedDomain) {
|
|
720
|
+
options.domain = resolvedDomain;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return options;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* RFC 7235 makes the auth-scheme case-insensitive, and an anchored match keeps
|
|
727
|
+
* a header without the scheme from being returned verbatim as a "token".
|
|
728
|
+
*/
|
|
729
|
+
getAuthBearerToken(event) {
|
|
730
|
+
const rawHeader = event.node.req.headers["authorization"];
|
|
731
|
+
const authHeader = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
732
|
+
if (!authHeader) {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
const separatorIndex = authHeader.indexOf(" ");
|
|
736
|
+
if (separatorIndex === -1) {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
const scheme = authHeader.slice(0, separatorIndex);
|
|
740
|
+
if (scheme.toLowerCase() !== "bearer") {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
return authHeader.slice(separatorIndex + 1).trim() || null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
export function createJWTStrategy(callbacks) {
|
|
747
|
+
return new JWTStrategy(callbacks);
|
|
748
|
+
}
|