Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
### Fixed

- `base44 link` now lists editor-created apps; previously only apps created by the CLI could be linked.
- `base44 dev` accepts sessions from production-minted tokens (e.g. Google OAuth round-trips): a valid
token whose subject never registered locally now gets a local user record instead of a 404 on `User/me`
that left the app stuck logged out.
- `base44 dev` handles `/api/apps/auth/logout` locally and redirects back to the app's `from_url`;
previously the request bounced to production, which drops localhost redirect targets and stranded the
browser on base44.com.

## [0.0.51] - 2026-04-28

Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/cli/dev/dev-server/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ import { WatchBase44 } from "./watcher.js";
const DEFAULT_PORT = 4400;
const BASE44_APP_URL = "https://base44.app";

function isLocalRedirectTarget(value: string): boolean {
try {
const { protocol, hostname } = new URL(value);
return (
(protocol === "http:" || protocol === "https:") &&
(hostname === "localhost" || hostname === "127.0.0.1")
);
} catch {
return false;
}
}

interface DevServerOptions {
log: Logger;
port?: number;
Expand Down Expand Up @@ -79,6 +91,18 @@ export async function createDevServer(
}),
);

// Handled locally (and registered before the auth redirect below): local
// sessions are JWTs the SDK already cleared client-side, and production's
// logout drops foreign from_url targets — bouncing there strands the
// browser on base44.com instead of returning to the app.
app.get("/api/apps/auth/logout", (req, res) => {
const fromUrl = req.query.from_url;
if (typeof fromUrl === "string" && isLocalRedirectTarget(fromUrl)) {
return res.redirect(fromUrl);
}
res.send("Logged out");
});

// Redirect OAuth routes to base44.app directly — proxying breaks the
// redirect flow and session cookies set by the provider.
const AUTH_ROUTE_PATTERN = /^\/api\/apps\/auth(\/|$)/;
Expand Down
5 changes: 2 additions & 3 deletions packages/cli/src/cli/dev/dev-server/routes/auth-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
PRIVATE_USER_COLLECTION,
USER_COLLECTION,
} from "../db/database.js";
import { getNowISOTimestamp } from "../utils.js";
import { deriveFullNameFromEmail, getNowISOTimestamp } from "../utils.js";

const TEN_MINUTES = 10 * 60 * 1000;

Expand Down Expand Up @@ -184,8 +184,7 @@ export function createAuthRouter(db: Database, logger: DevLogger): Router {

const collection = db.getCollection(USER_COLLECTION);
const now = getNowISOTimestamp();
const nameFromEmailMatch = /^([^@]+)/.exec(email);
const fullName = nameFromEmailMatch ? nameFromEmailMatch[1] : email;
const fullName = deriveFullNameFromEmail(email);
await collection?.insertAsync({
id: privateUserData.id,
email: email,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Document } from "@seald-io/nedb";
import type { Request } from "express";
import jwt, { type JwtPayload } from "jsonwebtoken";
import { nanoid } from "nanoid";
import {
isServiceSubject,
SERVICE_ROLE_EMAIL,
Expand All @@ -9,6 +10,10 @@ import {
type Database,
USER_COLLECTION,
} from "@/cli/dev/dev-server/db/database.js";
import {
deriveFullNameFromEmail,
getNowISOTimestamp,
} from "@/cli/dev/dev-server/utils.js";

export type UserDocument = Document<{
email: string;
Expand Down Expand Up @@ -64,12 +69,47 @@ export async function resolveCurrentUser(
.getCollection(USER_COLLECTION)
?.findOneAsync<UserDocument>({ email: subject });

if (!currentUser) {
if (currentUser) {
return { ok: true, user: currentUser };
}

// Tokens minted by production (e.g. Google OAuth round-trips) carry
// subjects that never registered locally — create them on first sight so
// the session works instead of 404ing on User/me.
const externallyAuthenticatedUser = await createLocalUser(db, subject);

if (!externallyAuthenticatedUser) {
return { ok: false, reason: "not_found" };
}

return { ok: true, user: currentUser };
return { ok: true, user: externallyAuthenticatedUser };
} catch {
return { ok: false, reason: "invalid" };
}
}

async function createLocalUser(
db: Database,
email: string,
): Promise<UserDocument | undefined> {
const collection = db.getCollection(USER_COLLECTION);
if (!collection) {
return undefined;
}

const now = getNowISOTimestamp();
const inserted = await collection.insertAsync({
id: nanoid(),
email,
full_name: deriveFullNameFromEmail(email),
is_service: false,
is_verified: true,
disabled: null,
role: "user",
collaborator_role: "editor",
created_date: now,
updated_date: now,
});

return inserted as unknown as UserDocument;
}
5 changes: 5 additions & 0 deletions packages/cli/src/cli/dev/dev-server/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,8 @@ export function stripInternalFields<T extends Record<string, unknown>>(
export const getNowISOTimestamp = () => {
return new Date().toISOString().replace("Z", "000");
};

export const deriveFullNameFromEmail = (email: string) => {
const nameFromEmailMatch = /^([^@]+)/.exec(email);
return nameFromEmailMatch ? nameFromEmailMatch[1] : email;
};
40 changes: 39 additions & 1 deletion packages/cli/tests/cli/dev-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ describe("auth in dev", () => {
const t = setupCLITests();
let handle: RunLiveHandle;
let base44: Base44Client;
let serverUrl: string;

beforeEach(async () => {
await t.givenLoggedInWithProject(fixture("basic"));

handle = await t.runLive("dev");
const serverUrl = await waitForDevServer(handle);
serverUrl = await waitForDevServer(handle);

base44 = createClient({
appId: t.kit.api.appId,
Expand Down Expand Up @@ -60,4 +61,41 @@ describe("auth in dev", () => {

expect(jwt.decode(access_token_login)?.sub).toBe(email);
});

it("creates a local user for a valid token whose subject never registered locally", async () => {
const email = "oauth-minted@example.com";
const externalToken = jwt.sign({}, "external-secret", { subject: email });

const authedClient = createClient({
appId: t.kit.api.appId,
serverUrl,
token: externalToken,
});

const me = await authedClient.auth.me();
expect(me.email).toBe(email);

const meAgain = await authedClient.auth.me();
expect(meAgain.id).toBe(me.id);
});

it("redirects logout back to a localhost from_url instead of production", async () => {
const fromUrl = "http://localhost:5173/";
const response = await fetch(
`${serverUrl}/api/apps/auth/logout?from_url=${encodeURIComponent(fromUrl)}`,
{ redirect: "manual" },
);

expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(fromUrl);
});

it("does not redirect logout to foreign origins", async () => {
const response = await fetch(
`${serverUrl}/api/apps/auth/logout?from_url=${encodeURIComponent("https://evil.example.com/")}`,
{ redirect: "manual" },
);

expect(response.status).toBe(200);
});
});
Loading