ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: Launcher handoff и invite auto-login
This commit is contained in:
parent
90249208b8
commit
22cc3ad0ce
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<PublicInviteResponse> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue