Skip to content
Merged
15 changes: 15 additions & 0 deletions .changeset/24773-oauth-form-types.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -1530,15 +1530,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1530,15 +1530,14 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
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"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1530,15 +1530,14 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
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: "ネイティブ"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1530,15 +1530,14 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
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: "原生应用"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, { type?: string; options?: unknown[] }>).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<string, { type?: string }>).public;
expect(column?.type).toBe('boolean');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading