Skip to content
Draft
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
11 changes: 9 additions & 2 deletions docs/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
31 changes: 30 additions & 1 deletion packages/cli/src/core/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const WORKSPACE_API_KEY_PREFIX = "b44k_";
// Lock to prevent concurrent token refreshes
let refreshPromise: Promise<string | null> | 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;
Expand Down Expand Up @@ -41,7 +46,20 @@ export async function seedAuthFromEnv(): Promise<void> {
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;
}

Expand All @@ -56,6 +74,7 @@ export async function seedAuthFromEnv(): Promise<void> {

/**
* 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.
Expand All @@ -65,6 +84,10 @@ export async function seedAuthFromEnv(): Promise<void> {
* console.log(`Logged in as: ${auth.email}`);
*/
export async function readAuth(): Promise<AuthData> {
if (envAccessTokenAuth) {
return envAccessTokenAuth;
}

try {
const authData = await readJsonFile(getAuthFilePath());
const result = AuthDataSchema.safeParse(authData);
Expand Down Expand Up @@ -132,6 +155,12 @@ export function isTokenExpired(auth: AuthData): boolean {
}

export async function refreshAndSaveTokens(): Promise<string | null> {
// 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;
Expand Down
63 changes: 59 additions & 4 deletions packages/cli/tests/cli/env-token-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading