Skip to content

Commit fcb40ad

Browse files
fix(service-settings): Google login saves ON when its credentials come from the server (#24874)
Fixes #24844 Clause-②: no ## What changes `auth.google_client_id` and `auth.google_client_secret` in `packages/services/service-settings/src/manifests/auth.manifest.ts` are now `required: false` (they were `required: true`). Nothing else moves: no default, `visible` rule, option, label, help text or translation bundle. The `google_enabled` help already states the conjunction (Google is offered once a client ID and a client secret both come through, saved below or set as `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` on the server, which win over saved values). It still reads true, so it is unchanged. A code comment above the toggle now says why the two keys are not `required`. One changeset: `@objectstack/service-settings` patch. ## Why - `SettingsService.setMany` judges `required` against the STORED value. It reads only `OS_*` env names (`envKeyOf`), so the `GOOGLE_*` pair is invisible to it. - plugin-auth offers Google when the toggle is on and both credentials are available, saved or from the env pair, with the env pair winning (`applyEnvSocialProviderFallbacks` at boot, the Google block of `bindAuthSettings`). It needs the pair to be available. It never needs it stored. - So on an env-credential deployment, the declaration refused a save that the runtime would honour. `setMany('auth', { google_enabled: true })` answered `SETTINGS_VALIDATION` with `google_client_id/required` and `google_client_secret/required`. The TOUCH gate judges both, because their `visible` reads `google_enabled`. An admin who switched Google off on the page could not switch it back on there. Route C (the resolver reports the env pair as the source) needs `settings-service.ts` or a spec-level env-name declaration, and both are outside this card. See the decision record below. ## The offer matrix, before and after This was measured with a one-off probe (not a repo file). It uses a real `SettingsService`, the real `auth` manifest from `builtinSettingsManifests`, a real `AuthPlugin` booted with that service as `settings`, and `AuthManager.getPublicConfig().socialProviders`. The credential values are fake. **Before** is the base build at `df8bb40a03`. **After** is this branch's build at `6c1589e41c`. "Offered" is the providers the login page lists; the source in brackets is whose client ID the registered provider carries. | deployment | admin action | before: save | before: offered | after: save | after: offered | |---|---|---|---|---|---| | env pair | none (toggle unset) | n/a | google (env) | n/a | google (env) | | env pair | save ON | **REFUSED** (`required` x2) | google (env), toggle stays unset | saved | google (env) | | env pair | save OFF | saved | none | saved | none | | env pair | save OFF, then ON | ON **REFUSED** | **none (stuck off)** | saved | google (env) | | stored pair | none | n/a | google (stored) | n/a | google (stored) | | stored pair | save ON | saved | google (stored) | saved | google (stored) | | stored pair | save OFF | saved | none | saved | none | | stored pair | save OFF, then ON | saved | google (stored) | saved | google (stored) | | env + stored | none | n/a | google (env) | n/a | google (env) | | env + stored | save ON | saved | google (env) | saved | google (env) | | env + stored | save OFF | saved | none | saved | none | | env + stored | save OFF, then ON | saved | google (env) | saved | google (env) | | no credentials | none | n/a | none | n/a | none | | no credentials | save ON | **REFUSED** (`required` x2) | none | saved | none | | no credentials | save OFF | saved | none | saved | none | | no credentials | save OFF, then ON | ON **REFUSED** | none | saved | none | Four more rows check what a cleared credential does. Their "before" column comes from an emulated base: this branch's build with `required: true` put back on the two keys in memory, which is the only behavioural difference in `service-settings`. The emulation's 16 shared rows were checked against the real base build's reading above, and they match exactly. | deployment | admin action | before: save | before: offered | after: save | after: offered | |---|---|---|---|---|---| | stored pair | client ID saved as an empty string | **REFUSED** (`google_client_id/required`) | google (stored) | saved | none | | stored pair | client ID reset (`null`) | saved | none | saved | none | | env + stored | client ID saved as an empty string | **REFUSED** | google (env) | saved | google (env) | | env + stored | client ID reset (`null`) | saved | google (env) | saved | google (env) | **Reading.** For every stored state, the providers offered are the same before and after. Only the saves that the base refused change. Three states can now be saved: - the toggle ON on an env deployment, which offers Google, the same as the default; - the toggle ON with nothing available, which offers no Google, the same as the default; - a credential stored as an empty string. plugin-auth reads that as unset (`asTrimmedString`). The base already reached the same offered state with a `null` reset, as the rows above show. Who may sign in, and which providers the login page offers, do not change for any deployment shape. ## Pins `packages/services/service-settings/src/manifests/auth.manifest.test.ts`, on a real `SettingsService` with the real `auth` manifest: - **declares neither credential key required.** - **saves Google ON with both credential fields empty, from unset and after an OFF.** The service's env map carries fake `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` values. `getNamespace` hands plugin-auth `google_enabled: true` from the `global` source and both credentials from `default`, so plugin-auth falls back to the env pair. - **control: a stored credential pair still saves and reads back.** The plugin-auth half, which maps stored or env values to offered providers, is already pinned in `packages/plugins/plugin-auth/src/auth-plugin.test.ts`: - "enables Google from env credentials when google_enabled is explicit true"; - "enables Google from settings-managed credentials"; - "keeps deployment Google credentials ahead of settings-managed credentials"; - "disables env-backed Google when google_enabled is explicit false". This PR does not touch plugin-auth. ## Ablation (one-off, no permanent file) `required: true` was put back on both keys through `scripts/ablation-replace.mjs`: the anchor hit once, and the blob changed `146f1574445f` to `04ddcdc314d2`. Then `pnpm --filter @objectstack/service-settings exec vitest run --maxWorkers=2 src/manifests/auth.manifest.test.ts` ran: - FAIL "declares neither credential key required": `google_client_id: expected true to be false`. - FAIL "saves Google ON with both credential fields empty, from unset and after an OFF": the promise rejected with `code: 'SETTINGS_VALIDATION'` and fields `google_client_id/required` and `google_client_secret/required`. - `Tests 2 failed | 16 passed (18)`. The stored-credential control is among the passes. The restore ran `git checkout HEAD -- PATH` on the absolute path. Afterwards the blob was `146f1574445f`, equal to the HEAD blob; `git diff HEAD` was 0 bytes; `git status --porcelain` showed 0 lines. The file then ran 18 of 18 green. The test imports the manifest from source, so no rebuild was needed for this ablation. ## Verification (head `6c1589e41c`) - **Closure:** `pnpm --workspace-concurrency=2 --filter '@objectstack/service-settings^...' --filter '@objectstack/plugin-auth...' build`, exit 0. This adds plugin-auth and service-settings themselves to the dispatch's closure, for the probe. Both were rebuilt at the final head. - **Tests:** `pnpm --filter @objectstack/service-settings test` gave `Test Files 54 passed (54)` and `Tests 906 passed (906)`. - **Typecheck:** `pnpm --filter @objectstack/service-settings typecheck`, exit 0. `tsc --listFiles` includes `src/manifests/auth.manifest.test.ts`. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 commands. 65 exited 0. `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET): it reads every package's `dist/` and needs a whole-tree build, so it was NOT MEASURED locally; CI's `Lint & Repo Gates` runs it. - **Reconciliation:** `--ran` printed `✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 65 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3)`. ## Decision record (options A / B / C from the card), on the four axes - **Real business need.** - The writer is the Setup → Authentication form (`PUT /api/settings/auth`, which calls `setMany`). The reader is plugin-auth's `bindAuthSettings`. - `content/docs/deployment/environment-variables.mdx` documents the env pair as a supported deployment shape. - The refusal is measured above, including the stuck-off row. - **B** and **C** both serve this need. **A** leaves the stuck-off state. C's extra, showing "set via env" on the page, has no measured pull. - **Long-term soundness.** - **B** makes the declaration say what the runtime needs: a credential that is available, not one that is stored. - **C** is also truthful. It teaches the resolver a non-`OS_` env name, which is new resolver surface in the 18.x C8 territory. - **A** keeps a declaration that is wrong on a documented deployment shape. - **Making AI mistakes harder.** - **B** removes a constraint that is broader than the runtime's need, and adds no consumer-side tolerance. The manifest stays strict, and the help carries the conjunction. - Under **A**, an author who reads `required: true` would conclude that the credentials must be stored. That invites bootstrap code that copies the env secrets into settings. - **C** adds a mapping declaration that is one more thing to get wrong. - **Startup focus.** - **B** adds no surface and removes one declared constraint. - **C** is a capability expansion with no named pull. - **A** costs nothing and fixes nothing. **Recommendation and route taken: B.** One forward note, a reading and not measured here: objectstack-ai/objectui#13239 makes the console refuse Save while a visible `required` field is empty. With `required: true` kept, that check would block every Save on the Authentication page of an env-credential deployment while Google is on, which is its default. With `required: false`, the console and the server agree. ## Related - #24776 is the parent card. Its PR #24842 changed only the help text. - objectstack-ai/objectui#13239 is the console's required check, which reads the same `required` flag. ## Acceptance notes - An empty-string credential is reachable through the API only. objectstack-ai/objectui#13145 settled that the console clears a field with `null`, and `null` already saved before this change. - Saving the toggle ON while nothing is available is accepted. The page cannot show that the server's pair is in force (that would be route C); the `google_enabled` help says it instead. - The probe's real-base reading used builds at `df8bb40a03`. The final-head reading used builds at `6c1589e41c`, after merging `origin/main` (which brought #24853's OAuth-registration status change in plugin-auth, unrelated to the Google binding). --- _Generated by [Claude Code](https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7035682 commit fcb40ad

3 files changed

Lines changed: 104 additions & 2 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-settings': patch
3+
---
4+
5+
fix(service-settings): Setup → Authentication saves "Enable Google login" ON when the Google credentials come from the server (#24844)
6+
7+
Clause-②: no
8+
9+
On a deployment that sets `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` and leaves the two credential fields empty, saving `auth.google_enabled: true` was refused with `SETTINGS_VALIDATION` (`google_client_id` / `google_client_secret`: `required`). An admin who switched Google off on the page could not switch it back on there, although the server held valid credentials. The settings service reads only `OS_*` names, so it cannot see that pair.
10+
11+
`auth.google_client_id` and `auth.google_client_secret` are no longer `required`. Google sign-in needs the two credentials to resolve, saved on the page or set on the server; it never needed them stored. The `google_enabled` help already says so: Google is offered once both resolve, and the server's pair wins over saved values.
12+
13+
Who may sign in, and which providers the login page offers, do not change for any deployment. Saving the toggle ON while neither pair resolves is now accepted; the login page then shows no Google button, as it does with the toggle left at its default.

‎packages/services/service-settings/src/manifests/auth.manifest.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { describe, it, expect } from 'vitest';
44
import { SettingsManifestSchema } from '@objectstack/spec/system';
5+
import type { CryptoAdapter } from '../crypto-adapter.js';
56
import { SettingsService } from '../settings-service.js';
67
import { en } from '../translations/index.js';
78
import { authSettingsManifest } from './auth.manifest.js';
@@ -281,3 +282,82 @@ describe('authSettingsManifest', () => {
281282
}
282283
});
283284
});
285+
286+
/**
287+
* A confidential stand-in, so the stored-credential control below can hold the
288+
* `password`-typed secret: the write path refuses a secret-valued key on an
289+
* adapter that declares no confidentiality.
290+
*/
291+
class ConfidentialTestAdapter implements CryptoAdapter {
292+
readonly confidential = true;
293+
async encrypt(plaintext: string): Promise<string> {
294+
return 'kms:' + Buffer.from(plaintext, 'utf8').toString('base64');
295+
}
296+
async decrypt(ciphertext: string): Promise<string> {
297+
return Buffer.from(ciphertext.replace(/^kms:/, ''), 'base64').toString('utf8');
298+
}
299+
digest(): string {
300+
return 'fnv32:deadbeef';
301+
}
302+
}
303+
304+
// plugin-auth offers Google once the toggle is on and a client ID and secret
305+
// both resolve, saved here or from GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET,
306+
// with the env pair winning. The settings service reads only OS_* names, so
307+
// it cannot see that pair; a `required` on the two keys made it refuse
308+
// `google_enabled: true` on every deployment whose credentials come from it.
309+
describe('authSettingsManifest — Google credentials may come from the server (not required)', () => {
310+
// FAKE values. This is the env map such a deployment hands the service.
311+
const ENV_CREDENTIALS = {
312+
GOOGLE_CLIENT_ID: 'fake-env-client-id.apps.googleusercontent.test',
313+
GOOGLE_CLIENT_SECRET: 'fake-env-client-secret',
314+
};
315+
const serviceWith = (env: Record<string, string>) => {
316+
const svc = new SettingsService({ env, crypto: new ConfidentialTestAdapter() });
317+
svc.registerManifest(authSettingsManifest as any);
318+
return svc;
319+
};
320+
321+
it('declares neither credential key required', () => {
322+
const specs = authSettingsManifest.specifiers as any[];
323+
for (const key of ['google_client_id', 'google_client_secret']) {
324+
expect(specs.find((s) => s.key === key).required, key).toBe(false);
325+
}
326+
});
327+
328+
it('saves Google ON with both credential fields empty, from unset and after an OFF', async () => {
329+
const svc = serviceWith({ ...ENV_CREDENTIALS });
330+
331+
await expect(svc.setMany('auth', { google_enabled: true })).resolves.toBeDefined();
332+
const { values } = await svc.getNamespace('auth');
333+
expect(values.google_enabled).toMatchObject({ value: true, source: 'global' });
334+
// Nothing is stored for the pair, so plugin-auth resolves it from the env.
335+
expect(values.google_client_id?.source).toBe('default');
336+
expect(values.google_client_secret?.source).toBe('default');
337+
338+
// The admin's round trip: OFF, then back ON.
339+
await expect(svc.setMany('auth', { google_enabled: false })).resolves.toBeDefined();
340+
await expect(svc.setMany('auth', { google_enabled: true })).resolves.toBeDefined();
341+
expect(await svc.get('auth', 'google_enabled')).toMatchObject({ value: true, source: 'global' });
342+
});
343+
344+
it('control: a stored credential pair still saves and reads back', async () => {
345+
const svc = serviceWith({});
346+
347+
await expect(
348+
svc.setMany('auth', {
349+
google_enabled: true,
350+
google_client_id: 'fake-stored-client-id',
351+
google_client_secret: 'fake-stored-client-secret',
352+
}),
353+
).resolves.toBeDefined();
354+
expect(await svc.get('auth', 'google_client_id')).toMatchObject({
355+
value: 'fake-stored-client-id',
356+
source: 'global',
357+
});
358+
expect(await svc.get('auth', 'google_client_secret')).toMatchObject({
359+
value: 'fake-stored-client-secret',
360+
source: 'global',
361+
});
362+
});
363+
});

