nuxt-auth-utils 0.0.4 → 0.0.6
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 +16 -4
- package/dist/module.json +2 -2
- package/dist/module.mjs +25 -6
- package/dist/runtime/server/lib/oauth/auth0.d.ts +45 -0
- package/dist/runtime/server/lib/oauth/auth0.mjs +78 -0
- package/dist/runtime/server/lib/oauth/battledotnet.d.ts +47 -0
- package/dist/runtime/server/lib/oauth/battledotnet.mjs +110 -0
- package/dist/runtime/server/lib/oauth/discord.d.ts +51 -0
- package/dist/runtime/server/lib/oauth/discord.mjs +85 -0
- package/dist/runtime/server/lib/oauth/google.d.ts +45 -0
- package/dist/runtime/server/lib/oauth/google.mjs +82 -0
- package/dist/runtime/server/lib/oauth/twitch.d.ts +45 -0
- package/dist/runtime/server/lib/oauth/twitch.mjs +89 -0
- package/dist/runtime/server/utils/oauth.d.ts +10 -0
- package/dist/runtime/server/utils/oauth.mjs +11 -1
- package/dist/types.d.mts +3 -2
- package/dist/types.d.ts +3 -2
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -7,14 +7,15 @@
|
|
|
7
7
|
|
|
8
8
|
Minimalist Authentication module for Nuxt exposing Vue composables and server utils.
|
|
9
9
|
|
|
10
|
-
- [
|
|
10
|
+
- [Release Notes](/CHANGELOG.md)
|
|
11
|
+
- [Demo](https://github.com/atinux/nuxt-todos-edge)
|
|
11
12
|
<!-- - [🏀 Online playground](https://stackblitz.com/github/your-org/nuxt-auth-utils?file=playground%2Fapp.vue) -->
|
|
12
13
|
<!-- - [📖 Documentation](https://example.com) -->
|
|
13
14
|
|
|
14
15
|
## Features
|
|
15
16
|
|
|
16
17
|
- Secured & sealed cookies sessions
|
|
17
|
-
- OAuth Providers
|
|
18
|
+
- [OAuth Providers](#supported-oauth-providers)
|
|
18
19
|
|
|
19
20
|
## Requirements
|
|
20
21
|
|
|
@@ -141,12 +142,21 @@ export default defineNuxtConfig({
|
|
|
141
142
|
```
|
|
142
143
|
|
|
143
144
|
It can also be set using environment variables:
|
|
145
|
+
|
|
144
146
|
- `NUXT_OAUTH_<PROVIDER>_CLIENT_ID`
|
|
145
147
|
- `NUXT_OAUTH_<PROVIDER>_CLIENT_SECRET`
|
|
146
148
|
|
|
147
|
-
Supported
|
|
149
|
+
#### Supported OAuth Providers
|
|
150
|
+
|
|
151
|
+
- Auth0
|
|
152
|
+
- Discord
|
|
148
153
|
- GitHub
|
|
154
|
+
- Google
|
|
149
155
|
- Spotify
|
|
156
|
+
- Twitch
|
|
157
|
+
- Battle.net
|
|
158
|
+
|
|
159
|
+
You can add your favorite provider by creating a new file in [src/runtime/server/lib/oauth/](./src/runtime/server/lib/oauth/).
|
|
150
160
|
|
|
151
161
|
### Example
|
|
152
162
|
|
|
@@ -154,6 +164,9 @@ Example: `~/server/routes/auth/github.get.ts`
|
|
|
154
164
|
|
|
155
165
|
```ts
|
|
156
166
|
export default oauth.githubEventHandler({
|
|
167
|
+
config: {
|
|
168
|
+
emailRequired: true
|
|
169
|
+
},
|
|
157
170
|
async onSuccess(event, { user, tokens }) {
|
|
158
171
|
await setUserSession(event, {
|
|
159
172
|
user: {
|
|
@@ -172,7 +185,6 @@ export default oauth.githubEventHandler({
|
|
|
172
185
|
|
|
173
186
|
Make sure to set the callback URL in your OAuth app settings as `<your-domain>/auth/github`.
|
|
174
187
|
|
|
175
|
-
|
|
176
188
|
## Development
|
|
177
189
|
|
|
178
190
|
```bash
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -4,20 +4,18 @@ import { defu } from 'defu';
|
|
|
4
4
|
|
|
5
5
|
const module = defineNuxtModule({
|
|
6
6
|
meta: {
|
|
7
|
-
name: "auth-
|
|
7
|
+
name: "auth-utils",
|
|
8
8
|
configKey: "auth"
|
|
9
9
|
},
|
|
10
10
|
// Default configuration options of the Nuxt module
|
|
11
11
|
defaults: {},
|
|
12
12
|
setup(options, nuxt) {
|
|
13
13
|
const resolver = createResolver(import.meta.url);
|
|
14
|
-
if (!process.env.NUXT_SESSION_PASSWORD) {
|
|
14
|
+
if (!process.env.NUXT_SESSION_PASSWORD && !nuxt.options._prepare) {
|
|
15
15
|
const randomPassword = sha256(`${Date.now()}${Math.random()}`).slice(0, 32);
|
|
16
16
|
process.env.NUXT_SESSION_PASSWORD = randomPassword;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
console.log(`NUXT_SESSION_PASSWORD=${randomPassword}`);
|
|
20
|
-
}
|
|
17
|
+
console.warn("No session password set, using a random password, please set NUXT_SESSION_PASSWORD in your .env file with at least 32 chars");
|
|
18
|
+
console.log(`NUXT_SESSION_PASSWORD=${randomPassword}`);
|
|
21
19
|
}
|
|
22
20
|
nuxt.options.alias["#auth-utils"] = resolver.resolve("./runtime/types/auth-utils-session");
|
|
23
21
|
addImportsDir(resolver.resolve("./runtime/composables"));
|
|
@@ -65,6 +63,27 @@ const module = defineNuxtModule({
|
|
|
65
63
|
clientId: "",
|
|
66
64
|
clientSecret: ""
|
|
67
65
|
});
|
|
66
|
+
runtimeConfig.oauth.google = defu(runtimeConfig.oauth.google, {
|
|
67
|
+
clientId: "",
|
|
68
|
+
clientSecret: ""
|
|
69
|
+
});
|
|
70
|
+
runtimeConfig.oauth.twitch = defu(runtimeConfig.oauth.twitch, {
|
|
71
|
+
clientId: "",
|
|
72
|
+
clientSecret: ""
|
|
73
|
+
});
|
|
74
|
+
runtimeConfig.oauth.auth0 = defu(runtimeConfig.oauth.auth0, {
|
|
75
|
+
clientId: "",
|
|
76
|
+
clientSecret: "",
|
|
77
|
+
domain: ""
|
|
78
|
+
});
|
|
79
|
+
runtimeConfig.oauth.discord = defu(runtimeConfig.oauth.discord, {
|
|
80
|
+
clientId: "",
|
|
81
|
+
clientSecret: ""
|
|
82
|
+
});
|
|
83
|
+
runtimeConfig.oauth.battledotnet = defu(runtimeConfig.oauth.battledotnet, {
|
|
84
|
+
clientId: "",
|
|
85
|
+
clientSecret: ""
|
|
86
|
+
});
|
|
68
87
|
}
|
|
69
88
|
});
|
|
70
89
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { H3Event, H3Error } from 'h3';
|
|
2
|
+
export interface OAuthAuth0Config {
|
|
3
|
+
/**
|
|
4
|
+
* Auth0 OAuth Client ID
|
|
5
|
+
* @default process.env.NUXT_OAUTH_AUTH0_CLIENT_ID
|
|
6
|
+
*/
|
|
7
|
+
clientId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Auth0 OAuth Client Secret
|
|
10
|
+
* @default process.env.NUXT_OAUTH_AUTH0_CLIENT_SECRET
|
|
11
|
+
*/
|
|
12
|
+
clientSecret?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Auth0 OAuth Issuer
|
|
15
|
+
* @default process.env.NUXT_OAUTH_AUTH0_DOMAIN
|
|
16
|
+
*/
|
|
17
|
+
domain?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Auth0 OAuth Audience
|
|
20
|
+
* @default process.env.NUXT_OAUTH_AUTH0_AUDIENCE
|
|
21
|
+
*/
|
|
22
|
+
audience?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Auth0 OAuth Scope
|
|
25
|
+
* @default []
|
|
26
|
+
* @see https://auth0.com/docs/get-started/apis/scopes/openid-connect-scopes
|
|
27
|
+
* @example ['openid']
|
|
28
|
+
*/
|
|
29
|
+
scope?: string[];
|
|
30
|
+
/**
|
|
31
|
+
* Require email from user, adds the ['email'] scope if not present
|
|
32
|
+
* @default false
|
|
33
|
+
*/
|
|
34
|
+
emailRequired?: boolean;
|
|
35
|
+
}
|
|
36
|
+
interface OAuthConfig {
|
|
37
|
+
config?: OAuthAuth0Config;
|
|
38
|
+
onSuccess: (event: H3Event, result: {
|
|
39
|
+
user: any;
|
|
40
|
+
tokens: any;
|
|
41
|
+
}) => Promise<void> | void;
|
|
42
|
+
onError?: (event: H3Event, error: H3Error) => Promise<void> | void;
|
|
43
|
+
}
|
|
44
|
+
export declare function auth0EventHandler({ config, onSuccess, onError }: OAuthConfig): import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { eventHandler, createError, getQuery, getRequestURL, sendRedirect } from "h3";
|
|
2
|
+
import { withQuery, parsePath } from "ufo";
|
|
3
|
+
import { ofetch } from "ofetch";
|
|
4
|
+
import { defu } from "defu";
|
|
5
|
+
import { useRuntimeConfig } from "#imports";
|
|
6
|
+
export function auth0EventHandler({ config, onSuccess, onError }) {
|
|
7
|
+
return eventHandler(async (event) => {
|
|
8
|
+
config = defu(config, useRuntimeConfig(event).oauth?.auth0);
|
|
9
|
+
const { code } = getQuery(event);
|
|
10
|
+
if (!config.clientId || !config.clientSecret || !config.domain) {
|
|
11
|
+
const error = createError({
|
|
12
|
+
statusCode: 500,
|
|
13
|
+
message: "Missing NUXT_OAUTH_AUTH0_CLIENT_ID or NUXT_OAUTH_AUTH0_CLIENT_SECRET or NUXT_OAUTH_AUTH0_DOMAIN env variables."
|
|
14
|
+
});
|
|
15
|
+
if (!onError)
|
|
16
|
+
throw error;
|
|
17
|
+
return onError(event, error);
|
|
18
|
+
}
|
|
19
|
+
const authorizationURL = `https://${config.domain}/authorize`;
|
|
20
|
+
const tokenURL = `https://${config.domain}/oauth/token`;
|
|
21
|
+
const redirectUrl = getRequestURL(event).href;
|
|
22
|
+
if (!code) {
|
|
23
|
+
config.scope = config.scope || ["openid", "offline_access"];
|
|
24
|
+
if (config.emailRequired && !config.scope.includes("email")) {
|
|
25
|
+
config.scope.push("email");
|
|
26
|
+
}
|
|
27
|
+
return sendRedirect(
|
|
28
|
+
event,
|
|
29
|
+
withQuery(authorizationURL, {
|
|
30
|
+
response_type: "code",
|
|
31
|
+
client_id: config.clientId,
|
|
32
|
+
redirect_uri: redirectUrl,
|
|
33
|
+
scope: config.scope.join(" "),
|
|
34
|
+
audience: config.audience || ""
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const tokens = await ofetch(
|
|
39
|
+
tokenURL,
|
|
40
|
+
{
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
"Content-Type": "application/json"
|
|
44
|
+
},
|
|
45
|
+
body: {
|
|
46
|
+
grant_type: "authorization_code",
|
|
47
|
+
client_id: config.clientId,
|
|
48
|
+
client_secret: config.clientSecret,
|
|
49
|
+
redirect_uri: parsePath(redirectUrl).pathname,
|
|
50
|
+
code
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
).catch((error) => {
|
|
54
|
+
return { error };
|
|
55
|
+
});
|
|
56
|
+
if (tokens.error) {
|
|
57
|
+
const error = createError({
|
|
58
|
+
statusCode: 401,
|
|
59
|
+
message: `Auth0 login failed: ${tokens.error?.data?.error_description || "Unknown error"}`,
|
|
60
|
+
data: tokens
|
|
61
|
+
});
|
|
62
|
+
if (!onError)
|
|
63
|
+
throw error;
|
|
64
|
+
return onError(event, error);
|
|
65
|
+
}
|
|
66
|
+
const tokenType = tokens.token_type;
|
|
67
|
+
const accessToken = tokens.access_token;
|
|
68
|
+
const user = await ofetch(`https://${config.domain}/userinfo`, {
|
|
69
|
+
headers: {
|
|
70
|
+
Authorization: `${tokenType} ${accessToken}`
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return onSuccess(event, {
|
|
74
|
+
tokens,
|
|
75
|
+
user
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { H3Event, H3Error } from 'h3';
|
|
2
|
+
export interface OAuthBattledotnetConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Battle.net OAuth Client ID
|
|
5
|
+
* @default process.env.NUXT_OAUTH_BATTLEDOTNET_CLIENT_ID
|
|
6
|
+
*/
|
|
7
|
+
clientId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Battle.net OAuth Client Secret
|
|
10
|
+
* @default process.env.NUXT_OAUTH_BATTLEDOTNET_CLIENT_SECRET
|
|
11
|
+
*/
|
|
12
|
+
clientSecret?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Battle.net OAuth Scope
|
|
15
|
+
* @default []
|
|
16
|
+
* @see https://develop.battle.net/documentation/guides/using-oauth
|
|
17
|
+
* @example ['openid', 'wow.profile', 'sc2.profile', 'd3.profile']
|
|
18
|
+
*/
|
|
19
|
+
scope?: string[];
|
|
20
|
+
/**
|
|
21
|
+
* Battle.net OAuth Region
|
|
22
|
+
* @default EU
|
|
23
|
+
* @see https://develop.battle.net/documentation/guides/using-oauth
|
|
24
|
+
* @example EU (possible values: US, EU, APAC)
|
|
25
|
+
*/
|
|
26
|
+
region?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Battle.net OAuth Authorization URL
|
|
29
|
+
* @default 'https://oauth.battle.net/authorize'
|
|
30
|
+
*/
|
|
31
|
+
authorizationURL?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Battle.net OAuth Token URL
|
|
34
|
+
* @default 'https://oauth.battle.net/token'
|
|
35
|
+
*/
|
|
36
|
+
tokenURL?: string;
|
|
37
|
+
}
|
|
38
|
+
interface OAuthConfig {
|
|
39
|
+
config?: OAuthBattledotnetConfig;
|
|
40
|
+
onSuccess: (event: H3Event, result: {
|
|
41
|
+
user: any;
|
|
42
|
+
tokens: any;
|
|
43
|
+
}) => Promise<void> | void;
|
|
44
|
+
onError?: (event: H3Event, error: H3Error) => Promise<void> | void;
|
|
45
|
+
}
|
|
46
|
+
export declare function battledotnetEventHandler({ config, onSuccess, onError }: OAuthConfig): import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { eventHandler, createError, getQuery, getRequestURL, sendRedirect } from "h3";
|
|
2
|
+
import { ofetch } from "ofetch";
|
|
3
|
+
import { withQuery, parsePath } from "ufo";
|
|
4
|
+
import { defu } from "defu";
|
|
5
|
+
import { useRuntimeConfig } from "#imports";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
export function battledotnetEventHandler({ config, onSuccess, onError }) {
|
|
8
|
+
return eventHandler(async (event) => {
|
|
9
|
+
config = defu(config, useRuntimeConfig(event).oauth?.battledotnet, {
|
|
10
|
+
authorizationURL: "https://oauth.battle.net/authorize",
|
|
11
|
+
tokenURL: "https://oauth.battle.net/token"
|
|
12
|
+
});
|
|
13
|
+
const query = getQuery(event);
|
|
14
|
+
const { code } = query;
|
|
15
|
+
if (query.error) {
|
|
16
|
+
const error = createError({
|
|
17
|
+
statusCode: 401,
|
|
18
|
+
message: `Battle.net login failed: ${query.error || "Unknown error"}`,
|
|
19
|
+
data: query
|
|
20
|
+
});
|
|
21
|
+
if (!onError)
|
|
22
|
+
throw error;
|
|
23
|
+
return onError(event, error);
|
|
24
|
+
}
|
|
25
|
+
if (!config.clientId || !config.clientSecret) {
|
|
26
|
+
const error = createError({
|
|
27
|
+
statusCode: 500,
|
|
28
|
+
message: "Missing NUXT_OAUTH_BATTLEDOTNET_CLIENT_ID or NUXT_OAUTH_BATTLEDOTNET_CLIENT_SECRET env variables."
|
|
29
|
+
});
|
|
30
|
+
if (!onError)
|
|
31
|
+
throw error;
|
|
32
|
+
return onError(event, error);
|
|
33
|
+
}
|
|
34
|
+
if (!code) {
|
|
35
|
+
config.scope = config.scope || ["openid"];
|
|
36
|
+
config.region = config.region || "EU";
|
|
37
|
+
if (config.region === "CN") {
|
|
38
|
+
config.authorizationURL = "https://oauth.battlenet.com.cn/authorize";
|
|
39
|
+
config.tokenURL = "https://oauth.battlenet.com.cn/token";
|
|
40
|
+
}
|
|
41
|
+
const redirectUrl2 = getRequestURL(event).href;
|
|
42
|
+
return sendRedirect(
|
|
43
|
+
event,
|
|
44
|
+
withQuery(config.authorizationURL, {
|
|
45
|
+
client_id: config.clientId,
|
|
46
|
+
redirect_uri: redirectUrl2,
|
|
47
|
+
scope: config.scope.join(" "),
|
|
48
|
+
state: randomUUID(),
|
|
49
|
+
// Todo: handle PKCE flow
|
|
50
|
+
response_type: "code"
|
|
51
|
+
})
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const redirectUrl = getRequestURL(event).href;
|
|
55
|
+
config.scope = config.scope || [];
|
|
56
|
+
if (!config.scope.includes("openid")) {
|
|
57
|
+
config.scope.push("openid");
|
|
58
|
+
}
|
|
59
|
+
const authCode = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
|
60
|
+
const tokens = await $fetch(
|
|
61
|
+
config.tokenURL,
|
|
62
|
+
{
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
66
|
+
Authorization: `Basic ${authCode}`
|
|
67
|
+
},
|
|
68
|
+
params: {
|
|
69
|
+
code,
|
|
70
|
+
grant_type: "authorization_code",
|
|
71
|
+
scope: config.scope.join(" "),
|
|
72
|
+
redirect_uri: parsePath(redirectUrl).pathname
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
).catch((error) => {
|
|
76
|
+
return { error };
|
|
77
|
+
});
|
|
78
|
+
if (tokens.error) {
|
|
79
|
+
const error = createError({
|
|
80
|
+
statusCode: 401,
|
|
81
|
+
message: `Battle.net login failed: ${tokens.error || "Unknown error"}`,
|
|
82
|
+
data: tokens
|
|
83
|
+
});
|
|
84
|
+
if (!onError)
|
|
85
|
+
throw error;
|
|
86
|
+
return onError(event, error);
|
|
87
|
+
}
|
|
88
|
+
const accessToken = tokens.access_token;
|
|
89
|
+
const user = await ofetch("https://oauth.battle.net/userinfo", {
|
|
90
|
+
headers: {
|
|
91
|
+
"User-Agent": `Battledotnet-OAuth-${config.clientId}`,
|
|
92
|
+
Authorization: `Bearer ${accessToken}`
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
if (!user) {
|
|
96
|
+
const error = createError({
|
|
97
|
+
statusCode: 500,
|
|
98
|
+
message: "Could not get Battle.net user",
|
|
99
|
+
data: tokens
|
|
100
|
+
});
|
|
101
|
+
if (!onError)
|
|
102
|
+
throw error;
|
|
103
|
+
return onError(event, error);
|
|
104
|
+
}
|
|
105
|
+
return onSuccess(event, {
|
|
106
|
+
user,
|
|
107
|
+
tokens
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { H3Event, H3Error } from 'h3';
|
|
2
|
+
export interface OAuthDiscordConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Discord OAuth Client ID
|
|
5
|
+
* @default process.env.NUXT_OAUTH_DISCORD_CLIENT_ID
|
|
6
|
+
*/
|
|
7
|
+
clientId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Discord OAuth Client Secret
|
|
10
|
+
* @default process.env.NUXT_OAUTH_DISCORD_CLIENT_SECRET
|
|
11
|
+
*/
|
|
12
|
+
clientSecret?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Discord OAuth Scope
|
|
15
|
+
* @default []
|
|
16
|
+
* @see https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes
|
|
17
|
+
* @example ['identify', 'email']
|
|
18
|
+
* Without the identify scope the user will not be returned.
|
|
19
|
+
*/
|
|
20
|
+
scope?: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Require email from user, adds the ['email'] scope if not present.
|
|
23
|
+
* @default false
|
|
24
|
+
*/
|
|
25
|
+
emailRequired?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Require profile from user, adds the ['identify'] scope if not present.
|
|
28
|
+
* @default true
|
|
29
|
+
*/
|
|
30
|
+
profileRequired?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Discord OAuth Authorization URL
|
|
33
|
+
* @default 'https://discord.com/oauth2/authorize'
|
|
34
|
+
*/
|
|
35
|
+
authorizationURL?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Discord OAuth Token URL
|
|
38
|
+
* @default 'https://discord.com/api/oauth2/token'
|
|
39
|
+
*/
|
|
40
|
+
tokenURL?: string;
|
|
41
|
+
}
|
|
42
|
+
interface OAuthConfig {
|
|
43
|
+
config?: OAuthDiscordConfig;
|
|
44
|
+
onSuccess: (event: H3Event, result: {
|
|
45
|
+
user: any;
|
|
46
|
+
tokens: any;
|
|
47
|
+
}) => Promise<void> | void;
|
|
48
|
+
onError?: (event: H3Event, error: H3Error) => Promise<void> | void;
|
|
49
|
+
}
|
|
50
|
+
export declare function discordEventHandler({ config, onSuccess, onError }: OAuthConfig): import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { eventHandler, createError, getQuery, getRequestURL, sendRedirect } from "h3";
|
|
2
|
+
import { withQuery, parseURL, stringifyParsedURL } from "ufo";
|
|
3
|
+
import { ofetch } from "ofetch";
|
|
4
|
+
import { defu } from "defu";
|
|
5
|
+
import { useRuntimeConfig } from "#imports";
|
|
6
|
+
export function discordEventHandler({ config, onSuccess, onError }) {
|
|
7
|
+
return eventHandler(async (event) => {
|
|
8
|
+
config = defu(config, useRuntimeConfig(event).oauth?.discord, {
|
|
9
|
+
authorizationURL: "https://discord.com/oauth2/authorize",
|
|
10
|
+
tokenURL: "https://discord.com/api/oauth2/token",
|
|
11
|
+
profileRequired: true
|
|
12
|
+
});
|
|
13
|
+
const { code } = getQuery(event);
|
|
14
|
+
if (!config.clientId || !config.clientSecret) {
|
|
15
|
+
const error = createError({
|
|
16
|
+
statusCode: 500,
|
|
17
|
+
message: "Missing NUXT_OAUTH_DISCORD_CLIENT_ID or NUXT_OAUTH_DISCORD_CLIENT_SECRET env variables."
|
|
18
|
+
});
|
|
19
|
+
if (!onError)
|
|
20
|
+
throw error;
|
|
21
|
+
return onError(event, error);
|
|
22
|
+
}
|
|
23
|
+
const redirectUrl = getRequestURL(event).href;
|
|
24
|
+
if (!code) {
|
|
25
|
+
config.scope = config.scope || [];
|
|
26
|
+
if (config.emailRequired && !config.scope.includes("email")) {
|
|
27
|
+
config.scope.push("email");
|
|
28
|
+
}
|
|
29
|
+
if (config.profileRequired && !config.scope.includes("identify")) {
|
|
30
|
+
config.scope.push("identify");
|
|
31
|
+
}
|
|
32
|
+
return sendRedirect(
|
|
33
|
+
event,
|
|
34
|
+
withQuery(config.authorizationURL, {
|
|
35
|
+
response_type: "code",
|
|
36
|
+
client_id: config.clientId,
|
|
37
|
+
redirect_uri: redirectUrl,
|
|
38
|
+
scope: config.scope.join(" ")
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const parsedRedirectUrl = parseURL(redirectUrl);
|
|
43
|
+
parsedRedirectUrl.search = "";
|
|
44
|
+
const tokens = await ofetch(
|
|
45
|
+
config.tokenURL,
|
|
46
|
+
{
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: {
|
|
49
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
50
|
+
},
|
|
51
|
+
body: new URLSearchParams({
|
|
52
|
+
client_id: config.clientId,
|
|
53
|
+
client_secret: config.clientSecret,
|
|
54
|
+
grant_type: "authorization_code",
|
|
55
|
+
redirect_uri: stringifyParsedURL(parsedRedirectUrl),
|
|
56
|
+
code
|
|
57
|
+
}).toString()
|
|
58
|
+
}
|
|
59
|
+
).catch((error) => {
|
|
60
|
+
return { error };
|
|
61
|
+
});
|
|
62
|
+
if (tokens.error) {
|
|
63
|
+
console.log(tokens);
|
|
64
|
+
const error = createError({
|
|
65
|
+
statusCode: 401,
|
|
66
|
+
message: `Discord login failed: ${tokens.error?.data?.error_description || "Unknown error"}`,
|
|
67
|
+
data: tokens
|
|
68
|
+
});
|
|
69
|
+
if (!onError)
|
|
70
|
+
throw error;
|
|
71
|
+
return onError(event, error);
|
|
72
|
+
}
|
|
73
|
+
const accessToken = tokens.access_token;
|
|
74
|
+
const user = await ofetch("https://discord.com/api/users/@me", {
|
|
75
|
+
headers: {
|
|
76
|
+
"user-agent": "Nuxt Auth Utils",
|
|
77
|
+
Authorization: `Bearer ${accessToken}`
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return onSuccess(event, {
|
|
81
|
+
tokens,
|
|
82
|
+
user
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { H3Event, H3Error } from 'h3';
|
|
2
|
+
export interface OAuthGoogleConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Google OAuth Client ID
|
|
5
|
+
* @default process.env.NUXT_OAUTH_GOOGLE_CLIENT_ID
|
|
6
|
+
*/
|
|
7
|
+
clientId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Google OAuth Client Secret
|
|
10
|
+
* @default process.env.NUXT_OAUTH_GOOGLE_CLIENT_SECRET
|
|
11
|
+
*/
|
|
12
|
+
clientSecret?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Google OAuth Scope
|
|
15
|
+
* @default []
|
|
16
|
+
* @see https://developers.google.com/identity/protocols/oauth2/scopes#google-sign-in
|
|
17
|
+
* @example ['email', 'openid', 'profile']
|
|
18
|
+
*/
|
|
19
|
+
scope?: string[];
|
|
20
|
+
/**
|
|
21
|
+
* Google OAuth Authorization URL
|
|
22
|
+
* @default 'https://accounts.google.com/o/oauth2/v2/auth'
|
|
23
|
+
*/
|
|
24
|
+
authorizationURL?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Google OAuth Token URL
|
|
27
|
+
* @default 'https://oauth2.googleapis.com/token'
|
|
28
|
+
*/
|
|
29
|
+
tokenURL?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Redirect URL post authenticating via google
|
|
32
|
+
* @default '/auth/google'
|
|
33
|
+
*/
|
|
34
|
+
redirectUrl: '/auth/google';
|
|
35
|
+
}
|
|
36
|
+
interface OAuthConfig {
|
|
37
|
+
config?: OAuthGoogleConfig;
|
|
38
|
+
onSuccess: (event: H3Event, result: {
|
|
39
|
+
user: any;
|
|
40
|
+
tokens: any;
|
|
41
|
+
}) => Promise<void> | void;
|
|
42
|
+
onError?: (event: H3Event, error: H3Error) => Promise<void> | void;
|
|
43
|
+
}
|
|
44
|
+
export declare function googleEventHandler({ config, onSuccess, onError, }: OAuthConfig): import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
eventHandler,
|
|
3
|
+
createError,
|
|
4
|
+
getQuery,
|
|
5
|
+
getRequestURL,
|
|
6
|
+
sendRedirect
|
|
7
|
+
} from "h3";
|
|
8
|
+
import { withQuery, parsePath } from "ufo";
|
|
9
|
+
import { ofetch } from "ofetch";
|
|
10
|
+
import { defu } from "defu";
|
|
11
|
+
import { useRuntimeConfig } from "#imports";
|
|
12
|
+
export function googleEventHandler({
|
|
13
|
+
config,
|
|
14
|
+
onSuccess,
|
|
15
|
+
onError
|
|
16
|
+
}) {
|
|
17
|
+
return eventHandler(async (event) => {
|
|
18
|
+
config = defu(config, useRuntimeConfig(event).oauth?.google, {
|
|
19
|
+
authorizationURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
20
|
+
tokenURL: "https://oauth2.googleapis.com/token"
|
|
21
|
+
});
|
|
22
|
+
const { code } = getQuery(event);
|
|
23
|
+
if (!config.clientId) {
|
|
24
|
+
const error = createError({
|
|
25
|
+
statusCode: 500,
|
|
26
|
+
message: "Missing NUXT_OAUTH_GOOGLE_CLIENT_ID env variables."
|
|
27
|
+
});
|
|
28
|
+
if (!onError)
|
|
29
|
+
throw error;
|
|
30
|
+
return onError(event, error);
|
|
31
|
+
}
|
|
32
|
+
const redirectUrl = getRequestURL(event).href;
|
|
33
|
+
if (!code) {
|
|
34
|
+
config.scope = config.scope || ["email", "profile"];
|
|
35
|
+
return sendRedirect(
|
|
36
|
+
event,
|
|
37
|
+
withQuery(config.authorizationURL, {
|
|
38
|
+
response_type: "code",
|
|
39
|
+
client_id: config.clientId,
|
|
40
|
+
redirect_uri: redirectUrl,
|
|
41
|
+
scope: config.scope.join(" ")
|
|
42
|
+
})
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const body = {
|
|
46
|
+
grant_type: "authorization_code",
|
|
47
|
+
redirect_uri: parsePath(redirectUrl).pathname,
|
|
48
|
+
client_id: config.clientId,
|
|
49
|
+
client_secret: config.clientSecret,
|
|
50
|
+
code
|
|
51
|
+
};
|
|
52
|
+
const tokens = await ofetch(config.tokenURL, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
body
|
|
55
|
+
}).catch((error) => {
|
|
56
|
+
return { error };
|
|
57
|
+
});
|
|
58
|
+
if (tokens.error) {
|
|
59
|
+
const error = createError({
|
|
60
|
+
statusCode: 401,
|
|
61
|
+
message: `Google login failed: ${tokens.error?.data?.error_description || "Unknown error"}`,
|
|
62
|
+
data: tokens
|
|
63
|
+
});
|
|
64
|
+
if (!onError)
|
|
65
|
+
throw error;
|
|
66
|
+
return onError(event, error);
|
|
67
|
+
}
|
|
68
|
+
const accessToken = tokens.access_token;
|
|
69
|
+
const user = await ofetch(
|
|
70
|
+
"https://www.googleapis.com/oauth2/v3/userinfo",
|
|
71
|
+
{
|
|
72
|
+
headers: {
|
|
73
|
+
Authorization: `Bearer ${accessToken}`
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
return onSuccess(event, {
|
|
78
|
+
tokens,
|
|
79
|
+
user
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { H3Event, H3Error } from 'h3';
|
|
2
|
+
export interface OAuthTwitchConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Twitch Client ID
|
|
5
|
+
* @default process.env.NUXT_OAUTH_TWITCH_CLIENT_ID
|
|
6
|
+
*/
|
|
7
|
+
clientId?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Twitch OAuth Client Secret
|
|
10
|
+
* @default process.env.NUXT_OAUTH_TWITCH_CLIENT_SECRET
|
|
11
|
+
*/
|
|
12
|
+
clientSecret?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Twitch OAuth Scope
|
|
15
|
+
* @default []
|
|
16
|
+
* @see https://dev.twitch.tv/docs/authentication/scopes
|
|
17
|
+
* @example ['user:read:email']
|
|
18
|
+
*/
|
|
19
|
+
scope?: string[];
|
|
20
|
+
/**
|
|
21
|
+
* Require email from user, adds the ['user:read:email'] scope if not present
|
|
22
|
+
* @default false
|
|
23
|
+
*/
|
|
24
|
+
emailRequired?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Twitch OAuth Authorization URL
|
|
27
|
+
* @default 'https://id.twitch.tv/oauth2/authorize'
|
|
28
|
+
*/
|
|
29
|
+
authorizationURL?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Twitch OAuth Token URL
|
|
32
|
+
* @default 'https://id.twitch.tv/oauth2/token'
|
|
33
|
+
*/
|
|
34
|
+
tokenURL?: string;
|
|
35
|
+
}
|
|
36
|
+
interface OAuthConfig {
|
|
37
|
+
config?: OAuthTwitchConfig;
|
|
38
|
+
onSuccess: (event: H3Event, result: {
|
|
39
|
+
user: any;
|
|
40
|
+
tokens: any;
|
|
41
|
+
}) => Promise<void> | void;
|
|
42
|
+
onError?: (event: H3Event, error: H3Error) => Promise<void> | void;
|
|
43
|
+
}
|
|
44
|
+
export declare function twitchEventHandler({ config, onSuccess, onError }: OAuthConfig): import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void>>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { eventHandler, createError, getQuery, getRequestURL, sendRedirect } from "h3";
|
|
2
|
+
import { withQuery, parsePath } from "ufo";
|
|
3
|
+
import { ofetch } from "ofetch";
|
|
4
|
+
import { defu } from "defu";
|
|
5
|
+
import { useRuntimeConfig } from "#imports";
|
|
6
|
+
export function twitchEventHandler({ config, onSuccess, onError }) {
|
|
7
|
+
return eventHandler(async (event) => {
|
|
8
|
+
config = defu(config, useRuntimeConfig(event).oauth?.twitch, {
|
|
9
|
+
authorizationURL: "https://id.twitch.tv/oauth2/authorize",
|
|
10
|
+
tokenURL: "https://id.twitch.tv/oauth2/token"
|
|
11
|
+
});
|
|
12
|
+
const { code } = getQuery(event);
|
|
13
|
+
if (!config.clientId) {
|
|
14
|
+
const error = createError({
|
|
15
|
+
statusCode: 500,
|
|
16
|
+
message: "Missing NUXT_OAUTH_TWITCH_CLIENT_ID env variables."
|
|
17
|
+
});
|
|
18
|
+
if (!onError)
|
|
19
|
+
throw error;
|
|
20
|
+
return onError(event, error);
|
|
21
|
+
}
|
|
22
|
+
const redirectUrl = getRequestURL(event).href;
|
|
23
|
+
if (!code) {
|
|
24
|
+
config.scope = config.scope || [];
|
|
25
|
+
if (config.emailRequired && !config.scope.includes("user:read:email")) {
|
|
26
|
+
config.scope.push("user:read:email");
|
|
27
|
+
}
|
|
28
|
+
return sendRedirect(
|
|
29
|
+
event,
|
|
30
|
+
withQuery(config.authorizationURL, {
|
|
31
|
+
response_type: "code",
|
|
32
|
+
client_id: config.clientId,
|
|
33
|
+
redirect_uri: redirectUrl,
|
|
34
|
+
scope: config.scope.join("%20")
|
|
35
|
+
})
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const tokens = await ofetch(
|
|
39
|
+
config.tokenURL,
|
|
40
|
+
{
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: {
|
|
43
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
44
|
+
},
|
|
45
|
+
params: {
|
|
46
|
+
grant_type: "authorization_code",
|
|
47
|
+
redirect_uri: parsePath(redirectUrl).pathname,
|
|
48
|
+
client_id: config.clientId,
|
|
49
|
+
client_secret: config.clientSecret,
|
|
50
|
+
code
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
).catch((error) => {
|
|
54
|
+
return { error };
|
|
55
|
+
});
|
|
56
|
+
if (tokens.error) {
|
|
57
|
+
const error = createError({
|
|
58
|
+
statusCode: 401,
|
|
59
|
+
message: `Twitch login failed: ${tokens.error?.data?.error_description || "Unknown error"}`,
|
|
60
|
+
data: tokens
|
|
61
|
+
});
|
|
62
|
+
if (!onError)
|
|
63
|
+
throw error;
|
|
64
|
+
return onError(event, error);
|
|
65
|
+
}
|
|
66
|
+
const accessToken = tokens.access_token;
|
|
67
|
+
const users = await ofetch("https://api.twitch.tv/helix/users", {
|
|
68
|
+
headers: {
|
|
69
|
+
"Client-ID": config.clientId,
|
|
70
|
+
Authorization: `Bearer ${accessToken}`
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
const user = users.data?.[0];
|
|
74
|
+
if (!user) {
|
|
75
|
+
const error = createError({
|
|
76
|
+
statusCode: 500,
|
|
77
|
+
message: "Could not get Twitch user",
|
|
78
|
+
data: tokens
|
|
79
|
+
});
|
|
80
|
+
if (!onError)
|
|
81
|
+
throw error;
|
|
82
|
+
return onError(event, error);
|
|
83
|
+
}
|
|
84
|
+
return onSuccess(event, {
|
|
85
|
+
tokens,
|
|
86
|
+
user
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { githubEventHandler } from '../lib/oauth/github';
|
|
2
|
+
import { googleEventHandler } from '../lib/oauth/google';
|
|
2
3
|
import { spotifyEventHandler } from '../lib/oauth/spotify';
|
|
4
|
+
import { twitchEventHandler } from '../lib/oauth/twitch';
|
|
5
|
+
import { auth0EventHandler } from '../lib/oauth/auth0';
|
|
6
|
+
import { discordEventHandler } from '../lib/oauth/discord';
|
|
7
|
+
import { battledotnetEventHandler } from '../lib/oauth/battledotnet';
|
|
3
8
|
export declare const oauth: {
|
|
4
9
|
githubEventHandler: typeof githubEventHandler;
|
|
5
10
|
spotifyEventHandler: typeof spotifyEventHandler;
|
|
11
|
+
googleEventHandler: typeof googleEventHandler;
|
|
12
|
+
twitchEventHandler: typeof twitchEventHandler;
|
|
13
|
+
auth0EventHandler: typeof auth0EventHandler;
|
|
14
|
+
discordEventHandler: typeof discordEventHandler;
|
|
15
|
+
battledotnetEventHandler: typeof battledotnetEventHandler;
|
|
6
16
|
};
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import { githubEventHandler } from "../lib/oauth/github.mjs";
|
|
2
|
+
import { googleEventHandler } from "../lib/oauth/google.mjs";
|
|
2
3
|
import { spotifyEventHandler } from "../lib/oauth/spotify.mjs";
|
|
4
|
+
import { twitchEventHandler } from "../lib/oauth/twitch.mjs";
|
|
5
|
+
import { auth0EventHandler } from "../lib/oauth/auth0.mjs";
|
|
6
|
+
import { discordEventHandler } from "../lib/oauth/discord.mjs";
|
|
7
|
+
import { battledotnetEventHandler } from "../lib/oauth/battledotnet.mjs";
|
|
3
8
|
export const oauth = {
|
|
4
9
|
githubEventHandler,
|
|
5
|
-
spotifyEventHandler
|
|
10
|
+
spotifyEventHandler,
|
|
11
|
+
googleEventHandler,
|
|
12
|
+
twitchEventHandler,
|
|
13
|
+
auth0EventHandler,
|
|
14
|
+
discordEventHandler,
|
|
15
|
+
battledotnetEventHandler
|
|
6
16
|
};
|
package/dist/types.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
|
-
import { ModuleOptions } from './module'
|
|
2
|
+
import type { ModuleOptions } from './module'
|
|
3
|
+
|
|
3
4
|
|
|
4
5
|
declare module '@nuxt/schema' {
|
|
5
6
|
interface NuxtConfig { ['auth']?: Partial<ModuleOptions> }
|
|
@@ -12,4 +13,4 @@ declare module 'nuxt/schema' {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
export { ModuleOptions, default } from './module'
|
|
16
|
+
export type { ModuleOptions, default } from './module'
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
|
-
import { ModuleOptions } from './module'
|
|
2
|
+
import type { ModuleOptions } from './module'
|
|
3
|
+
|
|
3
4
|
|
|
4
5
|
declare module '@nuxt/schema' {
|
|
5
6
|
interface NuxtConfig { ['auth']?: Partial<ModuleOptions> }
|
|
@@ -12,4 +13,4 @@ declare module 'nuxt/schema' {
|
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
export { ModuleOptions, default } from './module'
|
|
16
|
+
export type { ModuleOptions, default } from './module'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nuxt-auth-utils",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Minimalist Auth module for Nuxt with SSR",
|
|
5
5
|
"repository": "Atinux/nuxt-auth-utils",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,23 +32,24 @@
|
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@nuxt/kit": "^3.8.1",
|
|
34
34
|
"defu": "^6.1.3",
|
|
35
|
+
"ofetch": "^1.3.3",
|
|
35
36
|
"ohash": "^1.1.3"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
|
-
"@iconify-json/simple-icons": "^1.1.
|
|
39
|
+
"@iconify-json/simple-icons": "^1.1.78",
|
|
39
40
|
"@nuxt/devtools": "latest",
|
|
40
41
|
"@nuxt/eslint-config": "^0.2.0",
|
|
41
|
-
"@nuxt/module-builder": "^0.5.
|
|
42
|
+
"@nuxt/module-builder": "^0.5.4",
|
|
42
43
|
"@nuxt/schema": "^3.8.1",
|
|
43
44
|
"@nuxt/test-utils": "^3.8.1",
|
|
44
45
|
"@nuxt/ui": "^2.10.0",
|
|
45
46
|
"@nuxt/ui-pro": "^0.4.2",
|
|
46
|
-
"@types/node": "^20.
|
|
47
|
+
"@types/node": "^20.9.0",
|
|
47
48
|
"changelogen": "^0.5.5",
|
|
48
|
-
"eslint": "^8.
|
|
49
|
+
"eslint": "^8.53.0",
|
|
49
50
|
"nuxt": "^3.8.1",
|
|
50
51
|
"typescript": "^5.2.2",
|
|
51
|
-
"vitest": "^0.
|
|
52
|
+
"vitest": "^0.34.6",
|
|
52
53
|
"vue-tsc": "^1.8.22"
|
|
53
54
|
}
|
|
54
55
|
}
|