mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## 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>
262 lines
10 KiB
TypeScript
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");
|
|
});
|
|
});
|