diff --git a/.changeset/spicy-cameras-sleep.md b/.changeset/spicy-cameras-sleep.md new file mode 100644 index 000000000..d314e2e62 --- /dev/null +++ b/.changeset/spicy-cameras-sleep.md @@ -0,0 +1,16 @@ +--- +"@logto/console": minor +"@logto/core": minor +--- + +add access deny method to the custom token claims script + +Introduce a new `api` parameter to the custom token claims script. This parameter is used to provide more access control context over the token exchange process. +Use `api.denyAccess()` to reject the token exchange request. Use this method to implement your own access control logics. + +```javascript +const getCustomJwtClaims: async ({ api }) => { + // Reject the token request, with a custom error message + return api.denyAccess('Access denied'); +} +``` diff --git a/packages/console/scripts/custom-jwt-customizer-type-definition.ts b/packages/console/scripts/custom-jwt-customizer-type-definition.ts index 8ac316026..130238494 100644 --- a/packages/console/scripts/custom-jwt-customizer-type-definition.ts +++ b/packages/console/scripts/custom-jwt-customizer-type-definition.ts @@ -12,10 +12,10 @@ import { CustomJwtApiContext } from '@logto/schemas'; */ export const jwtCustomizerApiContextTypeDefinition = `type CustomJwtApiContext = { /** - * Reject the the current token exchange request. + * Reject the the current token request. * * @remarks - * This function will reject the current token exchange request and throw + * This function will reject the current token request and throw * an OIDC AccessDenied error to the client. * * @param {string} [message] - The custom error message. diff --git a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx index bdb631011..1bb464771 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/MainContent/SettingsSection/InstructionTab/index.tsx @@ -5,7 +5,6 @@ import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { isDevFeaturesEnabled } from '@/consts/env'; import { type JwtCustomizerForm } from '@/pages/CustomizeJwtDetails/type'; import { denyAccessCodeExample, @@ -138,24 +137,22 @@ function InstructionTab({ isActive }: Props) { options={sampleCodeEditorOptions} /> - {isDevFeaturesEnabled && ( - { - setExpendCard(expand ? CardType.ApiContext : undefined); - }} - > - - - )} + { + setExpendCard(expand ? CardType.ApiContext : undefined); + }} + > + +
{t('jwt_claims.jwt_claims_description')}
); diff --git a/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx b/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx index e45d82a7b..116bd1d04 100644 --- a/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx +++ b/packages/console/src/pages/CustomizeJwtDetails/utils/config.tsx @@ -9,7 +9,6 @@ import { type EditorProps } from '@monaco-editor/react'; import TokenFileIcon from '@/assets/icons/token-file-icon.svg?react'; import UserFileIcon from '@/assets/icons/user-file-icon.svg?react'; -import { isDevFeaturesEnabled } from '@/consts/env.js'; import type { ModelSettings } from '../MainContent/MonacoCodeEditor/type.js'; @@ -29,9 +28,7 @@ declare interface CustomJwtClaims extends Record {} /** Logto internal data that can be used to pass additional information * - * @param {${ - JwtCustomizerTypeDefinitionKey.JwtCustomizerUserContext - }} user - The user info associated with the token. + * @param {${JwtCustomizerTypeDefinitionKey.JwtCustomizerUserContext}} user - The user info associated with the token. */ declare type Context = { /** @@ -60,17 +57,12 @@ declare type Payload = { * Custom environment variables. */ environmentVariables: ${JwtCustomizerTypeDefinitionKey.EnvironmentVariables}; - ${ - isDevFeaturesEnabled - ? ` - /** + /** * Logto API context, provides callback methods for access control. * * @param {${JwtCustomizerTypeDefinitionKey.CustomJwtApiContext}} api */ - api: ${JwtCustomizerTypeDefinitionKey.CustomJwtApiContext};` - : '' - } + api: ${JwtCustomizerTypeDefinitionKey.CustomJwtApiContext}; };`; /** @@ -90,17 +82,12 @@ declare type Payload = { * Custom environment variables. */ environmentVariables: ${JwtCustomizerTypeDefinitionKey.EnvironmentVariables}; - ${ - isDevFeaturesEnabled - ? ` - /** + /** * Logto API context, callback methods for access control. * * @param {${JwtCustomizerTypeDefinitionKey.CustomJwtApiContext}} api */ - api: ${JwtCustomizerTypeDefinitionKey.CustomJwtApiContext};` - : '' - } + api: ${JwtCustomizerTypeDefinitionKey.CustomJwtApiContext}; };`; export const defaultAccessTokenJwtCustomizerCode = `/** @@ -111,9 +98,7 @@ export const defaultAccessTokenJwtCustomizerCode = `/** * * @returns The custom claims. */ -const getCustomJwtClaims = async ({ token, context, environmentVariables${ - isDevFeaturesEnabled ? ', api' : '' -} }) => { +const getCustomJwtClaims = async ({ token, context, environmentVariables, api }) => { return {}; }`; @@ -125,9 +110,7 @@ export const defaultClientCredentialsJwtCustomizerCode = `/** * * @returns The custom claims. */ -const getCustomJwtClaims = async ({ token, environmentVariables${ - isDevFeaturesEnabled ? ', api' : '' -} }) => { +const getCustomJwtClaims = async ({ token, environmentVariables, api }) => { return {}; }`; @@ -221,8 +204,7 @@ export const denyAccessCodeExample = `/** */ getCustomJwtClaims = async ({ api }) => { // Conditionally deny access - api.denyAccess('Access denied'); - return {}; + return api.denyAccess('Access denied'); };`; /** diff --git a/packages/core/src/libraries/jwt-customizer.ts b/packages/core/src/libraries/jwt-customizer.ts index f9c120635..fd1ba8360 100644 --- a/packages/core/src/libraries/jwt-customizer.ts +++ b/packages/core/src/libraries/jwt-customizer.ts @@ -12,7 +12,7 @@ import { type CustomJwtScriptPayload, } from '@logto/schemas'; import { type ConsoleLog } from '@logto/shared'; -import { assert, conditional, deduplicate, pick, pickState } from '@silverhand/essentials'; +import { assert, deduplicate, pick, pickState } from '@silverhand/essentials'; import deepmerge from 'deepmerge'; import { ZodError, z } from 'zod'; @@ -53,17 +53,11 @@ export class JwtCustomizerLibrary { // Convert errors to WithTyped client response error to share the error handling logic. static async runScriptInLocalVm(data: CustomJwtFetcher) { try { - // @ts-expect-error -- remove this when the dev feature is ready const payload: CustomJwtScriptPayload = { ...(data.tokenType === LogtoJwtTokenKeyType.AccessToken ? pick(data, 'token', 'context', 'environmentVariables') : pick(data, 'token', 'environmentVariables')), - ...conditional( - // TODO: @simeng remove this when the dev feature is ready - EnvSet.values.isDevFeaturesEnabled && { - api: apiContext, - } - ), + api: apiContext, }; const result = await runScriptFunctionInLocalVm(data.script, 'getCustomJwtClaims', payload); diff --git a/packages/core/src/oidc/extra-token-claims.ts b/packages/core/src/oidc/extra-token-claims.ts index 0c85ec3b6..f8f14fb3d 100644 --- a/packages/core/src/oidc/extra-token-claims.ts +++ b/packages/core/src/oidc/extra-token-claims.ts @@ -218,11 +218,6 @@ export const getExtraTokenClaimsForJwtCustomization = async ( }, }); - // TODO: @simeng remove this once the feature is ready - if (!EnvSet.values.isDevFeaturesEnabled) { - return; - } - // If the error is an instance of `ResponseError`, we need to parse the customJwtError body to get the error code. if (error instanceof ResponseError) { const customJwtError = await trySafe(async () => parseCustomJwtResponseError(error)); diff --git a/packages/integration-tests/src/__mocks__/jwt-customizer.ts b/packages/integration-tests/src/__mocks__/jwt-customizer.ts index f92648116..4636cc093 100644 --- a/packages/integration-tests/src/__mocks__/jwt-customizer.ts +++ b/packages/integration-tests/src/__mocks__/jwt-customizer.ts @@ -59,8 +59,7 @@ export const accessTokenSampleScript = `const getCustomJwtClaims = async ({ toke };`; export const accessTokenAccessDeniedSampleScript = `const getCustomJwtClaims = async ({ token, context, environmentVariables, api }) => { - api.denyAccess('You are not allowed to access this resource'); - return { test: 'foo'}; + return api.denyAccess('You are not allowed to access this resource'); };`; export const clientCredentialsSampleScript = `const getCustomJwtClaims = async ({ token, context, environmentVariables }) => { @@ -68,6 +67,5 @@ export const clientCredentialsSampleScript = `const getCustomJwtClaims = async ( }`; export const clientCredentialsAccessDeniedSampleScript = `const getCustomJwtClaims = async ({ token, context, environmentVariables, api }) => { - api.denyAccess('You are not allowed to access this resource'); - return { test: 'foo'}; + return api.denyAccess('You are not allowed to access this resource'); };`; diff --git a/packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts b/packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts index 8ffbe6af6..7cf27a3fb 100644 --- a/packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts +++ b/packages/integration-tests/src/tests/api/admin-user.personal-access-tokens.test.ts @@ -8,9 +8,9 @@ import { updatePersonalAccessToken, } from '#src/api/admin-user.js'; import { createUserByAdmin } from '#src/helpers/index.js'; -import { devFeatureTest, randomString } from '#src/utils.js'; +import { randomString } from '#src/utils.js'; -devFeatureTest.describe('personal access tokens', () => { +describe('personal access tokens', () => { it('should throw error when creating PAT with existing name', async () => { const user = await createUserByAdmin(); const name = randomString(); diff --git a/packages/integration-tests/src/tests/api/logto-config.test.ts b/packages/integration-tests/src/tests/api/logto-config.test.ts index 0298985ea..3128e50f7 100644 --- a/packages/integration-tests/src/tests/api/logto-config.test.ts +++ b/packages/integration-tests/src/tests/api/logto-config.test.ts @@ -28,7 +28,6 @@ import { testJwtCustomizer, } from '#src/api/index.js'; import { expectRejects } from '#src/helpers/index.js'; -import { devFeatureTest } from '#src/utils.js'; const defaultAdminConsoleConfig: AdminConsoleData = { signInExperienceCustomized: false, @@ -272,40 +271,34 @@ describe('logto config', () => { expect(testResult).toMatchObject(clientCredentialsJwtCustomizerPayload.environmentVariables); }); - devFeatureTest.it( - 'should throw access denied error when calling the denyAccess api in the script', - async () => { - await expectRejects( - testJwtCustomizer({ - tokenType: LogtoJwtTokenKeyType.AccessToken, - token: accessTokenJwtCustomizerPayload.tokenSample, - context: accessTokenJwtCustomizerPayload.contextSample, - script: accessTokenAccessDeniedSampleScript, - environmentVariables: accessTokenJwtCustomizerPayload.environmentVariables, - }), - { - code: 'jwt_customizer.general', - status: 403, - } - ); - } - ); + it('should throw access denied error when calling the denyAccess api in the script', async () => { + await expectRejects( + testJwtCustomizer({ + tokenType: LogtoJwtTokenKeyType.AccessToken, + token: accessTokenJwtCustomizerPayload.tokenSample, + context: accessTokenJwtCustomizerPayload.contextSample, + script: accessTokenAccessDeniedSampleScript, + environmentVariables: accessTokenJwtCustomizerPayload.environmentVariables, + }), + { + code: 'jwt_customizer.general', + status: 403, + } + ); + }); - devFeatureTest.it( - 'should throw access denied error when calling the denyAccess api in the script', - async () => { - await expectRejects( - testJwtCustomizer({ - tokenType: LogtoJwtTokenKeyType.ClientCredentials, - token: clientCredentialsJwtCustomizerPayload.tokenSample, - script: clientCredentialsAccessDeniedSampleScript, - environmentVariables: clientCredentialsJwtCustomizerPayload.environmentVariables, - }), - { - code: 'jwt_customizer.general', - status: 403, - } - ); - } - ); + it('should throw access denied error when calling the denyAccess api in the script', async () => { + await expectRejects( + testJwtCustomizer({ + tokenType: LogtoJwtTokenKeyType.ClientCredentials, + token: clientCredentialsJwtCustomizerPayload.tokenSample, + script: clientCredentialsAccessDeniedSampleScript, + environmentVariables: clientCredentialsJwtCustomizerPayload.environmentVariables, + }), + { + code: 'jwt_customizer.general', + status: 403, + } + ); + }); }); diff --git a/packages/phrases/src/locales/en/translation/admin-console/jwt-claims.ts b/packages/phrases/src/locales/en/translation/admin-console/jwt-claims.ts index 4d97d606a..2d8aef6f4 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/jwt-claims.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/jwt-claims.ts @@ -41,7 +41,7 @@ const jwt_claims = { }, api_context: { title: 'API context: access control', - subtitle: 'Use `api.denyAccess` method to reject the token exchange request.', + subtitle: 'Use `api.denyAccess` method to reject the token request.', }, fetch_external_data: { title: 'Fetch external data', diff --git a/packages/schemas/src/types/logto-config/jwt-customizer.ts b/packages/schemas/src/types/logto-config/jwt-customizer.ts index 732e690f5..d8f25cffa 100644 --- a/packages/schemas/src/types/logto-config/jwt-customizer.ts +++ b/packages/schemas/src/types/logto-config/jwt-customizer.ts @@ -168,10 +168,10 @@ export type CustomJwtErrorBody = z.infer; export type CustomJwtApiContext = { /** - * Reject the the current token exchange request. + * Reject the the current token request. * * @remarks - * By calling this function, the current token exchange request will be rejected, + * By calling this function, the current token request will be rejected, * and a OIDC `AccessDenied` error will be thrown to the client with the given message. * * @param message The message to be shown to the user.