diff --git a/apps/app/src/app/components/den-settings-panel.tsx b/apps/app/src/app/components/den-settings-panel.tsx index 6bc8a0148..af7690bc1 100644 --- a/apps/app/src/app/components/den-settings-panel.tsx +++ b/apps/app/src/app/components/den-settings-panel.tsx @@ -16,6 +16,11 @@ import { resolveDenBaseUrls, writeDenSettings, } from "../lib/den"; +import { + denSessionUpdatedEvent, + dispatchDenSessionUpdated, + type DenSessionUpdatedDetail, +} from "../lib/den-session-events"; import { clearDenTemplateCache, loadDenTemplateCache, @@ -78,9 +83,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const platform = usePlatform(); const tr = (key: string) => t(key, currentLocale()); const initial = readDenSettings(); - const initialBaseUrl = props.developerMode - ? initial.baseUrl || DEFAULT_DEN_BASE_URL - : DEFAULT_DEN_BASE_URL; + const initialBaseUrl = initial.baseUrl || DEFAULT_DEN_BASE_URL; const [baseUrl, setBaseUrl] = createSignal(initialBaseUrl); const [baseUrlDraft, setBaseUrlDraft] = createSignal(initialBaseUrl); @@ -155,7 +158,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { createEffect(() => { writeDenSettings({ - baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL, + baseUrl: baseUrl(), authToken: authToken() || null, activeOrgId: activeOrgId() || null, activeOrgSlug: activeOrg()?.slug ?? null, @@ -163,14 +166,6 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { }); }); - createEffect(() => { - if (!props.developerMode) { - setBaseUrl(DEFAULT_DEN_BASE_URL); - setBaseUrlDraft(DEFAULT_DEN_BASE_URL); - setBaseUrlError(null); - } - }); - const openControlPlane = () => { platform.openLink(resolveDenBaseUrls(baseUrl()).baseUrl); }; @@ -239,7 +234,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { } writeDenSettings({ - baseUrl: props.developerMode ? nextBaseUrl : DEFAULT_DEN_BASE_URL, + baseUrl: nextBaseUrl, authToken: result.token, activeOrgId: null, activeOrgSlug: null, @@ -248,26 +243,21 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { setManualAuthInput(""); setManualAuthOpen(false); - window.dispatchEvent( - new CustomEvent("openwork-den-session-updated", { - detail: { - status: "success", - email: result.user?.email ?? null, - }, - }), - ); + dispatchDenSessionUpdated({ + status: "success", + baseUrl: nextBaseUrl, + token: result.token, + user: result.user, + email: result.user?.email ?? null, + }); } catch (error) { - window.dispatchEvent( - new CustomEvent("openwork-den-session-updated", { - detail: { - status: "error", - message: - error instanceof Error - ? error.message - : tr("den.error_signin_failed"), - }, - }), - ); + dispatchDenSessionUpdated({ + status: "error", + message: + error instanceof Error + ? error.message + : tr("den.error_signin_failed"), + }); } finally { setAuthBusy(false); } @@ -337,7 +327,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const nextOrg = response.orgs.find((org) => org.id === next) ?? null; setActiveOrgId(next); writeDenSettings({ - baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL, + baseUrl: baseUrl(), authToken: authToken() || null, activeOrgId: next || null, activeOrgSlug: nextOrg?.slug ?? null, @@ -472,18 +462,27 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { createEffect(() => { const handler = (event: Event) => { - const customEvent = event as CustomEvent<{ - status?: string; - email?: string | null; - message?: string | null; - }>; + const customEvent = event as CustomEvent; const nextSettings = readDenSettings(); - setBaseUrl(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setBaseUrlDraft(nextSettings.baseUrl || DEFAULT_DEN_BASE_URL); - setAuthToken(nextSettings.authToken?.trim() || ""); + const nextBaseUrl = + customEvent.detail?.baseUrl?.trim() || + nextSettings.baseUrl || + DEFAULT_DEN_BASE_URL; + const nextToken = + customEvent.detail?.token?.trim() || + nextSettings.authToken?.trim() || + ""; + setBaseUrl(nextBaseUrl); + setBaseUrlDraft(nextBaseUrl); + setAuthToken(nextToken); setActiveOrgId(nextSettings.activeOrgId?.trim() || ""); if (customEvent.detail?.status === "success") { + clearSessionState(); + if (customEvent.detail.user) { + setUser(customEvent.detail.user); + } setAuthError(null); + setSessionBusy(false); setStatusMessage( customEvent.detail.email?.trim() ? t("den.status_cloud_signed_in_as", currentLocale(), { email: customEvent.detail.email.trim() }) @@ -498,12 +497,12 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { }; window.addEventListener( - "openwork-den-session-updated", + denSessionUpdatedEvent, handler as EventListener, ); return () => window.removeEventListener( - "openwork-den-session-updated", + denSessionUpdatedEvent, handler as EventListener, ); }); @@ -828,7 +827,7 @@ export default function DenSettingsPanel(props: DenSettingsPanelProps) { const nextOrg = orgs().find((org) => org.id === nextId) ?? null; setActiveOrgId(nextId); writeDenSettings({ - baseUrl: props.developerMode ? baseUrl() : DEFAULT_DEN_BASE_URL, + baseUrl: baseUrl(), authToken: authToken() || null, activeOrgId: nextId || null, activeOrgSlug: nextOrg?.slug ?? null, diff --git a/apps/app/src/app/lib/den-session-events.ts b/apps/app/src/app/lib/den-session-events.ts new file mode 100644 index 000000000..2fc6c7774 --- /dev/null +++ b/apps/app/src/app/lib/den-session-events.ts @@ -0,0 +1,24 @@ +import type { DenUser } from "./den"; + +export const denSessionUpdatedEvent = "openwork-den-session-updated"; + +export type DenSessionUpdatedDetail = { + status?: "success" | "error"; + baseUrl?: string | null; + token?: string | null; + user?: DenUser | null; + email?: string | null; + message?: string | null; +}; + +export function dispatchDenSessionUpdated(detail: DenSessionUpdatedDetail) { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(denSessionUpdatedEvent, { + detail, + }), + ); +} diff --git a/apps/app/src/app/lib/den.ts b/apps/app/src/app/lib/den.ts index 35bc68129..6841b16de 100644 --- a/apps/app/src/app/lib/den.ts +++ b/apps/app/src/app/lib/den.ts @@ -198,6 +198,35 @@ export function normalizeDenBaseUrl(input: string | null | undefined): string | function isWebAppHost(hostname: string): boolean { const normalized = hostname.trim().toLowerCase(); + + if ( + normalized === "localhost" || + normalized === "0.0.0.0" || + normalized === "::1" || + normalized === "[::1]" || + /^127(?:\.\d{1,3}){3}$/.test(normalized) + ) { + return true; + } + + const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) { + const [first, second, third, fourth] = ipv4Match.slice(1).map(Number); + const octets = [first, second, third, fourth]; + if (octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) { + if ( + first === 10 || + first === 127 || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) || + (first === 169 && second === 254) || + (first === 100 && second >= 64 && second <= 127) + ) { + return true; + } + } + } + return normalized === "app.openworklabs.com" || normalized === "app.openwork.software" || normalized.startsWith("app."); } diff --git a/apps/app/src/app/shell/deep-links.ts b/apps/app/src/app/shell/deep-links.ts index 9a840a581..70c1cbc78 100644 --- a/apps/app/src/app/shell/deep-links.ts +++ b/apps/app/src/app/shell/deep-links.ts @@ -3,6 +3,7 @@ import { createEffect, createSignal, type Accessor } from "solid-js"; import { t, currentLocale } from "../../i18n"; import { createDenClient, writeDenSettings } from "../lib/den"; +import { dispatchDenSessionUpdated } from "../lib/den-session-events"; import { stripBundleQuery } from "../bundles"; import type { createBundlesStore } from "../bundles/store"; import type { SettingsTab, View } from "../types"; @@ -35,6 +36,15 @@ export function createDeepLinksController(options: { const [pendingDenAuthDeepLink, setPendingDenAuthDeepLink] = createSignal(null); const [processingDenAuthDeepLink, setProcessingDenAuthDeepLink] = createSignal(false); const recentClaimedDeepLinks = new Map(); + const recentHandledDenGrants = new Map(); + + const pruneRecentHandledDenGrants = (now: number) => { + for (const [grant, seenAt] of recentHandledDenGrants) { + if (now - seenAt > 5 * 60 * 1000) { + recentHandledDenGrants.delete(grant); + } + } + }; const queueRemoteConnectDefaults = (pending: RemoteWorkspaceDefaults | null) => { setPendingRemoteConnectDeepLink(pending); @@ -87,6 +97,18 @@ export function createDeepLinksController(options: { if (!parsed) { return false; } + + const now = Date.now(); + pruneRecentHandledDenGrants(now); + if (recentHandledDenGrants.has(parsed.grant)) { + return true; + } + + const currentPending = pendingDenAuthDeepLink(); + if (currentPending?.grant === parsed.grant) { + return true; + } + setPendingDenAuthDeepLink(parsed); return true; }; @@ -173,6 +195,7 @@ export function createDeepLinksController(options: { setProcessingDenAuthDeepLink(true); setPendingDenAuthDeepLink(null); + recentHandledDenGrants.set(pending.grant, Date.now()); options.setView("settings"); options.setSettingsTab("den"); options.goToSettings("den"); @@ -192,24 +215,20 @@ export function createDeepLinksController(options: { activeOrgName: null, }); - window.dispatchEvent( - new CustomEvent("openwork-den-session-updated", { - detail: { - status: "success", - email: result.user?.email ?? null, - }, - }), - ); + dispatchDenSessionUpdated({ + status: "success", + baseUrl: pending.denBaseUrl, + token: result.token, + user: result.user, + email: result.user?.email ?? null, + }); }) .catch((error) => { - window.dispatchEvent( - new CustomEvent("openwork-den-session-updated", { - detail: { - status: "error", - message: error instanceof Error ? error.message : t("app.error_cloud_signin", currentLocale()), - }, - }), - ); + recentHandledDenGrants.delete(pending.grant); + dispatchDenSessionUpdated({ + status: "error", + message: error instanceof Error ? error.message : t("app.error_cloud_signin", currentLocale()), + }); }) .finally(() => { setProcessingDenAuthDeepLink(false); diff --git a/ee/apps/den-api/src/auth.ts b/ee/apps/den-api/src/auth.ts index aa7999ac7..7acab189c 100644 --- a/ee/apps/den-api/src/auth.ts +++ b/ee/apps/den-api/src/auth.ts @@ -1,21 +1,27 @@ -import { db } from "./db.js" -import { env } from "./env.js" -import { sendDenOrganizationInvitationEmail, sendDenVerificationEmail } from "./email.js" -import { syncDenSignupContact } from "./loops.js" +import { db } from "./db.js"; +import { env } from "./env.js"; +import { + sendDenOrganizationInvitationEmail, + sendDenVerificationEmail, +} from "./email.js"; +import { syncDenSignupContact } from "./loops.js"; import { DEN_API_KEY_DEFAULT_PREFIX, DEN_API_KEY_RATE_LIMIT_MAX, DEN_API_KEY_RATE_LIMIT_TIME_WINDOW_MS, -} from "./api-keys.js" -import { denOrganizationAccess, denOrganizationStaticRoles } from "./organization-access.js" -import { seedDefaultOrganizationRoles } from "./orgs.js" -import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid" -import * as schema from "@openwork-ee/den-db/schema" -import { apiKey } from "@better-auth/api-key" -import { APIError } from "better-call" -import { betterAuth } from "better-auth" -import { drizzleAdapter } from "better-auth/adapters/drizzle" -import { emailOTP, organization } from "better-auth/plugins" +} from "./api-keys.js"; +import { + denOrganizationAccess, + denOrganizationStaticRoles, +} from "./organization-access.js"; +import { seedDefaultOrganizationRoles } from "./orgs.js"; +import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"; +import * as schema from "@openwork-ee/den-db/schema"; +import { apiKey } from "@better-auth/api-key"; +import { APIError } from "better-call"; +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { emailOTP, organization } from "better-auth/plugins"; const socialProviders = { ...(env.github.clientId && env.github.clientSecret @@ -34,29 +40,39 @@ const socialProviders = { }, } : {}), -} +}; function hasRole(roleValue: string, roleName: string) { return roleValue .split(",") .map((entry) => entry.trim()) .filter(Boolean) - .includes(roleName) + .includes(roleName); } function getInvitationOrigin() { - return env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? env.betterAuthUrl + return ( + env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? + env.betterAuthUrl + ); } function buildInvitationLink(invitationId: string) { - return new URL(`/join-org?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString() + return new URL( + `/join-org?invite=${encodeURIComponent(invitationId)}`, + getInvitationOrigin(), + ).toString(); } export const auth = betterAuth({ baseURL: env.betterAuthUrl, secret: env.betterAuthSecret, - trustedOrigins: env.betterAuthTrustedOrigins.length > 0 ? env.betterAuthTrustedOrigins : undefined, - socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined, + trustedOrigins: + env.betterAuthTrustedOrigins.length > 0 + ? env.betterAuthTrustedOrigins + : undefined, + socialProviders: + Object.keys(socialProviders).length > 0 ? socialProviders : undefined, database: drizzleAdapter(db, { provider: "mysql", schema, @@ -70,32 +86,32 @@ export const auth = betterAuth({ generateId: (options) => { switch (options.model) { case "user": - return createDenTypeId("user") + return createDenTypeId("user"); case "session": - return createDenTypeId("session") + return createDenTypeId("session"); case "account": - return createDenTypeId("account") + return createDenTypeId("account"); case "verification": - return createDenTypeId("verification") + return createDenTypeId("verification"); case "apikey": case "apiKey": - return createDenTypeId("apiKey") + return createDenTypeId("apiKey"); case "rateLimit": - return createDenTypeId("rateLimit") + return createDenTypeId("rateLimit"); case "organization": - return createDenTypeId("organization") + return createDenTypeId("organization"); case "member": - return createDenTypeId("member") + return createDenTypeId("member"); case "invitation": - return createDenTypeId("invitation") + return createDenTypeId("invitation"); case "team": - return createDenTypeId("team") + return createDenTypeId("team"); case "teamMember": - return createDenTypeId("teamMember") + return createDenTypeId("teamMember"); case "organizationRole": - return createDenTypeId("organizationRole") + return createDenTypeId("organizationRole"); default: - return false + return false; } }, }, @@ -135,7 +151,7 @@ export const auth = betterAuth({ await syncDenSignupContact({ email: user.email, name: user.name, - }) + }); }, }, emailAndPassword: { @@ -150,14 +166,10 @@ export const auth = betterAuth({ expiresIn: 600, allowedAttempts: 5, async sendVerificationOTP({ email, otp, type }) { - if (type !== "email-verification") { - return - } - await sendDenVerificationEmail({ email, verificationCode: otp, - }) + }); }, }), organization({ @@ -182,30 +194,33 @@ export const auth = betterAuth({ invitedByEmail: data.inviter.user.email, organizationName: data.organization.name, role: data.role, - }) + }); }, organizationHooks: { afterCreateOrganization: async ({ organization }) => { - await seedDefaultOrganizationRoles(normalizeDenTypeId("organization", organization.id)) + await seedDefaultOrganizationRoles( + normalizeDenTypeId("organization", organization.id), + ); }, beforeRemoveMember: async ({ member }) => { if (hasRole(member.role, "owner")) { throw new APIError("BAD_REQUEST", { message: "The organization owner cannot be removed.", - }) + }); } }, beforeUpdateMemberRole: async ({ member, newRole }) => { if (hasRole(member.role, "owner")) { throw new APIError("BAD_REQUEST", { message: "The organization owner role cannot be changed.", - }) + }); } if (hasRole(newRole, "owner")) { throw new APIError("BAD_REQUEST", { - message: "Owner can only be assigned during organization creation.", - }) + message: + "Owner can only be assigned during organization creation.", + }); } }, }, @@ -224,4 +239,4 @@ export const auth = betterAuth({ }, }), ], -}) +}); diff --git a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts index ad838dd50..a2c6347a7 100644 --- a/ee/apps/den-api/src/routes/auth/desktop-handoff.ts +++ b/ee/apps/den-api/src/routes/auth/desktop-handoff.ts @@ -46,6 +46,35 @@ function readSingleHeader(value: string | null) { function isWebAppHost(hostname: string) { const normalized = hostname.trim().toLowerCase() + + if ( + normalized === "localhost" + || normalized === "0.0.0.0" + || normalized === "::1" + || normalized === "[::1]" + || /^127(?:\.\d{1,3}){3}$/.test(normalized) + ) { + return true + } + + const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (ipv4Match) { + const [first, second, third, fourth] = ipv4Match.slice(1).map(Number) + const octets = [first, second, third, fourth] + if (octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) { + if ( + first === 10 + || first === 127 + || (first === 172 && second >= 16 && second <= 31) + || (first === 192 && second === 168) + || (first === 169 && second === 254) + || (first === 100 && second >= 64 && second <= 127) + ) { + return true + } + } + } + return normalized === "app.openworklabs.com" || normalized === "app.openwork.software" || normalized.startsWith("app.") @@ -178,51 +207,75 @@ export function registerDesktopAuthRoutes { + const rows = await tx + .select({ + session: AuthSessionTable, + user: AuthUserTable, + }) + .from(DesktopHandoffGrantTable) + .innerJoin(AuthSessionTable, eq(DesktopHandoffGrantTable.session_token, AuthSessionTable.token)) + .innerJoin(AuthUserTable, eq(DesktopHandoffGrantTable.user_id, AuthUserTable.id)) + .where( + and( + eq(DesktopHandoffGrantTable.id, input.grant), + isNull(DesktopHandoffGrantTable.consumed_at), + gt(DesktopHandoffGrantTable.expires_at, now), + gt(AuthSessionTable.expiresAt, now), + ), + ) + .limit(1) + + const row = rows[0] + if (!row) { + return null + } + + const consumedAt = new Date() + await tx + .update(DesktopHandoffGrantTable) + .set({ consumed_at: consumedAt }) + .where( + and( + eq(DesktopHandoffGrantTable.id, input.grant), + isNull(DesktopHandoffGrantTable.consumed_at), + gt(DesktopHandoffGrantTable.expires_at, now), + ), + ) + + const claimed = await tx + .select({ id: DesktopHandoffGrantTable.id }) + .from(DesktopHandoffGrantTable) + .where( + and( + eq(DesktopHandoffGrantTable.id, input.grant), + eq(DesktopHandoffGrantTable.consumed_at, consumedAt), + ), + ) + .limit(1) + + if (!claimed[0]) { + return null + } + + return { + token: row.session.token, + user: { + id: row.user.id, + email: row.user.email, + name: row.user.name, + }, + } + }) + + if (!exchange) { return c.json({ error: "grant_not_found", message: "This desktop sign-in link is missing, expired, or already used.", }, 404) } - await db - .update(DesktopHandoffGrantTable) - .set({ consumed_at: now }) - .where( - and( - eq(DesktopHandoffGrantTable.id, input.grant), - isNull(DesktopHandoffGrantTable.consumed_at), - ), - ) - - return c.json({ - token: row.session.token, - user: { - id: row.user.id, - email: row.user.email, - name: row.user.name, - }, - }) + return c.json(exchange) }, ) }