diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f871affa..edd560c3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/cli/src/cli/dev/dev-server/main.ts b/packages/cli/src/cli/dev/dev-server/main.ts index fdd57254f..4e4e9948e 100644 --- a/packages/cli/src/cli/dev/dev-server/main.ts +++ b/packages/cli/src/cli/dev/dev-server/main.ts @@ -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; @@ -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(\/|$)/; diff --git a/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts b/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts index d71151b18..5554f2b8c 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/auth-router.ts @@ -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; @@ -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, diff --git a/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts b/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts index 3d5909cc3..bbe154f5c 100644 --- a/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts +++ b/packages/cli/src/cli/dev/dev-server/routes/entities/current-user.ts @@ -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, @@ -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; @@ -64,12 +69,47 @@ export async function resolveCurrentUser( .getCollection(USER_COLLECTION) ?.findOneAsync({ 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 { + 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; +} diff --git a/packages/cli/src/cli/dev/dev-server/utils.ts b/packages/cli/src/cli/dev/dev-server/utils.ts index 6fa0aef8d..eeff4ce53 100644 --- a/packages/cli/src/cli/dev/dev-server/utils.ts +++ b/packages/cli/src/cli/dev/dev-server/utils.ts @@ -17,3 +17,8 @@ export function stripInternalFields>( export const getNowISOTimestamp = () => { return new Date().toISOString().replace("Z", "000"); }; + +export const deriveFullNameFromEmail = (email: string) => { + const nameFromEmailMatch = /^([^@]+)/.exec(email); + return nameFromEmailMatch ? nameFromEmailMatch[1] : email; +}; diff --git a/packages/cli/tests/cli/dev-auth.spec.ts b/packages/cli/tests/cli/dev-auth.spec.ts index 5ef22d698..507c9ddb2 100644 --- a/packages/cli/tests/cli/dev-auth.spec.ts +++ b/packages/cli/tests/cli/dev-auth.spec.ts @@ -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, @@ -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); + }); });