Skip to content

fix(schemas): reject unknown customClientMetadata keys on the application APIs - #9931

Open
nicolaj0 wants to merge 2 commits into
logto-io:masterfrom
nicolaj0:fix/core-application-custom-client-metadata-strict
Open

nicolaj0 wants to merge 2 commits into
logto-io:masterfrom
nicolaj0:fix/core-application-custom-client-metadata-strict

Conversation

@nicolaj0

Copy link
Copy Markdown
Contributor

Summary

Closes #9885 (labelled contribution-welcome).

POST /api/applications and PATCH /api/applications/:id accept customClientMetadata through a plain z.object(...), which strips unknown keys. A misspelled or unsupported key was therefore answered 200 and silently dropped: a typo in refreshTokenTtlInDays from a script looked applied while the sessions kept the default TTL.

This PR makes the API input guard strict on that object. An unknown key is now rejected with a 400 guard.invalid_input whose message names the key:

{ "code": "guard.invalid_input", "message": "...", "data": [{ "code": "unrecognized_keys", "keys": ["refreshTokenTtlInDay"], "path": ["customClientMetadata"] }] }

Why only the API guard

  • customClientMetadataGuard is also the row guard (Applications.guard / createGuard) and is used by oidc/utils.ts through .shape. Making it strict there would make a stored application with a removed legacy key fail to parse, so the override lives in applicationCreateGuard (packages/schemas/src/types/application.ts), which applicationPatchGuard derives from. Same reasoning as the comment on passwordExpirationPolicyGuard.
  • Console is not affected: it only sends known keys, and koaGuard overwrites responses with the parsed value, so a legacy key stored in a row is never echoed back to a client that would then re-send it on PATCH.
  • SAML application guards pick only name, description and customData, so they are untouched.

Tests

  • Two integration tests in application.test.ts: an unknown key is rejected on create and on update, and the update leaves the stored value unchanged.
  • Verified locally against the rebuilt @logto/schemas: create/patch with refreshTokenTtlInDay ⇒ unrecognized_keys, patch with a valid key ⇒ ok, row guard with an extra legacy key ⇒ still parses. tsc for schemas, core and integration-tests, eslint and prettier are clean on the changed files.

Changeset

@logto/schemas + @logto/core patch: callers that relied on the lenient behaviour now get a 400 instead of a silent drop, so this is called out as a behaviour change.

🤖 Generated with Claude Code

…cation APIs

`POST /api/applications` and `PATCH /api/applications/:id` accepted any key under
`customClientMetadata` and silently dropped the unknown ones, so a typo such as
`refreshTokenTtlInDay` was answered 200 while the setting was never applied.

The API input guard is now `.strict()` on that object, which yields a 400
`guard.invalid_input` naming the key. The row guard in `Applications` stays lenient
so legacy rows that still carry removed keys keep parsing (and koaGuard already
strips them from responses, so Console never echoes such a key back).

Closes logto-io#9885

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment on lines +50 to +56
.extend({
/**
* Strict on the API input only: an unknown key (e.g. a typo such as `refreshTokenTtlInDay`)
* is rejected with a 400 instead of being silently stripped and answered 200. The row guard
* in `Applications` stays lenient so that legacy rows carrying removed keys still parse.
*/
customClientMetadata: customClientMetadataGuard.strict().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this input is now .strict(), the set of keys in customClientMetadataGuard becomes part of the API contract. Could you add a note for future maintainers, ideally right on customClientMetadataGuard in packages/schemas/src/foundations/jsonb-types/oidc-module.ts, since that's where keys get added and removed:

  • Never remove or rename an existing key. A client that still sends it would go from 200 to 400. Keep it and mark it @deprecated instead, the way refreshTokenTtl is handled.
  • New keys must be .optional(). A required key would reject every existing create/update payload that sends customClientMetadata without it, and PATCH replaces the whole object.

Also, the last sentence here ("legacy rows carrying removed keys still parse") reads as though removing keys is a supported path, which is the opposite of what we want now. Could you reword it to point at the rule above?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9e729a1. The two rules (never remove or rename a key, mark it @deprecated like refreshTokenTtl; new keys must be .optional() since PATCH replaces the whole object) now sit in a doc comment right on customClientMetadataGuard, and the comment on applicationCreateGuard no longer mentions legacy rows: it says the keys are part of the API contract and points to those rules.

Comment thread .changeset/strict-metadata-keys.md Outdated

reject unknown `customClientMetadata` keys on the application APIs

`POST /api/applications` and `PATCH /api/applications/:id` used to accept any key under `customClientMetadata` and silently drop the ones they did not know, so a typo such as `refreshTokenTtlInDay` was answered 200 while the setting was never applied. An unknown key is now rejected with a 400 `guard.invalid_input` that names it. Stored applications are not affected: the row guard still strips removed legacy keys on read.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This goes into the user-facing changelog, so I'd drop the row-guard wording (no key has ever been removed from this guard anyway). Also, the guard.invalid_input message itself is generic; the key name is in the error data. Suggest:

An unknown key is now rejected with a 400 guard.invalid_input error whose data lists the key. Existing applications are not affected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9e729a1, using your wording.

@simeng-li

Copy link
Copy Markdown
Contributor

The generated OpenAPI spec won't show that customClientMetadata now rejects unknown keys, because zodTypeToSwagger doesn't emit additionalProperties: false for a strict object. Could you add a short customClientMetadata description to the POST /api/applications and PATCH /api/applications/{id} request bodies in application.openapi.json saying unknown keys return 400?

@nicolaj0
nicolaj0 requested a review from wangsijie as a code owner October 30, 2026 07:20
@nicolaj0

Copy link
Copy Markdown
Contributor Author

Thanks for the review. OpenAPI: added a customClientMetadata description to the POST /api/applications and PATCH /api/applications/{id} request bodies stating that unknown keys are rejected with a 400 guard.invalid_input whose data lists the key (9e729a1).

@github-actions github-actions Bot added size/s and removed size/s labels Oct 30, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

customClientMetadata: an unknown key is silently dropped on PATCH /api/applications/:id (200 instead of 400)

3 participants