@devcoffee/nuxt-core 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +407 -84
- package/dist/module.d.mts +295 -156
- package/dist/module.d.ts +300 -0
- package/dist/module.json +1 -1
- package/dist/module.mjs +192 -37
- package/dist/runtime/app/composables/useAuthContext.d.ts +26 -0
- package/dist/runtime/app/composables/useAuthContext.js +111 -0
- package/dist/runtime/app/composables/useLogger.d.ts +3 -3
- package/dist/runtime/app/composables/useSessionContext.d.ts +22 -0
- package/dist/runtime/app/composables/useSessionContext.js +5 -0
- package/dist/runtime/app/middleware/authts.d.ts +12 -0
- package/dist/runtime/app/middleware/authts.js +101 -0
- package/dist/runtime/app/pages/authorize.d.vue.ts +3 -0
- package/dist/runtime/app/pages/authorize.vue +32 -0
- package/dist/runtime/app/pages/authorize.vue.d.ts +3 -0
- package/dist/runtime/app/plugins/authts.d.ts +82 -0
- package/dist/runtime/app/plugins/authts.js +91 -0
- package/dist/runtime/app/plugins/formatters.d.ts +29 -0
- package/dist/runtime/app/plugins/formatters.js +101 -0
- package/dist/runtime/app/plugins/locale.d.ts +37 -0
- package/dist/runtime/app/plugins/locale.js +39 -0
- package/dist/runtime/app/plugins/logging.d.ts +24 -16
- package/dist/runtime/app/plugins/logging.js +0 -1
- package/dist/runtime/app/utils/hashing.d.ts +1 -0
- package/dist/runtime/app/utils/hashing.js +3 -0
- package/dist/runtime/server/adapters/http.d.ts +5 -0
- package/dist/runtime/server/adapters/http.js +15 -0
- package/dist/runtime/server/adapters/oidc.d.ts +58 -0
- package/dist/runtime/server/adapters/oidc.js +21 -0
- package/dist/runtime/server/adapters/storage.d.ts +39 -0
- package/dist/runtime/server/adapters/storage.js +14 -0
- package/dist/runtime/server/adapters/utils.d.ts +31 -0
- package/dist/runtime/server/adapters/utils.js +28 -0
- package/dist/runtime/server/composables/useServerLogger.d.ts +3 -2
- package/dist/runtime/server/composables/useServerLogger.js +4 -4
- package/dist/runtime/server/core/crypto.d.ts +70 -0
- package/dist/runtime/server/core/crypto.js +55 -0
- package/dist/runtime/server/core/helpers.d.ts +194 -0
- package/dist/runtime/server/core/helpers.js +355 -0
- package/dist/runtime/server/core/index.d.ts +1 -0
- package/dist/runtime/server/core/index.js +1 -0
- package/dist/runtime/server/core/mutex.d.ts +19 -0
- package/dist/runtime/server/core/mutex.js +39 -0
- package/dist/runtime/server/core/nuxtAuthtsHandler.d.ts +26 -0
- package/dist/runtime/server/core/nuxtAuthtsHandler.js +238 -0
- package/dist/runtime/server/core/nuxtForwardHandler.d.ts +18 -0
- package/dist/runtime/server/core/nuxtForwardHandler.js +60 -0
- package/dist/runtime/server/dev/route/session.d.ts +2 -0
- package/dist/runtime/server/dev/route/session.js +8 -0
- package/dist/runtime/server/plugins/authts.d.ts +11 -0
- package/dist/runtime/server/plugins/authts.js +55 -0
- package/dist/runtime/server/plugins/logging.js +7 -2
- package/dist/runtime/server/tsconfig.json +3 -3
- package/dist/runtime/types/global.env.d.ts +21 -7
- package/dist/runtime/types/nitro.d.ts +7 -2
- package/dist/runtime/types/nuxt.d.ts +28 -8
- package/dist/runtime/utils.d.ts +31 -0
- package/dist/runtime/utils.js +28 -0
- package/dist/types.d.mts +6 -4
- package/package.json +23 -14
- package/dist/runtime/plugin.d.ts +0 -2
- package/dist/runtime/plugin.js +0 -4
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { createError } from "#devcoffee-core/server/adapters/http";
|
|
2
|
+
import {
|
|
3
|
+
allowInsecureRequests,
|
|
4
|
+
authorizationCodeGrant as authorizationCodeGrantOidc,
|
|
5
|
+
buildAuthorizationUrl as buildAuthorizationUrlOidc,
|
|
6
|
+
calculatePKCECodeChallenge,
|
|
7
|
+
Configuration,
|
|
8
|
+
discovery,
|
|
9
|
+
fetchUserInfo as fetchUserInfoFromOidc,
|
|
10
|
+
randomPKCECodeVerifier,
|
|
11
|
+
randomState,
|
|
12
|
+
refreshTokenGrant as refreshTokenGrantFromOidc,
|
|
13
|
+
revokeToken
|
|
14
|
+
} from "#devcoffee-core/server/adapters/oidc";
|
|
15
|
+
import { deepMerge, omit } from "#devcoffee-core/server/adapters/utils";
|
|
16
|
+
import useServerLogger from "#devcoffee-core/server/composables/useServerLogger";
|
|
17
|
+
import { useRuntimeConfig } from "#imports";
|
|
18
|
+
import { useStorage } from "nitropack/runtime/internal/storage";
|
|
19
|
+
import { decryptTokenSet, encryptTokenSet, generateSessionId, isValidSessionId, verifySessionId } from "./crypto.js";
|
|
20
|
+
import { tryAcquireLock } from "./mutex.js";
|
|
21
|
+
function getAnonymousUser(extras) {
|
|
22
|
+
const anonymous = useRuntimeConfig().nuxtCore.authts.auth.anonymousUser;
|
|
23
|
+
const { defaultLocale, defaultTimeZone, defaultLanguage } = useRuntimeConfig().nuxtCore;
|
|
24
|
+
return deepMerge(
|
|
25
|
+
{},
|
|
26
|
+
{
|
|
27
|
+
id: "anonymous",
|
|
28
|
+
email: "",
|
|
29
|
+
sub: "anonymous",
|
|
30
|
+
firstName: "Anonymous",
|
|
31
|
+
lastName: "User",
|
|
32
|
+
locale: defaultLocale,
|
|
33
|
+
language: defaultLanguage,
|
|
34
|
+
timezone: defaultTimeZone
|
|
35
|
+
},
|
|
36
|
+
anonymous,
|
|
37
|
+
extras || {}
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
function getSessionStorageKey(storagePrefix, sessionId) {
|
|
41
|
+
return storagePrefix ? `${storagePrefix}:${sessionId}` : sessionId;
|
|
42
|
+
}
|
|
43
|
+
export function isSameOrigin(redirectUrl, requestUrl) {
|
|
44
|
+
try {
|
|
45
|
+
return new URL(redirectUrl).origin === requestUrl.origin;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function getSession(sessionId, opts) {
|
|
51
|
+
const storage = useStorage(opts.storageName);
|
|
52
|
+
const sessingKey = getSessionStorageKey(opts.storagePrefix, sessionId);
|
|
53
|
+
if (!await storage.hasItem(sessingKey)) return null;
|
|
54
|
+
const session = await storage.getItem(sessingKey);
|
|
55
|
+
if (!session) return null;
|
|
56
|
+
if (session.auth?.tokenSet && session.auth.tokenSet.encrypted === true) {
|
|
57
|
+
if (opts.secret) {
|
|
58
|
+
const decrypted = decryptTokenSet(session.auth.tokenSet, opts.secret);
|
|
59
|
+
session.auth.tokenSet = decrypted ?? void 0;
|
|
60
|
+
} else {
|
|
61
|
+
const logger = useServerLogger({ tag: "authts-helper", level: 3 });
|
|
62
|
+
logger.warn(
|
|
63
|
+
"[getSession] tokenSet is encrypted but sessions.secret is not configured \u2014 tokenSet will be inaccessible"
|
|
64
|
+
);
|
|
65
|
+
session.auth.tokenSet = void 0;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return session;
|
|
69
|
+
}
|
|
70
|
+
function newSession(sessionId, expiresAt) {
|
|
71
|
+
return {
|
|
72
|
+
id: sessionId,
|
|
73
|
+
auth: { status: "unauthenticated" },
|
|
74
|
+
user: getAnonymousUser(),
|
|
75
|
+
data: {},
|
|
76
|
+
expiresAt,
|
|
77
|
+
issuedAt: Date.now()
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function validateSession(sessionCookieId, opts) {
|
|
81
|
+
const storage = useStorage(opts.storageName);
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
const expiresAt = now + opts.expiresIn;
|
|
84
|
+
let sessionId = void 0;
|
|
85
|
+
let sessionKey = void 0;
|
|
86
|
+
let deleteSessionKey = void 0;
|
|
87
|
+
let session = null;
|
|
88
|
+
const rawSessionId = verifySessionId(sessionCookieId, opts.secret);
|
|
89
|
+
if (rawSessionId && isValidSessionId(rawSessionId)) {
|
|
90
|
+
sessionId = rawSessionId;
|
|
91
|
+
}
|
|
92
|
+
if (sessionId) {
|
|
93
|
+
sessionKey = getSessionStorageKey(opts.storagePrefix, sessionId);
|
|
94
|
+
if (await storage.hasItem(sessionKey)) {
|
|
95
|
+
session = await storage.getItem(sessionKey);
|
|
96
|
+
}
|
|
97
|
+
if (!session || session.expiresAt <= now) {
|
|
98
|
+
deleteSessionKey = session ? sessionKey : void 0;
|
|
99
|
+
session = newSession(generateSessionId(), expiresAt);
|
|
100
|
+
sessionKey = getSessionStorageKey(opts.storagePrefix, session.id);
|
|
101
|
+
} else {
|
|
102
|
+
session.expiresAt = expiresAt;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
session = session || newSession(generateSessionId(), expiresAt);
|
|
106
|
+
sessionKey = sessionKey || getSessionStorageKey(opts.storagePrefix, session.id);
|
|
107
|
+
if (deleteSessionKey && deleteSessionKey !== sessionKey) {
|
|
108
|
+
await storage.removeItem(deleteSessionKey);
|
|
109
|
+
}
|
|
110
|
+
await storage.setItem(sessionKey, session, {
|
|
111
|
+
ttl: opts.expiresIn / 1e3 | 0
|
|
112
|
+
});
|
|
113
|
+
return session;
|
|
114
|
+
}
|
|
115
|
+
export async function updateSession(sessionId, input, opts) {
|
|
116
|
+
const now = Date.now();
|
|
117
|
+
const storage = useStorage(opts.storageName);
|
|
118
|
+
const serverKey = `${opts.storagePrefix}:${sessionId}`;
|
|
119
|
+
const normalizedInput = omit(
|
|
120
|
+
input,
|
|
121
|
+
["id", "issuedAt", "expiresAt"]
|
|
122
|
+
);
|
|
123
|
+
let session = await storage.getItem(serverKey);
|
|
124
|
+
if (!session) {
|
|
125
|
+
throw createError({
|
|
126
|
+
status: 500,
|
|
127
|
+
fatal: true,
|
|
128
|
+
message: `session '${sessionId}' was not found.`
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
session = deepMerge({}, session, normalizedInput);
|
|
132
|
+
session.expiresAt = now + opts.expiresIn;
|
|
133
|
+
const sessionToStore = { ...session };
|
|
134
|
+
if (opts.secret && sessionToStore.auth?.tokenSet) {
|
|
135
|
+
sessionToStore.auth = {
|
|
136
|
+
...sessionToStore.auth,
|
|
137
|
+
tokenSet: encryptTokenSet(
|
|
138
|
+
sessionToStore.auth.tokenSet,
|
|
139
|
+
opts.secret
|
|
140
|
+
)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
await storage.setItem(serverKey, sessionToStore, {
|
|
144
|
+
ttl: opts.expiresIn / 1e3 | 0
|
|
145
|
+
});
|
|
146
|
+
return session;
|
|
147
|
+
}
|
|
148
|
+
export async function renewSession(sessionId, opts) {
|
|
149
|
+
const storage = useStorage(opts.storageName);
|
|
150
|
+
let sessionKey = getSessionStorageKey(opts.storagePrefix, sessionId);
|
|
151
|
+
if (await storage.hasItem(sessionKey)) {
|
|
152
|
+
await storage.removeItem(sessionKey);
|
|
153
|
+
}
|
|
154
|
+
const session = newSession(generateSessionId(), Date.now() + opts.expiresIn);
|
|
155
|
+
sessionKey = getSessionStorageKey(opts.storagePrefix, session.id);
|
|
156
|
+
await storage.setItem(sessionKey, session, {
|
|
157
|
+
ttl: opts.expiresIn / 1e3 | 0
|
|
158
|
+
});
|
|
159
|
+
return session;
|
|
160
|
+
}
|
|
161
|
+
export async function deleteSession(sessionId, opts) {
|
|
162
|
+
const storage = useStorage(opts.storageName);
|
|
163
|
+
const sessionKey = getSessionStorageKey(opts.storagePrefix, sessionId);
|
|
164
|
+
if (await storage.hasItem(sessionKey)) {
|
|
165
|
+
await storage.removeItem(sessionKey);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export async function discoveryOpendId(wellKnownUrl, opts) {
|
|
169
|
+
const logger = useServerLogger({ tag: "authts-helper", level: 3 });
|
|
170
|
+
const {
|
|
171
|
+
cache: { prefix: cachedPrefix, expires },
|
|
172
|
+
clientId,
|
|
173
|
+
clientSecret
|
|
174
|
+
} = opts;
|
|
175
|
+
const storage = useStorage("cache");
|
|
176
|
+
const cacheKey = `${cachedPrefix}:${clientId}`;
|
|
177
|
+
let meta = await storage.getItem(cacheKey);
|
|
178
|
+
if (!meta) {
|
|
179
|
+
const isHttpEndpoint = wellKnownUrl.startsWith("http://");
|
|
180
|
+
const config = await discovery(
|
|
181
|
+
new URL(wellKnownUrl),
|
|
182
|
+
clientId,
|
|
183
|
+
{
|
|
184
|
+
client_id: clientId,
|
|
185
|
+
client_secret: clientSecret
|
|
186
|
+
},
|
|
187
|
+
void 0,
|
|
188
|
+
isHttpEndpoint ? { execute: [allowInsecureRequests] } : void 0
|
|
189
|
+
);
|
|
190
|
+
meta = {
|
|
191
|
+
server: config.serverMetadata(),
|
|
192
|
+
client: config.clientMetadata()
|
|
193
|
+
};
|
|
194
|
+
await storage.setItem(cacheKey, meta, { ttl: expires / 1e3 | 0 });
|
|
195
|
+
logger.info('Fetching OpenID Connect metadata from "%s"', wellKnownUrl);
|
|
196
|
+
}
|
|
197
|
+
return meta;
|
|
198
|
+
}
|
|
199
|
+
export async function getOpenIdConfiguration(wellKnownUrl, opts) {
|
|
200
|
+
const meta = await discoveryOpendId(wellKnownUrl, opts);
|
|
201
|
+
const configuration = new Configuration(meta.server, opts.clientId, meta.client);
|
|
202
|
+
if (wellKnownUrl.startsWith("http://")) {
|
|
203
|
+
allowInsecureRequests(configuration);
|
|
204
|
+
}
|
|
205
|
+
return configuration;
|
|
206
|
+
}
|
|
207
|
+
function getOpenIdRedirectUrl(origin, redirectUri) {
|
|
208
|
+
return new URL(redirectUri, origin.origin);
|
|
209
|
+
}
|
|
210
|
+
export async function buildAuthorizationUrl(session, opt) {
|
|
211
|
+
const {
|
|
212
|
+
wellKnownUrl,
|
|
213
|
+
cache,
|
|
214
|
+
clientId,
|
|
215
|
+
clientSecret,
|
|
216
|
+
origin,
|
|
217
|
+
redirectUri,
|
|
218
|
+
scopes,
|
|
219
|
+
usePkce,
|
|
220
|
+
codeChallengeMethod,
|
|
221
|
+
sessionExpires,
|
|
222
|
+
sessionStorageName,
|
|
223
|
+
sessionStoragePrefix,
|
|
224
|
+
sessionSecret = ""
|
|
225
|
+
} = opt;
|
|
226
|
+
const config = await getOpenIdConfiguration(wellKnownUrl, { cache, clientId, clientSecret });
|
|
227
|
+
const serverMeta = config.serverMetadata();
|
|
228
|
+
const state = randomState();
|
|
229
|
+
const results = {
|
|
230
|
+
state
|
|
231
|
+
};
|
|
232
|
+
const parameters = {
|
|
233
|
+
state,
|
|
234
|
+
redirect_uri: getOpenIdRedirectUrl(origin, redirectUri).toString(),
|
|
235
|
+
scope: scopes.join(" ")
|
|
236
|
+
};
|
|
237
|
+
if (usePkce && serverMeta.supportsPKCE(codeChallengeMethod)) {
|
|
238
|
+
const codeVerifier = randomPKCECodeVerifier();
|
|
239
|
+
parameters.code_challenge_method = codeChallengeMethod;
|
|
240
|
+
parameters.code_challenge = await calculatePKCECodeChallenge(codeVerifier);
|
|
241
|
+
results.pkceCodeVerifier = codeVerifier;
|
|
242
|
+
}
|
|
243
|
+
await updateSession(
|
|
244
|
+
session.id,
|
|
245
|
+
{ auth: { status: "unauthenticated" } },
|
|
246
|
+
{
|
|
247
|
+
storageName: sessionStorageName,
|
|
248
|
+
storagePrefix: sessionStoragePrefix,
|
|
249
|
+
expiresIn: sessionExpires,
|
|
250
|
+
secret: sessionSecret
|
|
251
|
+
}
|
|
252
|
+
);
|
|
253
|
+
results.authorizeUrl = buildAuthorizationUrlOidc(config, parameters).toString();
|
|
254
|
+
return results;
|
|
255
|
+
}
|
|
256
|
+
export async function authorizationCodeGrant(authorizeParams, opts) {
|
|
257
|
+
const { wellKnownUrl, cache, clientId, clientSecret, origin, redirectUri, usePkce, codeChallengeMethod } = opts;
|
|
258
|
+
const config = await getOpenIdConfiguration(wellKnownUrl, { cache, clientId, clientSecret });
|
|
259
|
+
const serverMeta = config.serverMetadata();
|
|
260
|
+
const redirectUrl = getOpenIdRedirectUrl(origin, redirectUri);
|
|
261
|
+
if (authorizeParams.code) {
|
|
262
|
+
redirectUrl.searchParams.set("code", authorizeParams.code);
|
|
263
|
+
}
|
|
264
|
+
if (authorizeParams.state) {
|
|
265
|
+
redirectUrl.searchParams.set("state", authorizeParams.state);
|
|
266
|
+
}
|
|
267
|
+
const checks = {
|
|
268
|
+
expectedState: authorizeParams.checks?.expectedState
|
|
269
|
+
};
|
|
270
|
+
if (usePkce && serverMeta.supportsPKCE(codeChallengeMethod)) {
|
|
271
|
+
checks.pkceCodeVerifier = authorizeParams.checks?.pkceCodeVerifier;
|
|
272
|
+
}
|
|
273
|
+
const parameters = {
|
|
274
|
+
grant_type: "authorization_code",
|
|
275
|
+
client_id: config.clientMetadata().client_id,
|
|
276
|
+
client_secret: config.clientMetadata().client_secret,
|
|
277
|
+
redirect_uri: redirectUrl.toString()
|
|
278
|
+
};
|
|
279
|
+
return authorizationCodeGrantOidc(config, redirectUrl, checks, parameters);
|
|
280
|
+
}
|
|
281
|
+
async function refreshTokenGrant(refreshToken, opts) {
|
|
282
|
+
const { wellKnownUrl, ...discoveryOption } = opts;
|
|
283
|
+
const config = await getOpenIdConfiguration(wellKnownUrl, discoveryOption);
|
|
284
|
+
return refreshTokenGrantFromOidc(config, refreshToken);
|
|
285
|
+
}
|
|
286
|
+
export function constructTokenSet(input) {
|
|
287
|
+
let tokenType = input.token_type;
|
|
288
|
+
if (tokenType.toUpperCase() === "BEARER") {
|
|
289
|
+
tokenType = "Bearer";
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
tokenType,
|
|
293
|
+
idToken: input.id_token,
|
|
294
|
+
accessToken: input.access_token,
|
|
295
|
+
refreshToken: input.refresh_token,
|
|
296
|
+
scopes: (input.scope || "").split(" "),
|
|
297
|
+
expiresAt: Date.now() + (input.expires_in || 0) * 1e3
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
export async function refreshTokenIfNeeded(session, opts) {
|
|
301
|
+
const logger = useServerLogger({ tag: "authts-helper", level: 3 });
|
|
302
|
+
let updateSession2 = {
|
|
303
|
+
auth: { status: "unauthenticated", tokenSet: void 0 },
|
|
304
|
+
user: getAnonymousUser()
|
|
305
|
+
};
|
|
306
|
+
if (session?.auth?.status === "authenticated" && session?.auth?.tokenSet) {
|
|
307
|
+
const { accessToken, refreshToken, expiresAt } = session.auth.tokenSet;
|
|
308
|
+
const accessExpired = Boolean(accessToken && expiresAt - opts.tokenRefreshBufferMs < Date.now());
|
|
309
|
+
if (!accessExpired) {
|
|
310
|
+
updateSession2 = {
|
|
311
|
+
auth: {
|
|
312
|
+
status: session.auth.status,
|
|
313
|
+
tokenSet: session.auth.tokenSet
|
|
314
|
+
},
|
|
315
|
+
user: session.user
|
|
316
|
+
};
|
|
317
|
+
} else if (accessExpired && refreshToken) {
|
|
318
|
+
const lockStorage = useStorage("cache");
|
|
319
|
+
const lockKey = `${opts.cache.prefix}:refresh-lock:${session.id}`;
|
|
320
|
+
const acquired = await tryAcquireLock(lockStorage, lockKey, 10, opts.distributedLock ?? false);
|
|
321
|
+
if (!acquired) {
|
|
322
|
+
logger.debug('[refreshTokenIfNeeded] refresh lock held for session "%s" \u2014 skipping', session.id);
|
|
323
|
+
return { auth: { status: session.auth.status, tokenSet: session.auth.tokenSet }, user: session.user };
|
|
324
|
+
}
|
|
325
|
+
logger.info('Refreshing access token for session "%s"', session.id);
|
|
326
|
+
const tokenSet = await refreshTokenGrant(refreshToken, {
|
|
327
|
+
wellKnownUrl: opts.wellKnownUrl,
|
|
328
|
+
cache: opts.cache,
|
|
329
|
+
clientId: opts.clientId,
|
|
330
|
+
clientSecret: opts.clientSecret
|
|
331
|
+
});
|
|
332
|
+
await lockStorage.removeItem(`${opts.cache.prefix}:userinfo:${session.id}`);
|
|
333
|
+
updateSession2 = {
|
|
334
|
+
auth: {
|
|
335
|
+
status: session.auth.status,
|
|
336
|
+
tokenSet: constructTokenSet(tokenSet)
|
|
337
|
+
},
|
|
338
|
+
user: session.user
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return updateSession2;
|
|
343
|
+
}
|
|
344
|
+
export async function fetchUserInfo(accessToken, sub, opts) {
|
|
345
|
+
const { wellKnownUrl, cache, clientId, clientSecret } = opts;
|
|
346
|
+
const config = await getOpenIdConfiguration(wellKnownUrl, { cache, clientId, clientSecret });
|
|
347
|
+
return fetchUserInfoFromOidc(config, accessToken, sub);
|
|
348
|
+
}
|
|
349
|
+
export async function revokeTokens(tokens, opts) {
|
|
350
|
+
const { wellKnownUrl, cache, clientId, clientSecret } = opts;
|
|
351
|
+
const config = await getOpenIdConfiguration(wellKnownUrl, { cache, clientId, clientSecret });
|
|
352
|
+
return Promise.allSettled(
|
|
353
|
+
tokens.map((token) => !token ? Promise.resolve() : revokeToken(config, token))
|
|
354
|
+
);
|
|
355
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as helpers from './helpers.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * as helpers from "./helpers.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Storage } from 'unstorage';
|
|
2
|
+
/**
|
|
3
|
+
* Attempts to acquire a distributed or optimistic mutex lock in Nitro cache storage.
|
|
4
|
+
*
|
|
5
|
+
* Two-tier strategy (D-01):
|
|
6
|
+
* - Tier 1 (atomic, `useAtomic: true`): Uses IORedis `SET NX EX` via the unstorage Redis driver's
|
|
7
|
+
* `getInstance()` accessor. Only effective when the Nitro `cache` mount is backed by a Redis driver.
|
|
8
|
+
* Falls through to optimistic path if the driver accessor is unavailable.
|
|
9
|
+
* - Tier 2 (optimistic, `useAtomic: false`): `hasItem` check followed by `setItem` with TTL.
|
|
10
|
+
* This is the Phase 7 behavior and remains the default path.
|
|
11
|
+
*
|
|
12
|
+
* @param storage - Prefixed cache storage (`useStorage('cache')`). Used for the optimistic path.
|
|
13
|
+
* @param lockKey - The lock key (without the cache prefix). Must be unique per session.
|
|
14
|
+
* @param ttlSeconds - Lock TTL in seconds. Lock expires automatically; no explicit release needed.
|
|
15
|
+
* @param useAtomic - When `true`, attempt atomic NX acquisition via Redis native client.
|
|
16
|
+
* @returns `true` if the lock was acquired by this caller, `false` if the lock was already held.
|
|
17
|
+
* @since 1.0.0
|
|
18
|
+
*/
|
|
19
|
+
export declare function tryAcquireLock(storage: Storage, lockKey: string, ttlSeconds: number, useAtomic: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import useServerLogger from "#devcoffee-core/server/composables/useServerLogger";
|
|
2
|
+
import { useStorage } from "nitropack/runtime/internal/storage";
|
|
3
|
+
const logger = useServerLogger({ tag: "authts-mutex", level: 3 });
|
|
4
|
+
export async function tryAcquireLock(storage, lockKey, ttlSeconds, useAtomic) {
|
|
5
|
+
if (useAtomic) {
|
|
6
|
+
try {
|
|
7
|
+
const baseStorage = useStorage();
|
|
8
|
+
const { driver, base } = baseStorage.getMount("cache:");
|
|
9
|
+
const client = driver.getInstance?.();
|
|
10
|
+
if (client) {
|
|
11
|
+
const prefixedKey = base + lockKey;
|
|
12
|
+
const result = await client.set(
|
|
13
|
+
prefixedKey,
|
|
14
|
+
"1",
|
|
15
|
+
"NX",
|
|
16
|
+
"EX",
|
|
17
|
+
ttlSeconds
|
|
18
|
+
);
|
|
19
|
+
if (result === "OK") {
|
|
20
|
+
logger.debug('[mutex] atomic lock acquired for key "%s"', lockKey);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
logger.debug('[mutex] atomic lock held for key "%s" \u2014 skipping', lockKey);
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
logger.warn("[mutex] atomic lock unavailable (getInstance not present on driver) \u2014 falling back to optimistic");
|
|
27
|
+
} catch (err) {
|
|
28
|
+
logger.warn("[mutex] atomic lock failed (%s) \u2014 falling back to optimistic", String(err));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
logger.debug('[mutex] using optimistic lock for key "%s"', lockKey);
|
|
32
|
+
const lockExists = await storage.hasItem(lockKey);
|
|
33
|
+
if (lockExists) {
|
|
34
|
+
logger.debug('[mutex] optimistic lock held for key "%s" \u2014 skipping', lockKey);
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
await storage.setItem(lockKey, true, { ttl: ttlSeconds });
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { NuxtAuthOptions } from '#devcoffee-core/server/adapters/http';
|
|
2
|
+
/**
|
|
3
|
+
* Creates a universal authentication handler for Nuxt, integrating with OpenID Connect.
|
|
4
|
+
*
|
|
5
|
+
* Handles three main endpoints:
|
|
6
|
+
* - `/api/_auth/session`: Returns the current normalized session.
|
|
7
|
+
* - `/api/_auth/authorize-url`: Builds and returns the OpenID authorization URL.
|
|
8
|
+
* - `/api/_auth/token`: Handles the authorization code exchange and updates the session.
|
|
9
|
+
*
|
|
10
|
+
* @param options - Optional custom overrides for session/user mapping callbacks.
|
|
11
|
+
* @returns An h3 event handler to be registered under the `/api/_auth/*` routes.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* export default NuxtAuthtsHandler({
|
|
16
|
+
* userInfo: async (user, tokenSet) => ({
|
|
17
|
+
* id: user.sub,
|
|
18
|
+
* email: user.email ?? '',
|
|
19
|
+
* name: user.name ?? '',
|
|
20
|
+
* }),
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @since 1.0.0
|
|
25
|
+
*/
|
|
26
|
+
export default function NuxtAuthtsHandler(options?: DeepPartial<NuxtAuthOptions>): any;
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createError,
|
|
3
|
+
deleteCookie,
|
|
4
|
+
eventHandler,
|
|
5
|
+
getCookie,
|
|
6
|
+
getQuery,
|
|
7
|
+
getRequestURL,
|
|
8
|
+
readFormData,
|
|
9
|
+
setCookie,
|
|
10
|
+
useRuntimeConfig
|
|
11
|
+
} from "#devcoffee-core/server/adapters/http";
|
|
12
|
+
import { deepMerge, omit } from "#devcoffee-core/server/adapters/utils";
|
|
13
|
+
import { useStorage } from "nitropack/runtime/internal/storage";
|
|
14
|
+
import {
|
|
15
|
+
authorizationCodeGrant,
|
|
16
|
+
buildAuthorizationUrl,
|
|
17
|
+
constructTokenSet,
|
|
18
|
+
deleteSession,
|
|
19
|
+
fetchUserInfo,
|
|
20
|
+
getSession,
|
|
21
|
+
isSameOrigin,
|
|
22
|
+
revokeTokens,
|
|
23
|
+
updateSession
|
|
24
|
+
} from "./helpers.js";
|
|
25
|
+
const AUTH_API_PREFIX = "/api/_auth";
|
|
26
|
+
var AuthSupportAction = /* @__PURE__ */ ((AuthSupportAction2) => {
|
|
27
|
+
AuthSupportAction2["GET_SESSION"] = "session";
|
|
28
|
+
AuthSupportAction2["AUTHORIZE_URL"] = "authorize-url";
|
|
29
|
+
AuthSupportAction2["TOKEN"] = "token";
|
|
30
|
+
AuthSupportAction2["LOGOUT"] = "logout";
|
|
31
|
+
return AuthSupportAction2;
|
|
32
|
+
})(AuthSupportAction || {});
|
|
33
|
+
function getAuthAction(requestUrl) {
|
|
34
|
+
const parts = requestUrl.pathname.toLocaleLowerCase().replace(AUTH_API_PREFIX, "").split("/").filter(Boolean);
|
|
35
|
+
if (!parts[0] || !Object.values(AuthSupportAction).includes(parts[0]))
|
|
36
|
+
throw createError({
|
|
37
|
+
status: 500,
|
|
38
|
+
fatal: true,
|
|
39
|
+
statusMessage: "auth action not support",
|
|
40
|
+
message: `AuthSupportAction does not support '${parts[0]}'`
|
|
41
|
+
});
|
|
42
|
+
return parts[0];
|
|
43
|
+
}
|
|
44
|
+
const defaultNuxtAuthOptions = {
|
|
45
|
+
/**
|
|
46
|
+
* Default Devcoffee Nuxt AuthTS option callbacks for session and user mapping.
|
|
47
|
+
* These can be overridden by passing custom callbacks to `NuxtAuthtsHandler()`.
|
|
48
|
+
* @since 1.0.0
|
|
49
|
+
*/
|
|
50
|
+
session: async (session, auth) => {
|
|
51
|
+
return Promise.resolve(
|
|
52
|
+
deepMerge(omit(session, ["issuedAt", "expiresAt"]), {
|
|
53
|
+
isAuthenticated: auth.status === "authenticated" && Boolean(auth.tokenSet && session.user.id)
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* Maps the OpenID Connect user info response to your local user schema.
|
|
59
|
+
*
|
|
60
|
+
* @param user - The raw user info response from the OpenID provider.
|
|
61
|
+
* @param opts - The token response containing access and ID tokens.
|
|
62
|
+
* @returns A normalized user object.
|
|
63
|
+
* @since 1.0.0
|
|
64
|
+
*/
|
|
65
|
+
userInfo: async (user, opts) => user
|
|
66
|
+
};
|
|
67
|
+
export default function NuxtAuthtsHandler(options) {
|
|
68
|
+
const { enabled: authEnabled, sessions: sessionConfig, openid, auth } = useRuntimeConfig().nuxtCore.authts;
|
|
69
|
+
const nuxtAuthOptions = deepMerge({ ...defaultNuxtAuthOptions }, options || {});
|
|
70
|
+
return eventHandler(async (event) => {
|
|
71
|
+
const requestUrl = getRequestURL(event);
|
|
72
|
+
const queryParams = getQuery(event);
|
|
73
|
+
const authAction = getAuthAction(requestUrl);
|
|
74
|
+
let session = await getSession(event.context.sessionId, {
|
|
75
|
+
storageName: sessionConfig.storage.name,
|
|
76
|
+
storagePrefix: sessionConfig.storage.prefix,
|
|
77
|
+
secret: sessionConfig.secret || ""
|
|
78
|
+
});
|
|
79
|
+
if (!session) {
|
|
80
|
+
throw createError({
|
|
81
|
+
status: 500,
|
|
82
|
+
fatal: true,
|
|
83
|
+
message: `session '${event.context.sessionId}' was not found!`
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!authEnabled && !["session" /* GET_SESSION */].includes(authAction)) {
|
|
87
|
+
throw createError({
|
|
88
|
+
status: 500,
|
|
89
|
+
fatal: true,
|
|
90
|
+
message: `Action '${authAction}' is disabled!`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const authCookieOpts = omit(sessionConfig.cookieOpts, ["expires", "maxAge"]);
|
|
94
|
+
let redirectUrl;
|
|
95
|
+
let tokenSet;
|
|
96
|
+
switch (authAction) {
|
|
97
|
+
case "session" /* GET_SESSION */:
|
|
98
|
+
const refetchSessionData = {};
|
|
99
|
+
if (openid.autoFetchUser && session.auth?.status === "authenticated" && session.auth?.tokenSet) {
|
|
100
|
+
const cacheStorage2 = useStorage("cache");
|
|
101
|
+
const userInfoCacheKey = `${openid.cache.prefix}:userinfo:${session.id}`;
|
|
102
|
+
let cachedUser = await cacheStorage2.getItem(userInfoCacheKey);
|
|
103
|
+
if (!cachedUser) {
|
|
104
|
+
cachedUser = await nuxtAuthOptions.userInfo(session.user, { tokenSet: session.auth.tokenSet });
|
|
105
|
+
await cacheStorage2.setItem(userInfoCacheKey, cachedUser, { ttl: openid.autoFetchUserTtl });
|
|
106
|
+
}
|
|
107
|
+
refetchSessionData.user = cachedUser;
|
|
108
|
+
}
|
|
109
|
+
if (Object.keys(refetchSessionData).length > 0) {
|
|
110
|
+
session = await updateSession(session.id, refetchSessionData, {
|
|
111
|
+
storageName: sessionConfig.storage.name,
|
|
112
|
+
storagePrefix: sessionConfig.storage.prefix,
|
|
113
|
+
expiresIn: sessionConfig.expiresIn,
|
|
114
|
+
secret: sessionConfig.secret || ""
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
event.context.sessionId = session.id;
|
|
118
|
+
return nuxtAuthOptions.session(omit(session, ["auth"]), session.auth);
|
|
119
|
+
case "authorize-url" /* AUTHORIZE_URL */:
|
|
120
|
+
const { authorizeUrl, state, pkceCodeVerifier } = await buildAuthorizationUrl(session, {
|
|
121
|
+
codeChallengeMethod: openid.codeChallengeMethod,
|
|
122
|
+
sessionExpires: sessionConfig.expiresIn,
|
|
123
|
+
cache: openid.cache,
|
|
124
|
+
origin: requestUrl,
|
|
125
|
+
wellKnownUrl: openid.wellKnownUrl,
|
|
126
|
+
usePkce: openid.usePkce,
|
|
127
|
+
clientId: openid.clientId,
|
|
128
|
+
clientSecret: openid.clientSecret,
|
|
129
|
+
redirectUri: openid.redirectUri,
|
|
130
|
+
scopes: openid.scopes,
|
|
131
|
+
sessionStorageName: sessionConfig.storage.name,
|
|
132
|
+
sessionStoragePrefix: sessionConfig.storage.prefix
|
|
133
|
+
});
|
|
134
|
+
if (pkceCodeVerifier) {
|
|
135
|
+
setCookie(event, sessionConfig.names.pkce, pkceCodeVerifier, authCookieOpts);
|
|
136
|
+
}
|
|
137
|
+
if (queryParams.redirectUrl) {
|
|
138
|
+
const candidateUrl = String(queryParams.redirectUrl);
|
|
139
|
+
if (!isSameOrigin(candidateUrl, requestUrl)) {
|
|
140
|
+
throw createError({
|
|
141
|
+
status: 400,
|
|
142
|
+
statusMessage: "redirect URL must be same-origin",
|
|
143
|
+
message: `Redirect URL '${candidateUrl}' is not same-origin with '${requestUrl.origin}'`
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
setCookie(event, sessionConfig.names.redirectUrl, candidateUrl, authCookieOpts);
|
|
147
|
+
}
|
|
148
|
+
setCookie(event, sessionConfig.names.state, state, authCookieOpts);
|
|
149
|
+
event.context.sessionId = session.id;
|
|
150
|
+
return { redirectUrl: authorizeUrl };
|
|
151
|
+
case "token" /* TOKEN */:
|
|
152
|
+
const formData = await readFormData(event);
|
|
153
|
+
const openIdTokenSet = await authorizationCodeGrant(
|
|
154
|
+
{
|
|
155
|
+
code: formData.get("code") || void 0,
|
|
156
|
+
state: formData.get("state") || void 0,
|
|
157
|
+
checks: {
|
|
158
|
+
expectedState: getCookie(event, sessionConfig.names.state),
|
|
159
|
+
pkceCodeVerifier: getCookie(event, sessionConfig.names.pkce)
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
cache: openid.cache,
|
|
164
|
+
origin: requestUrl,
|
|
165
|
+
usePkce: openid.usePkce,
|
|
166
|
+
clientId: openid.clientId,
|
|
167
|
+
redirectUri: openid.redirectUri,
|
|
168
|
+
wellKnownUrl: openid.wellKnownUrl,
|
|
169
|
+
clientSecret: openid.clientSecret,
|
|
170
|
+
codeChallengeMethod: openid.codeChallengeMethod
|
|
171
|
+
}
|
|
172
|
+
);
|
|
173
|
+
const candidateRedirect = getCookie(event, sessionConfig.names.redirectUrl);
|
|
174
|
+
if (candidateRedirect && !isSameOrigin(candidateRedirect, requestUrl)) {
|
|
175
|
+
throw createError({
|
|
176
|
+
status: 400,
|
|
177
|
+
statusMessage: "redirect URL must be same-origin",
|
|
178
|
+
message: `Redirect URL '${candidateRedirect}' is not same-origin with '${requestUrl.origin}'`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
redirectUrl = candidateRedirect || auth.defaultLoginRedirectUri;
|
|
182
|
+
Array.of(sessionConfig.names.state, sessionConfig.names.pkce, sessionConfig.names.redirectUrl).forEach(
|
|
183
|
+
(cookie) => deleteCookie(event, cookie, authCookieOpts)
|
|
184
|
+
);
|
|
185
|
+
tokenSet = constructTokenSet(openIdTokenSet);
|
|
186
|
+
const sessionData = {
|
|
187
|
+
auth: {
|
|
188
|
+
status: "authenticated",
|
|
189
|
+
tokenSet
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
if (openid.fetchUserOnLogin) {
|
|
193
|
+
const openidUser = await fetchUserInfo(tokenSet.accessToken, openIdTokenSet.claims()?.sub || "", {
|
|
194
|
+
cache: openid.cache,
|
|
195
|
+
clientId: openid.clientId,
|
|
196
|
+
clientSecret: openid.clientSecret,
|
|
197
|
+
wellKnownUrl: openid.wellKnownUrl
|
|
198
|
+
});
|
|
199
|
+
sessionData.user = await nuxtAuthOptions.userInfo(session.user, { openidUser, tokenSet });
|
|
200
|
+
}
|
|
201
|
+
await updateSession(session.id, sessionData, {
|
|
202
|
+
storageName: sessionConfig.storage.name,
|
|
203
|
+
storagePrefix: sessionConfig.storage.prefix,
|
|
204
|
+
expiresIn: sessionConfig.expiresIn,
|
|
205
|
+
secret: sessionConfig.secret || ""
|
|
206
|
+
});
|
|
207
|
+
event.context.sessionId = session.id;
|
|
208
|
+
return { redirectUrl };
|
|
209
|
+
case "logout" /* LOGOUT */:
|
|
210
|
+
redirectUrl = auth.defaultLogoutRedirectUri;
|
|
211
|
+
tokenSet = session.auth.tokenSet;
|
|
212
|
+
if (tokenSet) {
|
|
213
|
+
await revokeTokens([tokenSet.accessToken, tokenSet.refreshToken, tokenSet.idToken], {
|
|
214
|
+
cache: openid.cache,
|
|
215
|
+
clientId: openid.clientId,
|
|
216
|
+
clientSecret: openid.clientSecret,
|
|
217
|
+
wellKnownUrl: openid.wellKnownUrl
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
await deleteSession(session.id, {
|
|
221
|
+
storageName: sessionConfig.storage.name,
|
|
222
|
+
storagePrefix: sessionConfig.storage.prefix
|
|
223
|
+
});
|
|
224
|
+
const cacheStorage = useStorage("cache");
|
|
225
|
+
await cacheStorage.removeItem(`${openid.cache.prefix}:userinfo:${session.id}`);
|
|
226
|
+
deleteCookie(event, sessionConfig.names.sessionId, authCookieOpts);
|
|
227
|
+
event.context.sessionId = "";
|
|
228
|
+
return { redirectUrl };
|
|
229
|
+
default:
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
throw createError({
|
|
233
|
+
status: 500,
|
|
234
|
+
fatal: true,
|
|
235
|
+
message: `Action '${authAction}' is disabled!`
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|