From 9087e14b0e9ac3eb500828c5854249f3e7255ed4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:52:25 +0000 Subject: [PATCH 1/2] feat(spec): declare features.sso on the auth config response AuthFeaturesConfigSchema gains the optional boolean `sso` that GET /api/v1/auth/config already serves (refined to "usable" by isSsoUsable) and the console login form reads. Claude-Session: https://claude.ai/code/session_01ADHkmPzZFPzq2roY9uo1me Co-authored-by: Claude --- .changeset/29344-auth-config-features-sso.md | 16 +++++++ packages/spec/src/api/auth-endpoints.test.ts | 44 ++++++++++++++++++++ packages/spec/src/api/auth-endpoints.zod.ts | 9 ++++ 3 files changed, 69 insertions(+) create mode 100644 .changeset/29344-auth-config-features-sso.md diff --git a/.changeset/29344-auth-config-features-sso.md b/.changeset/29344-auth-config-features-sso.md new file mode 100644 index 00000000000..d93dd732199 --- /dev/null +++ b/.changeset/29344-auth-config-features-sso.md @@ -0,0 +1,16 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): declare `features.sso` on the `GET /api/v1/auth/config` response + +Clause-②: yes (widening) +Metadata-protocol: added + +`AuthFeaturesConfigSchema` gains an optional boolean `sso`. The server has served it for a long time, and the console login form reads it to decide whether to show "Sign in with SSO", but the contract did not declare it. A client that parsed the response through `GetAuthConfigResponseSchema` lost the flag, because a plain object schema strips keys it does not know. + +- **What the value means.** `true` when enterprise SSO is usable: the SSO plugin is wired and at least one SSO provider is configured. `false` when SSO is off, or on with no provider yet. The `/auth/config` route answers this "usable" value, not the coarser "wired" flag, and falls back to "wired" when providers cannot be counted. +- **How it differs from `ssoEnforced`.** `ssoEnforced` takes the password form away. `sso` only says whether the SSO entry exists. +- **Absent** means not usable. The schema sets no default. + +What to do: nothing. Every payload accepted before is accepted unchanged. A client that typed the response from `@objectstack/spec` (for example through `@objectstack/client`'s re-exported `AuthFeaturesConfig`) now sees `sso?: boolean`. diff --git a/packages/spec/src/api/auth-endpoints.test.ts b/packages/spec/src/api/auth-endpoints.test.ts index 9f6eb27b14f..e95e1aa29e8 100644 --- a/packages/spec/src/api/auth-endpoints.test.ts +++ b/packages/spec/src/api/auth-endpoints.test.ts @@ -7,6 +7,7 @@ import { AuthEndpointSchema, AuthEndpointAliases, AuthFeaturesConfigSchema, + GetAuthConfigResponseSchema, EndpointMapping, getAuthEndpointUrl, } from './auth-endpoints.zod'; @@ -132,6 +133,49 @@ describe('AuthFeaturesConfig retired flags (ruled: stop advertising them)', () = }); }); +// `features.sso` is served (plugin-auth's `/config` route and the hono +// adapter's, both refined to "usable") and read (the console LoginForm), so +// the contract declares it: a parse keeps it instead of stripping it. +describe('AuthFeaturesConfig.sso (served, now declared)', () => { + const valid = { twoFactor: false, organization: true }; + + it.each([true, false])('keeps a served `sso: %s` through a parse', (sso) => { + const parsed = AuthFeaturesConfigSchema.parse({ ...valid, sso }); + expect(parsed.sso).toBe(sso); + }); + + it('keeps it through the whole GET /auth/config response parse', () => { + const parsed = GetAuthConfigResponseSchema.parse({ + emailPassword: { enabled: true }, + socialProviders: [], + features: { ...valid, sso: true, ssoEnforced: false }, + }); + expect(parsed.features.sso).toBe(true); + expect(parsed.features.ssoEnforced).toBe(false); + }); + + it('CONTROL: an absent `sso` stays absent (no default is invented)', () => { + const parsed = AuthFeaturesConfigSchema.parse(valid); + expect(parsed).not.toHaveProperty('sso'); + }); + + it.each([['yes'], [1], [null]])('refuses a non-boolean `sso` (%j)', (sso) => { + const result = AuthFeaturesConfigSchema.safeParse({ ...valid, sso }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toEqual([ + expect.objectContaining({ code: 'invalid_type', expected: 'boolean', path: ['sso'] }), + ]); + }); + + it('says it is the usable answer and how it differs from ssoEnforced', () => { + const description = AuthFeaturesConfigSchema.shape.sso.description ?? ''; + expect(description).toMatch(/isSsoUsable/); + expect(description).toMatch(/ssoEnforced/); + expect(description).not.toMatch(/#\d{3,}/); + }); +}); + describe('AuthEndpointAliases', () => { it('should map common names to canonical endpoints', () => { expect(AuthEndpointAliases.login).toBe('/sign-in/email'); diff --git a/packages/spec/src/api/auth-endpoints.zod.ts b/packages/spec/src/api/auth-endpoints.zod.ts index b4f0ef87f67..fb278750ca1 100644 --- a/packages/spec/src/api/auth-endpoints.zod.ts +++ b/packages/spec/src/api/auth-endpoints.zod.ts @@ -230,6 +230,15 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({ passkeys: retiredKey(PASSKEYS_UNADVERTISED), magicLink: retiredKey(MAGIC_LINK_UNADVERTISED), organization: z.boolean().default(false).describe('Multi-tenant organization support enabled'), + // Served by plugin-auth (`getPublicConfig()`, refined by `isSsoUsable()` in + // the `/config` route) and by the hono adapter's `/auth/config` route, which + // applies the same refinement; read by the console LoginForm (#29344). + sso: z.boolean().optional().describe( + 'Enterprise SSO sign-in usable: the domain-routed SSO plugin is wired AND at least one SSO provider is configured, so the login UI offers its "Sign in with SSO" entry; ' + + 'false when SSO is off, or on with no provider yet. The served value is that usable answer (isSsoUsable), not the coarse wired flag: ' + + 'GET /auth/config refines it before answering, and falls open to the wired flag when providers cannot be counted. ' + + 'Unlike ssoEnforced, which takes the password form away, this only says whether the SSO entry exists; absent means not usable', + ), ssoEnforced: z.boolean().optional().describe( 'SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains)', ), From 0d814466bcee1734d0ae90b71458f7ed412c5784 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:56:10 +0000 Subject: [PATCH 2/2] chore(spec): regenerate authorable surface and auth-endpoints reference for features.sso Claude-Session: https://claude.ai/code/session_01ADHkmPzZFPzq2roY9uo1me Co-authored-by: Claude --- content/docs/references/api/auth-endpoints.mdx | 4 +++- packages/spec/authorable-surface/api.json | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/content/docs/references/api/auth-endpoints.mdx b/content/docs/references/api/auth-endpoints.mdx index 5fe2ee349ec..be25ae53a80 100644 --- a/content/docs/references/api/auth-endpoints.mdx +++ b/content/docs/references/api/auth-endpoints.mdx @@ -59,6 +59,7 @@ const result = AuthEndpointSchema.parse(data); | **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — it was served from introduction and consumed by nothing: no login UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not a capability this platform offers yet. It returns to this payload in the change that ships the login UI, classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. | | **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders a magic-link sign-in affordance off this flag, so it only told a deployer that a UI existed when none did. Delete the key. The server side is unchanged and still yours to call: `AuthPluginConfig.plugins.magicLink` wires better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive them from your own UI, or wait for the console release that restores this flag along with the login UI that reads it. | | **organization** | `boolean` | optional (default: `false`) | Multi-tenant organization support enabled | +| **sso** | `boolean` | optional | Enterprise SSO sign-in usable: the domain-routed SSO plugin is wired AND at least one SSO provider is configured, so the login UI offers its "Sign in with SSO" entry; false when SSO is off, or on with no provider yet. The served value is that usable answer (isSsoUsable), not the coarse wired flag: GET /auth/config refines it before answering, and falls open to the wired flag when providers cannot be counted. Unlike ssoEnforced, which takes the password form away, this only says whether the SSO entry exists; absent means not usable | | **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) | | **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) | | **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | @@ -159,7 +160,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **emailPassword** | `{ enabled: boolean; disableSignUp?: boolean; requireEmailVerification?: boolean }` | ✅ | Email/password authentication config | | **socialProviders** | `{ id: string; name: string; enabled: boolean; type?: Enum<'social' \| 'oidc'> }[]` | ✅ | Available social/OAuth providers | -| **features** | `{ twoFactor?: boolean; organization?: boolean; ssoEnforced?: boolean; phoneNumber?: boolean; … }` | ✅ | Enabled authentication features | +| **features** | `{ twoFactor?: boolean; organization?: boolean; sso?: boolean; ssoEnforced?: boolean; … }` | ✅ | Enabled authentication features | ### Nested Shape: `GetAuthConfigResponse.emailPassword` @@ -186,6 +187,7 @@ This schema accepts one of the following structures: | **passkeys** | `never` | optional | [REMOVED] `features.passkeys` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — it was served from introduction and consumed by nothing: no login UI in any client reads it, and no better-auth passkey plugin is wired behind it, so a deployer who set `plugins.passkeys: true` flipped a switch that changed no behaviour anywhere. Delete the key. There is no replacement flag to read: passkey sign-in is not a capability this platform offers yet. It returns to this payload in the change that ships the login UI, classified in PUBLIC_AUTH_FEATURES again at that point — do not re-add it ahead of a consumer. | | **magicLink** | `never` | optional | [REMOVED] `features.magicLink` was removed from GET /api/v1/auth/config in @objectstack/spec 17 (ADR-0049) — the ADVERTISEMENT was inert, not the capability: no client renders a magic-link sign-in affordance off this flag, so it only told a deployer that a UI existed when none did. Delete the key. The server side is unchanged and still yours to call: `AuthPluginConfig.plugins.magicLink` wires better-auth's magic-link plugin, and `/api/v1/auth/magic-link/send` + `/magic-link/verify` answer exactly as before — drive them from your own UI, or wait for the console release that restores this flag along with the login UI that reads it. | | **organization** | `boolean` | optional (default: `false`) | Multi-tenant organization support enabled | +| **sso** | `boolean` | optional | Enterprise SSO sign-in usable: the domain-routed SSO plugin is wired AND at least one SSO provider is configured, so the login UI offers its "Sign in with SSO" entry; false when SSO is off, or on with no provider yet. The served value is that usable answer (isSsoUsable), not the coarse wired flag: GET /auth/config refines it before answering, and falls open to the wired flag when providers cannot be counted. Unlike ssoEnforced, which takes the password form away, this only says whether the SSO entry exists; absent means not usable | | **ssoEnforced** | `boolean` | optional | SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains) | | **phoneNumber** | `boolean` | optional | Phone-number sign-in enabled (phone + password, V1.5) | | **phoneNumberOtp** | `boolean` | optional | Phone-number OTP sign-in and self-service password reset available — requires the phoneNumber plugin plus a deliverable SMS service | diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 9471c74aa49..20227ecd9ec 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -215,6 +215,7 @@ "api/AuthFeaturesConfig:passkeys [RETIRED]", "api/AuthFeaturesConfig:phoneNumber", "api/AuthFeaturesConfig:phoneNumberOtp", + "api/AuthFeaturesConfig:sso", "api/AuthFeaturesConfig:ssoEnforced", "api/AuthFeaturesConfig:turnstileSiteKey", "api/AuthFeaturesConfig:twoFactor",