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
31 changes: 31 additions & 0 deletions .changeset/sso-oidc-client-secret-at-rest.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@objectstack/plugin-auth': minor
---

security: encrypt the OIDC SSO `clientSecret` at rest

`sys_sso_provider.oidc_config` stored the OIDC `clientSecret` in cleartext inside
its JSON blob — measured on a real registration, byte for byte. That secret
authenticates the platform itself to the identity provider, and the object is
readable through the generic data API (`apiMethods: ['get','list']`), so anyone
who could read the row could impersonate the platform's OIDC client.

The secret now lives in `sys_sso_provider.oidc_client_secret`, a `Field.secret()`
column on the engine's encrypted credential channel: the engine wraps it with the
registered `ICryptoProvider`, stores the ciphertext as a `sys_secret` row, keeps
only an opaque ref on the provider row, and returns a mask on every generic read.
`oidc_config` keeps the rest of the config in cleartext on purpose, so the admin
UI can still render endpoints, scopes and mapping.

Both better-auth write doors are covered (`/sso/register` and
`/sso/update-provider`), and the adapter recovers the plaintext server-side for
`/sso/callback`, so federated login is unchanged.

Existing provider rows are migrated forward automatically at start. A row that
cannot be migrated — no `ICryptoProvider` wired — keeps working and is reported
with a warning rather than silently left looking protected.

⚠️ Registering or updating an SSO provider now REFUSES rather than storing
cleartext when no `ICryptoProvider` is registered. Self-hosted deployments get
`LocalCryptoProvider` automatically from `serve`; set `OS_SECRET_KEY` (or swap in
a KMS/Vault provider) so secrets survive a restart.
5 changes: 4 additions & 1 deletion packages/plugins/plugin-auth/objectstack.config.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { defineStack } from '@objectstack/spec';
import { authIdentityObjects, authPluginManifestHeader } from './src/manifest';
import { authIdentityObjects, authObjectExtensions, authPluginManifestHeader } from './src/manifest';

/**
* ObjectStack Configuration for plugin-auth
Expand All@@ -14,4 +14,7 @@ import { authIdentityObjects, authPluginManifestHeader } from './src/manifest';
export default defineStack({
manifest: authPluginManifestHeader,
objects: authIdentityObjects,
// [#8009] Declared in the same canonical source as `objects`, so the
// compile-time and runtime registration paths cannot drift (D7).
objectExtensions: authObjectExtensions,
});
23 changes: 23 additions & 0 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,8 +50,10 @@ import { runResendVerificationEmail } from './send-verification-email.js';
import type { CounterStore } from './rate-limit-storage.js';
import {
authIdentityObjects,
authObjectExtensions,
authPluginManifestHeader,
} from './manifest.js';
import { scheduleLegacySsoSecretMigration } from './sso-client-secret.js';


/**
Expand DownExpand Up@@ -512,6 +514,11 @@ export class AuthPlugin implements Plugin {
// write-side guardrail that keeps an ungoverned capability grant
// unrepresentable.
objects: authIdentityObjects,
// [#8009] `sys_sso_provider.oidc_client_secret` — the encrypted home of the
// OIDC client secret that used to sit in cleartext inside `oidc_config`.
// See `manifest.ts` for why the field is declared here and not on the
// object file.
objectExtensions: authObjectExtensions,
// ADR-0048 — Setup/Studio/Account apps (and the Setup nav contributions)
// moved to their own one-app packages (@objectstack/{setup,studio,account}),
// each registering under its own package id so /apps/<packageId> resolves
Expand DownExpand Up@@ -583,6 +590,22 @@ export class AuthPlugin implements Plugin {
throw new Error('Auth manager not initialized');
}

// [#8009] Move any provider row still holding a CLEARTEXT OIDC client
// secret in `oidc_config` into the encrypted column. Registered
// unconditionally (not under `registerRoutes`) because the disposition of
// an already-stored secret does not depend on whether this process serves
// the auth routes. The returned unsubscribe is deliberately dropped: the
// engine outlives the plugin and there is no `stop()` to unwind it in.
ctx.hook('kernel:ready', async () => {
let ql: IDataEngine | undefined;
try { ql = ctx.getService<IDataEngine>('objectql'); } catch { ql = undefined; }
if (!ql) {
try { ql = ctx.getService<IDataEngine>('data'); } catch { ql = undefined; }
}
if (!ql) return;
scheduleLegacySsoSecretMigration(ql, ctx.logger);
});

// Setup App translations are now loaded by `PlatformObjectsPlugin`
// (in @objectstack/platform-objects). Translation bundles belong with
// the package that defines them; auth-plugin no longer piggy-backs on
Expand Down
40 changes: 40 additions & 0 deletions packages/plugins/plugin-auth/src/manifest.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,7 @@ import {
SysUserPreference,
SysVerification,
} from '@objectstack/platform-objects/identity';
import { Field } from '@objectstack/spec/data';

export const AUTH_PLUGIN_ID = 'com.objectstack.plugin-auth';
export const AUTH_PLUGIN_VERSION = '3.0.1';
Expand DownExpand Up@@ -64,6 +65,45 @@ export const authIdentityObjects: any[] = [
SysScimProvider,
];

/**
* [#8009] Fields plugin-auth adds to identity objects it registers.
*
* `sys_sso_provider.oidc_client_secret` is the encrypted home of the OIDC
* `clientSecret`, which was measured landing BYTE-FOR-BYTE IN CLEARTEXT inside
* the `oidc_config` JSON textarea (#8009 step 0). `type: 'secret'` puts it on
* the engine's encrypted credential channel: the engine wraps the value with the
* registered `ICryptoProvider`, persists the ciphertext as a `sys_secret` row,
* keeps only an opaque `secret:` ref on this column, and returns the mask on
* every generic read. `plugin-auth/src/sso-client-secret.ts` is the seam that
* moves the value in and out; `objectql-adapter.ts` calls it.
*
* ⚠️ Declared HERE rather than on the object itself because the definition file
* (`sys-sso-provider.object.ts`) lives in `packages/platform-objects`, which is
* `domain:metadata`'s package, while this object is registered and owned by
* plugin-auth (`authIdentityObjects` below) and the mechanism is `domain:identity`'s.
* The engine merges an `objectExtensions` field into the resolved schema exactly
* as if it had been declared inline — measured on #8009, including DDL, the
* encrypt-on-write path and the privileged dereference. Consolidating the
* declaration onto the object file is the tidier end state and is worth doing
* the next time that file is opened; it is a move, not a behaviour change.
*/
export const authObjectExtensions = [
{
extend: 'sys_sso_provider',
fields: {
oidc_client_secret: Field.secret({
label: 'OIDC Client Secret',
required: false,
description:
'OAuth client secret issued by the IdP, in the engine\'s encrypted credential channel. '
+ 'Encrypted at rest into sys_secret; reads return a mask, never the secret. Written and '
+ 'read back only by the better-auth adapter seam (register / update-provider / callback).',
group: 'Protocol',
}),
},
},
];

/** Manifest header shared by compile-time config and runtime registration. */
export const authPluginManifestHeader = {
id: AUTH_PLUGIN_ID,
Expand Down
29 changes: 29 additions & 0 deletions packages/plugins/plugin-auth/src/objectql-adapter.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,11 @@ import {
hideRevokedSessionRow,
reconcileSessionDelete,
} from './session-tombstone.js';
import {
injectClientSecretOnRead,
liftClientSecretForWrite,
type SecretResolvingEngine,
} from './sso-client-secret.js';

/**
* Mapping from better-auth model names to ObjectStack protocol object names.
Expand DownExpand Up@@ -689,6 +694,12 @@ export const withSystemReadContext = withSystemContext;
*/
export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
const dataEngine = withSystemContext(rawDataEngine);
// [#8009] The OIDC `clientSecret` seam needs the engine's PRIVILEGED secret
// dereference, which `withSystemContext` deliberately does not carry (it
// exposes the CRUD verbs only). `resolveSecretField` is a separately-named
// privileged verb for exactly that reason (#7823), so it comes off the raw
// engine. See `sso-client-secret.ts` for why the seam sits here at all.
const secretEngine = rawDataEngine as unknown as SecretResolvingEngine;
// Field-name bridging for better-auth plugins that expose NO `schema` option
// (e.g. @better-auth/sso): when a model is remapped via AUTH_MODEL_TO_PROTOCOL,
// its camelCase model fields are also converted to snake_case columns on the
Expand DownExpand Up@@ -724,6 +735,10 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
const bridged = objectName !== model;
const payload = normaliseIdentifierWrite(model, data);
const row = bridged ? remapKeys(payload, camelToSnake) : payload;
// [#8009] Registration write door #1. Lift the OIDC `clientSecret` out
// of the cleartext JSON blob into the `secret`-typed column so the
// ENGINE encrypts it; `oidc_config` keeps everything else.
liftClientSecretForWrite(objectName, row);
// [#7725] `sys_member` declares `{organization_id, user_id}` unique, and
// the platform auto-binds every user at sign-up (ADR-0093 D1/D2), so
// better-auth's accept-invitation `createMember` collides on a pair that
Expand DownExpand Up@@ -760,6 +775,10 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
// [#7732] A revoked session is not a session — see `session-tombstone.ts`.
if (await hideRevokedSessionRow(objectName, result)) return null;
if (revokedAtIsBorrowed) delete (result as Record<string, unknown>).revoked_at;
// [#8009] Read half — MANDATORY. `/sso/callback` reads the provider back
// and authenticates to the IdP with the plaintext; encrypt-on-write
// without this breaks every federated login.
await injectClientSecretOnRead(secretEngine, objectName, result);
const norm = normaliseLegacyDates(model, result);
return (bridged ? remapKeys(norm, snakeToCamel) : norm) as T;
},
Expand All@@ -786,6 +805,9 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
});
// [#7732] A revoked session is not a session — see `session-tombstone.ts`.
const results = await filterRevokedSessionRows(objectName, found);
// [#8009] Same read half, per row — better-auth reaches the provider
// through findMany as well as findOne.
for (const r of results) await injectClientSecretOnRead(secretEngine, objectName, r);

return results.map((r) => {
const norm = normaliseLegacyDates(model, r as Record<string, any>);
Expand DownExpand Up@@ -815,6 +837,10 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {

const normalised = normaliseIdentifierWrite(model, update as any);
const patch = bridged ? remapKeys(normalised, camelToSnake) : normalised;
// [#8009] Write door #2 — `/sso/update-provider`. A create-only seam
// would encrypt at registration and then write cleartext back on the
// first config edit, leaving a column that only LOOKS protected.
liftClientSecretForWrite(objectName, patch);
const result = await dataEngine.update(objectName, { ...patch, id: record.id });
if (!result) return null;
const norm = normaliseLegacyDates(model, result);
Expand All@@ -832,6 +858,9 @@ export function createObjectQLAdapterFactory(rawDataEngine: IDataEngine) {
const records = await dataEngine.find(objectName, { where: filter });
const normalised = normaliseIdentifierWrite(model, update);
const patch = bridged ? remapKeys(normalised, camelToSnake) : normalised;
// [#8009] Write door #3. Same column, same rule — a bulk edit must not
// be the one path that writes the secret back in cleartext.
liftClientSecretForWrite(objectName, patch);
for (const record of records) {
await dataEngine.update(objectName, { ...patch, id: record.id });
}
Expand Down
Loading
Loading