0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-17 22:31:28 -05:00

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

This commit is contained in:
Xiao Yijun 2024-04-24 13:51:41 +08:00 committed by GitHub
parent f923a8e0cd
commit f9c7a72d51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 724 additions and 265 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 providers 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.

View file

@ -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"
@ -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",

View file

@ -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',
@ -18,20 +27,8 @@ 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',
@ -39,20 +36,9 @@ export const defaultMetadata: ConnectorMetadata = {
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',
@ -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',

View file

@ -1,6 +1,4 @@
import { assert, pick } from '@silverhand/essentials';
import { got, HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys';
import {
type GetAuthorizationUri,
@ -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),
state,
redirect_uri: redirectUri,
});
const { authorizationEndpoint, customConfig } = parsedConfig;
return `${parsedConfig.authorizationEndpoint}?${queryParameters.toString()}`;
return constructAuthorizationUri(authorizationEndpoint, {
...pick(parsedConfig, 'responseType', 'clientId', 'scope'),
redirectUri,
state,
...customConfig,
});
};
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(
@ -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) {
@ -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),
};

View file

@ -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>',
});

View file

@ -0,0 +1,3 @@
export * from './types.js';
export * from './utils.js';
export * from './form-items.js';

View file

@ -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>;

View file

@ -0,0 +1,183 @@
import nock from 'nock';
import ky from 'ky';
import { ClientSecretJwtSigningAlgorithm, TokenEndpointAuthMethod } from './types.js';
import { constructAuthorizationUri, type RequestTokenEndpointOptions } from './utils.js';
const kyPostMock = vi.spyOn(ky, 'post');
vi.mock('jose', () => ({
SignJWT: vi.fn(() => ({
setProtectedHeader: vi.fn().mockReturnThis(),
sign: vi.fn().mockResolvedValue('signed-jwt'),
})),
}));
const { requestTokenEndpoint } = await import('./utils.js');
const tokenEndpointUrl = new URL('https://example.com/token');
describe('requestTokenEndpoint', () => {
beforeEach(() => {
nock(tokenEndpointUrl.origin)
.post(tokenEndpointUrl.pathname)
.query(true)
.reply(
200,
JSON.stringify({
access_token: 'access_token',
token_type: 'bearer',
})
);
});
afterEach(() => {
nock.cleanAll();
});
afterAll(() => {
vi.clearAllMocks();
});
it('should handle TokenEndpointAuthMethod.ClientSecretJwt correctly', async () => {
const options: RequestTokenEndpointOptions = {
tokenEndpoint: 'https://example.com/token',
tokenEndpointAuthOptions: {
method: TokenEndpointAuthMethod.ClientSecretJwt,
clientSecretJwtSigningAlgorithm: ClientSecretJwtSigningAlgorithm.HS256,
},
tokenRequestBody: {
grantType: 'authorization_code',
code: 'authcode123',
redirectUri: 'https://example.com/callback',
clientId: 'client123',
clientSecret: 'secret123',
extraParam: 'extra',
},
timeout: 5000,
};
await requestTokenEndpoint(options);
expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, {
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'authcode123',
redirect_uri: 'https://example.com/callback',
extra_param: 'extra',
client_id: 'client123',
client_assertion: 'signed-jwt',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
}),
timeout: 5000,
});
});
it('should handle TokenEndpointAuthMethod.ClientSecretBasic correctly', async () => {
const options: RequestTokenEndpointOptions = {
tokenEndpoint: 'https://example.com/token',
tokenEndpointAuthOptions: {
method: TokenEndpointAuthMethod.ClientSecretBasic,
},
tokenRequestBody: {
grantType: 'authorization_code',
code: 'authcode123',
redirectUri: 'https://example.com/callback',
clientId: 'client123',
clientSecret: 'secret123',
extraParam: 'extra',
},
timeout: 5000,
};
await requestTokenEndpoint(options);
expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, {
headers: {
Authorization: `Basic ${Buffer.from('client123:secret123').toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'authcode123',
redirect_uri: 'https://example.com/callback',
extra_param: 'extra',
}),
timeout: 5000,
});
});
it('should handle TokenEndpointAuthMethod.ClientSecretPost correctly', async () => {
const options: RequestTokenEndpointOptions = {
tokenEndpoint: 'https://example.com/token',
tokenEndpointAuthOptions: {
method: TokenEndpointAuthMethod.ClientSecretPost,
},
tokenRequestBody: {
grantType: 'authorization_code',
code: 'authcode123',
redirectUri: 'https://example.com/callback',
clientId: 'client123',
clientSecret: 'secret123',
extraParam: 'extra',
},
timeout: 5000,
};
await requestTokenEndpoint(options);
expect(kyPostMock).toHaveBeenCalledWith(options.tokenEndpoint, {
body: new URLSearchParams({
grant_type: 'authorization_code',
code: 'authcode123',
redirect_uri: 'https://example.com/callback',
client_id: 'client123',
client_secret: 'secret123',
extra_param: 'extra',
}),
timeout: 5000,
});
});
});
describe('constructAuthorizationUri', () => {
it('constructs a valid authorization URL with all parameters', async () => {
const authorizationEndpoint = 'https://example.com/oauth/authorize';
const queryParameters = {
responseType: 'code',
clientId: 'client123',
scope: 'openid email',
redirectUri: 'https://example.com/callback',
state: 'state123',
};
const expectedParams = new URLSearchParams({
response_type: 'code',
client_id: 'client123',
scope: 'openid email',
redirect_uri: 'https://example.com/callback',
state: 'state123',
}).toString();
const result = constructAuthorizationUri(authorizationEndpoint, queryParameters);
expect(result).toBe(`${authorizationEndpoint}?${expectedParams}`);
});
it('omits undefined values from the constructed URL', async () => {
const authorizationEndpoint = 'https://example.com/oauth/authorize';
const queryParameters = {
responseType: 'code',
clientId: 'client123',
redirectUri: 'https://example.com/callback',
state: 'state123',
scope: undefined, // This should not appear in the final URL
};
const expectedParams = new URLSearchParams({
response_type: 'code',
client_id: 'client123',
redirect_uri: 'https://example.com/callback',
state: 'state123',
}).toString();
const result = constructAuthorizationUri(authorizationEndpoint, queryParameters);
expect(result).toBe(`${authorizationEndpoint}?${expectedParams}`);
});
});

View file

@ -0,0 +1,151 @@
import { removeUndefinedKeys } from '@silverhand/essentials';
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 TokenEndpointAuthOptions<T extends TokenEndpointAuthMethod = TokenEndpointAuthMethod> =
T extends TokenEndpointAuthMethod.ClientSecretJwt
? {
method: TokenEndpointAuthMethod.ClientSecretJwt;
clientSecretJwtSigningAlgorithm: string;
}
: {
method:
| TokenEndpointAuthMethod.ClientSecretBasic
| TokenEndpointAuthMethod.ClientSecretPost;
};
export type RequestTokenEndpointOptions = {
tokenEndpoint: string;
tokenEndpointAuthOptions: TokenEndpointAuthOptions;
tokenRequestBody: {
grantType: string;
code: string;
redirectUri: string;
clientId: string;
clientSecret: string;
} & 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 tokenRequestBody - The request body to be sent as application/x-www-form-urlencoded to the token endpoint. Parameters are automatically converted to snake_case and undefined values are removed.
* @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,
tokenEndpointAuthOptions,
tokenRequestBody,
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(removeUndefinedKeys(snakecaseKeys(form))),
timeout,
});
} catch (error: unknown) {
if (error instanceof HTTPError) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body));
}
throw error;
}
};
const { clientId, clientSecret, ...requestBodyWithoutClientCredentials } = tokenRequestBody;
switch (tokenEndpointAuthOptions.method) {
case TokenEndpointAuthMethod.ClientSecretJwt: {
const clientSecretJwt = await new SignJWT({
iss: clientId,
sub: 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(clientSecret))
.catch((error: unknown) => {
if (error instanceof Error) {
throw new ConnectorError(
ConnectorErrorCodes.General,
'Failed to sign client secret JWT'
);
}
throw error;
});
return postTokenEndpoint({
form: {
...requestBodyWithoutClientCredentials,
clientId,
clientAssertion: 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
*/
clientAssertionType: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
},
});
}
case TokenEndpointAuthMethod.ClientSecretBasic: {
return postTokenEndpoint({
form: requestBodyWithoutClientCredentials,
headers: {
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
},
});
}
case TokenEndpointAuthMethod.ClientSecretPost: {
return postTokenEndpoint({
form: tokenRequestBody,
});
}
}
};
/**
* Constructs a complete URL for initiating OAuth authorization by appending properly formatted
* query parameters to the provided authorization endpoint URL.
*
* @param authorizationEndpoint The base URL to which the OAuth authorization request is sent.
* @param queryParameters An object containing OAuth specific parameters such as responseType, clientId, scope, redirectUri, and state. Additional custom parameters can also be included as needed. Parameters are automatically converted to snake_case and undefined values are removed.
* @returns A string representing the fully constructed URL to be used for OAuth authorization.
*/
export const constructAuthorizationUri = (
authorizationEndpoint: string,
queryParameters: {
responseType: string;
clientId: string;
scope?: string;
redirectUri: string;
state: string;
} & Record<string, string | undefined>
) =>
`${authorizationEndpoint}?${new URLSearchParams(
removeUndefinedKeys(snakecaseKeys(queryParameters))
).toString()}`;

View file

@ -1,5 +1,7 @@
import { z } from 'zod';
import { oauth2ConfigGuard } from './oauth2/types.js';
export const profileMapGuard = z
.object({
id: z.string().optional().default('id'),
@ -36,35 +38,11 @@ const tokenEndpointResponseTypeGuard = z
export type TokenEndpointResponseType = z.input<typeof tokenEndpointResponseTypeGuard>;
export const oauthConfigGuard = z.object({
responseType: z.literal('code').optional().default('code'),
grantType: z.literal('authorization_code').optional().default('authorization_code'),
tokenEndpointResponseType: tokenEndpointResponseTypeGuard,
authorizationEndpoint: z.string(),
tokenEndpoint: z.string(),
export const oauth2ConnectorConfigGuard = oauth2ConfigGuard.extend({
userInfoEndpoint: z.string(),
clientId: z.string(),
clientSecret: z.string(),
scope: z.string().optional(),
tokenEndpointResponseType: tokenEndpointResponseTypeGuard,
profileMap: profileMapGuard,
customConfig: z.record(z.string()).optional(),
});
export type OauthConfig = z.infer<typeof oauthConfigGuard>;
export const authResponseGuard = z.object({
code: z.string(),
state: z.string().optional(),
});
export type AuthResponse = z.infer<typeof authResponseGuard>;
export const accessTokenResponseGuard = z.object({
access_token: z.string(),
token_type: z.string(),
expires_in: z.number().optional(),
refresh_token: z.string().optional(),
scope: z.string().optional(),
});
export type AccessTokenResponse = z.infer<typeof accessTokenResponseGuard>;
export type Oauth2ConnectorConfig = z.infer<typeof oauth2ConnectorConfigGuard>;

View file

@ -1,48 +1,25 @@
import { assert, pick } from '@silverhand/essentials';
import type { Response } from 'got';
import { got, HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys';
import { assert } from '@silverhand/essentials';
import { ConnectorError, ConnectorErrorCodes, parseJson } from '@logto/connector-kit';
import { type KyResponse } from 'ky';
import qs from 'query-string';
import { defaultTimeout } from './constant.js';
import type {
OauthConfig,
TokenEndpointResponseType,
AccessTokenResponse,
ProfileMap,
} 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;
}
};
import {
type Oauth2AccessTokenResponse,
oauth2AccessTokenResponseGuard,
oauth2AuthResponseGuard,
} from './oauth2/types.js';
import { requestTokenEndpoint } from './oauth2/utils.js';
import type { Oauth2ConnectorConfig, TokenEndpointResponseType, ProfileMap } from './types.js';
import { userProfileGuard } from './types.js';
const accessTokenResponseHandler = async (
response: Response<string>,
response: KyResponse,
tokenEndpointResponseType: TokenEndpointResponseType
): Promise<AccessTokenResponse> => {
const result = accessTokenResponseGuard.safeParse(
tokenEndpointResponseType === 'json' ? parseJson(response.body) : qs.parse(response.body)
): Promise<Oauth2AccessTokenResponse> => {
const responseContent = await response.text();
const result = oauth2AccessTokenResponseGuard.safeParse(
tokenEndpointResponseType === 'json' ? parseJson(responseContent) : qs.parse(responseContent)
); // Why it works with qs.parse()
if (!result.success) {
@ -84,8 +61,12 @@ export const userProfileMapping = (
return result.data;
};
export const getAccessToken = async (config: OauthConfig, data: unknown, redirectUri: string) => {
const result = authResponseGuard.safeParse(data);
export const getAccessToken = async (
config: Oauth2ConnectorConfig,
data: unknown,
redirectUri: string
) => {
const result = oauth2AuthResponseGuard.safeParse(data);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.General, data);
@ -93,18 +74,32 @@ export const getAccessToken = async (config: OauthConfig, data: unknown, redirec
const { code } = result.data;
const { customConfig, ...rest } = config;
const {
grantType,
tokenEndpoint,
tokenEndpointResponseType,
clientId,
clientSecret,
tokenEndpointAuthMethod,
clientSecretJwtSigningAlgorithm,
customConfig,
} = config;
const parameterObject = snakecaseKeys({
...pick(rest, 'grantType', 'clientId', 'clientSecret'),
...customConfig,
code,
redirectUri,
const tokenResponse = await requestTokenEndpoint({
tokenEndpoint,
tokenEndpointAuthOptions: {
method: tokenEndpointAuthMethod,
clientSecretJwtSigningAlgorithm,
},
tokenRequestBody: {
grantType,
code,
redirectUri,
clientId,
clientSecret,
...customConfig,
},
});
return accessTokenRequester(
config.tokenEndpoint,
parameterObject,
config.tokenEndpointResponseType
);
return accessTokenResponseHandler(tokenResponse, tokenEndpointResponseType);
};

View file

@ -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 providers 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`, `jwksUri` and `issuer` as OpenID Provider's configuration information. They should be available in social vendor's documentation.

View file

@ -4,10 +4,11 @@
"description": "OIDC standard connector implementation.",
"dependencies": {
"@logto/connector-kit": "workspace:^3.0.0",
"@logto/connector-oauth": "workspace:^1.2.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"
@ -65,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",

View file

@ -1,5 +1,13 @@
import type { ConnectorMetadata } from '@logto/connector-kit';
import { ConnectorConfigFormItemType, ConnectorPlatform } from '@logto/connector-kit';
import {
tokenEndpointAuthOptionsFormItems,
clientSecretFormItem,
clientIdFormItem,
tokenEndpointFormItem,
authorizationEndpointFormItem,
scopeFormItem,
} from '@logto/connector-oauth';
export const defaultMetadata: ConnectorMetadata = {
id: 'oidc',
@ -18,40 +26,14 @@ export const defaultMetadata: ConnectorMetadata = {
readme: './README.md',
isStandard: true,
formItems: [
authorizationEndpointFormItem,
tokenEndpointFormItem,
clientIdFormItem,
clientSecretFormItem,
...tokenEndpointAuthOptionsFormItems,
{
key: 'authorizationEndpoint',
label: 'Authorization Endpoint',
type: ConnectorConfigFormItemType.Text,
...scopeFormItem,
required: true,
placeholder: '<authorization-endpoint>',
},
{
key: 'tokenEndpoint',
label: 'Token Endpoint',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<token-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>',
},
{
key: 'scope',
label: 'Scope',
type: ConnectorConfigFormItemType.Text,
required: true,
placeholder: '<scope>',
},
{
key: 'idTokenVerificationConfig',

View file

@ -1,6 +1,4 @@
import { assert, conditional, pick } from '@silverhand/essentials';
import { HTTPError } from 'got';
import snakecaseKeys from 'snakecase-keys';
import { assert, conditional } from '@silverhand/essentials';
import type {
GetAuthorizationUri,
@ -16,11 +14,13 @@ import {
ConnectorType,
jsonGuard,
} from '@logto/connector-kit';
import { constructAuthorizationUri } from '@logto/connector-oauth';
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';
import { idTokenProfileStandardClaimsGuard, oidcConnectorConfigGuard } from './types.js';
import { getIdToken } from './utils.js';
const generateNonce = () => generateStandardId();
@ -29,8 +29,8 @@ const getAuthorizationUri =
(getConfig: GetConnectorConfig): GetAuthorizationUri =>
async ({ state, redirectUri }, setSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, oidcConfigGuard);
const parsedConfig = oidcConfigGuard.parse(config);
validateConfig(config, oidcConnectorConfigGuard);
const parsedConfig = oidcConnectorConfigGuard.parse(config);
const nonce = generateNonce();
@ -42,28 +42,33 @@ const getAuthorizationUri =
);
await setSession({ nonce, redirectUri });
const { customConfig, authRequestOptionalConfig, ...rest } = parsedConfig;
const {
authorizationEndpoint,
responseType,
clientId,
scope,
customConfig,
authRequestOptionalConfig,
} = parsedConfig;
const queryParameters = new URLSearchParams({
return constructAuthorizationUri(authorizationEndpoint, {
responseType,
clientId,
scope,
redirectUri,
state,
...snakecaseKeys({
...pick(rest, 'responseType', 'scope', 'clientId'),
...authRequestOptionalConfig,
...customConfig,
}),
nonce,
redirect_uri: redirectUri,
...authRequestOptionalConfig,
...customConfig,
});
return `${parsedConfig.authorizationEndpoint}?${queryParameters.toString()}`;
};
const getUserInfo =
(getConfig: GetConnectorConfig): GetUserInfo =>
async (data, getSession) => {
const config = await getConfig(defaultMetadata.id);
validateConfig(config, oidcConfigGuard);
const parsedConfig = oidcConfigGuard.parse(config);
validateConfig(config, oidcConnectorConfigGuard);
const parsedConfig = oidcConnectorConfigGuard.parse(config);
assert(
getSession,
@ -153,7 +158,7 @@ const createOidcConnector: CreateConnector<SocialConnector> = async ({ getConfig
return {
metadata: defaultMetadata,
type: ConnectorType.Social,
configGuard: oidcConfigGuard,
configGuard: oidcConnectorConfigGuard,
getAuthorizationUri: getAuthorizationUri(getConfig),
getUserInfo: getUserInfo(getConfig),
};

View file

@ -1,5 +1,7 @@
import { z } from 'zod';
import { oauth2ConfigGuard } from '@logto/connector-oauth';
const scopeOpenid = 'openid';
export const delimiter = /[ +]/;
@ -38,16 +40,6 @@ export const userProfileGuard = z.object({
export type UserProfile = z.infer<typeof userProfileGuard>;
const endpointConfigObject = {
authorizationEndpoint: z.string(),
tokenEndpoint: z.string(),
};
const clientConfigObject = {
clientId: z.string(),
clientSecret: z.string(),
};
/**
* 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.
@ -84,18 +76,15 @@ export const idTokenVerificationConfigGuard = z.object({ jwksUri: z.string() }).
export type IdTokenVerificationConfig = z.infer<typeof idTokenVerificationConfigGuard>;
export const oidcConfigGuard = z.object({
responseType: z.literal('code').optional().default('code'),
grantType: z.literal('authorization_code').optional().default('authorization_code'),
export const oidcConnectorConfigGuard = oauth2ConfigGuard.extend({
// Override `scope` to ensure it contains 'openid'.
scope: z.string().transform(scopePostProcessor),
idTokenVerificationConfig: idTokenVerificationConfigGuard,
authRequestOptionalConfig: authRequestOptionalConfigGuard.optional(),
customConfig: z.record(z.string()).optional(),
...endpointConfigObject,
...clientConfigObject,
});
export type OidcConfig = z.infer<typeof oidcConfigGuard>;
export type OidcConnectorConfig = z.infer<typeof oidcConnectorConfigGuard>;
export const authResponseGuard = z
.object({

View file

@ -1,39 +1,12 @@
import { 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 { requestTokenEndpoint } from '@logto/connector-oauth';
import { type KyResponse } from 'ky';
import { defaultTimeout } from './constant.js';
import type { AccessTokenResponse, OidcConfig } from './types.js';
import type { AccessTokenResponse, OidcConnectorConfig } from './types.js';
import { accessTokenResponseGuard, authResponseGuard } from './types.js';
export const accessTokenRequester = async (
tokenEndpoint: string,
queryParameters: Record<string, string>,
timeout: number = defaultTimeout
): Promise<AccessTokenResponse> => {
try {
const httpResponse = await got.post({
url: tokenEndpoint,
form: queryParameters,
timeout: { request: timeout },
});
return await accessTokenResponseHandler(httpResponse);
} catch (error: unknown) {
if (error instanceof HTTPError) {
throw new ConnectorError(ConnectorErrorCodes.General, JSON.stringify(error.response.body));
}
throw error;
}
};
const accessTokenResponseHandler = async (
response: Response<string>
): Promise<AccessTokenResponse> => {
const result = accessTokenResponseGuard.safeParse(parseJson(response.body));
const accessTokenResponseHandler = async (response: KyResponse): Promise<AccessTokenResponse> => {
const result = accessTokenResponseGuard.safeParse(parseJson(await response.text()));
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidResponse, result.error);
@ -42,7 +15,11 @@ const accessTokenResponseHandler = async (
return result.data;
};
export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri: string) => {
export const getIdToken = async (
config: OidcConnectorConfig,
data: unknown,
redirectUri: string
) => {
const result = authResponseGuard.safeParse(data);
if (!result.success) {
@ -51,14 +28,31 @@ export const getIdToken = async (config: OidcConfig, data: unknown, redirectUri:
const { code } = result.data;
const { customConfig, ...rest } = config;
const {
tokenEndpoint,
grantType,
clientId,
clientSecret,
tokenEndpointAuthMethod,
clientSecretJwtSigningAlgorithm,
customConfig,
} = config;
const parameterObject = snakecaseKeys({
...pick(rest, 'grantType', 'clientId', 'clientSecret'),
...customConfig,
code,
redirectUri,
const tokenResponse = await requestTokenEndpoint({
tokenEndpoint,
tokenEndpointAuthOptions: {
method: tokenEndpointAuthMethod,
clientSecretJwtSigningAlgorithm,
},
tokenRequestBody: {
grantType,
code,
redirectUri,
clientId,
clientSecret,
...customConfig,
},
});
return accessTokenRequester(config.tokenEndpoint, parameterObject);
return accessTokenResponseHandler(tokenResponse);
};

View file

@ -11,6 +11,8 @@ const config = {
moduleNameMapper: {
'^#src/(.*)\\.js(x)?$': '<rootDir>/build/$1',
'^(chalk|inquirer)$': '<rootDir>/../shared/lib/esm/module-proxy.js',
// Map the connector-kit to the installed version rather than finding it from the `shared` package (which is the default behavior of `mockEsm` in the `shared` package)
'^@logto/connector-kit$': '<rootDir>/node_modules/@logto/connector-kit/lib/index.js',
},
};

View file

@ -9,7 +9,8 @@
"../core/src/",
"../core/node_modules/",
".env",
"../../.env"
"../../.env",
"../connectors/*/lib/"
],
"ext": "json,js,jsx,ts,tsx",
"delay": 500

View file

@ -38,7 +38,6 @@
},
"devDependencies": {
"@jest/globals": "^29.7.0",
"@logto/connector-kit": "workspace:^3.0.0",
"@silverhand/eslint-config": "6.0.1",
"@silverhand/ts-config": "6.0.0",
"@types/node": "^20.9.5",

40
pnpm-lock.yaml generated
View file

@ -1860,12 +1860,18 @@ importers:
'@logto/connector-kit':
specifier: workspace:^3.0.0
version: link:../../toolkit/connector-kit
'@logto/shared':
specifier: workspace:^3.1.0
version: link:../../shared
'@silverhand/essentials':
specifier: ^2.9.0
version: 2.9.0
got:
specifier: ^14.0.0
version: 14.0.0
jose:
specifier: ^5.0.0
version: 5.2.2
ky:
specifier: ^1.2.3
version: 1.2.3
query-string:
specifier: ^9.0.0
version: 9.0.0
@ -1910,8 +1916,8 @@ importers:
specifier: ^15.0.2
version: 15.0.2
nock:
specifier: ^13.3.1
version: 13.3.1
specifier: 14.0.0-beta.6
version: 14.0.0-beta.6
prettier:
specifier: ^3.0.0
version: 3.0.0
@ -1936,18 +1942,21 @@ importers:
'@logto/connector-kit':
specifier: workspace:^3.0.0
version: link:../../toolkit/connector-kit
'@logto/connector-oauth':
specifier: workspace:^1.2.0
version: link:../connector-oauth2
'@logto/shared':
specifier: workspace:^3.1.0
version: link:../../shared
'@silverhand/essentials':
specifier: ^2.9.0
version: 2.9.0
got:
specifier: ^14.0.0
version: 14.0.0
jose:
specifier: ^5.0.0
version: 5.0.1
ky:
specifier: ^1.2.3
version: 1.2.3
nanoid:
specifier: ^5.0.1
version: 5.0.1
@ -1992,8 +2001,8 @@ importers:
specifier: ^15.0.2
version: 15.0.2
nock:
specifier: ^13.3.1
version: 13.3.1
specifier: 14.0.0-beta.6
version: 14.0.0-beta.6
prettier:
specifier: ^3.0.0
version: 3.0.0
@ -3851,9 +3860,6 @@ importers:
'@jest/globals':
specifier: ^29.7.0
version: 29.7.0
'@logto/connector-kit':
specifier: workspace:^3.0.0
version: link:../toolkit/connector-kit
'@silverhand/eslint-config':
specifier: 6.0.1
version: 6.0.1(eslint@8.57.0)(prettier@3.0.0)(typescript@5.3.3)
@ -17269,6 +17275,14 @@ packages:
propagate: 2.0.1
dev: true
/nock@14.0.0-beta.6:
resolution: {integrity: sha512-b7lc7qvj1dQzxtbU7TqyTMnKbNKwGQd585xsRtcCZOv3I/yOK9Vwv4nOgnLFxFtX9m1yjhQDRbgqFCqNh9HuEw==}
engines: {node: '>= 18'}
dependencies:
json-stringify-safe: 5.0.1
propagate: 2.0.1
dev: true
/node-addon-api@3.2.1:
resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==}
dev: true