From 22cc3ad0ce8abcb505e6e8bcd01e1bac18241e6a Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 6 May 2026 08:58:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20Launcher=20handoff=20=D0=B8=20invit?= =?UTF-8?q?e=20auto-login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/dev-server.mjs | 203 ++++++++++++++++++++++++++++++++---- src/app/LauncherApp.tsx | 1 + src/shared/api/inviteApi.ts | 2 + 3 files changed, 185 insertions(+), 21 deletions(-) diff --git a/server/dev-server.mjs b/server/dev-server.mjs index 791db53..7589001 100644 --- a/server/dev-server.mjs +++ b/server/dev-server.mjs @@ -14,6 +14,7 @@ const serverRoot = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(serverRoot, ".."); const maxStorageJsonBodyBytes = "260mb"; const pendingLoginTtlMs = 10 * 60 * 1000; +const serviceHandoffTtlMs = 60 * 1000; const sessionTtlMs = 12 * 60 * 60 * 1000; const oidcStateCookieName = "nodedc_oidc_state"; const maxOidcStateCookieEntries = 8; @@ -32,6 +33,7 @@ const httpServer = createHttpServer(app); const controlPlaneStore = createControlPlaneStore({ projectRoot }); const authentikSyncClient = createAuthentikSyncClient({ baseUrl: config.authentikBaseUrl, token: config.authentikApiToken }); const pendingLogins = new Map(); +const serviceHandoffs = new Map(); const sessions = new Map(); const runtimeEventClients = new Set(); let discoveryCache = null; @@ -135,22 +137,11 @@ app.get("/auth/callback", asyncRoute(async (req, res) => { const discovery = await getOidcDiscovery(); const tokenSet = await exchangeCodeForTokens(discovery, code, pendingLogin.codeVerifier); const claims = await verifyIdToken(discovery, tokenSet.id_token, pendingLogin.nonce); - const sessionId = randomBase64Url(48); - const session = { - id: sessionId, - user: normalizeUser(claims), - tokenSet: { - idToken: tokenSet.id_token, - accessToken: tokenSet.access_token ?? null, - expiresAt: tokenSet.expires_in ? Date.now() + Number(tokenSet.expires_in) * 1000 : null, - }, - createdAt: Date.now(), - expiresAt: Date.now() + sessionTtlMs, - }; - - pruneExpiredSessions(); - sessions.set(sessionId, session); - res.cookie(sessionCookieName, sessionId, cookieOptions(sessionTtlMs)); + createLauncherSession(res, normalizeUser(claims), { + idToken: tokenSet.id_token, + accessToken: tokenSet.access_token ?? null, + expiresAt: tokenSet.expires_in ? Date.now() + Number(tokenSet.expires_in) * 1000 : null, + }); res.redirect(pendingLogin.returnTo); })); @@ -243,6 +234,81 @@ app.get("/api/apps", (req, res) => { res.json({ apps: getAppsForSession(session) }); }); +app.get("/api/services/:serviceSlug/launch", requireSession, (req, res) => { + const serviceSlug = sanitizeServiceSlug(req.params.serviceSlug); + const runtimeContext = getRuntimeSessionContext(req.nodedcSession); + const app = getAppsForUser(runtimeContext.groups).find((candidate) => candidate.slug === serviceSlug); + + if (!app || !app.hasAccess || app.status !== "active") { + res.status(403).type("text/plain").send("NODE.DC service access denied."); + return; + } + + if (serviceSlug !== "task-manager") { + res.redirect(app.openUrl || app.url || "/"); + return; + } + + const handoffToken = createServiceHandoff(serviceSlug, runtimeContext.user); + const taskBaseUrl = getTaskBaseUrl(); + const targetUrl = new URL("/auth/nodedc/handoff/", taskBaseUrl); + const nextPath = sanitizeReturnTo(req.query.next_path || req.query.returnTo || "/"); + + targetUrl.searchParams.set("token", handoffToken); + + if (nextPath && nextPath !== "/") { + targetUrl.searchParams.set("next_path", nextPath); + } + + res.redirect(targetUrl.toString()); +}); + +app.post("/api/internal/handoff/consume", (req, res) => { + if (!isInternalRequestAuthorized(req)) { + res.status(config.internalAccessToken ? 401 : 503).json({ + ok: false, + error: config.internalAccessToken ? "internal_handoff_unauthorized" : "internal_handoff_not_configured", + }); + return; + } + + const token = typeof req.body?.token === "string" ? req.body.token : ""; + const serviceSlug = sanitizeServiceSlug(req.body?.serviceSlug); + const handoff = consumeServiceHandoff(token, serviceSlug); + + if (!handoff) { + res.status(404).json({ ok: false, error: "handoff_not_found" }); + return; + } + + const snapshot = controlPlaneStore.getSnapshot({ name: "NODE.DC handoff validation" }); + const user = findInternalAccessUser(snapshot.data, { + subject: handoff.user.sub, + email: handoff.user.email, + }); + const groups = user ? resolveRequiredGroups(snapshot.data, user) : handoff.user.groups; + const app = getAppsForUser(groups).find((candidate) => candidate.slug === serviceSlug); + + if (!user || !app?.hasAccess) { + res.status(403).json({ ok: false, error: "handoff_access_denied" }); + return; + } + + res.json({ + ok: true, + serviceSlug, + user: { + id: user.id, + email: user.email, + name: user.name, + avatarUrl: user.avatarUrl ?? null, + subject: user.authentikUserId || handoff.user.sub, + authentikUserId: user.authentikUserId ?? null, + groups, + }, + }); +}); + app.get("/api/profile", requireSession, (req, res) => { const { actor, data } = getLauncherProfileContext(req.nodedcSession); const user = findLauncherUser(data, actor.id); @@ -396,6 +462,17 @@ app.post("/api/invites/:token/register", asyncRoute(async (req, res) => { email: result.user.email, name: result.user.name, }); + const groups = resolveRequiredGroups(storeResult.data, storeResult.user); + + createLauncherSession( + res, + normalizeControlPlaneSessionUser(storeResult.user, groups), + { + idToken: null, + accessToken: null, + expiresAt: null, + } + ); publishControlPlaneEvent("invite.registered", [result.user.id]); res.json({ @@ -403,7 +480,9 @@ app.post("/api/invites/:token/register", asyncRoute(async (req, res) => { user: storeResult.user, data: storeResult.data, provisioning: toProvisioningResponse(provisionedUser), - loginUrl: buildLoginRedirectUrl("/", { forceLogin: true }), + loginUrl: buildLoginRedirectUrl("/", { forceLogin: true, includeReturnTo: true }), + redirectUrl: "/", + authenticated: true, }); })); @@ -809,6 +888,37 @@ function normalizeUser(claims) { }; } +function normalizeControlPlaneSessionUser(user, groups) { + return { + sub: String(user.authentikUserId || user.id), + email: user.email, + name: user.name, + preferredUsername: user.email, + avatarUrl: user.avatarUrl ?? null, + groups, + }; +} + +function createLauncherSession(res, user, tokenSet = {}) { + const sessionId = randomBase64Url(48); + const session = { + id: sessionId, + user, + tokenSet: { + idToken: tokenSet.idToken ?? null, + accessToken: tokenSet.accessToken ?? null, + expiresAt: tokenSet.expiresAt ?? null, + }, + createdAt: Date.now(), + expiresAt: Date.now() + sessionTtlMs, + }; + + pruneExpiredSessions(); + sessions.set(sessionId, session); + res.cookie(sessionCookieName, sessionId, cookieOptions(sessionTtlMs)); + return session; +} + function firstStringClaim(...values) { for (const value of values) { if (typeof value === "string" && value) return value; @@ -1066,13 +1176,64 @@ function specialRequiredGroups(slug) { function getServiceUrl(service) { if (service.slug === "task-manager") { - const taskBaseUrl = process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`; - return `${taskBaseUrl.replace(/\/$/, "")}/auth/oidc/login/`; + return `/api/services/${encodeURIComponent(service.slug)}/launch`; } return service.launchUrl || service.url || "#"; } +function getTaskBaseUrl() { + const taskBaseUrl = process.env.TASK_BASE_URL ?? `http://${process.env.TASK_DOMAIN ?? "task.local.nodedc"}`; + return taskBaseUrl.replace(/\/$/, ""); +} + +function createServiceHandoff(serviceSlug, user) { + pruneExpiredServiceHandoffs(); + + const token = randomBase64Url(48); + serviceHandoffs.set(token, { + serviceSlug, + user: { + sub: user.sub, + email: user.email, + name: user.name, + preferredUsername: user.preferredUsername ?? user.email, + avatarUrl: user.avatarUrl ?? null, + groups: user.groups ?? [], + }, + expiresAt: Date.now() + serviceHandoffTtlMs, + }); + + return token; +} + +function consumeServiceHandoff(token, serviceSlug) { + pruneExpiredServiceHandoffs(); + + if (!token) { + return null; + } + + const handoff = serviceHandoffs.get(token); + serviceHandoffs.delete(token); + + if (!handoff || handoff.expiresAt < Date.now() || handoff.serviceSlug !== serviceSlug) { + return null; + } + + return handoff; +} + +function pruneExpiredServiceHandoffs() { + const now = Date.now(); + + for (const [token, handoff] of serviceHandoffs.entries()) { + if (!handoff || handoff.expiresAt < now) { + serviceHandoffs.delete(token); + } + } +} + function getFrontchannelLogoutUrls() { const urls = [config.taskLogoutUrl]; const launcherData = readLauncherData(); @@ -1621,7 +1782,7 @@ function setNoStore(res) { res.setHeader("Expires", "0"); } -function buildLoginRedirectUrl(returnTo, { forceLogin = false } = {}) { +function buildLoginRedirectUrl(returnTo, { forceLogin = false, includeReturnTo = false } = {}) { const loginUrl = new URL("/auth/login", config.appBaseUrl); const cleanReturnTo = sanitizeReturnTo(returnTo); @@ -1629,7 +1790,7 @@ function buildLoginRedirectUrl(returnTo, { forceLogin = false } = {}) { loginUrl.searchParams.set("prompt", "login"); } - if (cleanReturnTo !== "/") { + if (includeReturnTo || cleanReturnTo !== "/") { loginUrl.searchParams.set("returnTo", cleanReturnTo); } diff --git a/src/app/LauncherApp.tsx b/src/app/LauncherApp.tsx index 87772c5..ac7768b 100644 --- a/src/app/LauncherApp.tsx +++ b/src/app/LauncherApp.tsx @@ -475,6 +475,7 @@ export function LauncherApp() { setData(syncLauncherServiceLinks(result.data)); setInviteFlow({ status: "registered", payload, loginUrl: result.loginUrl }); + window.location.replace(result.redirectUrl || "/"); } catch (error) { setInviteFlow({ status: "error", diff --git a/src/shared/api/inviteApi.ts b/src/shared/api/inviteApi.ts index c4a844d..47aa844 100644 --- a/src/shared/api/inviteApi.ts +++ b/src/shared/api/inviteApi.ts @@ -24,6 +24,8 @@ export interface RegisterInviteCommand { export interface RegisterInviteResponse extends AcceptInviteResponse { loginUrl: string; + redirectUrl: string; + authenticated: boolean; } export async function fetchPublicInvite(token: string): Promise {