diff --git a/.changeset/chilled-pugs-notice.md b/.changeset/chilled-pugs-notice.md new file mode 100644 index 00000000000..06b33009b6f --- /dev/null +++ b/.changeset/chilled-pugs-notice.md @@ -0,0 +1,6 @@ +--- +"@logto/connector-oauth": minor +"@logto/connector-oidc": minor +--- + +Support `client_secret_basic` and `client_secret_jwt` token endpoint auth method for oauth & oidc connectors diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a0af63a9655..cf4bbafcb65 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -36,6 +36,10 @@ jobs: - name: Prepack run: pnpm prepack + # Build connectors before running lint since some connectors rely on the generated types + - name: Build connectors + run: pnpm connectors build + - name: Lint run: pnpm ci:lint diff --git a/.github/workflows/upload-annotations.yml b/.github/workflows/upload-annotations.yml index a7d48c5ce80..f4ec39c7478 100644 --- a/.github/workflows/upload-annotations.yml +++ b/.github/workflows/upload-annotations.yml @@ -27,6 +27,10 @@ jobs: - name: Prepack run: pnpm prepack + # Build connectors before running lint since some connectors rely on the generated types + - name: Build connectors + run: pnpm connectors build + - name: Lint with Report run: pnpm -r --parallel lint:report && node .scripts/merge-eslint-reports.js diff --git a/packages/connectors/connector-oauth2/README.md b/packages/connectors/connector-oauth2/README.md index 9098feaa73e..16263a1fd76 100644 --- a/packages/connectors/connector-oauth2/README.md +++ b/packages/connectors/connector-oauth2/README.md @@ -24,6 +24,10 @@ We ONLY support "Authorization Code" grant type for security consideration and i *clientSecret*: The client secret is a confidential key that is issued to the client application by the authorization server during registration. The client application uses this secret key to authenticate itself with the authorization server when requesting access tokens. The client secret is considered confidential information and should be kept secure at all times. +*tokenEndpointAuthMethod*: The token endpoint authentication method is used by the client application to authenticate itself with the authorization server when requesting access tokens. To discover supported methods, consult the `token_endpoint_auth_methods_supported` field available at the OAuth 2.0 service provider’s OpenID Connect discovery endpoint, or refer to the relevant documentation provided by the OAuth 2.0 service provider. + +*clientSecretJwtSigningAlgorithm (Optional)*: Only required when `tokenEndpointAuthMethod` is `client_secret_jwt`. The client secret JWT signing algorithm is used by the client application to sign the JWT that is sent to the authorization server during the token request. + *scope*: The scope parameter is used to specify the set of resources and permissions that the client application is requesting access to. The scope parameter is typically defined as a space-separated list of values that represent specific permissions. For example, a scope value of "read write" might indicate that the client application is requesting read and write access to a user's data. You are expected to find `authorizationEndpoint`, `tokenEndpoint` and `userInfoEndpoint` in social vendor's documentation. diff --git a/packages/connectors/connector-oauth2/package.json b/packages/connectors/connector-oauth2/package.json index 9b62d82e912..0112f67ec51 100644 --- a/packages/connectors/connector-oauth2/package.json +++ b/packages/connectors/connector-oauth2/package.json @@ -5,8 +5,10 @@ "author": "Silverhand Inc. ", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", + "@logto/shared": "workspace:^3.1.0", "@silverhand/essentials": "^2.9.0", - "got": "^14.0.0", + "jose": "^5.0.0", + "ky": "^1.2.3", "query-string": "^9.0.0", "snakecase-keys": "^8.0.0", "zod": "^3.22.4" @@ -64,7 +66,7 @@ "@vitest/coverage-v8": "^1.4.0", "eslint": "^8.56.0", "lint-staged": "^15.0.2", - "nock": "^13.3.1", + "nock": "14.0.0-beta.6", "prettier": "^3.0.0", "rollup": "^4.12.0", "rollup-plugin-output-size": "^1.3.0", diff --git a/packages/connectors/connector-oauth2/src/constant.ts b/packages/connectors/connector-oauth2/src/constant.ts index 4624d338a5f..cd07445dded 100644 --- a/packages/connectors/connector-oauth2/src/constant.ts +++ b/packages/connectors/connector-oauth2/src/constant.ts @@ -1,6 +1,15 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit'; +import { + authorizationEndpointFormItem, + clientIdFormItem, + clientSecretFormItem, + scopeFormItem, + tokenEndpointAuthOptionsFormItems, + tokenEndpointFormItem, +} from './oauth2/form-items.js'; + export const defaultMetadata: ConnectorMetadata = { id: 'oauth2', target: 'oauth2', @@ -18,20 +27,8 @@ export const defaultMetadata: ConnectorMetadata = { readme: './README.md', isStandard: true, formItems: [ - { - key: 'authorizationEndpoint', - label: 'Authorization Endpoint', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'tokenEndpoint', - label: 'Token Endpoint', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, + authorizationEndpointFormItem, + tokenEndpointFormItem, { key: 'userInfoEndpoint', label: 'User Info Endpoint', @@ -39,20 +36,9 @@ export const defaultMetadata: ConnectorMetadata = { required: true, placeholder: '', }, - { - key: 'clientId', - label: 'Client ID', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'clientSecret', - label: 'Client Secret', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, + clientIdFormItem, + clientSecretFormItem, + ...tokenEndpointAuthOptionsFormItems, { key: 'tokenEndpointResponseType', label: 'Token Endpoint Response Type', @@ -67,13 +53,7 @@ export const defaultMetadata: ConnectorMetadata = { required: false, defaultValue: 'query-string', }, - { - key: 'scope', - label: 'Scope', - type: ConnectorConfigFormItemType.Text, - required: false, - placeholder: '', - }, + scopeFormItem, { key: 'profileMap', label: 'Profile Map', diff --git a/packages/connectors/connector-oauth2/src/index.ts b/packages/connectors/connector-oauth2/src/index.ts index f2b1c274b1c..6d07368306c 100644 --- a/packages/connectors/connector-oauth2/src/index.ts +++ b/packages/connectors/connector-oauth2/src/index.ts @@ -1,6 +1,4 @@ import { assert, pick } from '@silverhand/essentials'; -import { got, HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; import { type GetAuthorizationUri, @@ -14,45 +12,40 @@ import { validateConfig, ConnectorType, } from '@logto/connector-kit'; +import ky, { HTTPError } from 'ky'; import { defaultMetadata, defaultTimeout } from './constant.js'; -import { oauthConfigGuard } from './types.js'; +import { constructAuthorizationUri } from './oauth2/utils.js'; +import { oauth2ConnectorConfigGuard } from './types.js'; import { userProfileMapping, getAccessToken } from './utils.js'; -const removeUndefinedKeys = (object: Record) => - Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined)); +export * from './oauth2/index.js'; const getAuthorizationUri = (getConfig: GetConnectorConfig): GetAuthorizationUri => async ({ state, redirectUri }, setSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oauthConfigGuard); - const parsedConfig = oauthConfigGuard.parse(config); - - const { customConfig, ...rest } = parsedConfig; - - const parameterObject = snakecaseKeys({ - ...pick(rest, 'responseType', 'clientId', 'scope'), - ...customConfig, - }); + validateConfig(config, oauth2ConnectorConfigGuard); + const parsedConfig = oauth2ConnectorConfigGuard.parse(config); await setSession({ redirectUri }); - const queryParameters = new URLSearchParams({ - ...removeUndefinedKeys(parameterObject), + const { authorizationEndpoint, customConfig } = parsedConfig; + + return constructAuthorizationUri(authorizationEndpoint, { + ...pick(parsedConfig, 'responseType', 'clientId', 'scope'), + redirectUri, state, - redirect_uri: redirectUri, + ...customConfig, }); - - return `${parsedConfig.authorizationEndpoint}?${queryParameters.toString()}`; }; const getUserInfo = (getConfig: GetConnectorConfig): GetUserInfo => async (data, getSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oauthConfigGuard); - const parsedConfig = oauthConfigGuard.parse(config); + validateConfig(config, oauth2ConnectorConfigGuard); + const parsedConfig = oauth2ConnectorConfigGuard.parse(config); const { redirectUri } = await getSession(); assert( @@ -65,13 +58,14 @@ const getUserInfo = const { access_token, token_type } = await getAccessToken(parsedConfig, data, redirectUri); try { - const httpResponse = await got.get(parsedConfig.userInfoEndpoint, { + const httpResponse = await ky.get(parsedConfig.userInfoEndpoint, { headers: { authorization: `${token_type} ${access_token}`, }, - timeout: { request: defaultTimeout }, + timeout: defaultTimeout, }); - const rawData = parseJsonObject(httpResponse.body); + + const rawData = parseJsonObject(await httpResponse.text()); return { ...userProfileMapping(rawData, parsedConfig.profileMap), rawData }; } catch (error: unknown) { @@ -87,7 +81,7 @@ const createOauthConnector: CreateConnector = async ({ getConfi return { metadata: defaultMetadata, type: ConnectorType.Social, - configGuard: oauthConfigGuard, + configGuard: oauth2ConnectorConfigGuard, getAuthorizationUri: getAuthorizationUri(getConfig), getUserInfo: getUserInfo(getConfig), }; diff --git a/packages/connectors/connector-oauth2/src/oauth2/form-items.ts b/packages/connectors/connector-oauth2/src/oauth2/form-items.ts new file mode 100644 index 00000000000..3e04565cb73 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/form-items.ts @@ -0,0 +1,96 @@ +import { type ConnectorConfigFormItem, ConnectorConfigFormItemType } from '@logto/connector-kit'; + +import { TokenEndpointAuthMethod, ClientSecretJwtSigningAlgorithm } from './types.js'; + +export const authorizationEndpointFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'authorizationEndpoint', + label: 'Authorization Endpoint', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const tokenEndpointFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'tokenEndpoint', + label: 'Token Endpoint', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const clientIdFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'clientId', + label: 'Client ID', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const clientSecretFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'clientSecret', + label: 'Client Secret', + type: ConnectorConfigFormItemType.Text, + required: true, + placeholder: '', +}); + +export const tokenEndpointAuthOptionsFormItems: ConnectorConfigFormItem[] = [ + Object.freeze({ + key: 'tokenEndpointAuthMethod', + label: 'Token Endpoint Auth Method', + type: ConnectorConfigFormItemType.Select, + selectItems: [ + { + title: TokenEndpointAuthMethod.ClientSecretPost, + value: TokenEndpointAuthMethod.ClientSecretPost, + }, + { + title: TokenEndpointAuthMethod.ClientSecretBasic, + value: TokenEndpointAuthMethod.ClientSecretBasic, + }, + { + title: TokenEndpointAuthMethod.ClientSecretJwt, + value: TokenEndpointAuthMethod.ClientSecretJwt, + }, + ], + required: true, + defaultValue: TokenEndpointAuthMethod.ClientSecretPost, + description: 'The method used for client authentication at the token endpoint in OAuth 2.0.', + }), + Object.freeze({ + key: 'clientSecretJwtSigningAlgorithm', + label: 'Client Secret JWT Signing Algorithm', + type: ConnectorConfigFormItemType.Select, + selectItems: [ + { + title: ClientSecretJwtSigningAlgorithm.HS256, + value: ClientSecretJwtSigningAlgorithm.HS256, + }, + { + title: ClientSecretJwtSigningAlgorithm.HS384, + value: ClientSecretJwtSigningAlgorithm.HS384, + }, + { + title: ClientSecretJwtSigningAlgorithm.HS512, + value: ClientSecretJwtSigningAlgorithm.HS512, + }, + ], + showConditions: [ + { + targetKey: 'tokenEndpointAuthMethod', + expectValue: TokenEndpointAuthMethod.ClientSecretJwt, + }, + ], + required: true, + defaultValue: ClientSecretJwtSigningAlgorithm.HS256, + description: 'The signing algorithm used for the client secret JWT.', + }), +]; + +export const scopeFormItem: ConnectorConfigFormItem = Object.freeze({ + key: 'scope', + label: 'Scope', + type: ConnectorConfigFormItemType.Text, + required: false, + placeholder: '', +}); diff --git a/packages/connectors/connector-oauth2/src/oauth2/index.ts b/packages/connectors/connector-oauth2/src/oauth2/index.ts new file mode 100644 index 00000000000..6449585de66 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './utils.js'; +export * from './form-items.js'; diff --git a/packages/connectors/connector-oauth2/src/oauth2/types.ts b/packages/connectors/connector-oauth2/src/oauth2/types.ts new file mode 100644 index 00000000000..6c9e8c47331 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/types.ts @@ -0,0 +1,68 @@ +import { z } from 'zod'; + +/** + * OAuth 2.0 Client Authentication methods that are used by Clients to authenticate to the Authorization Server when using the Token Endpoint. + */ +export enum TokenEndpointAuthMethod { + ClientSecretBasic = 'client_secret_basic', + ClientSecretPost = 'client_secret_post', + ClientSecretJwt = 'client_secret_jwt', +} + +/* + * Enumeration of algorithms supported for JWT signing when using client secrets. + * + * These "HS" algorithms (HMAC using SHA) are specifically chosen for scenarios where the + * client authentication method is 'client_secret_jwt'. HMAC algorithms utilize the + * client_secret as a shared symmetric key to generate a secure hash, ensuring the integrity + * and authenticity of the JWT. + * + * Other types of algorithms, such as RSASSA (RS256, RS384, RS512) or ECDSA (ES256, ES384, ES512), + * utilize asymmetric keys, are complex and requires secure key management infrastructure. + * + * In the 'client_secret_jwt' context, where simplicity and symmetric key usage are preferred for + * straightforward validation by the authorization server without the need to manage or distribute + * public keys, HMAC algorithms are more suitable. + */ +export enum ClientSecretJwtSigningAlgorithm { + /** HMAC using SHA-256 hash algorithm */ + HS256 = 'HS256', + /** HMAC using SHA-384 hash algorithm */ + HS384 = 'HS384', + /** HMAC using SHA-512 hash algorithm */ + HS512 = 'HS512', +} + +export const oauth2ConfigGuard = z.object({ + responseType: z.literal('code').optional().default('code'), + grantType: z.literal('authorization_code').optional().default('authorization_code'), + authorizationEndpoint: z.string(), + tokenEndpoint: z.string(), + clientId: z.string(), + clientSecret: z.string(), + tokenEndpointAuthMethod: z + .nativeEnum(TokenEndpointAuthMethod) + .optional() + .default(TokenEndpointAuthMethod.ClientSecretPost), + clientSecretJwtSigningAlgorithm: z + .nativeEnum(ClientSecretJwtSigningAlgorithm) + .optional() + .default(ClientSecretJwtSigningAlgorithm.HS256), + scope: z.string().optional(), +}); + +export const oauth2AuthResponseGuard = z.object({ + code: z.string(), + state: z.string().optional(), +}); + +export type Oauth2AuthResponse = z.infer; + +export const oauth2AccessTokenResponseGuard = z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number().optional(), + refresh_token: z.string().optional(), +}); + +export type Oauth2AccessTokenResponse = z.infer; diff --git a/packages/connectors/connector-oauth2/src/oauth2/utils.test.ts b/packages/connectors/connector-oauth2/src/oauth2/utils.test.ts new file mode 100644 index 00000000000..e4b7184991c --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/utils.test.ts @@ -0,0 +1,183 @@ +import nock from 'nock'; + +import ky from 'ky'; + +import { ClientSecretJwtSigningAlgorithm, TokenEndpointAuthMethod } from './types.js'; +import { constructAuthorizationUri, type RequestTokenEndpointOptions } from './utils.js'; + +const kyPostMock = vi.spyOn(ky, 'post'); + +vi.mock('jose', () => ({ + SignJWT: vi.fn(() => ({ + setProtectedHeader: vi.fn().mockReturnThis(), + sign: vi.fn().mockResolvedValue('signed-jwt'), + })), +})); + +const { requestTokenEndpoint } = await import('./utils.js'); + +const tokenEndpointUrl = new URL('https://example.com/token'); + +describe('requestTokenEndpoint', () => { + beforeEach(() => { + nock(tokenEndpointUrl.origin) + .post(tokenEndpointUrl.pathname) + .query(true) + .reply( + 200, + JSON.stringify({ + access_token: 'access_token', + token_type: 'bearer', + }) + ); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + afterAll(() => { + vi.clearAllMocks(); + }); + + it('should handle TokenEndpointAuthMethod.ClientSecretJwt correctly', async () => { + const options: RequestTokenEndpointOptions = { + tokenEndpoint: 'https://example.com/token', + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretJwt, + clientSecretJwtSigningAlgorithm: ClientSecretJwtSigningAlgorithm.HS256, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code: 'authcode123', + redirectUri: 'https://example.com/callback', + clientId: 'client123', + clientSecret: 'secret123', + extraParam: 'extra', + }, + timeout: 5000, + }; + + await requestTokenEndpoint(options); + expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, { + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'authcode123', + redirect_uri: 'https://example.com/callback', + extra_param: 'extra', + client_id: 'client123', + client_assertion: 'signed-jwt', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + }), + timeout: 5000, + }); + }); + + it('should handle TokenEndpointAuthMethod.ClientSecretBasic correctly', async () => { + const options: RequestTokenEndpointOptions = { + tokenEndpoint: 'https://example.com/token', + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretBasic, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code: 'authcode123', + redirectUri: 'https://example.com/callback', + clientId: 'client123', + clientSecret: 'secret123', + extraParam: 'extra', + }, + timeout: 5000, + }; + + await requestTokenEndpoint(options); + expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, { + headers: { + Authorization: `Basic ${Buffer.from('client123:secret123').toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'authcode123', + redirect_uri: 'https://example.com/callback', + extra_param: 'extra', + }), + timeout: 5000, + }); + }); + + it('should handle TokenEndpointAuthMethod.ClientSecretPost correctly', async () => { + const options: RequestTokenEndpointOptions = { + tokenEndpoint: 'https://example.com/token', + tokenEndpointAuthOptions: { + method: TokenEndpointAuthMethod.ClientSecretPost, + }, + tokenRequestBody: { + grantType: 'authorization_code', + code: 'authcode123', + redirectUri: 'https://example.com/callback', + clientId: 'client123', + clientSecret: 'secret123', + extraParam: 'extra', + }, + timeout: 5000, + }; + + await requestTokenEndpoint(options); + expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, { + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: 'authcode123', + redirect_uri: 'https://example.com/callback', + client_id: 'client123', + client_secret: 'secret123', + extra_param: 'extra', + }), + timeout: 5000, + }); + }); +}); + +describe('constructAuthorizationUri', () => { + it('constructs a valid authorization URL with all parameters', async () => { + const authorizationEndpoint = 'https://example.com/oauth/authorize'; + const queryParameters = { + responseType: 'code', + clientId: 'client123', + scope: 'openid email', + redirectUri: 'https://example.com/callback', + state: 'state123', + }; + + const expectedParams = new URLSearchParams({ + response_type: 'code', + client_id: 'client123', + scope: 'openid email', + redirect_uri: 'https://example.com/callback', + state: 'state123', + }).toString(); + + const result = constructAuthorizationUri(authorizationEndpoint, queryParameters); + expect(result).toBe(`${authorizationEndpoint}?${expectedParams}`); + }); + + it('omits undefined values from the constructed URL', async () => { + const authorizationEndpoint = 'https://example.com/oauth/authorize'; + const queryParameters = { + responseType: 'code', + clientId: 'client123', + redirectUri: 'https://example.com/callback', + state: 'state123', + scope: undefined, // This should not appear in the final URL + }; + + const expectedParams = new URLSearchParams({ + response_type: 'code', + client_id: 'client123', + redirect_uri: 'https://example.com/callback', + state: 'state123', + }).toString(); + + const result = constructAuthorizationUri(authorizationEndpoint, queryParameters); + expect(result).toBe(`${authorizationEndpoint}?${expectedParams}`); + }); +}); diff --git a/packages/connectors/connector-oauth2/src/oauth2/utils.ts b/packages/connectors/connector-oauth2/src/oauth2/utils.ts new file mode 100644 index 00000000000..51f87565f15 --- /dev/null +++ b/packages/connectors/connector-oauth2/src/oauth2/utils.ts @@ -0,0 +1,151 @@ +import { removeUndefinedKeys } from '@silverhand/essentials'; +import snakecaseKeys from 'snakecase-keys'; + +import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit'; +import { generateStandardId } from '@logto/shared/universal'; +import { SignJWT } from 'jose'; +import ky, { HTTPError } from 'ky'; + +import { TokenEndpointAuthMethod } from './types.js'; + +type TokenEndpointAuthOptions = + T extends TokenEndpointAuthMethod.ClientSecretJwt + ? { + method: TokenEndpointAuthMethod.ClientSecretJwt; + clientSecretJwtSigningAlgorithm: string; + } + : { + method: + | TokenEndpointAuthMethod.ClientSecretBasic + | TokenEndpointAuthMethod.ClientSecretPost; + }; + +export type RequestTokenEndpointOptions = { + tokenEndpoint: string; + tokenEndpointAuthOptions: TokenEndpointAuthOptions; + tokenRequestBody: { + grantType: string; + code: string; + redirectUri: string; + clientId: string; + clientSecret: string; + } & Record; + timeout?: number; +}; + +/** + * Requests the token endpoint for an access token with given client authentication options. + * + * @param tokenEndpoint - The URL of the token endpoint. + * @param clientCredentials - The client credentials (client ID and client secret). + * @param tokenEndpointAuthOptions - The options for authenticating with the token endpoint. + * @param tokenEndpointAuthOptions.method - The method to use for authenticating with the token endpoint. + * @param tokenEndpointAuthOptions.clientSecretJwtSigningAlgorithm - The signing algorithm to use for the client secret JWT. Required if the `method` is `TokenEndpointAuthMethod.ClientSecretJwt`. + * @param tokenRequestBody - The request body to be sent as application/x-www-form-urlencoded to the token endpoint. Parameters are automatically converted to snake_case and undefined values are removed. + * @param timeout - The timeout for the request in milliseconds. + * @returns A Promise that resolves to the response from the token endpoint. + */ +export const requestTokenEndpoint = async ({ + tokenEndpoint, + tokenEndpointAuthOptions, + tokenRequestBody, + timeout, +}: RequestTokenEndpointOptions) => { + const postTokenEndpoint = async ({ + form, + headers, + }: { + form: Record; + headers?: Record; + }) => { + try { + return await ky.post(tokenEndpoint, { + headers, + body: new URLSearchParams(removeUndefinedKeys(snakecaseKeys(form))), + timeout, + }); + } catch (error: unknown) { + if (error instanceof HTTPError) { + throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); + } + + throw error; + } + }; + + const { clientId, clientSecret, ...requestBodyWithoutClientCredentials } = tokenRequestBody; + + switch (tokenEndpointAuthOptions.method) { + case TokenEndpointAuthMethod.ClientSecretJwt: { + const clientSecretJwt = await new SignJWT({ + iss: clientId, + sub: clientId, + aud: tokenEndpoint, + jti: generateStandardId(), + exp: Math.floor(Date.now() / 1000) + 600, // Expiration time is 10 minutes + iat: Math.floor(Date.now() / 1000), + }) + .setProtectedHeader({ + alg: tokenEndpointAuthOptions.clientSecretJwtSigningAlgorithm, + }) + .sign(Buffer.from(clientSecret)) + .catch((error: unknown) => { + if (error instanceof Error) { + throw new ConnectorError( + ConnectorErrorCodes.General, + 'Failed to sign client secret JWT' + ); + } + throw error; + }); + + return postTokenEndpoint({ + form: { + ...requestBodyWithoutClientCredentials, + clientId, + clientAssertion: clientSecretJwt, + /** + * `client_assertion_type` parameter MUST be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + * see https://datatracker.ietf.org/doc/html/rfc7523#section-2.2 + */ + clientAssertionType: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + }, + }); + } + case TokenEndpointAuthMethod.ClientSecretBasic: { + return postTokenEndpoint({ + form: requestBodyWithoutClientCredentials, + headers: { + Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, + }, + }); + } + case TokenEndpointAuthMethod.ClientSecretPost: { + return postTokenEndpoint({ + form: tokenRequestBody, + }); + } + } +}; + +/** + * Constructs a complete URL for initiating OAuth authorization by appending properly formatted + * query parameters to the provided authorization endpoint URL. + * + * @param authorizationEndpoint The base URL to which the OAuth authorization request is sent. + * @param queryParameters An object containing OAuth specific parameters such as responseType, clientId, scope, redirectUri, and state. Additional custom parameters can also be included as needed. Parameters are automatically converted to snake_case and undefined values are removed. + * @returns A string representing the fully constructed URL to be used for OAuth authorization. + */ +export const constructAuthorizationUri = ( + authorizationEndpoint: string, + queryParameters: { + responseType: string; + clientId: string; + scope?: string; + redirectUri: string; + state: string; + } & Record +) => + `${authorizationEndpoint}?${new URLSearchParams( + removeUndefinedKeys(snakecaseKeys(queryParameters)) + ).toString()}`; diff --git a/packages/connectors/connector-oauth2/src/types.ts b/packages/connectors/connector-oauth2/src/types.ts index efae11b4c5f..1aa04aaee27 100644 --- a/packages/connectors/connector-oauth2/src/types.ts +++ b/packages/connectors/connector-oauth2/src/types.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { oauth2ConfigGuard } from './oauth2/types.js'; + export const profileMapGuard = z .object({ id: z.string().optional().default('id'), @@ -36,35 +38,11 @@ const tokenEndpointResponseTypeGuard = z export type TokenEndpointResponseType = z.input; -export const oauthConfigGuard = z.object({ - responseType: z.literal('code').optional().default('code'), - grantType: z.literal('authorization_code').optional().default('authorization_code'), - tokenEndpointResponseType: tokenEndpointResponseTypeGuard, - authorizationEndpoint: z.string(), - tokenEndpoint: z.string(), +export const oauth2ConnectorConfigGuard = oauth2ConfigGuard.extend({ userInfoEndpoint: z.string(), - clientId: z.string(), - clientSecret: z.string(), - scope: z.string().optional(), + tokenEndpointResponseType: tokenEndpointResponseTypeGuard, profileMap: profileMapGuard, customConfig: z.record(z.string()).optional(), }); -export type OauthConfig = z.infer; - -export const authResponseGuard = z.object({ - code: z.string(), - state: z.string().optional(), -}); - -export type AuthResponse = z.infer; - -export const accessTokenResponseGuard = z.object({ - access_token: z.string(), - token_type: z.string(), - expires_in: z.number().optional(), - refresh_token: z.string().optional(), - scope: z.string().optional(), -}); - -export type AccessTokenResponse = z.infer; +export type Oauth2ConnectorConfig = z.infer; diff --git a/packages/connectors/connector-oauth2/src/utils.ts b/packages/connectors/connector-oauth2/src/utils.ts index a304854bd7c..18d51f407a4 100644 --- a/packages/connectors/connector-oauth2/src/utils.ts +++ b/packages/connectors/connector-oauth2/src/utils.ts @@ -1,48 +1,25 @@ -import { assert, pick } from '@silverhand/essentials'; -import type { Response } from 'got'; -import { got, HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; +import { assert } from '@silverhand/essentials'; import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit'; +import { type KyResponse } from 'ky'; import qs from 'query-string'; -import { defaultTimeout } from './constant.js'; -import type { - OauthConfig, - TokenEndpointResponseType, - AccessTokenResponse, - ProfileMap, -} from './types.js'; -import { authResponseGuard, accessTokenResponseGuard, userProfileGuard } from './types.js'; - -export const accessTokenRequester = async ( - tokenEndpoint: string, - queryParameters: Record, - tokenEndpointResponseType: TokenEndpointResponseType, - timeout: number = defaultTimeout -): Promise => { - try { - const httpResponse = await got.post({ - url: tokenEndpoint, - form: queryParameters, - timeout: { request: timeout }, - }); - - return await accessTokenResponseHandler(httpResponse, tokenEndpointResponseType); - } catch (error: unknown) { - if (error instanceof HTTPError) { - throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); - } - throw error; - } -}; +import { + type Oauth2AccessTokenResponse, + oauth2AccessTokenResponseGuard, + oauth2AuthResponseGuard, +} from './oauth2/types.js'; +import { requestTokenEndpoint } from './oauth2/utils.js'; +import type { Oauth2ConnectorConfig, TokenEndpointResponseType, ProfileMap } from './types.js'; +import { userProfileGuard } from './types.js'; const accessTokenResponseHandler = async ( - response: Response, + response: KyResponse, tokenEndpointResponseType: TokenEndpointResponseType -): Promise => { - const result = accessTokenResponseGuard.safeParse( - tokenEndpointResponseType === 'json' ? parseJson(response.body) : qs.parse(response.body) +): Promise => { + const responseContent = await response.text(); + const result = oauth2AccessTokenResponseGuard.safeParse( + tokenEndpointResponseType === 'json' ? parseJson(responseContent) : qs.parse(responseContent) ); // Why it works with qs.parse() if (!result.success) { @@ -84,8 +61,12 @@ export const userProfileMapping = ( return result.data; }; -export const getAccessToken = async (config: OauthConfig, data: unknown, redirectUri: string) => { - const result = authResponseGuard.safeParse(data); +export const getAccessToken = async ( + config: Oauth2ConnectorConfig, + data: unknown, + redirectUri: string +) => { + const result = oauth2AuthResponseGuard.safeParse(data); if (!result.success) { throw new ConnectorError(ConnectorErrorCodes.General, data); @@ -93,18 +74,32 @@ export const getAccessToken = async (config: OauthConfig, data: unknown, redirec const { code } = result.data; - const { customConfig, ...rest } = config; - - const parameterObject = snakecaseKeys({ - ...pick(rest, 'grantType', 'clientId', 'clientSecret'), - ...customConfig, - code, - redirectUri, + const { + grantType, + tokenEndpoint, + tokenEndpointResponseType, + clientId, + clientSecret, + tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + customConfig, + } = config; + + const tokenResponse = await requestTokenEndpoint({ + tokenEndpoint, + tokenEndpointAuthOptions: { + method: tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + }, + tokenRequestBody: { + grantType, + code, + redirectUri, + clientId, + clientSecret, + ...customConfig, + }, }); - return accessTokenRequester( - config.tokenEndpoint, - parameterObject, - config.tokenEndpointResponseType - ); + return accessTokenResponseHandler(tokenResponse, tokenEndpointResponseType); }; diff --git a/packages/connectors/connector-oidc/README.md b/packages/connectors/connector-oidc/README.md index eb58ca8e3ab..68bae7495ad 100644 --- a/packages/connectors/connector-oidc/README.md +++ b/packages/connectors/connector-oidc/README.md @@ -24,6 +24,10 @@ We ONLY support "Authorization Code" grant type for security consideration and i *clientSecret*: The client secret is a confidential key that is issued to the client application by the authorization server during registration. The client application uses this secret key to authenticate itself with the authorization server when requesting access tokens. The client secret is considered confidential information and should be kept secure at all times. +*tokenEndpointAuthMethod*: The token endpoint authentication method is used by the client application to authenticate itself with the authorization server when requesting access tokens. To discover supported methods, consult the `token_endpoint_auth_methods_supported` field available at the OAuth 2.0 service provider’s OpenID Connect discovery endpoint, or refer to the relevant documentation provided by the OAuth 2.0 service provider. + +*clientSecretJwtSigningAlgorithm (Optional)*: Only required when `tokenEndpointAuthMethod` is `client_secret_jwt`. The client secret JWT signing algorithm is used by the client application to sign the JWT that is sent to the authorization server during the token request. + *scope*: The scope parameter is used to specify the set of resources and permissions that the client application is requesting access to. The scope parameter is typically defined as a space-separated list of values that represent specific permissions. For example, a scope value of "read write" might indicate that the client application is requesting read and write access to a user's data. You are expected to find `authorizationEndpoint`, `tokenEndpoint`, `jwksUri` and `issuer` as OpenID Provider's configuration information. They should be available in social vendor's documentation. diff --git a/packages/connectors/connector-oidc/package.json b/packages/connectors/connector-oidc/package.json index 56dc91630af..d605f729d69 100644 --- a/packages/connectors/connector-oidc/package.json +++ b/packages/connectors/connector-oidc/package.json @@ -4,10 +4,11 @@ "description": "OIDC standard connector implementation.", "dependencies": { "@logto/connector-kit": "workspace:^3.0.0", + "@logto/connector-oauth": "workspace:^1.2.0", "@logto/shared": "workspace:^3.1.0", "@silverhand/essentials": "^2.9.0", - "got": "^14.0.0", "jose": "^5.0.0", + "ky": "^1.2.3", "nanoid": "^5.0.1", "snakecase-keys": "^8.0.0", "zod": "^3.22.4" @@ -65,7 +66,7 @@ "@vitest/coverage-v8": "^1.4.0", "eslint": "^8.56.0", "lint-staged": "^15.0.2", - "nock": "^13.3.1", + "nock": "14.0.0-beta.6", "prettier": "^3.0.0", "rollup": "^4.12.0", "rollup-plugin-output-size": "^1.3.0", diff --git a/packages/connectors/connector-oidc/src/constant.ts b/packages/connectors/connector-oidc/src/constant.ts index 2315bc1e4a0..44fc1349a4d 100644 --- a/packages/connectors/connector-oidc/src/constant.ts +++ b/packages/connectors/connector-oidc/src/constant.ts @@ -1,5 +1,13 @@ import type { ConnectorMetadata } from '@logto/connector-kit'; import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit'; +import { + tokenEndpointAuthOptionsFormItems, + clientSecretFormItem, + clientIdFormItem, + tokenEndpointFormItem, + authorizationEndpointFormItem, + scopeFormItem, +} from '@logto/connector-oauth'; export const defaultMetadata: ConnectorMetadata = { id: 'oidc', @@ -18,40 +26,14 @@ export const defaultMetadata: ConnectorMetadata = { readme: './README.md', isStandard: true, formItems: [ + authorizationEndpointFormItem, + tokenEndpointFormItem, + clientIdFormItem, + clientSecretFormItem, + ...tokenEndpointAuthOptionsFormItems, { - key: 'authorizationEndpoint', - label: 'Authorization Endpoint', - type: ConnectorConfigFormItemType.Text, + ...scopeFormItem, required: true, - placeholder: '', - }, - { - key: 'tokenEndpoint', - label: 'Token Endpoint', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'clientId', - label: 'Client ID', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'clientSecret', - label: 'Client Secret', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', - }, - { - key: 'scope', - label: 'Scope', - type: ConnectorConfigFormItemType.Text, - required: true, - placeholder: '', }, { key: 'idTokenVerificationConfig', diff --git a/packages/connectors/connector-oidc/src/index.ts b/packages/connectors/connector-oidc/src/index.ts index a413a26c06f..a05af49ca09 100644 --- a/packages/connectors/connector-oidc/src/index.ts +++ b/packages/connectors/connector-oidc/src/index.ts @@ -1,6 +1,4 @@ -import { assert, conditional, pick } from '@silverhand/essentials'; -import { HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; +import { assert, conditional } from '@silverhand/essentials'; import type { GetAuthorizationUri, @@ -16,11 +14,13 @@ import { ConnectorType, jsonGuard, } from '@logto/connector-kit'; +import { constructAuthorizationUri } from '@logto/connector-oauth'; import { generateStandardId } from '@logto/shared/universal'; import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { HTTPError } from 'ky'; import { defaultMetadata } from './constant.js'; -import { idTokenProfileStandardClaimsGuard, oidcConfigGuard } from './types.js'; +import { idTokenProfileStandardClaimsGuard, oidcConnectorConfigGuard } from './types.js'; import { getIdToken } from './utils.js'; const generateNonce = () => generateStandardId(); @@ -29,8 +29,8 @@ const getAuthorizationUri = (getConfig: GetConnectorConfig): GetAuthorizationUri => async ({ state, redirectUri }, setSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oidcConfigGuard); - const parsedConfig = oidcConfigGuard.parse(config); + validateConfig(config, oidcConnectorConfigGuard); + const parsedConfig = oidcConnectorConfigGuard.parse(config); const nonce = generateNonce(); @@ -42,28 +42,33 @@ const getAuthorizationUri = ); await setSession({ nonce, redirectUri }); - const { customConfig, authRequestOptionalConfig, ...rest } = parsedConfig; - - const queryParameters = new URLSearchParams({ + const { + authorizationEndpoint, + responseType, + clientId, + scope, + customConfig, + authRequestOptionalConfig, + } = parsedConfig; + + return constructAuthorizationUri(authorizationEndpoint, { + responseType, + clientId, + scope, + redirectUri, state, - ...snakecaseKeys({ - ...pick(rest, 'responseType', 'scope', 'clientId'), - ...authRequestOptionalConfig, - ...customConfig, - }), nonce, - redirect_uri: redirectUri, + ...authRequestOptionalConfig, + ...customConfig, }); - - return `${parsedConfig.authorizationEndpoint}?${queryParameters.toString()}`; }; const getUserInfo = (getConfig: GetConnectorConfig): GetUserInfo => async (data, getSession) => { const config = await getConfig(defaultMetadata.id); - validateConfig(config, oidcConfigGuard); - const parsedConfig = oidcConfigGuard.parse(config); + validateConfig(config, oidcConnectorConfigGuard); + const parsedConfig = oidcConnectorConfigGuard.parse(config); assert( getSession, @@ -153,7 +158,7 @@ const createOidcConnector: CreateConnector = async ({ getConfig return { metadata: defaultMetadata, type: ConnectorType.Social, - configGuard: oidcConfigGuard, + configGuard: oidcConnectorConfigGuard, getAuthorizationUri: getAuthorizationUri(getConfig), getUserInfo: getUserInfo(getConfig), }; diff --git a/packages/connectors/connector-oidc/src/types.ts b/packages/connectors/connector-oidc/src/types.ts index 4039d553c7c..432a43af916 100644 --- a/packages/connectors/connector-oidc/src/types.ts +++ b/packages/connectors/connector-oidc/src/types.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { oauth2ConfigGuard } from '@logto/connector-oauth'; + const scopeOpenid = 'openid'; export const delimiter = /[ +]/; @@ -38,16 +40,6 @@ export const userProfileGuard = z.object({ export type UserProfile = z.infer; -const endpointConfigObject = { - authorizationEndpoint: z.string(), - tokenEndpoint: z.string(), -}; - -const clientConfigObject = { - clientId: z.string(), - clientSecret: z.string(), -}; - /** * We remove `nonce` in `authRequestOptionalConfigGuard` because it should be a randomly generated string, * should not be fixed in config and will be generated in Logto core according to `response_type` of authorization request. @@ -84,18 +76,15 @@ export const idTokenVerificationConfigGuard = z.object({ jwksUri: z.string() }). export type IdTokenVerificationConfig = z.infer; -export const oidcConfigGuard = z.object({ - responseType: z.literal('code').optional().default('code'), - grantType: z.literal('authorization_code').optional().default('authorization_code'), +export const oidcConnectorConfigGuard = oauth2ConfigGuard.extend({ + // Override `scope` to ensure it contains 'openid'. scope: z.string().transform(scopePostProcessor), idTokenVerificationConfig: idTokenVerificationConfigGuard, authRequestOptionalConfig: authRequestOptionalConfigGuard.optional(), customConfig: z.record(z.string()).optional(), - ...endpointConfigObject, - ...clientConfigObject, }); -export type OidcConfig = z.infer; +export type OidcConnectorConfig = z.infer; export const authResponseGuard = z .object({ diff --git a/packages/connectors/connector-oidc/src/utils.ts b/packages/connectors/connector-oidc/src/utils.ts index fa227364d0a..e623eb20272 100644 --- a/packages/connectors/connector-oidc/src/utils.ts +++ b/packages/connectors/connector-oidc/src/utils.ts @@ -1,39 +1,12 @@ -import { pick } from '@silverhand/essentials'; -import type { Response } from 'got'; -import { got, HTTPError } from 'got'; -import snakecaseKeys from 'snakecase-keys'; - import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit'; +import { requestTokenEndpoint } from '@logto/connector-oauth'; +import { type KyResponse } from 'ky'; -import { defaultTimeout } from './constant.js'; -import type { AccessTokenResponse, OidcConfig } from './types.js'; +import type { AccessTokenResponse, OidcConnectorConfig } from './types.js'; import { accessTokenResponseGuard, authResponseGuard } from './types.js'; -export const accessTokenRequester = async ( - tokenEndpoint: string, - queryParameters: Record, - timeout: number = defaultTimeout -): Promise => { - try { - const httpResponse = await got.post({ - url: tokenEndpoint, - form: queryParameters, - timeout: { request: timeout }, - }); - - return await accessTokenResponseHandler(httpResponse); - } catch (error: unknown) { - if (error instanceof HTTPError) { - throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body)); - } - throw error; - } -}; - -const accessTokenResponseHandler = async ( - response: Response -): Promise => { - const result = accessTokenResponseGuard.safeParse(parseJson(response.body)); +const accessTokenResponseHandler = async (response: KyResponse): Promise => { + const result = accessTokenResponseGuard.safeParse(parseJson(await response.text())); if (!result.success) { throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error); @@ -42,7 +15,11 @@ const accessTokenResponseHandler = async ( return result.data; }; -export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri: string) => { +export const getIdToken = async ( + config: OidcConnectorConfig, + data: unknown, + redirectUri: string +) => { const result = authResponseGuard.safeParse(data); if (!result.success) { @@ -51,14 +28,31 @@ export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri: const { code } = result.data; - const { customConfig, ...rest } = config; - - const parameterObject = snakecaseKeys({ - ...pick(rest, 'grantType', 'clientId', 'clientSecret'), - ...customConfig, - code, - redirectUri, + const { + tokenEndpoint, + grantType, + clientId, + clientSecret, + tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + customConfig, + } = config; + + const tokenResponse = await requestTokenEndpoint({ + tokenEndpoint, + tokenEndpointAuthOptions: { + method: tokenEndpointAuthMethod, + clientSecretJwtSigningAlgorithm, + }, + tokenRequestBody: { + grantType, + code, + redirectUri, + clientId, + clientSecret, + ...customConfig, + }, }); - return accessTokenRequester(config.tokenEndpoint, parameterObject); + return accessTokenResponseHandler(tokenResponse); }; diff --git a/packages/core/jest.config.js b/packages/core/jest.config.js index dad1a7aff3c..7345a0ed30d 100644 --- a/packages/core/jest.config.js +++ b/packages/core/jest.config.js @@ -11,6 +11,8 @@ const config = { moduleNameMapper: { '^#src/(.*)\\.js(x)?$': '/build/$1', '^(chalk|inquirer)$': '/../shared/lib/esm/module-proxy.js', + // Map the connector-kit to the installed version rather than finding it from the `shared` package (which is the default behavior of `mockEsm` in the `shared` package) + '^@logto/connector-kit$': '/node_modules/@logto/connector-kit/lib/index.js', }, }; diff --git a/packages/core/nodemon.json b/packages/core/nodemon.json index b56fb7e927f..ca594d4cb81 100644 --- a/packages/core/nodemon.json +++ b/packages/core/nodemon.json @@ -9,7 +9,8 @@ "../core/src/", "../core/node_modules/", ".env", - "../../.env" + "../../.env", + "../connectors/*/lib/" ], "ext": "json,js,jsx,ts,tsx", "delay": 500 diff --git a/packages/shared/package.json b/packages/shared/package.json index 54ed3e0c77c..d667762af30 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -38,7 +38,6 @@ }, "devDependencies": { "@jest/globals": "^29.7.0", - "@logto/connector-kit": "workspace:^3.0.0", "@silverhand/eslint-config": "6.0.1", "@silverhand/ts-config": "6.0.0", "@types/node": "^20.9.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8fd3bcfc17..d8f35161727 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1857,12 +1857,18 @@ importers: '@logto/connector-kit': specifier: workspace:^3.0.0 version: link:../../toolkit/connector-kit + '@logto/shared': + specifier: workspace:^3.1.0 + version: link:../../shared '@silverhand/essentials': specifier: ^2.9.0 version: 2.9.0 - got: - specifier: ^14.0.0 - version: 14.0.0 + jose: + specifier: ^5.0.0 + version: 5.2.2 + ky: + specifier: ^1.2.3 + version: 1.2.3 query-string: specifier: ^9.0.0 version: 9.0.0 @@ -1907,8 +1913,8 @@ importers: specifier: ^15.0.2 version: 15.0.2 nock: - specifier: ^13.3.1 - version: 13.3.1 + specifier: 14.0.0-beta.6 + version: 14.0.0-beta.6 prettier: specifier: ^3.0.0 version: 3.0.0 @@ -1933,18 +1939,21 @@ importers: '@logto/connector-kit': specifier: workspace:^3.0.0 version: link:../../toolkit/connector-kit + '@logto/connector-oauth': + specifier: workspace:^1.2.0 + version: link:../connector-oauth2 '@logto/shared': specifier: workspace:^3.1.0 version: link:../../shared '@silverhand/essentials': specifier: ^2.9.0 version: 2.9.0 - got: - specifier: ^14.0.0 - version: 14.0.0 jose: specifier: ^5.0.0 version: 5.0.1 + ky: + specifier: ^1.2.3 + version: 1.2.3 nanoid: specifier: ^5.0.1 version: 5.0.1 @@ -1989,8 +1998,8 @@ importers: specifier: ^15.0.2 version: 15.0.2 nock: - specifier: ^13.3.1 - version: 13.3.1 + specifier: 14.0.0-beta.6 + version: 14.0.0-beta.6 prettier: specifier: ^3.0.0 version: 3.0.0 @@ -3848,9 +3857,6 @@ importers: '@jest/globals': specifier: ^29.7.0 version: 29.7.0 - '@logto/connector-kit': - specifier: workspace:^3.0.0 - version: link:../toolkit/connector-kit '@silverhand/eslint-config': specifier: 6.0.1 version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.3.3) @@ -17254,6 +17260,14 @@ packages: propagate: 2.0.1 dev: true + /nock@14.0.0-beta.6: + resolution: {integrity: sha512-b7lc7qvj1dQzxtbU7TqyTMnKbNKwGQd585xsRtcCZOv3I/yOK9Vwv4nOgnLFxFtX9m1yjhQDRbgqFCqNh9HuEw==} + engines: {node: '>= 18'} + dependencies: + json-stringify-safe: 5.0.1 + propagate: 2.0.1 + dev: true + /node-addon-api@3.2.1: resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} dev: true