Files
PaperClipAI/server/src/__tests__/better-auth.test.ts
T
DottaandPaperclip e625505ac9 fix(auth): preserve credential sign-in during server overlap (#20839)
## Thinking Path

> - Paperclip controls AI agent work across software updates.
> - Authenticated instances must keep human sign-in available during an
update.
> - Better Auth 1.7.16 identifies accounts by provider and account ID.
> - It creates credential accounts without the issuer required by the
previous reader.
> - A new account can therefore fail to sign in through a surviving
1.7.2 process.
> - This pull request keeps the legacy credential issuer on trusted
account creation.
> - The security upgrade and provider/account uniqueness stay in place.

## Linked Issues or Issue Description

Refs #18906.

**What happened?**

After the Better Auth upgrade, a credential account created by the new
version has a NULL issuer. A surviving Better Auth 1.7.2 process rejects
its correct password because it selects only the `local:credential`
account identity.

**Expected behavior**

A new credential account must sign in through both versions during an
update. The new version must keep its provider/account lookup and
uniqueness rules.

**Steps to reproduce**

1. Apply migration 0465 to a disposable database.
2. Create an email/password account with the new auth mount.
3. Sign in to the same database through Better Auth 1.7.2.
4. Without this change, signup and new-version sign-in return 200, but
old-version sign-in returns 401.

## What Changed

- Register issuer as an optional internal account field with client
input and response output disabled.
- Set `local:credential` in the trusted before-create hook. Preserve any
nonempty issuer supplied by internal code.
- Leave unconfigured providers without an issuer on the new NULL-issuer
behavior. No social provider is configured by this module.
- Add credential integration and issuer-policy controls. Document the
exact compatibility scope.

## Verification

- Node 24: `node scripts/agent-preflight.mjs` passed using the existing
build cache.
- `node cli/node_modules/tsx/dist/cli.mjs
packages/db/src/test-postgres-command.ts -- node
node_modules/vitest/vitest.mjs run --project @paperclipai/server
server/src/__tests__/better-auth.test.ts
server/src/__tests__/better-auth-credential-signup.integration.test.ts`:
22 passed.
- Separate local qualification used the actual cached Better Auth 1.7.2
SDK and the current 1.7.16 mount against one disposable PostgreSQL
database. Before this change, old-version sign-in returned 401 for the
new account. After this change, signup through either writer and sign-in
through both readers returned 200.
- The integration covers forged client issuer input, the frozen 1.7.2
credential selector, current-version sign-in for existing NULL-issuer
rows, and the unchanged provider/account unique index.
- `pnpm --filter @paperclipai/server typecheck`: passed, including all
server and test-tree layers.
- The first required Runner support run failed three SDK proof suites
because two generated lock references were stale on the current base.
The existing generator corrected those references and the three suites
passed locally (58 tests in 17.07 seconds). Master then merged the same
repair in #20834 and advanced its runtime/dependency lock. This branch
was rebased onto current master; the duplicate proof commit was omitted.
The final diff contains only the four auth/test/doc files. The
exact-head checks and review listed below passed on the rebased
candidate.
- Final candidate `577fd7612a6d80ac35e2f5ee9ea2ce9b77169687`: focused
auth verification passed again (22 tests, 51.81 seconds). [Required
CI](https://github.com/paperclipai/paperclip/actions/runs/43051216288)
passed, including build, typecheck, server/Runner tests and all eight
browser shards. One unchanged Rust process-lineage orphan-census test
failed on the first attempt; the failed job was rerun after the workflow
completed and passed. This PR changes no Rust source.
- [Greptile
review](https://github.com/paperclipai/paperclip/pull/20839#issuecomment-6799551882)
is 5/5 on the final head with no actionable findings or open review
threads. No deployment or original database change was performed for
this branch.

## Risks

The bridge applies to new credential accounts. It does not backfill
existing NULL issuers or claim old-reader compatibility for unconfigured
social providers. Trusted internal issuers retain their exact value.
Account lookup, password checks, account linking, provider/account
uniqueness, and migration 0465 remain with their existing owners.

## Model Used

OpenAI Codex (GPT-6), with tool-assisted code editing and local
PostgreSQL qualification. The exact serving model ID and context window
are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-11-11 10:17:49 -06:00

262 lines
10 KiB
TypeScript

import { afterEach, describe, expect, it } from "vitest";
import type { BetterAuthOptions } from "better-auth";
import { getCookies } from "better-auth/cookies";
import {
buildBetterAuthAdvancedOptions,
buildBetterAuthRateLimitOptions,
deriveAuthCookiePrefix,
deriveAuthTrustedOrigins,
legacyAccountIssuerForCreate,
shouldDisableSecureAuthCookies,
} from "../auth/better-auth.js";
const ORIGINAL_INSTANCE_ID = process.env.PAPERCLIP_INSTANCE_ID;
const ORIGINAL_PUBLIC_URL = process.env.PAPERCLIP_PUBLIC_URL;
afterEach(() => {
if (ORIGINAL_INSTANCE_ID === undefined) delete process.env.PAPERCLIP_INSTANCE_ID;
else process.env.PAPERCLIP_INSTANCE_ID = ORIGINAL_INSTANCE_ID;
if (ORIGINAL_PUBLIC_URL === undefined) delete process.env.PAPERCLIP_PUBLIC_URL;
else process.env.PAPERCLIP_PUBLIC_URL = ORIGINAL_PUBLIC_URL;
});
describe("Better Auth legacy account issuer", () => {
it("keeps credential identities readable by the previous credential reader", () => {
expect(legacyAccountIssuerForCreate({ providerId: "credential" })).toBe("local:credential");
});
it("preserves a trusted internal OAuth issuer without normalizing its identity", () => {
expect(legacyAccountIssuerForCreate({ providerId: "google", issuer: "https://accounts.google.com" }))
.toBe("https://accounts.google.com");
expect(legacyAccountIssuerForCreate({ providerId: "custom", issuer: "local:oauth:custom" }))
.toBe("local:oauth:custom");
});
it("does not invent a legacy issuer for unconfigured or dynamic providers", () => {
expect(legacyAccountIssuerForCreate({ providerId: "unknown" })).toBeNull();
expect(legacyAccountIssuerForCreate({ providerId: "google", issuer: " " })).toBeNull();
});
});
describe("Better Auth cookie scoping", () => {
it("derives an instance-scoped cookie prefix", () => {
expect(deriveAuthCookiePrefix("default")).toBe("paperclip-default");
expect(deriveAuthCookiePrefix("PAP-1601-worktree")).toBe("paperclip-PAP-1601-worktree");
});
it("uses PAPERCLIP_INSTANCE_ID for the Better Auth cookie prefix", () => {
process.env.PAPERCLIP_INSTANCE_ID = "sat-worktree";
const advanced = buildBetterAuthAdvancedOptions({ disableSecureCookies: false });
expect(advanced).toEqual({
cookiePrefix: "paperclip-sat-worktree",
});
expect(getCookies({ advanced } as BetterAuthOptions).sessionToken.name).toMatch(
/paperclip-sat-worktree\.session_token$/,
);
});
it("keeps local http auth cookies non-secure while preserving the scoped prefix", () => {
process.env.PAPERCLIP_INSTANCE_ID = "pap-worktree";
expect(buildBetterAuthAdvancedOptions({ disableSecureCookies: true })).toEqual({
cookiePrefix: "paperclip-pap-worktree",
useSecureCookies: false,
});
expect(getCookies({
advanced: buildBetterAuthAdvancedOptions({ disableSecureCookies: true }),
} as BetterAuthOptions).sessionToken.name).toBe("paperclip-pap-worktree.session_token");
});
it("enables Better Auth rate limiting for authenticated private instances by default", () => {
expect(buildBetterAuthRateLimitOptions({
deploymentMode: "authenticated",
deploymentExposure: "private",
})).toEqual({ enabled: true });
});
it("keeps Better Auth rate limiting enabled for authenticated public instances", () => {
expect(buildBetterAuthRateLimitOptions({
deploymentMode: "authenticated",
deploymentExposure: "public",
})).toEqual({ enabled: true });
});
it("allows an explicit Better Auth rate-limit override", () => {
expect(buildBetterAuthRateLimitOptions({
deploymentMode: "authenticated",
deploymentExposure: "private",
override: "true",
})).toEqual({ enabled: true });
expect(buildBetterAuthRateLimitOptions({
deploymentMode: "authenticated",
deploymentExposure: "public",
override: "false",
})).toEqual({ enabled: false });
});
it("disables secure cookies for authenticated private auto-origin dev servers", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: undefined,
})).toBe(true);
});
it("keeps secure cookies for authenticated public auto-origin servers", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: undefined,
})).toBe(false);
});
it("uses an explicit public URL when deciding whether secure cookies are required", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
publicUrl: "https://paperclip.example.test",
})).toBe(false);
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://paperclip.local.test:3100",
publicUrl: undefined,
})).toBe(true);
});
it("disables secure cookies when no canonical public auth URL is configured", () => {
delete process.env.PAPERCLIP_PUBLIC_URL;
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
});
it("derives secure cookie behavior from the configured public auth URL", () => {
delete process.env.PAPERCLIP_PUBLIC_URL;
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://paperclip-dev:46259",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://paperclip.example.test",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
});
it("uses the caller-resolved public URL for cookie security", () => {
process.env.PAPERCLIP_PUBLIC_URL = "https://ignored.example.test";
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://paperclip.example.test",
publicUrl: "http://paperclip-dev:46259",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
});
it("disables secure cookies for private authenticated auto mode without a public URL", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
})).toBe(true);
});
it("disables secure cookies for explicit HTTP public URLs", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "http://board.example.test:3101",
})).toBe(true);
});
it("keeps secure cookies for explicit HTTPS public URLs", () => {
expect(shouldDisableSecureAuthCookies({
deploymentMode: "authenticated",
deploymentExposure: "public",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://board.example.test",
})).toBe(false);
});
it("disables secure cookies only for HTTP loopback requests in a managed HTTPS runtime", () => {
const managedRuntimeInput = {
deploymentMode: "authenticated",
deploymentExposure: "private",
authBaseUrlMode: "explicit",
authPublicBaseUrl: "https://worktree.example.test",
publicUrl: "https://worktree.example.test",
managedRuntimePublicUrl: "https://worktree.example.test",
} as const;
expect(shouldDisableSecureAuthCookies({
...managedRuntimeInput,
requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(true);
expect(shouldDisableSecureAuthCookies({
...managedRuntimeInput,
requestUrl: "https://worktree.example.test/api/auth/sign-in/email",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
expect(shouldDisableSecureAuthCookies({
...managedRuntimeInput,
managedRuntimePublicUrl: undefined,
requestUrl: "http://127.0.0.1:42013/api/auth/sign-in/email",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
expect(shouldDisableSecureAuthCookies({
...managedRuntimeInput,
requestUrl: "http://board.example.test:42013/api/auth/sign-in/email",
} as Parameters<typeof shouldDisableSecureAuthCookies>[0])).toBe(false);
});
it("adds hostname port variants for authenticated mode on non-default ports", () => {
const trustedOrigins = deriveAuthTrustedOrigins({
deploymentMode: "authenticated",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
allowedHostnames: ["Board.Example.Test"],
port: 3101,
} as Parameters<typeof deriveAuthTrustedOrigins>[0]);
expect(trustedOrigins).toEqual(expect.arrayContaining([
"https://board.example.test",
"http://board.example.test",
"https://board.example.test:3101",
"http://board.example.test:3101",
]));
});
it("prefers an explicit resolved listen port over the configured port", () => {
const trustedOrigins = deriveAuthTrustedOrigins({
deploymentMode: "authenticated",
authBaseUrlMode: "auto",
authPublicBaseUrl: undefined,
allowedHostnames: ["board.example.test"],
port: 3100,
} as Parameters<typeof deriveAuthTrustedOrigins>[0], { listenPort: 3101 });
expect(trustedOrigins).toEqual(expect.arrayContaining([
"https://board.example.test:3101",
"http://board.example.test:3101",
]));
expect(trustedOrigins).not.toContain("https://board.example.test:3100");
expect(trustedOrigins).not.toContain("http://board.example.test:3100");
});
});