feat: generic oauth plugin (#149)

This commit is contained in:
Bereket Engida
2024-10-12 02:53:25 +03:00
committed by GitHub
parent 94f1fe10c0
commit a95a1ab4bd
9 changed files with 837 additions and 4 deletions

View File

@@ -633,7 +633,29 @@ export const contents: Content[] = [
</svg>
),
},
{
title: "Generic OAuth",
href: "/docs/plugins/generic-oauth",
icon: () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M2 12a10 10 0 1 0 20 0a10 10 0 1 0-20 0"></path>
<path d="M12.556 6c.65 0 1.235.373 1.508.947l2.839 7.848a1.646 1.646 0 0 1-1.01 2.108a1.673 1.673 0 0 1-2.068-.851L13.365 15h-2.73l-.398.905A1.67 1.67 0 0 1 8.26 16.95l-.153-.047a1.647 1.647 0 0 1-1.056-1.956l2.824-7.852a1.66 1.66 0 0 1 1.409-1.087z"></path>
</g>
</svg>
),
},
{
title: "Authorization",
group: true,

View File

@@ -0,0 +1,146 @@
---
title: Generic OAuth
description: Authenticate users with any OAuth provider
---
The Generic OAuth plugin provides a flexible way to integrate authentication with any OAuth provider. It supports both OAuth 2.0 and OpenID Connect (OIDC) flows, allowing you to easily add social login or custom OAuth authentication to your application.
## Installation
<Steps>
<Step>
### Add the plugin to your auth config
To use the Generic OAuth plugin, add it to your auth config.
```ts title="auth.ts"
import { betterAuth } from "better-auth"
import { genericOAuth } from "better-auth/plugins" // [!code highlight]
export const auth = betterAuth({
// ... other config options
plugins: [
genericOAuth({ // [!code highlight]
config: [ // [!code highlight]
{ // [!code highlight]
providerId: "provider-id", // [!code highlight]
clientId: "test-client-id", // [!code highlight]
clientSecret: "test-client-secret", // [!code highlight]
discoveryUrl: "https://auth.example.com/.well-known/openid-configuration", // [!code highlight]
// ... other config options // [!code highlight]
}, // [!code highlight]
// Add more providers as needed // [!code highlight]
] // [!code highlight]
}) // [!code highlight]
]
})
```
</Step>
<Step>
### Add the client plugin
Include the Generic OAuth client plugin in your authentication client instance.
```ts title="auth-client.ts"
import { createAuthClient } from "better-auth/client"
import { genericOAuthClient } from "better-auth/client/plugins"
const authClient = createAuthClient({
plugins: [
genericOAuthClient()
]
})
```
</Step>
</Steps>
## Usage
The Generic OAuth plugin provides endpoints for initiating the OAuth flow and handling the callback. Here's how to use them:
### Initiate OAuth Sign-In
To start the OAuth sign-in process:
```ts title="sign-in.ts"
const response = await authClient.signIn.oauth2({
providerId: "provider-id",
callbackURL: "/dashboard" // the path to redirect to after the user is authenticated
});
```
### Handle OAuth Callback
The plugin mounts a route to handle the OAuth callback `/oauth2/callback/:providerId`. This means by default `${baseURL}/api/auth/oauth2/callback/:providerId` will be used as the callback URL. Make sure your OAuth provider is configured to use this URL.
## Configuration
When adding the plugin to your auth config, you can configure multiple OAuth providers. Each provider configuration object supports the following options:
```ts
interface GenericOAuthConfig {
providerId: string;
discoveryUrl?: string;
type?: "oauth2" | "oidc";
authorizationUrl?: string;
tokenUrl?: string;
userInfoUrl?: string;
clientId: string;
clientSecret: string;
scopes?: string[];
redirectURI?: string;
responseType?: string;
prompt?: string;
pkce?: boolean;
accessType?: string;
getUserInfo?: (tokens: OAuth2Tokens) => Promise<User | null>;
}
```
- `providerId`: A unique identifier for the OAuth provider.
- `discoveryUrl`: URL to fetch OAuth 2.0 configuration (optional, but recommended for OIDC providers).
- `type`: Type of OAuth flow ("oauth2" or "oidc", defaults to "oauth2").
- `authorizationUrl`: URL for the authorization endpoint (optional if using discoveryUrl).
- `tokenUrl`: URL for the token endpoint (optional if using discoveryUrl).
- `userInfoUrl`: URL for the user info endpoint (optional if using discoveryUrl).
- `clientId`: OAuth client ID.
- `clientSecret`: OAuth client secret.
- `scopes`: Array of OAuth scopes to request.
- `redirectURI`: Custom redirect URI (optional).
- `responseType`: OAuth response type (defaults to "code").
- `prompt`: Controls the authentication experience for the user.
- `pkce`: Whether to use PKCE (Proof Key for Code Exchange, defaults to false).
- `accessType`: Access type for the authorization request.
- `getUserInfo`: Custom function to fetch user info (optional).
## Advanced Usage
### Custom User Info Fetching
You can provide a custom `getUserInfo` function to handle specific provider requirements:
```ts
genericOAuth({
config: [
{
providerId: "custom-provider",
// ... other config options
getUserInfo: async (tokens) => {
// Custom logic to fetch and return user info
const userInfo = await fetchUserInfoFromCustomProvider(tokens);
return {
id: userInfo.sub,
email: userInfo.email,
name: userInfo.name,
// ... map other fields as needed
};
}
}
]
})
```
### Error Handling
The plugin includes built-in error handling for common OAuth issues. Errors are typically redirected to your application's error page with an appropriate error message in the URL parameters. If the callback URL is not provided, the user will be redirected to Better Auth's default error page.

View File

@@ -66,6 +66,7 @@
"mongodb": "^6.9.0",
"mysql2": "^3.11.0",
"next": "^14.2.8",
"oauth2-mock-server": "^7.1.2",
"pg": "^8.12.0",
"prisma": "^5.19.1",
"react": "^18.3.1",

View File

@@ -40,6 +40,7 @@ export type AuthEndpoint = Endpoint<
options: BetterAuthOptions;
body: any;
query: any;
params: any;
headers: Headers;
}) => Promise<EndpointResponse>
>;

