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
13 changes: 13 additions & 0 deletions .changeset/21731-totp-issuer-app-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/plugin-auth': patch
'@objectstack/cli': patch
---

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.

Clause-②: no

- **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.
- **`@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.
- **`@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.
- 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.
2 changes: 1 addition & 1 deletion content/docs/deployment/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ Auth settings precedence:
|:---|:---|:---|:---|
| `OS_PRODUCT_NAME` | string | `ObjectStack` | Product name shown in the UI chrome and emails. |
| `OS_PRODUCT_SHORT_NAME` | string | `OS` | Short name used in compact UI surfaces (favicons, mobile titles). |
| `OS_APP_NAME` | string | — | Application name surfaced to auth providers (used as the OAuth app display name). |
| `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. |

---

Expand Down
97 changes: 97 additions & 0 deletions packages/cli/src/commands/serve-auth-app-name.contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// `objectstack serve` hands the deployment's product name to AuthPlugin, from
// the SAME resolver the email capability's template context uses.
//
// The name reaches two auth surfaces through `AuthManager.getAppName()`: the
// `{{appName}}` of every auth email, and the issuer and label prefix of the
// otpauth URI a TOTP enrollment hands to an authenticator app. AuthPlugin used
// to be constructed with no `appName`, so under `serve` `getAppName()` answered
// 'ObjectStack' whatever `OS_APP_NAME` said. Auth emails hid it: their own
// `appName` outranks the email service's template context, which DID carry
// `OS_APP_NAME`, so an operator who set it saw the env var honoured in every
// other mail and the framework's name in the auth ones.
//
// Two halves, both needed. The resolver half proves the chain is the email
// chain, rung for rung, so the two consumers cannot disagree about the name.
// The construction half scans `serve.ts` (comments masked, as its siblings
// `serve-email-config-parity.contract.test.ts` and
// `serve-verify-security-parity.contract.test.ts` do), because nothing else
// sees the AuthPlugin literal: the plugin is imported through a variable
// specifier, so the type checker cannot, and only a booted CLI could.

import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveDeploymentAppName, resolveEmailCapabilityArg } from './serve.js';
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';

const SERVE_SOURCE = maskComments(
readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'serve.ts'), 'utf8'),
);

/** The text between the parenthesis or brace opening at `open` and its match. */
function balanced(source: string, open: number): string {
const pairs: Record<string, string> = { '(': ')', '{': '}' };
const close = pairs[source[open]];
if (!close) throw new Error(`no bracket at offset ${open}`);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === source[open]) depth++;
else if (source[i] === close && --depth === 0) return source.slice(open + 1, i);
}
throw new Error(`unbalanced bracket opened at offset ${open}`);
}

/** Every `<callee>(` call's argument text, declarations excluded. */
function callArguments(source: string, callee: string): string[] {
const out: string[] = [];
const needle = `${callee}(`;
for (let i = source.indexOf(needle); i !== -1; i = source.indexOf(needle, i + 1)) {
if (/function\s+$/.test(source.slice(Math.max(0, i - 20), i))) continue;
out.push(balanced(source, i + callee.length));
}
return out;
}

/** Argument text compared as code: whitespace and a trailing comma dropped. */
const normalise = (args: string) => args.replace(/\s+/g, '').replace(/,$/, '');

const emailAppName = (cfgEmail: Record<string, any>, env: NodeJS.ProcessEnv, configAppName?: string) =>
(resolveEmailCapabilityArg(cfgEmail, env, configAppName).options
.defaultTemplateContext as Record<string, unknown>).appName;

describe('resolveDeploymentAppName is the email chain, rung for rung', () => {
const RUNGS: Array<[string, Record<string, any>, NodeJS.ProcessEnv, string | undefined, string]> = [
['OS_APP_NAME', { appName: 'Key', defaultTemplateContext: { appName: 'Context' } }, { OS_APP_NAME: 'Env' }, 'Top', 'Env'],
['config.email.appName', { appName: 'Key', defaultTemplateContext: { appName: 'Context' } }, {}, 'Top', 'Key'],
['defaultTemplateContext.appName', { defaultTemplateContext: { appName: 'Context' } }, {}, 'Top', 'Context'],
['top-level config.appName', {}, {}, 'Top', 'Top'],
["'ObjectStack'", {}, {}, undefined, 'ObjectStack'],
];

for (const [rung, cfgEmail, env, top, expected] of RUNGS) {
it(`${rung} decides both consumers`, () => {
expect(resolveDeploymentAppName(cfgEmail, env, top)).toBe(expected);
expect(emailAppName(cfgEmail, env, top)).toBe(expected);
});
}
});

describe('serve constructs AuthPlugin with the deployment app name', () => {
it('passes appName from resolveDeploymentAppName, with the arguments the email capability gets', () => {
const constructions = callArguments(SERVE_SOURCE, 'new AuthPlugin');
// Absence must be loud: a scan that finds no construction proves nothing.
expect(constructions.length, 'serve.ts must construct AuthPlugin exactly once').toBe(1);

const literal = constructions[0];
const passed = callArguments(literal, 'resolveDeploymentAppName');
expect(passed.length, 'AuthPlugin must receive appName from resolveDeploymentAppName').toBe(1);
expect(literal).toMatch(/\bappName:\s*resolveDeploymentAppName\(/);

const emailCalls = callArguments(SERVE_SOURCE, 'resolveEmailCapabilityArg');
expect(emailCalls.length, 'serve.ts must resolve the email capability exactly once').toBe(1);
expect(normalise(passed[0])).toBe(normalise(emailCalls[0]));
});
});
29 changes: 27 additions & 2 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4083,6 +4083,11 @@ export default class Serve extends Command {
await kernel.use(new AuthPlugin({
secret,
baseUrl,
// The deployment's product name, from the resolver the email
// capability's template context uses: the auth emails'
// `{{appName}}` and the TOTP issuer. Unpassed, auth answered
// 'ObjectStack' whatever `OS_APP_NAME` said.
appName: resolveDeploymentAppName((config as any).email ?? {}, process.env, (config as any).appName),
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
trustedOrigins: trustedOrigins.length ? trustedOrigins : undefined,
// Enable the admin plugin by default so the Setup app's
Expand Down Expand Up @@ -6405,6 +6410,27 @@ function envBooleanFlag(raw: string | undefined): boolean | undefined {
return ['1', 'true', 'yes', 'on'].includes(String(raw).trim().toLowerCase());
}

/**
* The deployment's product name: `OS_APP_NAME` > `config.email.appName` >
* `config.email.defaultTemplateContext.appName` > top-level `config.appName` >
* `'ObjectStack'` (the chain {@link resolveEmailCapabilityArg} documents).
*
* ONE resolver for every boot-time consumer of the name, so they cannot
* disagree: the email capability's template context, and the `appName`
* AuthPlugin receives (the auth emails' `{{appName}}` and the TOTP issuer an
* authenticator app lists the account under). AuthPlugin used to be built
* without it, so `OS_APP_NAME` never reached auth: its emails, whose own
* `appName` outranks the template context, said 'ObjectStack'.
*/
export function resolveDeploymentAppName(
cfgEmail: Record<string, any> = {},
env: NodeJS.ProcessEnv = process.env,
configAppName?: string,
): string {
return env.OS_APP_NAME || cfgEmail.appName || cfgEmail.defaultTemplateContext?.appName
|| configAppName || 'ObjectStack';
}

/**
* Resolve what `EmailServicePlugin` is constructed with, from `config.email`
* plus `OS_EMAIL_*` env (env wins, so an operator can override per environment).
Expand Down Expand Up @@ -6520,8 +6546,7 @@ export function resolveEmailCapabilityArg(
const cfgTemplateContext = cfgEmail.defaultTemplateContext || {};
const defaultTemplateContext = {
...cfgTemplateContext,
appName: env.OS_APP_NAME || cfgEmail.appName || cfgTemplateContext.appName
|| configAppName || 'ObjectStack',
appName: resolveDeploymentAppName(cfgEmail, env, configAppName),
};
// Provide a sensible fallback `from` so templates can render even before
// operators configure SMTP/SaaS. The log transport simply prints to stdout;
Expand Down
30 changes: 27 additions & 3 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,8 +768,10 @@ export interface AuthManagerOptions extends Partial<AuthConfig> {
};

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

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

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

/** @internal `{{appName}}` placeholder value for built-in templates. */
/**
* @internal The deployment's product name: `{{appName}}` in built-in
* templates, and the TOTP issuer handed to better-auth as its `appName`.
*/
private getAppName(): string {
return this.appNameOverride ?? this.config.appName ?? 'ObjectStack';
}
Expand Down
Loading
Loading