Skip to content

Commit 1c3a4d9

Browse files
fix(auth): TOTP enrollment names the deployment app name as its issuer, not Better Auth (#21752)
Fixes #21731 Clause-②: no A TOTP enrollment now names the deployment, not the auth library. `/two-factor/enable` and `/two-factor/get-totp-uri` answered `otpauth://totp/Better%20Auth:...&issuer=Better+Auth`. They now carry the deployment's app name: `OS_APP_NAME`, else the configured app name, else `ObjectStack`. An explicitly set `branding.workspace_name` still outranks it. Existing enrollments keep working, because the issuer is a display label beside an unchanged secret. ## What changed - **`@objectstack/plugin-auth`.** `AuthManager.createAuthInstance()` passes `getAppName()` to better-auth as its `appName`. That is the one app-name authority every auth email already reads. No second read was added, and `twoFactor({ issuer })` is left unset (see the better-auth reading below). - **`@objectstack/cli` (producer side).** `objectstack serve` constructs `AuthPlugin` with `appName: resolveDeploymentAppName(...)`. `resolveDeploymentAppName` is the email capability's existing chain (`OS_APP_NAME` > `config.email.appName` > `config.email.defaultTemplateContext.appName` > `config.appName` > `ObjectStack`), factored out of `resolveEmailCapabilityArg` unchanged. Both consumers now call that one resolver with the same arguments. - **Docs.** The `OS_APP_NAME` row in `content/docs/deployment/environment-variables.mdx` now says what the variable names. It used to say "the OAuth app display name", which was never true. - **Changeset.** `.changeset/21731-totp-issuer-app-name.md`, `patch` for both packages. It says that an existing enrollment keeps working. ## Why the landing reaches `packages/cli`: the triage premise was false under `serve` The card and triage both assumed that `getAppName()` honours `OS_APP_NAME`. Under `objectstack serve` it did not. `serve` built `AuthPlugin` with no `appName`, so `getAppName()` answered `ObjectStack`. Auth emails hid this: their own `appName` outranks the email service's template context (`plugin-email/src/email-service.ts`, the `{ ...defaultTemplateContext, ...input.data }` merge), and the template context was the only place that carried `OS_APP_NAME`. These readings come from the public door, an `os dev` boot (`pnpm dev:todo -- --fresh`, `OS_AUTH_TWO_FACTOR=true`, `OS_APP_NAME='Acme Ops'`, seeded admin, secrets redacted): | build | `POST /api/v1/auth/two-factor/enable` totpURI | password-reset email subject (`sys_email`) | |---|---|---| | the `serve.ts` line ablated, `auth-manager.ts` fix in place | `otpauth://totp/ObjectStack:admin%40objectos.ai?...&issuer=ObjectStack` | `Reset your ObjectStack password` | | this PR | `otpauth://totp/Acme%20Ops:admin%40objectos.ai?...&issuer=Acme+Ops` | (see the liveness table) | Without the `serve` half, the card's first pin (the issuer equals `OS_APP_NAME`) fails at the public door. The same root cause also fixes the auth emails' `{{appName}}` under `serve`. ## better-auth 1.7.3, read off its dist - `appName` is read once, at init: `dist/context/create-context.mjs:131`, `appName: options.appName || "Better Auth"`. - That context value is read in exactly two places, both otpauth URIs: - `dist/plugins/two-factor/index.mjs:170` (enable): `issuer || options?.issuer || ctx.context.appName`. The first term is the request body's own `issuer`. - `dist/plugins/two-factor/totp/index.mjs:133` (get-totp-uri): `options?.issuer || ctx.context.appName`. Here `options` is `totpOptions`, and its type omits `issuer` (`dist/plugins/two-factor/types.d.mts:25` declares `totpOptions` as `TOTPOptions` with `issuer` omitted). - No other runtime read of `appName` exists in `better-auth`, `@better-auth/core`, `sso`, `scim`, `oauth-provider`, `telemetry` or `kysely-adapter`. `core` has 3 hits and all of them are `.d.mts` docs. The other packages have 0. - **No stored or wire identifier changes.** The cookie prefix is the literal `"better-auth"` (`dist/cookies/index.mjs:28`). The core type doc says the prefix defaults from `appName`, but in 1.7.3 it does not. better-auth reads no `APP_NAME` env either. - So `appName` is the one typed key that names both routes. `twoFactor({ issuer })` alone would leave `get-totp-uri` on `Better Auth`, so the triage condition for setting it explicitly does not hold. ## Liveness: what follows a branding change | reading (public door) | result | |---|---| | boot with `OS_BRANDING_WORKSPACE_NAME='Env Brand'` and `OS_APP_NAME='Acme Ops'`, first enable | `issuer=Env+Brand`: the boot-time branding value reaches the issuer | | after boot, `PUT /api/settings/branding {"workspace_name":"Renamed Co"}` (200), then enable and get-totp-uri | both still `issuer=Acme+Ops` | | same process, password-reset email | subject `Reset your Renamed Co password` | `ctx.context.appName` is fixed when the better-auth instance is built. A rename after that reaches the issuer at the next rebuild (a restart, or an auth-settings change that calls `applyConfigPatch`). Auth emails follow it on the next send. No route in `auth-manager.ts` follows the live value on both TOTP routes without rebuilding the instance: a getter-backed `twoFactor({ issuer })` would follow it on `enable` only. The divergence is recorded in the report's open question for the PM, and the code comment and changeset state it. ## Tests (HEAD `e7e32f4f`) - `packages/plugins/plugin-auth/src/two-factor-totp-issuer.test.ts` (new, real better-auth pipeline through `AuthManager.handleRequest`, on the in-memory engine the sibling 2FA suites use): - the configured app name is the issuer and the label prefix on `enable`, and `ObjectStack` when unset; - `get-totp-uri` names the same issuer for the same secret; - a branding override set before the instance is built outranks the configured name; - **still-works:** an enrollment made under the `Better Auth` label and confirmed is then served by a process configured as `Acme Ops` over the same store. The codes from the OLD URI's secret complete a sign-in challenge, and `get-totp-uri` returns the same secret relabelled. The stored row does not contain the label. - `packages/cli/src/commands/serve-auth-app-name.contract.test.ts` (new, unit tier): - `resolveDeploymentAppName` equals the email chain's answer on all five rungs; - a comment-masked scan of `serve.ts` finds exactly one `new AuthPlugin(` call, passing `appName: resolveDeploymentAppName(...)` with the same argument text as the one `resolveEmailCapabilityArg(...)` call. - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2`: 119 files, 2509 passed, 10 skipped. - `pnpm --filter @objectstack/plugin-auth typecheck`: exit 0. It includes `check:test-typecheck`, and the new file compiles clean, with no ledger entry. - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: 258 files, 3777 passed. The integration tier is left to CI, because the diff touches no spawn entry and no integration-tier file. - `pnpm --filter @objectstack/cli typecheck`: exit 0. ## Ablations (each through `scripts/ablation-replace.mjs`, restore proven blob == HEAD) - `auth-manager.ts` without the `appName` line: all 5 new plugin-auth tests turn red, for example `expected { labelIssuer: 'Better Auth', ... } to match object { labelIssuer: 'Acme Ops', ... }`. Restored to blob `a6696db0`, which equals HEAD. - `serve.ts` without the `AuthPlugin` `appName` line: the construction scan turns red (`AuthPlugin must receive appName from resolveDeploymentAppName: expected +0 to be 1`). Restored to blob `121c8125`, which equals HEAD. - The same `serve.ts` mutation was rebuilt into `packages/cli/dist` and booted (`ablation-dist-preflight --absent` passed). The result is the `ObjectStack` row in the first table. The restore leg rebuilt `dist` and the preflight found the marker present again, with a clean tree. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 99 commands from this branch, and every one exited 0. `--ran` reports: 99 derived, 99 run, 0 NOT-MEASURED, 0 UNRUN. ESLint (`--no-inline-config`) on the 4 changed TS files reports 0 errors and 0 warnings. That run covers only the changed files. `eslint.config.mjs` lints `**/*.{ts,...}` and never enables type-aware linting, so this diff cannot move a verdict on an untouched file. ## Acceptance notes - `AuthPluginOptions` does not declare `appName`, although `AuthPlugin` forwards it to `AuthManager` by spread. `serve` imports the plugin through a variable specifier, so the call is untyped. A typed host writing `new AuthPlugin({ appName })` gets a loud excess-property error, not a silent drop. Declaring it would widen a published type, so it is left out of this PR, which is `Clause-②: no`. Carrier: none. - Watch item for a better-auth upgrade: if a later version makes the cookie prefix default from `appName`, as its type doc already claims, the `appName` passed here would rename session cookies on upgrade. Re-check `dist/cookies` when bumping. Carrier: whoever bumps better-auth. - `plugin-dev` and the `verify` harness construct `AuthPlugin` without `appName`. Neither reads `OS_APP_NAME` anywhere, so no surface there disagrees with another. Noted only. - `/two-factor/enable` accepts a request-body `issuer` (vendor behaviour, first term above). It only relabels the caller's own enrollment and is display-only. Noted only. --- _Generated by [Claude Code](https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 866b439 commit 1c3a4d9

6 files changed

Lines changed: 414 additions & 6 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
A TOTP enrollment names the deployment, not the auth library. `/two-factor/enable` and `/two-factor/get-totp-uri` answered an otpauth URI whose issuer and label prefix were `Better Auth`, so every authenticator app listed the account under that name. They now carry the deployment's app name: `OS_APP_NAME`, else the configured `appName`, else `ObjectStack`. An explicitly set `branding.workspace_name` setting still outranks it.
7+
8+
Clause-②: no
9+
10+
- **Existing enrollments keep working.** The issuer is a display label. The stored enrollment holds only the encrypted secret, the backup codes and the confirmation flag, and the codes depend only on the secret, digits and period. An authenticator app enrolled under `Better Auth` keeps producing codes that verify. It keeps its old label until the user re-enrolls.
11+
- **`@objectstack/plugin-auth`.** `AuthManager` passes its app name to better-auth as `appName`. In better-auth 1.7.3 that key names only these two otpauth URIs. No cookie name or stored value derives from it.
12+
- **`@objectstack/cli`.** `objectstack serve` now passes the deployment app name to `AuthPlugin`. It is resolved by the same chain the email service's template context uses: `OS_APP_NAME` > `config.email.appName` > `config.email.defaultTemplateContext.appName` > `config.appName` > `ObjectStack`. Before, `serve` built `AuthPlugin` with no app name, so auth answered `ObjectStack` whatever `OS_APP_NAME` said. Auth emails were affected too: under `serve` they now name the deployment the way every other email already did.
13+
- The issuer is read when the auth instance is built. A `branding.workspace_name` change made after that reaches new enrollments at the next restart or auth-settings change, while auth emails pick it up on their next send.

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Auth settings precedence:
123123
|:---|:---|:---|:---|
124124
| `OS_PRODUCT_NAME` | string | `ObjectStack` | Product name shown in the UI chrome and emails. |
125125
| `OS_PRODUCT_SHORT_NAME` | string | `OS` | Short name used in compact UI surfaces (favicons, mobile titles). |
126-
| `OS_APP_NAME` | string | — | Application name surfaced to auth providers (used as the OAuth app display name). |
126+
| `OS_APP_NAME` | string | — | The deployment's product name under `objectstack serve`: `{{appName}}` in every email template (auth emails included), the issuer an authenticator app lists a TOTP enrollment under, and the MCP OAuth resource name. Wins over `config.email.appName`, `config.email.defaultTemplateContext.appName` and `config.appName`; with none of them set the name is `ObjectStack`. An explicitly set `branding.workspace_name` setting outranks it in auth. |
127127

128128
---
129129

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `objectstack serve` hands the deployment's product name to AuthPlugin, from
4+
// the SAME resolver the email capability's template context uses.
5+
//
6+
// The name reaches two auth surfaces through `AuthManager.getAppName()`: the
7+
// `{{appName}}` of every auth email, and the issuer and label prefix of the
8+
// otpauth URI a TOTP enrollment hands to an authenticator app. AuthPlugin used
9+
// to be constructed with no `appName`, so under `serve` `getAppName()` answered
10+
// 'ObjectStack' whatever `OS_APP_NAME` said. Auth emails hid it: their own
11+
// `appName` outranks the email service's template context, which DID carry
12+
// `OS_APP_NAME`, so an operator who set it saw the env var honoured in every
13+
// other mail and the framework's name in the auth ones.
14+
//
15+
// Two halves, both needed. The resolver half proves the chain is the email
16+
// chain, rung for rung, so the two consumers cannot disagree about the name.
17+
// The construction half scans `serve.ts` (comments masked, as its siblings
18+
// `serve-email-config-parity.contract.test.ts` and
19+
// `serve-verify-security-parity.contract.test.ts` do), because nothing else
20+
// sees the AuthPlugin literal: the plugin is imported through a variable
21+
// specifier, so the type checker cannot, and only a booted CLI could.
22+
23+
import { describe, it, expect } from 'vitest';
24+
import { readFileSync } from 'node:fs';
25+
import path from 'node:path';
26+
import { fileURLToPath } from 'node:url';
27+
import { resolveDeploymentAppName, resolveEmailCapabilityArg } from './serve.js';
28+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
29+
30+
const SERVE_SOURCE = maskComments(
31+
readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'serve.ts'), 'utf8'),
32+
);
33+
34+
/** The text between the parenthesis or brace opening at `open` and its match. */
35+
function balanced(source: string, open: number): string {
36+
const pairs: Record<string, string> = { '(': ')', '{': '}' };
37+
const close = pairs[source[open]];
38+
if (!close) throw new Error(`no bracket at offset ${open}`);
39+
let depth = 0;
40+
for (let i = open; i < source.length; i++) {
41+
if (source[i] === source[open]) depth++;
42+
else if (source[i] === close && --depth === 0) return source.slice(open + 1, i);
43+
}
44+
throw new Error(`unbalanced bracket opened at offset ${open}`);
45+
}
46+
47+
/** Every `<callee>(` call's argument text, declarations excluded. */
48+
function callArguments(source: string, callee: string): string[] {
49+
const out: string[] = [];
50+
const needle = `${callee}(`;
51+
for (let i = source.indexOf(needle); i !== -1; i = source.indexOf(needle, i + 1)) {
52+
if (/function\s+$/.test(source.slice(Math.max(0, i - 20), i))) continue;
53+
out.push(balanced(source, i + callee.length));
54+
}
55+
return out;
56+
}
57+
58+
/** Argument text compared as code: whitespace and a trailing comma dropped. */
59+
const normalise = (args: string) => args.replace(/\s+/g, '').replace(/,$/, '');
60+
61+
const emailAppName = (cfgEmail: Record<string, any>, env: NodeJS.ProcessEnv, configAppName?: string) =>
62+
(resolveEmailCapabilityArg(cfgEmail, env, configAppName).options
63+
.defaultTemplateContext as Record<string, unknown>).appName;
64+
65+
describe('resolveDeploymentAppName is the email chain, rung for rung', () => {
66+
const RUNGS: Array<[string, Record<string, any>, NodeJS.ProcessEnv, string | undefined, string]> = [
67+
['OS_APP_NAME', { appName: 'Key', defaultTemplateContext: { appName: 'Context' } }, { OS_APP_NAME: 'Env' }, 'Top', 'Env'],
68+
['config.email.appName', { appName: 'Key', defaultTemplateContext: { appName: 'Context' } }, {}, 'Top', 'Key'],
69+
['defaultTemplateContext.appName', { defaultTemplateContext: { appName: 'Context' } }, {}, 'Top', 'Context'],
70+
['top-level config.appName', {}, {}, 'Top', 'Top'],
71+
["'ObjectStack'", {}, {}, undefined, 'ObjectStack'],
72+
];
73+
74+
for (const [rung, cfgEmail, env, top, expected] of RUNGS) {
75+
it(`${rung} decides both consumers`, () => {
76+
expect(resolveDeploymentAppName(cfgEmail, env, top)).toBe(expected);
77+
expect(emailAppName(cfgEmail, env, top)).toBe(expected);
78+
});
79+
}
80+
});
81+
82+
describe('serve constructs AuthPlugin with the deployment app name', () => {
83+
it('passes appName from resolveDeploymentAppName, with the arguments the email capability gets', () => {
84+
const constructions = callArguments(SERVE_SOURCE, 'new AuthPlugin');
85+
// Absence must be loud: a scan that finds no construction proves nothing.
86+
expect(constructions.length, 'serve.ts must construct AuthPlugin exactly once').toBe(1);
87+
88+
const literal = constructions[0];
89+
const passed = callArguments(literal, 'resolveDeploymentAppName');
90+
expect(passed.length, 'AuthPlugin must receive appName from resolveDeploymentAppName').toBe(1);
91+
expect(literal).toMatch(/\bappName:\s*resolveDeploymentAppName\(/);
92+
93+
const emailCalls = callArguments(SERVE_SOURCE, 'resolveEmailCapabilityArg');
94+
expect(emailCalls.length, 'serve.ts must resolve the email capability exactly once').toBe(1);
95+
expect(normalise(passed[0])).toBe(normalise(emailCalls[0]));
96+
});
97+
});

‎packages/cli/src/commands/serve.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4083,6 +4083,11 @@ export default class Serve extends Command {
40834083
await kernel.use(new AuthPlugin({
40844084
secret,
40854085
baseUrl,
4086+
// The deployment's product name, from the resolver the email
4087+
// capability's template context uses: the auth emails'
4088+
// `{{appName}}` and the TOTP issuer. Unpassed, auth answered
4089+
// 'ObjectStack' whatever `OS_APP_NAME` said.
4090+
appName: resolveDeploymentAppName((config as any).email ?? {}, process.env, (config as any).appName),
40864091
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
40874092
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
40884093
// Enable the admin plugin by default so the Setup app's
@@ -6405,6 +6410,27 @@ function envBooleanFlag(raw: string | undefined): boolean | undefined {
64056410
return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
64066411
}
64076412

6413+
/**
6414+
* The deployment's product name: `OS_APP_NAME` > `config.email.appName` >
6415+
* `config.email.defaultTemplateContext.appName` > top-level `config.appName` >
6416+
* `'ObjectStack'` (the chain {@link resolveEmailCapabilityArg} documents).
6417+
*
6418+
* ONE resolver for every boot-time consumer of the name, so they cannot
6419+
* disagree: the email capability's template context, and the `appName`
6420+
* AuthPlugin receives (the auth emails' `{{appName}}` and the TOTP issuer an
6421+
* authenticator app lists the account under). AuthPlugin used to be built
6422+
* without it, so `OS_APP_NAME` never reached auth: its emails, whose own
6423+
* `appName` outranks the template context, said 'ObjectStack'.
6424+
*/
6425+
export function resolveDeploymentAppName(
6426+
cfgEmail: Record<string, any> = {},
6427+
env: NodeJS.ProcessEnv = process.env,
6428+
configAppName?: string,
6429+
): string {
6430+
return env.OS_APP_NAME || cfgEmail.appName || cfgEmail.defaultTemplateContext?.appName
6431+
|| configAppName || 'ObjectStack';
6432+
}
6433+
64086434
/**
64096435
* Resolve what `EmailServicePlugin` is constructed with, from `config.email`
64106436
* plus `OS_EMAIL_*` env (env wins, so an operator can override per environment).
@@ -6520,8 +6546,7 @@ export function resolveEmailCapabilityArg(
65206546
const cfgTemplateContext = cfgEmail.defaultTemplateContext || {};
65216547
const defaultTemplateContext = {
65226548
...cfgTemplateContext,
6523-
appName: env.OS_APP_NAME || cfgEmail.appName || cfgTemplateContext.appName
6524-
|| configAppName || 'ObjectStack',
6549+
appName: resolveDeploymentAppName(cfgEmail, env, configAppName),
65256550
};
65266551
// Provide a sensible fallback `from` so templates can render even before
65276552
// operators configure SMTP/SaaS. The log transport simply prints to stdout;

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -768,8 +768,10 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
768768
};
769769

770770
/**
771-
* Display name used by built-in auth email templates (`{{appName}}`
772-
* placeholder). Defaults to `'ObjectStack'` when omitted.
771+
* The deployment's product name: the `{{appName}}` placeholder of the
772+
* built-in auth email templates, and the issuer and label prefix of the
773+
* otpauth URI a TOTP enrollment hands to the user's authenticator app.
774+
* Defaults to `'ObjectStack'` when omitted.
773775
*/
774776
appName?: string;
775777

@@ -1463,6 +1465,21 @@ export class AuthManager {
14631465
// better-auth derives from baseURL are always clickable links.
14641466
baseURL: this.getCanonicalOrigin(),
14651467
basePath: this.configuredBasePath(),
1468+
// The deployment's product name, from the one authority every auth
1469+
// email already reads (`getAppName()`: the `branding.workspace_name`
1470+
// override, else the configured `appName`, else 'ObjectStack').
1471+
// better-auth 1.7.3 reads this key once, into its context's `appName`
1472+
// (unset ⇒ "Better Auth"), and reads that in exactly two places: the
1473+
// issuer and label prefix of the otpauth URI that `/two-factor/enable`
1474+
// and `/two-factor/get-totp-uri` answer. No cookie name derives from it
1475+
// (the prefix is the literal "better-auth") and nothing stores it, so an
1476+
// authenticator enrolled under the old label keeps producing codes that
1477+
// verify: the issuer is a display label beside an unchanged secret.
1478+
//
1479+
// Read when this instance is built. A later branding rename reaches the
1480+
// issuer only when the instance is rebuilt (a config patch or a
1481+
// restart), while auth emails call `getAppName()` on every send.
1482+
appName: this.getAppName(),
14661483

14671484
// Database adapter configuration
14681485
database: await this.createDatabaseConfig(),
@@ -5652,13 +5669,20 @@ export class AuthManager {
56525669
* value only reflects an *explicitly set* setting — when the operator has
56535670
* not customised it, AuthPlugin passes `undefined` so a deployment's
56545671
* configured `appName` (e.g. `OS_APP_NAME`) keeps precedence.
5672+
*
5673+
* The TOTP issuer reads the same value, but when the better-auth instance
5674+
* is BUILT (see `createAuthInstance`), not per enrollment: an override set
5675+
* after the build reaches it at the next rebuild.
56555676
*/
56565677
setAppName(name: string | undefined): void {
56575678
this.appNameOverride = name?.trim() || undefined;
56585679
}
56595680
private appNameOverride?: string;
56605681

5661-
/** @internal `{{appName}}` placeholder value for built-in templates. */
5682+
/**
5683+
* @internal The deployment's product name: `{{appName}}` in built-in
5684+
* templates, and the TOTP issuer handed to better-auth as its `appName`.
5685+
*/
56625686
private getAppName(): string {
56635687
return this.appNameOverride ?? this.config.appName ?? 'ObjectStack';
56645688
}

0 commit comments

Comments
 (0)