View File

@@ -0,0 +1,9 @@
import type { genericOAuth } from ".";
import type { BetterAuthClientPlugin } from "../../types";
export const genericOAuthClient = () => {
return {
id: "generic-oauth-client",
$InferServerPlugin: {} as ReturnType<typeof genericOAuth>,
} satisfies BetterAuthClientPlugin;
};

View File

@@ -0,0 +1,459 @@
import { z } from "zod";
import { APIError } from "better-call";
import type { BetterAuthPlugin, User } from "../../types";
import { createAuthEndpoint } from "../../api";
import { betterFetch } from "@better-fetch/fetch";
import { generateState, parseState } from "../../utils/state";
import { generateCodeVerifier } from "oslo/oauth2";
import { logger } from "../../utils/logger";
import {
createAuthorizationURL,
validateAuthorizationCode,
} from "../../social-providers/utils";
import type { OAuth2Tokens } from "arctic";
import { parseJWT } from "oslo/jwt";
import { userSchema } from "../../db/schema";
import { generateId } from "../../utils/id";
import { getAccountTokens } from "../../utils/getAccount";
import { setSessionCookie } from "../../cookies";
/**
* Configuration interface for generic OAuth providers.
*/
interface GenericOAuthConfig {
/** Unique identifier for the OAuth provider */
providerId: string;
/**
* URL to fetch OAuth 2.0 configuration.
* If provided, the authorization and token endpoints will be fetched from this URL.
*/
discoveryUrl?: string;
/**
* Type of OAuth flow.
* @default "oauth2"
*/
type?: "oauth2" | "oidc";
/**
* URL for the authorization endpoint.
* Optional if using discoveryUrl.
*/
authorizationUrl?: string;
/**
* URL for the token endpoint.
* Optional if using discoveryUrl.
*/
tokenUrl?: string;
/**
* URL for the user info endpoint.
* Optional if using discoveryUrl.
*/
userInfoUrl?: string;
/** OAuth client ID */
clientId: string;
/** OAuth client secret */
clientSecret: string;
/**
* Array of OAuth scopes to request.
* @default []
*/
scopes?: string[];
/**
* Custom redirect URI.
* If not provided, a default URI will be constructed.
*/
redirectURI?: string;
/**
* OAuth response type.
* @default "code"
*/
responseType?: string;
/**
* Prompt parameter for the authorization request.
* Controls the authentication experience for the user.
*/
prompt?: string;
/**
* Whether to use PKCE (Proof Key for Code Exchange)
* @default false
*/
pkce?: boolean;
/**
* Access type for the authorization request.
* Use "offline" to request a refresh token.
*/
accessType?: string;
/**
* Custom function to fetch user info.
* If provided, this function will be used instead of the default user info fetching logic.
* @param tokens - The OAuth tokens received after successful authentication
* @returns A promise that resolves to a User object or null
*/
getUserInfo?: (tokens: OAuth2Tokens) => Promise<User | null>;
}
interface GenericOAuthOptions {
/**
* Array of OAuth provider configurations.
*/
config: GenericOAuthConfig[];
}
async function getUserInfo(
tokens: OAuth2Tokens,
type: "oauth2" | "oidc",
finalUserInfoUrl: string | undefined,
) {
if (type === "oidc") {
const idToken = tokens.idToken();
const decoded = parseJWT(idToken);
if (decoded?.payload) {
return decoded.payload;
}
}
if (!finalUserInfoUrl) {
return null;
}
const userInfo = await betterFetch<User>(finalUserInfoUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${tokens.accessToken()}`,
},
});
return userInfo.data;
}
/**
* A generic OAuth plugin that can be used to add OAuth support to any provider
*/
export const genericOAuth = (options: GenericOAuthOptions) => {
return {
id: "generic-oauth",
endpoints: {
signInWithOAuth2: createAuthEndpoint(
"/sign-in/oauth2",
{
method: "POST",
query: z
.object({
/**
* Redirect to the current URL after the
* user has signed in.
*/
currentURL: z.string().optional(),
})
.optional(),
body: z.object({
providerId: z.string(),
callbackURL: z.string().optional(),
}),
},
async (ctx) => {
const { providerId } = ctx.body;
const config = options.config.find(
(c) => c.providerId === providerId,
);
if (!config) {
throw new APIError("BAD_REQUEST", {
message: `No config found for provider ${providerId}`,
});
}
const {
discoveryUrl,
authorizationUrl,
tokenUrl,
clientId,
clientSecret,
scopes,
redirectURI,
responseType,
pkce,
prompt,
accessType,
} = config;
let finalAuthUrl = authorizationUrl;
let finalTokenUrl = tokenUrl;
if (discoveryUrl) {
const discovery = await betterFetch<{
authorization_endpoint: string;
token_endpoint: string;
}>(discoveryUrl, {
onError(context) {
logger.error(context.error, {
discoveryUrl,
});
},
});
if (discovery.data) {
finalAuthUrl = discovery.data.authorization_endpoint;
finalTokenUrl = discovery.data.token_endpoint;
}
}
if (!finalAuthUrl || !finalTokenUrl) {
throw new APIError("BAD_REQUEST", {
message: "Invalid OAuth configuration.",
});
}
const currentURL = ctx.query?.currentURL
? new URL(ctx.query?.currentURL)
: null;
const callbackURL = ctx.body.callbackURL?.startsWith("http")
? ctx.body.callbackURL
: `${currentURL?.origin}${ctx.body.callbackURL || ""}`;
const state = generateState(
callbackURL || currentURL?.origin || ctx.context.baseURL,
ctx.query?.currentURL,
);
const cookie = ctx.context.authCookies;
await ctx.setSignedCookie(
cookie.state.name,
state.code,
ctx.context.secret,
cookie.state.options,
);
const codeVerifier = generateCodeVerifier();
await ctx.setSignedCookie(
cookie.pkCodeVerifier.name,
codeVerifier,
ctx.context.secret,
cookie.pkCodeVerifier.options,
);
const authUrl = createAuthorizationURL(
providerId,
{
clientId,
clientSecret,
redirectURI:
redirectURI ||
`${ctx.context.baseURL}/oauth2/callback/${providerId}`,
},
finalAuthUrl,
state.state,
codeVerifier,
scopes || [],
!pkce,
);
if (responseType && responseType !== "code") {
authUrl.searchParams.set("response_type", responseType);
}
if (prompt) {
authUrl.searchParams.set("prompt", prompt);
}
if (accessType) {
authUrl.searchParams.set("access_type", accessType);
}
return {
url: authUrl.toString(),
state: state.state,
codeVerifier,
redirect: true,
};
},
),
oAuth2Callback: createAuthEndpoint(
"/oauth2/callback/:providerId",
{
method: "GET",
query: z.object({
code: z.string().optional(),
error: z.string().optional(),
state: z.string(),
}),
},
async (ctx) => {
if (ctx.query.error || !ctx.query.code) {
const parsedState = parseState(ctx.query.state);
const callbackURL =
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
ctx.context.logger.error(ctx.query.error, ctx.params.providerId);
throw ctx.redirect(
`${callbackURL}?error=${ctx.query.error || "oAuth_code_missing"}`,
);
}
const provider = options.config.find(
(p) => p.providerId === ctx.params.providerId,
);
if (!provider) {
throw new APIError("BAD_REQUEST", {
message: `No config found for provider ${ctx.params.providerId}`,
});
}
const codeVerifier = await ctx.getSignedCookie(
ctx.context.authCookies.pkCodeVerifier.name,
ctx.context.secret,
);
let tokens: OAuth2Tokens | undefined = undefined;
const parsedState = parseState(ctx.query.state);
if (!parsedState.success) {
throw ctx.redirect(
`${ctx.context.baseURL}/error?error=invalid_state`,
);
}
const {
data: { callbackURL, currentURL, dontRememberMe, code },
} = parsedState;
const errorURL =
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
const storedCode = await ctx.getSignedCookie(
ctx.context.authCookies.state.name,
ctx.context.secret,
);
if (storedCode !== code) {
logger.error("OAuth code mismatch", storedCode, code);
throw ctx.redirect(`${errorURL}?error=please_restart_the_process`);
}
let finalTokenUrl = provider.tokenUrl;
let finalUserInfoUrl = provider.userInfoUrl;
if (provider.discoveryUrl) {
const discovery = await betterFetch<{
token_endpoint: string;
userinfo_endpoint: string;
}>(provider.discoveryUrl, {
method: "GET",
});
if (discovery.data) {
finalTokenUrl = discovery.data.token_endpoint;
finalUserInfoUrl = discovery.data.userinfo_endpoint;
}
}
try {
if (!finalTokenUrl) {
throw new APIError("BAD_REQUEST", {
message: "Invalid OAuth configuration.",
});
}
tokens = await validateAuthorizationCode({
code,
codeVerifier,
redirectURI: `${ctx.context.baseURL}/oauth2/callback/${provider.providerId}`,
options: {
clientId: provider.clientId,
clientSecret: provider.clientSecret,
},
tokenEndpoint: finalTokenUrl,
});
} catch (e) {
ctx.context.logger.error(e);
throw ctx.redirect(
`${errorURL}?error=oauth_code_verification_failed`,
);
}
if (!tokens) {
throw new APIError("BAD_REQUEST", {
message: "Invalid OAuth configuration.",
});
}
const userInfo = provider.getUserInfo
? await provider.getUserInfo(tokens)
: await getUserInfo(
tokens,
provider.type || "oauth2",
finalUserInfoUrl,
);
const id = generateId();
const user = userInfo
? userSchema.safeParse({
...userInfo,
id,
})
: null;
if (!user?.success) {
throw ctx.redirect(`${errorURL}?error=oauth_user_info_invalid`);
}
const dbUser = await ctx.context.internalAdapter
.findUserByEmail(user.data.email)
.catch((e) => {
logger.error(
"Better auth was unable to query your database.\nError: ",
e,
);
throw ctx.redirect(`${errorURL}?error=internal_server_error`);
});
const userId = dbUser?.user.id || id;
if (dbUser) {
//check if user has already linked this provider
const hasBeenLinked = dbUser.accounts.find(
(a) => a.providerId === provider.providerId,
);
const trustedProviders =
ctx.context.options.account?.accountLinking?.trustedProviders;
const isTrustedProvider = trustedProviders
? trustedProviders.includes(provider.providerId as "apple")
: true;
if (
!hasBeenLinked &&
(!user?.data.emailVerified || !isTrustedProvider)
) {
let url: URL;
try {
url = new URL(errorURL);
url.searchParams.set("error", "account_not_linked");
} catch (e) {
throw ctx.redirect(`${errorURL}?error=account_not_linked`);
}
throw ctx.redirect(url.toString());
}
if (!hasBeenLinked) {
try {
await ctx.context.internalAdapter.linkAccount({
providerId: provider.providerId,
accountId: user.data.id,
id: `${provider.providerId}:${user.data.id}`,
userId: dbUser.user.id,
...getAccountTokens(tokens),
});
} catch (e) {
console.log(e);
throw ctx.redirect(`${errorURL}?error=failed_linking_account`);
}
}
} else {
try {
await ctx.context.internalAdapter.createOAuthUser(user.data, {
...getAccountTokens(tokens),
id: `${provider.providerId}:${user.data.id}`,
providerId: provider.providerId,
accountId: user.data.id,
userId: userId!,
});
} catch (e) {
const url = new URL(errorURL);
url.searchParams.set("error", "unable_to_create_user");
ctx.setHeader("Location", url.toString());
throw ctx.redirect(url.toString());
}
}
try {
const session = await ctx.context.internalAdapter.createSession(
userId || id,
ctx.request,
parsedState.data?.dontRememberMe,
);
if (!session) {
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
}
await setSessionCookie(ctx, session.id, dontRememberMe);
} catch {
throw ctx.redirect(`${errorURL}?error=unable_to_create_session`);
}
throw ctx.redirect(callbackURL || currentURL || "");
},
),
},
} satisfies BetterAuthPlugin;
};

View File

@@ -0,0 +1,159 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getTestInstance } from "../../test-utils/test-instance";
import { genericOAuth } from ".";
import { genericOAuthClient } from "./client";
import { createAuthClient } from "../../client";
import { OAuth2Server } from "oauth2-mock-server";
import { betterFetch } from "@better-fetch/fetch";
import { parseSetCookieHeader } from "../../cookies";
let server = new OAuth2Server();
describe("oauth2", async () => {
const providerId = "test";
const clientId = "test-client-id";
const clientSecret = "test-client-secret";
beforeAll(async () => {
await server.issuer.keys.generate("RS256");
server.issuer.on;
// Start the server
await server.start(8080, "localhost");
console.log("Issuer URL:", server.issuer.url); // -> http://localhost:8080
});
afterAll(async () => {
await server.stop();
});
const { customFetchImpl } = await getTestInstance({
plugins: [
genericOAuth({
config: [
{
providerId,
discoveryUrl:
server.issuer.url ||
"http://localhost:8080/.well-known/openid-configuration",
clientId: clientId,
clientSecret: clientSecret,
},
],
}),
],
});
const authClient = createAuthClient({
plugins: [genericOAuthClient()],
baseURL: "http://localhost:3000",
fetchOptions: {
customFetchImpl,
},
});
server.service.once("beforeUserinfo", (userInfoResponse, req) => {
userInfoResponse.body = {
email: "oauth2@test.com",
name: "OAuth2 Test",
sub: "oauth2",
picture: "https://test.com/picture.png",
email_verified: true,
};
userInfoResponse.statusCode = 200;
});
async function simulateOAuthFlow(authUrl: string, headers: Headers) {
let location: string | null = null;
await betterFetch(authUrl, {
method: "GET",
redirect: "manual",
onError(context) {
location = context.response.headers.get("location");
},
});
if (!location) throw new Error("No redirect location found");
let callbackURL = "";
const callbackResponse = await betterFetch(location, {
method: "GET",
customFetchImpl,
headers,
onError(context) {
callbackURL = context.response.headers.get("location") || "";
},
});
return callbackURL;
}
it("should redirect to the provider and handle the response", async () => {
let headers = new Headers();
const res = await authClient.signIn.oauth2(
{
providerId: "test",
callbackURL: "http://localhost:3000/dashboard",
},
{
onSuccess(context) {
const parsedSetCookie = parseSetCookieHeader(
context.response.headers.get("Set-Cookie") || "",
);
headers.set(
"cookie",
`better-auth.state=${
parsedSetCookie.get("better-auth.state")?.value
}; better-auth.pk_code_verifier=${
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
}`,
);
},
},
);
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
expect(callbackURL).toBe("http://localhost:3000/dashboard");
});
it("should handle invalid provider ID", async () => {
const res = await authClient.signIn.oauth2({
providerId: "invalid-provider",
callbackURL: "http://localhost:3000/dashboard",
});
expect(res.error?.status).toBe(400);
});
it("should handle server error during OAuth flow", async () => {
server.service.once("beforeTokenResponse", (tokenResponse) => {
tokenResponse.statusCode = 500;
tokenResponse.body = { error: "internal_server_error" };
});
let headers = new Headers();
const res = await authClient.signIn.oauth2(
{
providerId: "test",
callbackURL: "http://localhost:3000/dashboard",
},
{
onSuccess(context) {
const parsedSetCookie = parseSetCookieHeader(
context.response.headers.get("Set-Cookie") || "",
);
headers.set(
"cookie",
`better-auth.state=${
parsedSetCookie.get("better-auth.state")?.value
}; better-auth.pk_code_verifier=${
parsedSetCookie.get("better-auth.pk_code_verifier")?.value
}`,
);
},
},
);
const callbackURL = await simulateOAuthFlow(res.data?.url || "", headers);
expect(callbackURL).toContain("?error=");
});
});

View File

@@ -59,6 +59,7 @@ export function createAuthorizationURL(
state: string,
codeVerifier: string,
scopes: string[],
disablePkce?: boolean,
): URL {
const url = new URL(authorizationEndpoint);
url.searchParams.set("response_type", "code");
@@ -69,8 +70,10 @@ export function createAuthorizationURL(
"redirect_uri",
options.redirectURI || getRedirectURI(id),
);
const codeChallenge = generateCodeChallenge(codeVerifier);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", codeChallenge);
if (!disablePkce) {
const codeChallenge = generateCodeChallenge(codeVerifier);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", codeChallenge);
}
return url;
}

33
pnpm-lock.yaml generated
View File

@@ -1571,6 +1571,9 @@ importers:
next:
specifier: ^14.2.8
version: 14.2.13(@babel/core@7.25.2)(react-dom@19.0.0-rc-7771d3a7-20240827(react@18.3.1))(react@18.3.1)
oauth2-mock-server:
specifier: ^7.1.2
version: 7.1.2
pg:
specifier: ^8.12.0
version: 8.13.0
@@ -7403,6 +7406,10 @@ packages:
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
cose-base@1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@@ -9650,6 +9657,10 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -11048,6 +11059,11 @@ packages:
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
oauth2-mock-server@7.1.2:
resolution: {integrity: sha512-xUg/YOTcMRe8W+q2jphecq1fB1BAjlAPbeeA9lvqwGaQSPJKxI2e8JUnDXHrrKGNJAVXQdHgE/9h4RpCtOfYOA==}
engines: {node: ^18.12 || ^20 || ^22, yarn: ^1.15.2}
hasBin: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -22177,6 +22193,11 @@ snapshots:
core-util-is@1.0.3: {}
cors@2.8.5:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
cose-base@1.0.3:
dependencies:
layout-base: 1.0.2
@@ -25075,6 +25096,8 @@ snapshots:
is-plain-obj@4.1.0: {}
is-plain-object@5.0.0: {}
is-potential-custom-element-name@1.0.1: {}
is-property@1.0.2: {}
@@ -27147,6 +27170,16 @@ snapshots:
pkg-types: 1.2.0
ufo: 1.5.4
oauth2-mock-server@7.1.2:
dependencies:
basic-auth: 2.0.1
cors: 2.8.5
express: 4.21.0
is-plain-object: 5.0.0
jose: 5.9.3
transitivePeerDependencies:
- supports-color
object-assign@4.1.1: {}
object-hash@3.0.0: {}