Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/29344-auth-config-features-sso.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 3 additions & 1 deletion content/docs/references/api/auth-endpoints.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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`

Expand All @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions packages/spec/src/api/auth-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
AuthEndpointSchema,
AuthEndpointAliases,
AuthFeaturesConfigSchema,
GetAuthConfigResponseSchema,
EndpointMapping,
getAuthEndpointUrl,
} from './auth-endpoints.zod';
Expand Down Expand Up @@ -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');
Expand Down
9 changes: 9 additions & 0 deletions packages/spec/src/api/auth-endpoints.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
),
Expand Down
Loading