‎packages/services/service-settings/src/manifests/auth.manifest.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,15 @@ const manifest = {
442442
// is the runtime's own default for an undeclared toggle, and a deployment
443443
// signing in with env credentials relies on it; the help text carries the
444444
// conjunction, so ON beside two empty fields reads as "not offered yet".
445+
//
446+
// The two credential keys below are therefore NOT `required`. `required`
447+
// is judged by `setMany` against the stored value, and the runtime needs
448+
// the pair to RESOLVE, not to be stored: on a deployment that sets
449+
// GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET, a `required` here refused
450+
// `google_enabled: true` (the save touches both through `visible`), so an
451+
// admin who switched Google off could not switch it back on, although
452+
// the server held valid credentials. Saving ON with nothing resolving is
453+
// the same runtime state as the default: no Google button.
445454
{
446455
type: 'toggle',
447456
key: 'google_enabled',
@@ -455,7 +464,7 @@ const manifest = {
455464
type: 'text',
456465
key: 'google_client_id',
457466
label: 'Google client ID',
458-
required: true,
467+
required: false,
459468
description:
460469
'OAuth client ID from Google Cloud Console. GOOGLE_CLIENT_ID can also be set on the server.',
461470
visible: "${data.google_enabled !== false}",
@@ -464,7 +473,7 @@ const manifest = {
464473
type: 'password',
465474
key: 'google_client_secret',
466475
label: 'Google client secret',
467-
required: true,
476+
required: false,
468477
encrypted: true,
469478
description:
470479
'Stored encrypted at rest. GOOGLE_CLIENT_SECRET can also be set on the server.',

0 commit comments

Comments
 (0)