Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(connector): support client_secret_basic and client_secret_jwt methods for oauth2 connectors #5762

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading