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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
53 changes: 53 additions & 0 deletions .changeset/operator-provisioning-ticket-binding.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
---
"@objectstack/plugin-auth": minor
---

fix(plugin-auth): bind the dev-admin seed's operator-provisioning ticket to more than the seed address, so a concurrent stranger posting that address cannot ride the window (#14373)

`AuthManager.stageOperatorProvisioning` staged its ticket keyed on
`email.trim().toLowerCase()` alone. The address is **not a secret** —
`admin@objectos.ai` is the documented default and the boot banner prints it
(with the password) once the seed completes — so "the address is the
operator's own" did not narrow the attacker set the way it would for an
unguessable value. A stranger's own concurrent `POST /sign-up/email` for that
same address, arriving while the ticket was staged, would satisfy an
email-only peek at both admission seams (the `disableSignUp` before-hook and
`validateAudienceAdmission`'s `creationClass` computation) and be admitted as
the `operator` class too — and since a unique-email constraint lets only one
of the two concurrent `signUpEmail` calls actually land, a stranger who won
that race would not merely read as the operator, their row would BECOME the
account at that address. The safety this rested on — "milliseconds, and
`NODE_ENV==='development'` only" — was true today, but both are properties of
the *caller*, not of what the ticket asserted.

`stageOperatorProvisioning(email)` now also generates a random, unguessable
ticket value (128 bits from WebCrypto's `getRandomValues`, matching the
existing `resolvePasswordHasher` salt convention) and returns it.
`AuthPlugin.maybeSeedDevAdmin` threads that value into the SAME `signUpEmail`
call's body, under the new `AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`
key — an unrecognized key that better-auth's `signUpEmailBodySchema` (`.and(
z.record(z.string(), z.any()))`) lets ride through untouched, so it never
becomes a declared `sys_user` field. `isOperatorProvisioning(email, ticket)`
now requires an exact match on both; a missing, wrong-typed, or mismatched
ticket reads as "not provisioning" — the same as no ticket staged at all,
with no email-only fallback. This converts what was a timing argument
("the window is short and dev-only") into a structural one: admission now
asks "did THIS process's own boot command make THIS exact call", and a
stranger's request carries no value that was ever transmitted anywhere for
them to replay, however precisely they time the window.

**Public surface**: `stageOperatorProvisioning`'s return type widens from
`void` to `string` (additive — any existing caller ignoring the return value
is unaffected), `isOperatorProvisioning` gains an optional second parameter
(omitting it now always reads as "not provisioning", which is the safe
default), and the class gains one new static member,
`AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD`. `AuthManager` is
barrel-public (`@objectstack/plugin-auth`'s `index.ts` re-exports it in
full), so this is graded `minor` rather than `patch`.

**Not touched**: the JSDoc documenting the in-process trust assumption on
`stageOperatorProvisioning` (already accurate on landed `main`), and the
method's barrel-public location (a published-surface removal is a decision
card, not a dev-agent edit) — both dispositions triage already closed out on
this card. Whether the bootstrap window should count humans or logins is
`#14349`'s question, not touched here.
117 changes: 104 additions & 13 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2066,8 +2066,17 @@ export class AuthManager {
// carve-out: an app that seeds people makes the bootstrap probe
// answer "populated" before the seed ever runs. Cheap synchronous
// check first, so the ticket path costs no I/O.
// [#14373] The email alone no longer admits — see
// `stageOperatorProvisioning`'s doc. `ctx.body` is the vendor's own
// zod-validated body, which keeps any key outside the declared
// schema (`signUpEmailBodySchema.and(z.record(...))`), so the
// in-process seed call's ticket field survives here untouched.
const signUpEmail = typeof ctx?.body?.email === 'string' ? ctx.body.email : undefined;
if (this.isOperatorProvisioning(signUpEmail) || (await this.isBootstrapCreation())) {
const provisioningTicket = ctx?.body?.[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD];
if (
this.isOperatorProvisioning(signUpEmail, provisioningTicket) ||
(await this.isBootstrapCreation())
) {
ctx.context.__osDisableSignUpOrig = ep.disableSignUp;
ep.disableSignUp = false;
}
Expand DownExpand Up@@ -3809,8 +3818,12 @@ export class AuthManager {
* [#14157] Addresses the deployment's OWN boot command is provisioning right
* now — see {@link stageOperatorProvisioning} for why this exists and why it
* is not the bootstrap probe.
*
* [#14373] Keyed by email, but admission also requires the per-stage
* `ticket` below — see {@link stageOperatorProvisioning} for why the
* address alone stopped being enough.
*/
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number }>();
private pendingOperatorProvisioning = new Map<string, { stagedAtMs: number; ticket: string }>();

/**
* Deliberately short. The window this covers is a single in-process
Expand All@@ -3819,6 +3832,19 @@ export class AuthManager {
*/
private static readonly OPERATOR_PROVISIONING_STAGE_TTL_MS = 60 * 1000;

/**
* [#14373] Body field the ticket's random value rides on for the single
* in-process `signUpEmail` call it admits. `signUpEmailBodySchema` in
* better-auth is `z.object({...}).and(z.record(z.string(), z.any()))`, so
* an unrecognized key survives validation on `ctx.body` untouched — this
* rides that catch-all rather than a declared `additionalFields` column,
* so it never becomes a `sys_user` field. The name is deliberately
* internal-looking; nothing public ever sends this key, so its presence in
* a request is itself the credential this seam demands (see
* {@link isOperatorProvisioning}).
*/
static readonly OPERATOR_PROVISIONING_TICKET_FIELD = '__osOperatorProvisioningTicket';

/**
* Page size of the bootstrap population probe ({@link isBootstrapCreation}).
* Matches the bound the dev-admin seed reads with, so the two ask the same
Expand DownExpand Up@@ -3887,17 +3913,23 @@ export class AuthManager {
* OAuth flows redirect to the error URL carrying the same code).
*
* [#11767] The vendor's second argument — the endpoint context — is
* deliberately UNREAD. Both probes below take their own ctx-independent data
* path, because sourcing this gate's I/O from the request context is exactly
* what made the bootstrap bypass inert (see {@link isBootstrapCreation}).
* The parameter is kept so the signature still reads as the vendor's.
* deliberately UNREAD for the population probes below: both take their own
* ctx-independent data path, because sourcing THEIR I/O from the request
* context is exactly what made the bootstrap bypass inert (see {@link
* isBootstrapCreation}). [#14373] carves one narrow exception: the operator
* ticket's random value (see `stageOperatorProvisioning`), which travels
* only as a `ctx.body` field and has no other channel to reach this gate —
* `data.user` here is `internalAdapter.createUser`'s own input, not the raw
* request body, so it never carries an undeclared key. That is a plain read
* of an already-parsed value, not a re-derived I/O probe, so it does not
* reopen what #11767 closed.
*/
private async validateAudienceAdmission(
data: {
user?: Record<string, unknown>;
source?: { action?: string; method?: string; oauth?: { providerId?: string } };
},
_ctx?: unknown,
ctx?: unknown,
): Promise<{ error: string; errorDescription?: string } | undefined> {
try {
// link-account / provider sign-in concern an EXISTING user's identity,
Expand All@@ -3911,7 +3943,15 @@ export class AuthManager {
// because the seed reaches better-auth through the same `signUpEmail`
// API a person's sign-up does. See `stageOperatorProvisioning` for why
// this is a declared ticket and not a wider bootstrap probe.
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email)
// [#14373] …and why email alone no longer decides it: the ticket's
// random value must also match, read off the SAME endpoint context the
// `disableSignUp` before-hook already reads `ctx.body.email` from
// (`getCurrentAuthEndpointContext()` — one context per request, shared
// across the before/after hooks and this validateUserInfo callback).
const provisioningTicket = (ctx as { body?: Record<string, unknown> } | undefined)?.body?.[
AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD
];
const creationClass: AudienceCreationClass = this.isOperatorProvisioning(email, provisioningTicket)
? 'operator'
: classifyCreationMethod(data?.source, {
enterpriseOAuthProviderIds: this.enterpriseOAuthProviderIds(),
Expand DownExpand Up@@ -4229,29 +4269,80 @@ export class AuthManager {
* `NODE_ENV==='development'`, the ticket names ONE address, the caller
* clears it in a `finally`, and anything that outlives that is pruned by
* {@link OPERATOR_PROVISIONING_STAGE_TTL_MS}.
*/
stageOperatorProvisioning(email: string): void {
*
* ## [#14373] Why the address alone stopped being the whole ticket
*
* The address is **not a secret**: `admin@objectos.ai` is the documented
* default and the boot banner prints it (with the password) once the seed
* completes. "The address is the operator's own" is true but does not
* narrow the attacker set the way it would for an unguessable value — a
* stranger's OWN concurrent `POST /sign-up/email` for that same address,
* arriving while this ticket is staged, would satisfy an email-only peek at
* BOTH admission seams and get admitted as `operator` class: past the
* `disableSignUp` bypass, and (since the stamp only cares about the
* creation class) potentially email-verified at creation too — a stranger
* would not merely read as the operator, their row would BECOME the
* account at that address, since a unique-email constraint lets only one of
* the two concurrent `signUpEmail` calls actually land. Milliseconds and
* `NODE_ENV==='development'` are true today, but both are properties of the
* *caller*, not of what the ticket asserts.
*
* So the ticket now also carries a random, unguessable `ticket` string,
* returned here and threaded by the caller into the SAME `signUpEmail`
* call's body under {@link OPERATOR_PROVISIONING_TICKET_FIELD} — see
* `AuthPlugin.maybeSeedDevAdmin`. {@link isOperatorProvisioning} requires an
* exact match on both email AND this ticket, so admission now asks "did
* THIS process's own boot command make THIS exact call" rather than "does
* the address match" — a stranger's request, however precisely it times
* the window, carries no value that was ever transmitted anywhere for them
* to replay. Not a stronger *guess* — a different question.
*/
stageOperatorProvisioning(email: string): string {
this.prunePendingOperatorProvisioning();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now() });
const ticket = this.generateOperatorProvisioningTicket();
this.pendingOperatorProvisioning.set(email.trim().toLowerCase(), { stagedAtMs: Date.now(), ticket });
return ticket;
}

/** [#14157] Drop the ticket staged by {@link stageOperatorProvisioning}. */
clearOperatorProvisioning(email: string): void {
this.pendingOperatorProvisioning.delete(email.trim().toLowerCase());
}

/**
* [#14373] 128 bits from WebCrypto, hex-encoded — same `getRandomValues`
* baseline `resolvePasswordHasher`'s WebContainer salt uses, so this stays
* portable to hosts without `node:crypto`. Never persisted, never logged,
* never sent anywhere but the one in-process `signUpEmail` body call that
* consumes it.
*/
private generateOperatorProvisioningTicket(): string {
const bytes = (globalThis as any).crypto.getRandomValues(new Uint8Array(16));
let hex = '';
for (let i = 0; i < bytes.length; i++) hex += bytes[i].toString(16).padStart(2, '0');
return hex;
}

/**
* [#14157] Is this address being provisioned by the deployment's own boot
* command? A PEEK, not a consume: both admission seams (the `disableSignUp`
* bypass and `validateAudienceAdmission`) ask about the same single
* creation, so a one-shot read here would admit at the first seam and refuse
* at the second. The ticket's lifetime is bounded by its owner's `finally`
* and by the TTL instead.
*
* [#14373] `ticket` is REQUIRED to match the value {@link
* stageOperatorProvisioning} returned — see that method's doc for why the
* address alone is no longer sufficient. A missing, wrong-typed, or
* mismatched ticket reads as "not provisioning", the same as no ticket at
* all; there is no email-only fallback path.
*/
isOperatorProvisioning(email: unknown): boolean {
isOperatorProvisioning(email: unknown, ticket?: unknown): boolean {
if (typeof email !== 'string' || email.trim() === '') return false;
this.prunePendingOperatorProvisioning();
return this.pendingOperatorProvisioning.has(email.trim().toLowerCase());
const entry = this.pendingOperatorProvisioning.get(email.trim().toLowerCase());
if (!entry) return false;
return typeof ticket === 'string' && ticket === entry.ticket;
}

private prunePendingOperatorProvisioning(): void {
Expand Down
20 changes: 18 additions & 2 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1871,9 +1871,25 @@ export class AuthPlugin implements Plugin {
// accounts. The ticket says what this creation IS (the deployment's own
// boot command provisioning its admin — the operator class) for exactly
// this address, and is cleared whatever happens.
this.authManager.stageOperatorProvisioning(email);
//
// [#14373] The address alone is not the ticket — it is the documented
// default and the boot banner prints it, so it does not narrow a
// concurrent stranger's guess. `stageOperatorProvisioning` also returns
// a random value that exists only in this process's memory; threading
// it into THIS SAME call's body (under `AuthManager
// .OPERATOR_PROVISIONING_TICKET_FIELD`) is what a stranger's own
// concurrent sign-up for this address cannot replay, however it times
// the window — see that method's doc for the full argument.
const provisioningTicket = this.authManager.stageOperatorProvisioning(email);
try {
await api.signUpEmail({ body: { email, password, name } });
await api.signUpEmail({
body: {
email,
password,
name,
[AuthManager.OPERATOR_PROVISIONING_TICKET_FIELD]: provisioningTicket,
},
});
} finally {
this.authManager.clearOperatorProvisioning(email);
}
Expand Down
Loading
Loading