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 22, 2024
1 parent 9bece65 commit 1ce139b
Show file tree
Hide file tree
Showing 19 changed files with 432 additions and 85 deletions.
3 changes: 2 additions & 1 deletion 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
55 changes: 54 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 {
ClientAuthenticationMethod,
ClientSecretJwtSigningAlgorithm,
ConnectorConfigFormItemType,
ConnectorPlatform,
} from '@logto/connector-kit';

export const defaultMetadata: ConnectorMetadata = {
id: 'oauth2',
Expand Down Expand Up @@ -53,6 +58,54 @@ export const defaultMetadata: ConnectorMetadata = {
required: true,
placeholder: '<client-secret>',
},
{
key: 'clientAuthenticationMethod',
label: 'Client Authentication Method',
type: ConnectorConfigFormItemType.Select,
selectItems: [
{
title: ClientAuthenticationMethod.ClientSecretPost,
value: ClientAuthenticationMethod.ClientSecretPost,
},
{
title: ClientAuthenticationMethod.ClientSecretBasic,
value: ClientAuthenticationMethod.ClientSecretBasic,
},
{
title: ClientAuthenticationMethod.ClientSecretJwt,
value: ClientAuthenticationMethod.ClientSecretJwt,
},
],
required: true,
defaultValue: ClientAuthenticationMethod.ClientSecretPost,
},
{
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: 'clientAuthenticationMethod',
expectValue: ClientAuthenticationMethod.ClientSecretJwt,
},
],
required: true,
defaultValue: ClientSecretJwtSigningAlgorithm.HS256,
},
{
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 { ClientAuthenticationMethod, 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(),
clientAuthenticationMethod: z
.nativeEnum(ClientAuthenticationMethod)
.optional()
.default(ClientAuthenticationMethod.ClientSecretPost),
clientSecretJwtSigningAlgorithm: z
.nativeEnum(ClientSecretJwtSigningAlgorithm)
.optional()
.default(ClientSecretJwtSigningAlgorithm.HS256),
scope: z.string().optional(),
profileMap: profileMapGuard,
customConfig: z.record(z.string()).optional(),
Expand Down
88 changes: 56 additions & 32 deletions packages/connectors/connector-oauth2/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
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 {
ConnectorError,
ConnectorErrorCodes,
parseJson,
requestTokenEndpoint,
type RequestTokenEndpointOptions,
} from '@logto/connector-kit';
import { type KyResponse } from 'ky';
import qs from 'query-string';

import { defaultTimeout } from './constant.js';
Expand All @@ -15,34 +20,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,
clientAuthenticationOptions,
timeout = defaultTimeout,
}: AccessTokenRequesterOptions): Promise<AccessTokenResponse> => {
const httpResponse = await requestTokenEndpoint({
tokenEndpoint,
clientCredentials,
clientAuthenticationOptions,
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 +100,35 @@ export const getAccessToken = async (config: OauthConfig, data: unknown, redirec

const { code } = result.data;

const { customConfig, ...rest } = config;
const {
tokenEndpoint,
tokenEndpointResponseType,
clientId,
clientSecret,
clientAuthenticationMethod,
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,
},
clientAuthenticationOptions: {
method: clientAuthenticationMethod,
clientSecretJwtSigningAlgorithm,
},
});
};
2 changes: 1 addition & 1 deletion 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
55 changes: 54 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 {
ClientAuthenticationMethod,
ClientSecretJwtSigningAlgorithm,
ConnectorConfigFormItemType,
ConnectorPlatform,
} from '@logto/connector-kit';

export const defaultMetadata: ConnectorMetadata = {
id: 'oidc',
Expand Down Expand Up @@ -46,6 +51,54 @@ export const defaultMetadata: ConnectorMetadata = {
required: true,
placeholder: '<client-secret>',
},
{
key: 'clientAuthenticationMethod',
label: 'Client Authentication Method',
type: ConnectorConfigFormItemType.Select,
selectItems: [
{
title: ClientAuthenticationMethod.ClientSecretPost,
value: ClientAuthenticationMethod.ClientSecretPost,
},
{
title: ClientAuthenticationMethod.ClientSecretBasic,
value: ClientAuthenticationMethod.ClientSecretBasic,
},
{
title: ClientAuthenticationMethod.ClientSecretJwt,
value: ClientAuthenticationMethod.ClientSecretJwt,
},
],
required: true,
defaultValue: ClientAuthenticationMethod.ClientSecretPost,
},
{
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: 'clientAuthenticationMethod',
expectValue: ClientAuthenticationMethod.ClientSecretJwt,
},
],
required: true,
defaultValue: ClientSecretJwtSigningAlgorithm.HS256,
},
{
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
14 changes: 14 additions & 0 deletions packages/connectors/connector-oidc/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { z } from 'zod';

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

const scopeOpenid = 'openid';
export const delimiter = /[ +]/;

Expand Down Expand Up @@ -48,6 +50,17 @@ const clientConfigObject = {
clientSecret: z.string(),
};

const clientAuthenticationConfigObject = {
clientAuthenticationMethod: z
.nativeEnum(ClientAuthenticationMethod)
.optional()
.default(ClientAuthenticationMethod.ClientSecretPost),
clientSecretJwtSigningAlgorithm: z
.nativeEnum(ClientSecretJwtSigningAlgorithm)
.optional()
.default(ClientSecretJwtSigningAlgorithm.HS256),
};

/**
* We remove `nonce` in `authRequestOptionalConfigGuard` because it should be a randomly generated string,
* should not be fixed in config and will be generated in Logto core according to `response_type` of authorization request.
Expand Down Expand Up @@ -93,6 +106,7 @@ export const oidcConfigGuard = z.object({
customConfig: z.record(z.string()).optional(),
...endpointConfigObject,
...clientConfigObject,
...clientAuthenticationConfigObject,
});

export type OidcConfig = z.infer<typeof oidcConfigGuard>;
Expand Down
Loading

0 comments on commit 1ce139b

Please sign in to comment.