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 (#5762)
  • Loading branch information
xiaoyijun committed Apr 24, 2024
1 parent f923a8e commit f9c7a72
Show file tree
Hide file tree
Showing 24 changed files with 726 additions and 267 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 .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/upload-annotations.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
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
6 changes: 4 additions & 2 deletions packages/connectors/connector-oauth2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
"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",
"jose": "^5.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 +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",
Expand Down
50 changes: 15 additions & 35 deletions packages/connectors/connector-oauth2/src/constant.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -18,41 +27,18 @@ export const defaultMetadata: ConnectorMetadata = {
readme: './README.md',
isStandard: true,
formItems: [
{
key: 'authorizationEndpoint',
label: 'Authorization Endpoint',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<authorization-endpoint>',
},
{
key: 'tokenEndpoint',
label: 'Token Endpoint',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<token-endpoint>',
},
authorizationEndpointFormItem,
tokenEndpointFormItem,
{
key: 'userInfoEndpoint',
label: 'User Info Endpoint',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<user-info-endpoint>',
},
{
key: 'clientId',
label: 'Client ID',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<client-id>',
},
{
key: 'clientSecret',
label: 'Client Secret',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<client-secret>',
},
clientIdFormItem,
clientSecretFormItem,
...tokenEndpointAuthOptionsFormItems,
{
key: 'tokenEndpointResponseType',
label: 'Token Endpoint Response Type',
Expand All @@ -67,13 +53,7 @@ export const defaultMetadata: ConnectorMetadata = {
required: false,
defaultValue: 'query-string',
},
{
key: 'scope',
label: 'Scope',
type: ConnectorConfigFormItemType.Text,
required: false,
placeholder: '<space-delimited-scope>',
},
scopeFormItem,
{
key: 'profileMap',
label: 'Profile Map',
Expand Down
44 changes: 19 additions & 25 deletions packages/connectors/connector-oauth2/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { assert, pick } from '@silverhand/essentials';
import { got, HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys';

import {
type GetAuthorizationUri,
Expand All @@ -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<string, unknown>) =>
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(
Expand All @@ -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) {
Expand All @@ -87,7 +81,7 @@ const createOauthConnector: CreateConnector<SocialConnector> = async ({ getConfi
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: oauthConfigGuard,
configGuard: oauth2ConnectorConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};
Expand Down
96 changes: 96 additions & 0 deletions packages/connectors/connector-oauth2/src/oauth2/form-items.ts
Original file line number Diff line number Diff line change
@@ -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: '<authorization-endpoint>',
});

export const tokenEndpointFormItem: ConnectorConfigFormItem = Object.freeze({
key: 'tokenEndpoint',
label: 'Token Endpoint',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<token-endpoint>',
});

export const clientIdFormItem: ConnectorConfigFormItem = Object.freeze({
key: 'clientId',
label: 'Client ID',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<client-id>',
});

export const clientSecretFormItem: ConnectorConfigFormItem = Object.freeze({
key: 'clientSecret',
label: 'Client Secret',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<client-secret>',
});

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: '<space-delimited-scope>',
});
3 changes: 3 additions & 0 deletions packages/connectors/connector-oauth2/src/oauth2/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from './types.js';
export * from './utils.js';
export * from './form-items.js';
Loading

0 comments on commit f9c7a72

Please sign in to comment.