diff --git a/docs/api-patterns.md b/docs/api-patterns.md index 26c88fc72..88abc1ee2 100644 --- a/docs/api-patterns.md +++ b/docs/api-patterns.md @@ -70,8 +70,15 @@ app's credentials via the environment, the `ensureAuth` middleware calls validates), pairs it with `BASE44_REFRESH_TOKEN`, and writes a standard `~/.base44/auth/auth.json`, so everything downstream uses one file-based path. -This overwrites an existing login when env vars are present, and no-ops when -they can't form a complete record. The seeded file looks like a normal login, so +When `BASE44_ACCESS_TOKEN` is set **without** `BASE44_REFRESH_TOKEN` (e.g. the +MCP `run_command` tool injecting a short-lived token into a shared app sandbox +for one command), it is held in memory for the current process only: +`readAuth()` returns it ahead of `auth.json`, it is never written to disk, and +`refreshAndSaveTokens()` returns `null` without refreshing, writing or deleting +anything, so on expiry the 401 surfaces and any stored login is left untouched. + +The paired path overwrites an existing login when env vars are present; seeding +no-ops when the token lacks `sub`/`exp`. The seeded file looks like a normal login, so a 401 refresh applies to it too; if refresh fails it deletes the file, which self-heals on the next command (re-seeded from the still-present env vars). See `seedAuthFromEnv()` in `core/auth/config.js`. diff --git a/packages/cli/src/core/auth/config.ts b/packages/cli/src/core/auth/config.ts index d24bfc489..caa8803a1 100644 --- a/packages/cli/src/core/auth/config.ts +++ b/packages/cli/src/core/auth/config.ts @@ -13,6 +13,11 @@ const WORKSPACE_API_KEY_PREFIX = "b44k_"; // Lock to prevent concurrent token refreshes let refreshPromise: Promise | null = null; +// Process-scoped auth from a lone BASE44_ACCESS_TOKEN. Held in memory only and +// never written to auth.json: it may be injected into a shared sandbox for a +// single command, so persisting it would let the next command run as this user. +let envAccessTokenAuth: AuthData | null = null; + export function getWorkspaceApiKeyFromEnv(): string | null { const key = process.env.BASE44_API_KEY?.trim(); return key ? key : null; @@ -41,7 +46,20 @@ export async function seedAuthFromEnv(): Promise { const sub = typeof payload?.sub === "string" ? payload.sub : undefined; const exp = typeof payload?.exp === "number" ? payload.exp : undefined; - if (!refreshToken || !sub || !exp) { + if (!sub || !exp) { + return; + } + + if (!refreshToken) { + // No refresh token: use the access token for this process only and let a + // 401 surface on expiry. + envAccessTokenAuth = { + accessToken, + refreshToken: "", + expiresAt: exp * 1000, + email: sub, + name: sub, + }; return; } @@ -56,6 +74,7 @@ export async function seedAuthFromEnv(): Promise { /** * Reads and validates the stored authentication data. + * An in-memory env access token (see seedAuthFromEnv) takes precedence. * * @returns The parsed authentication data (tokens, user info). * @throws {Error} If not logged in or if auth data is corrupted. @@ -65,6 +84,10 @@ export async function seedAuthFromEnv(): Promise { * console.log(`Logged in as: ${auth.email}`); */ export async function readAuth(): Promise { + if (envAccessTokenAuth) { + return envAccessTokenAuth; + } + try { const authData = await readJsonFile(getAuthFilePath()); const result = AuthDataSchema.safeParse(authData); @@ -132,6 +155,12 @@ export function isTokenExpired(auth: AuthData): boolean { } export async function refreshAndSaveTokens(): Promise { + // An env access token has nothing to refresh with, and auth.json belongs to + // someone else: never refresh, write, or delete it. + if (envAccessTokenAuth) { + return null; + } + // If a refresh is already in progress, wait for it if (refreshPromise) { return refreshPromise; diff --git a/packages/cli/tests/cli/env-token-auth.spec.ts b/packages/cli/tests/cli/env-token-auth.spec.ts index 0b8f0c2db..bcc5c6907 100644 --- a/packages/cli/tests/cli/env-token-auth.spec.ts +++ b/packages/cli/tests/cli/env-token-auth.spec.ts @@ -232,16 +232,71 @@ describe("env credential seeding", () => { expect(authHeader).toBe("Bearer test-access-token"); }); - it("does not overwrite a stored login when env credentials are incomplete", async () => { - await t.givenLoggedIn({ email: "real@example.com", name: "Real User" }); - // Access token present but no refresh token → can't form a standard record, - // so seeding is skipped and the existing login is left untouched. + it("does not seed auth.json from a lone access token", async () => { + // Access token without a refresh token is a per-process credential (e.g. + // injected into a shared sandbox for one command): it must never persist. + t.givenEnv({ + BASE44_ACCESS_TOKEN: makeJwt({ + sub: "alice@example.com", + exp: futureExp(), + }), + }); + + const result = await t.run("whoami"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("alice@example.com"); + expect(await t.readAuthFile()).toBeNull(); + }); + + it("uses a lone access token as the bearer without touching a stored login", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + const authBefore = await t.readAuthFile(); + const jwt = makeJwt({ sub: "alice@example.com", exp: futureExp() }); + t.givenEnv({ BASE44_ACCESS_TOKEN: jwt }); + + let authHeader: string | undefined; + t.api.mockRoute("PUT", `/api/apps/${APP_ID}/entity-schemas`, (req, res) => { + authHeader = req.headers.authorization; + res.status(200).json({ created: ["customer"], updated: [], deleted: [] }); + }); + + const result = await t.run("entities", "push", "--yes"); + + t.expectResult(result).toSucceed(); + expect(authHeader).toBe(`Bearer ${jwt}`); + expect(await t.readAuthFile()).toEqual(authBefore); + }); + + it("does not refresh or delete a stored login when a lone access token is rejected", async () => { + await t.givenLoggedInWithProject(fixture("with-entities")); + const authBefore = await t.readAuthFile(); t.givenEnv({ BASE44_ACCESS_TOKEN: makeJwt({ sub: "alice@example.com", exp: futureExp(), }), }); + t.api.mockEntitiesPushError({ + status: 401, + body: { error: "Unauthorized", detail: "Token expired" }, + }); + let refreshCalled = false; + t.api.mockRoute("POST", "/oauth/token", (_req, res) => { + refreshCalled = true; + res.status(500).json({ error: "Unexpected refresh" }); + }); + + const result = await t.run("entities", "push", "--yes"); + + t.expectResult(result).toFail(); + expect(refreshCalled).toBe(false); + expect(await t.readAuthFile()).toEqual(authBefore); + }); + + it("does not use an access token without sub or exp claims", async () => { + await t.givenLoggedIn({ email: "real@example.com", name: "Real User" }); + t.givenEnv({ BASE44_ACCESS_TOKEN: makeJwt({ foo: "bar" }) }); const result = await t.run("whoami");