Skip to content

Commit 04f8fdb

Browse files
hotlongclaude
andauthored
fix(platform-objects): drop the dead mapId param from register_sso_provider (#8222) (#8800)
The OIDC subject claim is not configurable in @better-auth/sso@1.7.0-rc.2: oidcConfig.mapping is a z.strictObject with no `id` member, the subject is hard-wired to the `sub` claim, and extraFields is spread before `id` so it is not an escape hatch either. The form field's only accepted values were empty and the `sub` it already defaulted to; anything else has answered INVALID_REQUEST since #8221. Removing it restores declared = enforced. Translation bundles regenerated via scripts/check-i18n-bundles.mjs --write, not hand-edited. The plugin-auth bridge guard is kept as belt-and-braces for non-form callers; only its doc comment changed. Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent d09d0fd commit 04f8fdb

8 files changed

Lines changed: 180 additions & 24 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/platform-objects": patch
3+
"@objectstack/plugin-auth": patch
4+
---
5+
6+
fix(platform-objects): drop the dead `mapId` ("Map: User ID claim") param from `register_sso_provider` — the OIDC subject claim is not configurable (#8222)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) One action PARAM is
9+
removed from a UI action declaration, plus the generated i18n entries that
10+
carried its label/helpText. `params` are the form fields an `type: 'api'` action
11+
collects for its request body — not an authorable metadata property, not a field,
12+
not a stored column, so there is nothing to tombstone and no conversion to
13+
register. No stored `sys_sso_provider` row changes shape: the param was only ever
14+
a transient form input, and since #8193/#8221 the bridge has not forwarded it to
15+
better-auth at all. The runtime accept set does not move. -->
16+
17+
The `register_sso_provider` action on `sys_sso_provider` offered an optional
18+
**"Map: User ID claim"** text field (`mapId`), with helpText reading *"Optional.
19+
ID-token claim mapped to the user ID. Defaults to `sub`."*
20+
21+
**That capability no longer exists.** It was retired upstream in
22+
`@better-auth/sso@1.7.0-rc.2`:
23+
24+
- `oidcConfig.mapping` is a `z.strictObject` whose members are
25+
`{ email, emailVerified?, name, image?, extraFields? }` — there is no `id`;
26+
- the federated subject is hard-wired to the OIDC `sub` claim
27+
(`id: readStringClaim(rawUserInfo, "sub")` and `id: idToken.sub`), then
28+
cross-checked (`id_token_subject_missing`,
29+
`id_token_userinfo_subject_mismatch`);
30+
- `extraFields` is not an escape hatch — it is spread **before** `id` in the
31+
profile literal, so an `extraFields.id` is overwritten by `sub` before anything
32+
reads it.
33+
34+
`1.6.20` did honour `mapping.id` (`id: rawUserInfo[mapping.id || "sub"]`); the
35+
version bump deleted the member.
36+
37+
So the field's only accepted values were "empty" and the `sub` it already
38+
defaulted to. #8193 (PR #8221) stopped the bridge emitting the retired key and —
39+
rather than accept a value it would silently discard — made a non-`sub` value
40+
answer `INVALID_REQUEST`. That left the last half of the problem: **the form
41+
still advertised a free-form optional field that 400s on anything meaningful.**
42+
Removing it restores declared = enforced. Nothing else about registration moves:
43+
the runtime accept set is unchanged, and a registration that never sent `mapId`
44+
behaves exactly as before.
45+
46+
`mapEmail` and `mapName` are untouched — they map to live `oidcMappingSchema`
47+
members and are still honoured.
48+
49+
**The bridge-side guard in `plugin-auth`'s `register-sso-provider.ts` is kept**,
50+
and its refusal test with it. The admin form was only one caller: a direct API
51+
client, a script, or a stale cached console bundle can still put `mapId` on the
52+
wire, and telling those callers plainly still beats discarding the value in
53+
silence. Only the guard's doc comment changed, to stop describing `mapId` as a
54+
field the form sends.
55+
56+
The generated translation bundles (`*.objects.generated.ts`, all four locales)
57+
were **regenerated**, not hand-edited, so the retired label disappears from every
58+
locale rather than lingering as a stale entry.

packages/platform-objects/src/apps/translations/en.objects.generated.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,11 +1804,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
18041804
helpText: "Optional. Space- or comma-separated OAuth scopes. Defaults to \"openid email profile\".",
18051805
placeholder: "openid email profile"
18061806
},
1807-
mapId: {
1808-
label: "Map: User ID claim",
1809-
helpText: "Optional. ID-token claim mapped to the user ID. Defaults to \"sub\".",
1810-
placeholder: "sub"
1811-
},
18121807
mapEmail: {
18131808
label: "Map: Email claim",
18141809
helpText: "Optional. Claim mapped to email. Defaults to \"email\".",

packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,11 +1804,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
18041804
helpText: "Opcional. Ámbitos OAuth separados por espacios o comas. Valor predeterminado: «openid email profile».",
18051805
placeholder: "openid email profile"
18061806
},
1807-
mapId: {
1808-
label: "Mapeo: reclamo de ID de usuario",
1809-
helpText: "Opcional. Reclamo del token de ID asignado al ID de usuario. Valor predeterminado: «sub».",
1810-
placeholder: "sub"
1811-
},
18121807
mapEmail: {
18131808
label: "Mapeo: reclamo de correo",
18141809
helpText: "Opcional. Reclamo asignado al correo. Valor predeterminado: «email».",

packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,11 +1804,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
18041804
helpText: "任意。スペースまたはカンマ区切りの OAuth スコープ。既定値は「openid email profile」。",
18051805
placeholder: "openid email profile"
18061806
},
1807-
mapId: {
1808-
label: "マッピング: ユーザー ID クレーム",
1809-
helpText: "任意。ユーザー ID にマッピングする ID トークンのクレーム。既定値は「sub」。",
1810-
placeholder: "sub"
1811-
},
18121807
mapEmail: {
18131808
label: "マッピング: メールクレーム",
18141809
helpText: "任意。メールにマッピングするクレーム。既定値は「email」。",

packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1804,11 +1804,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
18041804
helpText: "可选。以空格或逗号分隔的 OAuth 授权范围(scopes)。默认为 “openid email profile”。",
18051805
placeholder: "openid email profile"
18061806
},
1807-
mapId: {
1808-
label: "映射:用户 ID claim",
1809-
helpText: "可选。映射到用户 ID 的 ID-token claim。默认为 “sub”。",
1810-
placeholder: "sub"
1811-
},
18121807
mapEmail: {
18131808
label: "映射:邮箱 claim",
18141809
helpText: "可选。映射到邮箱的 claim。默认为 “email”。",

packages/platform-objects/src/identity/sys-sso-provider.object.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,16 @@ export const SysSsoProvider = ObjectSchema.create({
9999
{ name: 'clientSecret', label: 'Client Secret', type: 'text', required: true, helpText: 'OAuth client secret (stored encrypted by better-auth).' },
100100
{ name: 'discoveryEndpoint', label: 'Discovery URL', type: 'text', required: false, helpText: 'Optional. OIDC discovery document URL. Leave blank to derive `<issuer>/.well-known/openid-configuration`.' },
101101
{ name: 'scopes', label: 'Scopes', type: 'text', required: false, placeholder: 'openid email profile', helpText: 'Optional. Space- or comma-separated OAuth scopes. Defaults to "openid email profile".' },
102-
{ name: 'mapId', label: 'Map: User ID claim', type: 'text', required: false, placeholder: 'sub', helpText: 'Optional. ID-token claim mapped to the user ID. Defaults to "sub".' },
102+
// No "Map: User ID claim" param here on purpose. `@better-auth/sso@1.7.0-rc.2`
103+
// retired the capability: `oidcConfig.mapping` is a `z.strictObject` with no
104+
// `id` member, the federated subject is hard-wired to the OIDC `sub` claim
105+
// (`id: readStringClaim(rawUserInfo, "sub")` / `id: idToken.sub`), and
106+
// `extraFields` is spread BEFORE `id` in the profile literal, so it is not an
107+
// escape hatch either. Offering the field made the form promise a choice the
108+
// runtime does not have — its only accepted values were "empty" and the `sub`
109+
// it already defaults to, and anything else answers 400 (see the guard in
110+
// plugin-auth `register-sso-provider.ts`, which stays as belt-and-braces for
111+
// non-form callers). Removing it restores declared = enforced.
103112
{ name: 'mapEmail', label: 'Map: Email claim', type: 'text', required: false, placeholder: 'email', helpText: 'Optional. Claim mapped to email. Defaults to "email".' },
104113
{ name: 'mapName', label: 'Map: Name claim', type: 'text', required: false, placeholder: 'name', helpText: 'Optional. Claim mapped to display name. Defaults to "name".' },
105114
],
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #8222 — the OIDC subject claim is not authorable, so the form must not offer it.
5+
*
6+
* `register_sso_provider` used to collect a `mapId` ("Map: User ID claim") param.
7+
* The capability behind it was retired UPSTREAM in `@better-auth/sso@1.7.0-rc.2`:
8+
* `oidcConfig.mapping` is a `z.strictObject` whose members are
9+
* `{ email, emailVerified?, name, image?, extraFields? }` — no `id` — and the
10+
* federated subject is hard-wired to the OIDC `sub` claim
11+
* (`id: readStringClaim(rawUserInfo, "sub")` / `id: idToken.sub`, then
12+
* cross-checked). `extraFields` is not an escape hatch: it is spread BEFORE `id`
13+
* in the profile literal, so an `extraFields.id` is overwritten by `sub` before
14+
* anything reads it. 1.6.20 did honour `mapping.id`; the version bump deleted it.
15+
*
16+
* The field therefore promised a choice the runtime does not have: its only
17+
* accepted values were "empty" and the `sub` it already defaulted to, and #8193
18+
* (PR #8221) made anything else answer `INVALID_REQUEST` — a 400 from a field the
19+
* UI presented as optional and free-form. Removing it restores declared = enforced.
20+
*
21+
* **What this file pins is the admin-visible affordance, in both directions.**
22+
* The risk is not that the identifier `mapId` reappears somewhere — a grep sees
23+
* that. It is (a) that the param comes back under some other spelling while every
24+
* `mapId` grep stays green, and (b) that a future sweep over-applies and takes the
25+
* mapping params that ARE still real down with it. `mapEmail` and `mapName` map to
26+
* live `oidcMappingSchema` members and must survive.
27+
*
28+
* The bridge-side guard in plugin-auth `register-sso-provider.ts` is deliberately
29+
* NOT removed and is NOT this file's subject: the form is only one caller, and a
30+
* direct API client can still put `mapId` on the wire. Its refusal is pinned where
31+
* it lives, in `register-sso-provider.test.ts`.
32+
*/
33+
34+
import { describe, expect, it } from 'vitest';
35+
import { SysSsoProvider } from './sys-sso-provider.object.js';
36+
import { enObjects } from '../apps/translations/en.objects.generated.js';
37+
import { zhCNObjects } from '../apps/translations/zh-CN.objects.generated.js';
38+
import { jaJPObjects } from '../apps/translations/ja-JP.objects.generated.js';
39+
import { esESObjects } from '../apps/translations/es-ES.objects.generated.js';
40+
41+
const registerAction = (): any => {
42+
const action = ((SysSsoProvider as any).actions ?? []).find(
43+
(a: any) => a.name === 'register_sso_provider',
44+
);
45+
if (!action) throw new Error('sys_sso_provider declares no `register_sso_provider` action');
46+
return action;
47+
};
48+
49+
const paramNames = (): string[] => (registerAction().params ?? []).map((p: any) => p.name);
50+
51+
describe('#8222 — `register_sso_provider` does not offer a user-ID claim mapping', () => {
52+
it('declares no `mapId` param', () => {
53+
expect(
54+
paramNames(),
55+
'the OIDC subject is read from `sub` and is not configurable in '
56+
+ '@better-auth/sso@1.7.0-rc.2 — offering the field makes the form promise '
57+
+ 'a choice the runtime does not have',
58+
).not.toContain('mapId');
59+
});
60+
61+
it('offers no param that collects a user-ID / subject claim under ANY spelling', () => {
62+
// The half a `mapId` grep cannot see: the same false promise re-added as
63+
// `subjectClaim`, `idClaim`, `mapSubject`, … Judge the admin-facing copy,
64+
// not the identifier.
65+
const offenders = (registerAction().params ?? []).filter((p: any) => {
66+
const copy = `${p.name ?? ''} ${p.label ?? ''} ${p.helpText ?? ''}`.toLowerCase();
67+
const namesSubject = /\bsub\b|subject|user id|userid|user-id/.test(copy);
68+
// `mapEmail`/`mapName` mention neither; the scopes param mentions neither.
69+
return namesSubject;
70+
});
71+
expect(
72+
offenders.map((p: any) => p.name),
73+
'these params collect the federated subject claim, which is hard-wired to '
74+
+ '`sub` upstream — the value would be refused (INVALID_REQUEST) or silently discarded',
75+
).toEqual([]);
76+
});
77+
78+
it('keeps the claim mappings that ARE still honoured — the over-removal guard', () => {
79+
// `oidcMappingSchema` still carries `email` (required) and `name` (required),
80+
// and the bridge emits both. A sweep that took these out with `mapId` would
81+
// remove real capability, and no `mapId` assertion above would notice.
82+
const names = paramNames();
83+
expect(names).toContain('mapEmail');
84+
expect(names).toContain('mapName');
85+
});
86+
87+
it('ships no translated label for the retired param in any locale', () => {
88+
// What the admin actually reads. A stale bundle entry would keep rendering
89+
// "Map: User ID claim" for a param the action no longer declares.
90+
for (const [locale, bundle] of [
91+
['en', enObjects],
92+
['zh-CN', zhCNObjects],
93+
['ja-JP', jaJPObjects],
94+
['es-ES', esESObjects],
95+
] as const) {
96+
const params: Record<string, unknown> =
97+
(bundle as any)?.sys_sso_provider?._actions?.register_sso_provider?.params ?? {};
98+
expect(Object.keys(params), `${locale} bundle still translates the retired param`).not.toContain('mapId');
99+
// Counter-probe: the surviving params ARE translated in this bundle, so an
100+
// empty/misshaped lookup path cannot make the assertion above pass vacuously.
101+
expect(Object.keys(params), `${locale} bundle lookup path is wrong — it resolved nothing`).toContain('mapEmail');
102+
}
103+
});
104+
});

packages/plugins/plugin-auth/src/register-sso-provider.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,14 @@ async function resolveActiveOrganizationId(
7777
* @param request the raw Web `Request` — its headers carry the caller's session
7878
* cookie / bearer + Origin; its body carries the flat form
7979
* fields ({ providerId, issuer, domain, clientId, clientSecret,
80-
* discoveryEndpoint?, scopes?, mapId?, mapEmail?, mapName? }).
81-
* `mapId` is accepted only as the (empty or `sub`) no-op it now
82-
* is — see the mapping block below.
80+
* discoveryEndpoint?, scopes?, mapEmail?, mapName? }).
81+
* `mapId` is NOT among them any more: #8222 removed it from the
82+
* `register_sso_provider` action, so the admin form no longer
83+
* offers a user-ID claim mapping. The guard below is kept as
84+
* belt-and-braces for the callers the form does not cover — a
85+
* direct API client, a script, a stale cached console bundle —
86+
* which can still put `mapId` on the wire. It is accepted only as
87+
* the (empty or `sub`) no-op it now is; see the mapping block.
8388
*/
8489
export async function runRegisterSsoProviderFromForm(
8590
handle: AuthRequestHandler,

0 commit comments

Comments
 (0)