diff --git a/.changeset/24773-oauth-form-types.md b/.changeset/24773-oauth-form-types.md new file mode 100644 index 0000000000..7112b2dbe3 --- /dev/null +++ b/.changeset/24773-oauth-form-types.md @@ -0,0 +1,15 @@ +--- +'@objectstack/platform-objects': patch +--- + +fix(platform-objects): the OAuth application register form offers only Web and Native, and its Redirect URLs help states the rule for each (#24773) + +Clause-②: no (a form's offering: the register route still accepts every type value it accepted, and nothing on the server reads the action's param options, so no accept set or served shape moves) + +Setup › OAuth Applications › Register offered four application types: Web, Native, User-agent based and Public. The OAuth provider judges only `web` and `native`, so the register route registers the other two as `web`, with a client secret. The form now offers Web and Native, and still defaults to Web. + +- **Redirect URLs help.** It said "Must use https:// in production", which did not tell an admin that a local `http://localhost` callback needs the Native type. It now states the rule for each type: a Web application needs `https://` on a host other than localhost or another loopback address; a Native application may also use `http://` on exactly `localhost`, `127.0.0.1` or `[::1]`, on any port. +- **Application Type help.** New: choose Native for a desktop or mobile app, or for an app in local development that redirects to `http://localhost`. +- **Unchanged.** The register route accepts the same values as before, including `user-agent-based` and `public`. The stored `sys_oauth_application.type` column still lists all four values, so a row written under an earlier provider line keeps its value. The `public` column is unchanged. + +The zh-CN, ja-JP and es-ES translations carry the new help texts. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index 6d2afeae3a..eb51af14dd 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -1530,15 +1530,14 @@ export const enObjects: NonNullable = { }, redirectURLs: { label: "Redirect URLs", - helpText: "One URL per line. Must use https:// in production." + helpText: "One URL per line. Web applications need https:// on a host other than localhost or another loopback address. Native applications may also use http:// on exactly localhost, 127.0.0.1 or [::1], on any port." }, type: { label: "Application Type", + helpText: "Choose Native for a desktop or mobile app, or for an app in local development that redirects to http://localhost.", options: { web: "Web", - native: "Native", - "user-agent-based": "User-agent based", - public: "Public" + native: "Native" } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index edbcf7f5d3..bbcd973e65 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -1530,15 +1530,14 @@ export const esESObjects: NonNullable = { }, redirectURLs: { label: "URLs de redirección", - helpText: "Una URL por línea. Debe usar https:// en producción." + helpText: "Una URL por línea. Las aplicaciones web necesitan https:// en un host que no sea localhost ni otra dirección de loopback. Las aplicaciones nativas también pueden usar http:// exactamente en localhost, 127.0.0.1 o [::1], en cualquier puerto." }, type: { label: "Tipo de aplicación", + helpText: "Elija Nativa para una aplicación de escritorio o móvil, o para una aplicación en desarrollo local que redirige a http://localhost.", options: { web: "Web", - native: "Nativa", - "user-agent-based": "Basada en user-agent", - public: "Pública" + native: "Nativa" } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index 4df7c58f27..9e36d181da 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -1530,15 +1530,14 @@ export const jaJPObjects: NonNullable = { }, redirectURLs: { label: "リダイレクト URL", - helpText: "1 行につき 1 つの URL。本番環境では https:// が必須です。" + helpText: "1 行につき 1 つの URL。Web アプリケーションでは、localhost やその他のループバックアドレス以外のホストで https:// を使用する必要があります。ネイティブアプリケーションでは、ホストが厳密に localhost、127.0.0.1、[::1] のいずれかであれば http:// も使用できます(ポートは任意)。" }, type: { label: "アプリケーションタイプ", + helpText: "デスクトップアプリやモバイルアプリ、または http://localhost にリダイレクトするローカル開発中のアプリには「ネイティブ」を選択してください。", options: { web: "Web", - native: "ネイティブ", - "user-agent-based": "ユーザーエージェント型", - public: "パブリック" + native: "ネイティブ" } } }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 0cf8748071..5326f33a27 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -1530,15 +1530,14 @@ export const zhCNObjects: NonNullable = { }, redirectURLs: { label: "回调 URL", - helpText: "每行一个 URL。生产环境必须使用 https://。" + helpText: "每行一个 URL。Web 应用必须使用 https://,且主机不能是 localhost 或其他回环地址。原生应用还可以使用 http://,但主机必须恰好是 localhost、127.0.0.1 或 [::1],端口不限。" }, type: { label: "应用类型", + helpText: "桌面或移动应用,以及在本地开发中回调到 http://localhost 的应用,请选择“原生应用”。", options: { web: "Web 应用", - native: "原生应用", - "user-agent-based": "基于 User-Agent", - public: "公共客户端" + native: "原生应用" } } }, diff --git a/packages/platform-objects/src/identity/oauth-register-form-types.test.ts b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts new file mode 100644 index 0000000000..9cebb2019a --- /dev/null +++ b/packages/platform-objects/src/identity/oauth-register-form-types.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24773] `sys_oauth_application.create_oauth_application` — the register + * form offers the two application types the OAuth provider judges, and its + * redirect-URL help states the provider's rule per type. + * + * The form posts to plugin-auth's `POST /api/v1/auth/sys-oauth-application/register`, + * which forwards the chosen type as the provider's `application_type`. + * `@better-auth/oauth-provider` 1.7.3 declares `web` | `native` there and + * judges redirect URIs by it: web needs https on a host that is not loopback; + * native may also use http on exactly `localhost`, `127.0.0.1` or `[::1]`. + * The form used to offer `user-agent-based` and `public` as well. The provider + * has neither, so the route registers both as `web`. The form stops offering + * them; the route still accepts them, so this is the form only. + * + * The stored `type` column is a different surface and keeps all four options: + * its narrowing waits for the major version that refuses the two values at the + * route, so the pin below holds it as it is on purpose (including a declared + * value that differs from the one earlier provider lines wrote — see there). + * + * The help text is not pinned word for word. What is pinned is what it names: + * the scheme each type needs and the three native loopback hosts, in the source + * and in all four shipped locales. The translated leaves are hand-written, so + * `pnpm i18n:extract` keeps them in merge mode and no other gate notices a + * translation of the old wording. + */ + +import { describe, expect, it } from 'vitest'; +import { SysOauthApplication } from './index.js'; +import { enObjects } from '../apps/translations/en.objects.generated.js'; +import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js'; +import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js'; +import { esESObjects } from '../apps/translations/es-ES.objects.generated.js'; + +type Param = { name?: string; type?: string; required?: boolean; defaultValue?: unknown; helpText?: string; options?: unknown[] }; + +const register = (SysOauthApplication.actions ?? []).find((a) => a.name === 'create_oauth_application'); +const param = (name: string): Param | undefined => + ((register?.params ?? []) as Param[]).find((p) => p.name === name); +const optionValues = (options: unknown[] | undefined): unknown[] => + (options ?? []).map((o) => (typeof o === 'string' ? o : (o as { value?: unknown })?.value)); + +/** The hosts a native client may use with http://, as the provider spells them. */ +const NATIVE_LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]'] as const; + +const LOCALES: readonly (readonly [string, any])[] = [ + ['en', enObjects], + ['zh-CN', zhCNObjects], + ['ja-JP', jaJPObjects], + ['es-ES', esESObjects], +]; +const registerNode = (bundle: any) => bundle?.sys_oauth_application?._actions?.create_oauth_application; + +describe('[#24773] create_oauth_application — the type param offers the provider\'s two application types', () => { + it('offers exactly web and native, defaulting to web', () => { + expect(register, 'create_oauth_application is declared').toBeDefined(); + const type = param('type'); + expect(type?.type).toBe('select'); + expect(type?.required).toBe(true); + expect(type?.defaultValue).toBe('web'); + expect(optionValues(type?.options)).toEqual(['web', 'native']); + }); + + it('every shipped locale translates exactly those two options', () => { + for (const [locale, bundle] of LOCALES) { + const options = registerNode(bundle)?.params?.type?.options; + expect(Object.keys(options ?? {}), `${locale} type options`).toEqual(['web', 'native']); + } + }); +}); + +describe('[#24773] create_oauth_application — the redirect-URL help states the rule per type', () => { + it('names the scheme each type needs and the three native loopback hosts', () => { + const help = param('redirectURLs')?.helpText ?? ''; + expect(help).toContain('https://'); + expect(help).toContain('http://'); + for (const host of NATIVE_LOOPBACK_HOSTS) { + expect(help, `the help names the native loopback host ${host}`).toContain(host); + } + }); + + it('the type help names Native and the loopback redirect it is for', () => { + const help = param('type')?.helpText ?? ''; + expect(help).toContain('Native'); + expect(help).toContain('http://localhost'); + }); + + it('the English bundle mirrors the source, and every locale names the hosts in its own translation', () => { + expect(registerNode(enObjects)?.params?.redirectURLs?.helpText).toBe(param('redirectURLs')?.helpText); + expect(registerNode(enObjects)?.params?.type?.helpText).toBe(param('type')?.helpText); + for (const [locale, bundle] of LOCALES) { + const node = registerNode(bundle); + const help: string = node?.params?.redirectURLs?.helpText ?? ''; + expect(help, `${locale} redirectURLs help`).toContain('https://'); + expect(help, `${locale} redirectURLs help`).toContain('http://'); + for (const host of NATIVE_LOOPBACK_HOSTS) { + expect(help, `${locale} redirectURLs help names ${host}`).toContain(host); + } + expect(node?.params?.type?.helpText ?? '', `${locale} type help`).toContain('http://localhost'); + if (locale !== 'en') { + expect(help, `${locale} redirectURLs help is the English source`).not.toBe(param('redirectURLs')?.helpText); + expect(node?.params?.type?.helpText, `${locale} type help is the English source`).not.toBe(param('type')?.helpText); + } + } + }); +}); + +describe('[#24773] the stored columns are not narrowed in this major', () => { + it('the stored `type` select still declares all four options, exactly as it is served today', () => { + const stored = (SysOauthApplication.fields as Record).type; + expect(stored?.type).toBe('select'); + const options = (stored?.options ?? []) as { label?: unknown }[]; + expect(options.map((o) => o?.label)).toEqual(['web', 'native', 'user-agent-based', 'public']); + // Measured, not intended: the column is declared through `Field.select`'s + // string-array shorthand, which snake-cases each value and drops the + // hyphens, so the third option's VALUE is `useragentbased`, not the + // `user-agent-based` an earlier provider line wrote. Recorded for the + // major version that narrows this column; this PR changes neither. + expect(optionValues(stored?.options)).toEqual(['web', 'native', 'useragentbased', 'public']); + }); + + it('the stored `public` column is still declared', () => { + const column = (SysOauthApplication.fields as Record).public; + expect(column?.type).toBe('boolean'); + }); +}); diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index d5ffc56c6b..240ded11b6 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -147,15 +147,39 @@ export const SysOauthApplication = ObjectSchema.create({ target: '/api/v1/auth/sys-oauth-application/register', requiresFeature: 'oidcProvider', refreshAfter: true, + // [#24773] The form offers exactly the two application types the OAuth + // provider judges (`@better-auth/oauth-provider` 1.7.3 declares + // `application_type` as `web` | `native`), and the redirect-URL help + // states that provider's redirect-URI rule per type, which the register + // route forwards the type to. `user-agent-based` and `public` are no + // longer offered: the provider has no such application type, so the + // route registers either as `web`. The route itself still accepts both + // (refusing them narrows the door, which waits for a major version), and + // nothing on the server reads these options: the generic action door + // refuses a `type: 'api'` action before its param contract runs, so the + // route is the only judge. The stored `type` column below is a separate + // surface and is not narrowed here: that waits for the same major version. params: [ { name: 'name', label: 'Application Name', type: 'text', required: true }, - { name: 'redirectURLs', label: 'Redirect URLs', type: 'textarea', required: true, helpText: 'One URL per line. Must use https:// in production.' }, - { name: 'type', label: 'Application Type', type: 'select', required: true, defaultValue: 'web', options: [ - { label: 'Web', value: 'web' }, - { label: 'Native', value: 'native' }, - { label: 'User-agent based', value: 'user-agent-based' }, - { label: 'Public', value: 'public' }, - ] }, + { + name: 'redirectURLs', + label: 'Redirect URLs', + type: 'textarea', + required: true, + helpText: 'One URL per line. Web applications need https:// on a host other than localhost or another loopback address. Native applications may also use http:// on exactly localhost, 127.0.0.1 or [::1], on any port.', + }, + { + name: 'type', + label: 'Application Type', + type: 'select', + required: true, + defaultValue: 'web', + helpText: 'Choose Native for a desktop or mobile app, or for an app in local development that redirects to http://localhost.', + options: [ + { label: 'Web', value: 'web' }, + { label: 'Native', value: 'native' }, + ], + }, ], resultDialog: { title: 'OAuth application registered',