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 f41b402
Show file tree
Hide file tree
Showing 22 changed files with 541 additions and 130 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
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
23 changes: 13 additions & 10 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,20 +13,23 @@ import {
validateConfig,
ConnectorType,
} from '@logto/connector-kit';
import ky, { HTTPError } from 'ky';

import { defaultMetadata, defaultTimeout } from './constant.js';
import { oauthConfigGuard } from './types.js';
import { oauth2ConnectorConfigGuard } from './types.js';
import { userProfileMapping, getAccessToken } from './utils.js';

export * from './oauth2/index.js';

const removeUndefinedKeys = (object: Record<string, unknown>) =>
Object.fromEntries(Object.entries(object).filter(([, value]) => value !== undefined));

const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }, setSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, oauthConfigGuard);
const parsedConfig = oauthConfigGuard.parse(config);
validateConfig(config, oauth2ConnectorConfigGuard);
const parsedConfig = oauth2ConnectorConfigGuard.parse(config);

const { customConfig, ...rest } = parsedConfig;

Expand All @@ -51,8 +53,8 @@ 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 +67,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 +90,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';
69 changes: 69 additions & 0 deletions packages/connectors/connector-oauth2/src/oauth2/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
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(),
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(),
});

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>;
129 changes: 129 additions & 0 deletions packages/connectors/connector-oauth2/src/oauth2/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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 ClientCredentials = {
clientId: string;
clientSecret: string;
};

type TokenEndpointAuthOptions<T extends TokenEndpointAuthMethod = TokenEndpointAuthMethod> =
T extends TokenEndpointAuthMethod.ClientSecretJwt
? {
method: TokenEndpointAuthMethod.ClientSecretJwt;
clientSecretJwtSigningAlgorithm: string;
}
: {
method:
| TokenEndpointAuthMethod.ClientSecretBasic
| TokenEndpointAuthMethod.ClientSecretPost;
};

export type RequestTokenEndpointOptions = {
tokenEndpoint: string;
clientCredentials: ClientCredentials;
tokenEndpointAuthOptions: TokenEndpointAuthOptions;
queryParameters: Record<string, string>;
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 queryParameters - The query parameters to include in the request.
* @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,
clientCredentials,
tokenEndpointAuthOptions,
queryParameters,
timeout,
}: RequestTokenEndpointOptions) => {
const postTokenEndpoint = async ({
form,
headers,
}: {
form: Record<string, string>;
headers?: Record<string, string>;
}) => {
try {
return await ky.post(tokenEndpoint, {
headers,
body: new URLSearchParams(snakecaseKeys(form)),
timeout,
});
} catch (error: unknown) {
if (error instanceof HTTPError) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body));
}

throw error;
}
};

switch (tokenEndpointAuthOptions.method) {
case TokenEndpointAuthMethod.ClientSecretJwt: {
const clientSecretJwt = await new SignJWT({
iss: clientCredentials.clientId,
sub: clientCredentials.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(clientCredentials.clientSecret))
.catch((error: unknown) => {
if (error instanceof Error) {
throw new ConnectorError(
ConnectorErrorCodes.General,
'Failed to sign client secret JWT'
);
}
throw error;
});

return postTokenEndpoint({
form: {
...queryParameters,
client_id: clientCredentials.clientId,
client_assertion: 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
*/
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
},
});
}
case TokenEndpointAuthMethod.ClientSecretBasic: {
return postTokenEndpoint({
form: queryParameters,
headers: {
Authorization: `Basic ${Buffer.from(
`${clientCredentials.clientId}:${clientCredentials.clientSecret}`
).toString('base64')}`,
},
});
}
case TokenEndpointAuthMethod.ClientSecretPost: {
return postTokenEndpoint({
form: { ...queryParameters, ...clientCredentials },
});
}
}
};
Loading

0 comments on commit f41b402

Please sign in to comment.