diff --git a/packages/console/src/pages/ApplicationDetails/components/Settings.tsx b/packages/console/src/pages/ApplicationDetails/components/Settings.tsx index f2bcc3238..925590801 100644 --- a/packages/console/src/pages/ApplicationDetails/components/Settings.tsx +++ b/packages/console/src/pages/ApplicationDetails/components/Settings.tsx @@ -1,4 +1,9 @@ -import { Application, ApplicationType, SnakeCaseOidcConfig } from '@logto/schemas'; +import { + Application, + ApplicationType, + SnakeCaseOidcConfig, + validateRedirectUrl, +} from '@logto/schemas'; import { useEffect } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -10,7 +15,7 @@ import { MultiTextInputRule } from '@/components/MultiTextInput/types'; import { createValidatorForRhf, convertRhfErrorMessage } from '@/components/MultiTextInput/utils'; import TextInput from '@/components/TextInput'; import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal'; -import { uriOriginValidator, uriValidator } from '@/utilities/validator'; +import { uriOriginValidator } from '@/utilities/validator'; import * as styles from '../index.module.scss'; @@ -38,9 +43,10 @@ const Settings = ({ applicationType, oidcConfig, defaultData, isDeleted }: Props }; }, [reset, defaultData]); + const isNativeApp = applicationType === ApplicationType.Native; const uriPatternRules: MultiTextInputRule = { pattern: { - verify: (value) => !value || uriValidator(value), + verify: (value) => !value || validateRedirectUrl(value, isNativeApp ? 'mobile' : 'web'), message: t('errors.invalid_uri_format'), }, }; diff --git a/packages/core/src/routes/application.test.ts b/packages/core/src/routes/application.test.ts index c74ac6bd1..5ae81c654 100644 --- a/packages/core/src/routes/application.test.ts +++ b/packages/core/src/routes/application.test.ts @@ -41,6 +41,11 @@ const customClientMetadata = { refreshTokenTtl: 100_000_000, }; +const customOidcClientMetadata = { + redirectUris: [], + postLogoutRedirectUris: [], +}; + describe('application route', () => { const applicationRequest = createRequester({ authedRoutes: applicationRoutes }); @@ -146,6 +151,57 @@ describe('application route', () => { ).resolves.toHaveProperty('status', 400); }); + it('PATCH /applications/:applicationId should save the formatted URIs as per RFC', async () => { + await expect( + applicationRequest.patch('/applications/foo').send({ + oidcClientMetadata: { + redirectUris: [ + 'https://example.com/callback?auth=true', + 'https://Example.com', + 'http://127.0.0.1', + 'http://localhost:3002', + ], + }, + }) + ).resolves.toHaveProperty('status', 200); + }); + + it('PATCH /application/:applicationId expect to throw with invalid redirectURI', async () => { + await expect( + applicationRequest.patch('/applications/foo').send({ + oidcClientMetadata: { + redirectUris: ['www.example.com', 'com.example://callback'], + }, + }) + ).resolves.toHaveProperty('status', 400); + }); + + it('PATCH /application/:applicationId should save the formatted custom scheme URIs for native apps', async () => { + await expect( + applicationRequest.patch('/applications/foo').send({ + type: ApplicationType.Native, + oidcClientMetadata: { + redirectUris: [ + 'com.example://demo-app/callback', + 'com.example://callback', + 'io.logto://Abc123', + ], + }, + }) + ).resolves.toHaveProperty('status', 200); + }); + + it('PATCH /application/:applicationId expect to throw with invalid custom scheme for native apps', async () => { + await expect( + applicationRequest.patch('/applications/foo').send({ + type: ApplicationType.Native, + oidcClientMetadata: { + redirectUris: ['https://www.example.com', 'com.example/callback'], + }, + }) + ).resolves.toHaveProperty('status', 400); + }); + it('DELETE /applications/:applicationId', async () => { await expect(applicationRequest.delete('/applications/foo')).resolves.toHaveProperty( 'status', diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts index caae5662f..9b6932243 100644 --- a/packages/core/src/utils/zod.ts +++ b/packages/core/src/utils/zod.ts @@ -5,6 +5,7 @@ import { OpenAPIV3 } from 'openapi-types'; import { ZodArray, ZodBoolean, + ZodEffects, ZodEnum, ZodLiteral, ZodNativeEnum, @@ -218,5 +219,13 @@ export const zodTypeToSwagger = ( }; } + // TO-DO: Improve swagger output for zod schema with refinement (validate through JS functions) + if (config instanceof ZodEffects && config._def.effect.type === 'refinement') { + return { + type: 'object', + description: 'Validator function', + }; + } + throw new RequestError('swagger.invalid_zod_type', config); }; diff --git a/packages/schemas/src/foundations/jsonb-types.ts b/packages/schemas/src/foundations/jsonb-types.ts index 6f69f180e..2ff053e7f 100644 --- a/packages/schemas/src/foundations/jsonb-types.ts +++ b/packages/schemas/src/foundations/jsonb-types.ts @@ -30,8 +30,28 @@ export const oidcModelInstancePayloadGuard = z export type OidcModelInstancePayload = z.infer; +// Import from @logto/core-kit later, pending for new version publish +export const webRedirectUriProtocolRegEx = /^https?:$/; +export const mobileUriSchemeProtocolRegEx = /^[a-z][\d_a-z]*(\.[\d_a-z]+)+:$/; + +export const validateRedirectUrl = (urlString: string, type: 'web' | 'mobile') => { + try { + const { protocol } = new URL(urlString); + const protocolRegEx = + type === 'mobile' ? mobileUriSchemeProtocolRegEx : webRedirectUriProtocolRegEx; + + return protocolRegEx.test(protocol); + } catch { + return false; + } +}; + export const oidcClientMetadataGuard = z.object({ - redirectUris: z.string().url().array(), + redirectUris: z + .string() + .refine((url) => validateRedirectUrl(url, 'web')) + .or(z.string().refine((url) => validateRedirectUrl(url, 'mobile'))) + .array(), postLogoutRedirectUris: z.string().url().array(), logoUri: z.string().optional(), });