Skip to content

Commit

Permalink
feat(connector): support client_secret_basic and `client_secret_jwt…
Browse files Browse the repository at this point in the history
…` methods for oauth2 connectors
  • Loading branch information
xiaoyijun committed Apr 23, 2024
1 parent 9bece65 commit ed89d1a
Show file tree
Hide file tree
Showing 22 changed files with 460 additions and 85 deletions.
6 changes: 6 additions & 0 deletions .changeset/chilled-pugs-notice.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions packages/connectors/connector-oauth2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions packages/connectors/connector-oauth2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
"author": "Silverhand Inc. <contact@silverhand.io>",
"dependencies": {
"@logto/connector-kit": "workspace:^3.0.0",
"@logto/shared": "workspace:^3.1.0",
"@silverhand/essentials": "^2.9.0",
"got": "^14.0.0",
"ky": "^1.2.3",
"query-string": "^9.0.0",
"snakecase-keys": "^8.0.0",
"zod": "^3.22.4"
Expand Down Expand Up @@ -64,7 +65,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",
Expand Down
57 changes: 56 additions & 1 deletion packages/connectors/connector-oauth2/src/constant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit';
import {
TokenEndpointAuthMethod,
ClientSecretJwtSigningAlgorithm,
ConnectorConfigFormItemType,
ConnectorPlatform,
} from '@logto/connector-kit';

export const defaultMetadata: ConnectorMetadata = {
id: 'oauth2',
Expand Down Expand Up @@ -53,6 +58,56 @@ export const defaultMetadata: ConnectorMetadata = {
required: true,
placeholder: '<client-secret>',
},
{
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.',
},
{
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.',
},
{
key: 'tokenEndpointResponseType',
label: 'Token Endpoint Response Type',
Expand Down
9 changes: 5 additions & 4 deletions packages/connectors/connector-oauth2/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { assert, pick } from '@silverhand/essentials';
import { got, HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys';

import {
Expand All @@ -14,6 +13,7 @@ import {
validateConfig,
ConnectorType,
} from '@logto/connector-kit';
import ky, { HTTPError } from 'ky';

import { defaultMetadata, defaultTimeout } from './constant.js';
import { oauthConfigGuard } from './types.js';
Expand Down Expand Up @@ -65,13 +65,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) {
Expand Down
10 changes: 10 additions & 0 deletions packages/connectors/connector-oauth2/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from 'zod';

import { TokenEndpointAuthMethod, ClientSecretJwtSigningAlgorithm } from '@logto/connector-kit';

export const profileMapGuard = z
.object({
id: z.string().optional().default('id'),
Expand Down Expand Up @@ -45,6 +47,14 @@ export const oauthConfigGuard = z.object({
userInfoEndpoint: 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(),
profileMap: profileMapGuard,
customConfig: z.record(z.string()).optional(),
Expand Down
81 changes: 50 additions & 31 deletions packages/connectors/connector-oauth2/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { assert, 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, type RequestTokenEndpointOptions } from '@logto/connector-kit/node';
import { type KyResponse } from 'ky';
import qs from 'query-string';

import { defaultTimeout } from './constant.js';
Expand All @@ -15,34 +15,36 @@ import type {
} from './types.js';
import { authResponseGuard, accessTokenResponseGuard, userProfileGuard } from './types.js';

export const accessTokenRequester = async (
tokenEndpoint: string,
queryParameters: Record<string, string>,
tokenEndpointResponseType: TokenEndpointResponseType,
timeout: number = defaultTimeout
): Promise<AccessTokenResponse> => {
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;
}
type AccessTokenRequesterOptions = RequestTokenEndpointOptions & {
tokenEndpointResponseType: TokenEndpointResponseType;
};

export const accessTokenRequester = async ({
tokenEndpoint,
queryParameters,
tokenEndpointResponseType,
clientCredentials,
tokenEndpointAuthOptions,
timeout = defaultTimeout,
}: AccessTokenRequesterOptions): Promise<AccessTokenResponse> => {
const httpResponse = await requestTokenEndpoint({
tokenEndpoint,
clientCredentials,
tokenEndpointAuthOptions,
queryParameters,
timeout,
});

return accessTokenResponseHandler(httpResponse, tokenEndpointResponseType);
};

const accessTokenResponseHandler = async (
response: Response<string>,
response: KyResponse,
tokenEndpointResponseType: TokenEndpointResponseType
): Promise<AccessTokenResponse> => {
const responseContent = await response.text();
const result = accessTokenResponseGuard.safeParse(
tokenEndpointResponseType === 'json' ? parseJson(response.body) : qs.parse(response.body)
tokenEndpointResponseType === 'json' ? parseJson(responseContent) : qs.parse(responseContent)
); // Why it works with qs.parse()

if (!result.success) {
Expand Down Expand Up @@ -93,18 +95,35 @@ export const getAccessToken = async (config: OauthConfig, data: unknown, redirec

const { code } = result.data;

const { customConfig, ...rest } = config;
const {
tokenEndpoint,
tokenEndpointResponseType,
clientId,
clientSecret,
tokenEndpointAuthMethod,
clientSecretJwtSigningAlgorithm,
customConfig,
...rest
} = config;

const parameterObject = snakecaseKeys({
...pick(rest, 'grantType', 'clientId', 'clientSecret'),
...pick(rest, 'grantType'),
...customConfig,
code,
redirectUri,
});

return accessTokenRequester(
config.tokenEndpoint,
parameterObject,
config.tokenEndpointResponseType
);
return accessTokenRequester({
tokenEndpoint,
queryParameters: parameterObject,
tokenEndpointResponseType,
clientCredentials: {
clientId,
clientSecret,
},
tokenEndpointAuthOptions: {
method: tokenEndpointAuthMethod,
clientSecretJwtSigningAlgorithm,
},
});
};
4 changes: 4 additions & 0 deletions packages/connectors/connector-oidc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/connectors/connector-oidc/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"@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",
"nanoid": "^5.0.1",
"snakecase-keys": "^8.0.0",
"zod": "^3.22.4"
Expand Down Expand Up @@ -65,7 +65,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",
Expand Down
57 changes: 56 additions & 1 deletion packages/connectors/connector-oidc/src/constant.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit';
import {
TokenEndpointAuthMethod,
ClientSecretJwtSigningAlgorithm,
ConnectorConfigFormItemType,
ConnectorPlatform,
} from '@logto/connector-kit';

export const defaultMetadata: ConnectorMetadata = {
id: 'oidc',
Expand Down Expand Up @@ -46,6 +51,56 @@ export const defaultMetadata: ConnectorMetadata = {
required: true,
placeholder: '<client-secret>',
},
{
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.',
},
{
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.',
},
{
key: 'scope',
label: 'Scope',
Expand Down
2 changes: 1 addition & 1 deletion packages/connectors/connector-oidc/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { assert, conditional, pick } from '@silverhand/essentials';
import { HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys';

import type {
Expand All @@ -18,6 +17,7 @@ import {
} from '@logto/connector-kit';
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';
Expand Down
Loading

0 comments on commit ed89d1a

Please sign in to comment.