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 24, 2024
1 parent 9bece65 commit f3617e4
Show file tree
Hide file tree
Showing 23 changed files with 703 additions and 200 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
52 changes: 52 additions & 0 deletions packages/connectors/connector-oauth2/src/constant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit';

import { ClientSecretJwtSigningAlgorithm, TokenEndpointAuthMethod } from './oauth2/types.js';

export const defaultMetadata: ConnectorMetadata = {
id: 'oauth2',
target: 'oauth2',
Expand Down Expand Up @@ -53,6 +55,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
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
2 changes: 2 additions & 0 deletions packages/connectors/connector-oauth2/src/oauth2/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './types.js';
export * from './utils.js';
68 changes: 68 additions & 0 deletions packages/connectors/connector-oauth2/src/oauth2/types.ts
Original file line number Diff line number Diff line change
@@ -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<typeof oauth2AuthResponseGuard>;

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<typeof oauth2AccessTokenResponseGuard>;
Loading

0 comments on commit f3617e4

Please sign in to comment.