OnShape auto-login

Enables auto-login to OnShape

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         OnShape auto-login
// @namespace    V@no
// @version      26.3.3-010212
// @description  Enables auto-login to OnShape
// @author       V@no
// @license      MIT
// @match        https://cad.onshape.com/*
// @match        https://cad.onshape.com/*?*
// @icon         https://cad.onshape.com/favicon.png
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValues
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// ==/UserScript==

{
	"use strict";

	const VERSION = "26.3.3-010212";
	const prefix = "OnShape auto-login:";
	/**
	 * @typedef {Object} AutoLoginOptions
	 * @property {boolean} autofillEmail
	 * @property {boolean} autofillPassword
	 * @property {boolean} isDarkModeOS
	 * @property {boolean} isDarkModeBS
	 */
	/** @type {AutoLoginOptions} */
	const options = {
		autofillEmail: GM_getValue("ae", true),
		autofillPassword: GM_getValue("ap", false),
		get isDarkModeOS () {
			return GM_getValue("dos");
		},
		get isDarkModeBS () {
			return GM_getValue("dbs");
		}
	};
	/**
	 * Greasemonkey menu bindings.
	 * @type {{register: () => void, autofillEmail: number|null, autofillPassword: number|null}}
	 */
	const menu = {
		register: () =>
		{
			GM_unregisterMenuCommand(menu.autofillEmail);
			GM_unregisterMenuCommand(menu.autofillPassword);
			menu.autofillEmail = GM_registerMenuCommand(`Autofill email ${options.autofillEmail ? "ON" : "OFF"}`, () =>
			{
				options.autofillEmail = !options.autofillEmail;
				GM_setValue("ae", options.autofillEmail);
				if (!options.autofillEmail)
					GM_deleteValues(["e"]);

				menu.register();
			});
			menu.autofillPassword = GM_registerMenuCommand(`Autofill password ${options.autofillPassword ? "ON" : "OFF"}`, () =>
			{
				options.autofillPassword = !options.autofillPassword;
				GM_setValue("ap", options.autofillPassword);
				if (!options.autofillPassword)
					GM_deleteValues(["p"]);

				menu.register();
			});
		},
		autofillEmail: null,
		autofillPassword: null
	};
	menu.register();
	/**
	 * Clears all stored credentials and encryption key.
	 */
	const clearData = () =>
	{
		console.trace(prefix, "Clearing stored credentials");
		GM_deleteValues(["k", "e", "p"]);
	};
	/**
	 * Generate a random hex key.
	 * @param {number} [size=32]
	 * @returns {string}
	 */
	const randomKey = (size = 32) => [...crypto.getRandomValues(new Uint8Array(size))].map(b => b.toString(16).padStart(2, "0")).join("");
	const KEY_ROTATE_LOCK = "autologin_key_rotation_lock";
	const KEY_ROTATE_LOCK_TTL = 5000;
	/**
	 * Try to acquire cross-tab key rotation lock.
	 * @returns {string|null}
	 */
	const acquireRotationLock = () =>
	{
		const token = randomKey(12);
		const now = Date.now();
		const lockRaw = localStorage.getItem(KEY_ROTATE_LOCK);
		if (lockRaw)
		{
			try
			{
				const lock = JSON.parse(lockRaw);
				if (lock?.token && lock.expires > now)
					return null;
			}
			catch (err)
			{
				console.log(prefix, "Invalid rotation lock format, resetting", err);
			}
		}

		localStorage.setItem(KEY_ROTATE_LOCK, JSON.stringify({ token, expires: now + KEY_ROTATE_LOCK_TTL }));

		try
		{
			const lock = JSON.parse(localStorage.getItem(KEY_ROTATE_LOCK) || "{}");
			if (lock.token !== token)
				return null;
		}
		catch (_err)
		{
			return null;
		}

		return token;
	};
	/**
	 * Release cross-tab key rotation lock.
	 * @param {string|null} token
	 */
	const releaseRotationLock = token =>
	{
		if (!token)
			return;

		try
		{
			const lock = JSON.parse(localStorage.getItem(KEY_ROTATE_LOCK) || "{}");
			if (lock.token === token)
				localStorage.removeItem(KEY_ROTATE_LOCK);
		}
		catch (_err)
		{
			localStorage.removeItem(KEY_ROTATE_LOCK);
		}
	};
	/**
	 * Returns a persistent random key used for encryption.
	 * @param {boolean} [newKey] Force regeneration when true.
	 * @returns {string}
	 */
	const getKey = newKey =>
	{
		let key = GM_getValue("k");
		let autologin = localStorage.getItem("autologin");
		if (!(newKey || !key || !autologin))
		{
			GM_setValue("k", key);
			return key + autologin;
		}

		const lockToken = acquireRotationLock();
		if (!lockToken)
		{
			key = GM_getValue("k");
			autologin = localStorage.getItem("autologin");
			if (key && autologin)
			{
				GM_setValue("k", key);
				return key + autologin;
			}
			console.log(prefix, "Rotation lock held by another tab; proceeding without lock fallback");
		}

		try
		{
			key = GM_getValue("k");
			autologin = localStorage.getItem("autologin");
			if (newKey || !key || !autologin)
			{
				console.log(prefix, "Generating new encryption key", newKey ? "(forced)" : "", "key:", key, "autologin:", autologin);
				clearData();
				key = randomKey();
				autologin = randomKey();
				localStorage.setItem("autologin", autologin);
			}
			GM_setValue("k", key);
			return key + autologin;
		}
		finally
		{
			releaseRotationLock(lockToken);
		}
	};
	getKey();

	const enc = new TextEncoder();
	const dec = new TextDecoder();

	/**
	 * Derive an AES-GCM key from a password and salt.
	 * @param {string} password
	 * @param {Uint8Array} salt
	 * @returns {Promise<CryptoKey>}
	 */
	const deriveKey = async (password, salt) => {
		const baseKey = await crypto.subtle.importKey(
			"raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]
		);
		return crypto.subtle.deriveKey(
			{ name: "PBKDF2", salt, iterations: 100_000, hash: "SHA-256" },
			baseKey,
			{ name: "AES-GCM", length: 256 },
			false,
			["encrypt", "decrypt"]
		);
	};

	/**
	 * Encrypt a string and return Base64 payload.
	 * Payload layout: [salt(16)][iv(12)][ciphertext].
	 * @param {string} plainText
	 * @returns {Promise<string>}
	 */
	const encryptString = async (plainText) => {
		const salt = crypto.getRandomValues(new Uint8Array(16));
		const iv = crypto.getRandomValues(new Uint8Array(12));
		const key = await deriveKey(getKey(), salt);
		const cipher = await crypto.subtle.encrypt(
			{ name: key.algorithm.name, iv },
			key,
			enc.encode(plainText)
		);
		const payload = new Uint8Array([...salt, ...iv, ...new Uint8Array(cipher)]);
		return btoa(String.fromCharCode(...payload));
	};

	/**
	 * Decrypt a Base64 payload created by encryptString.
	 * @param {string} b64
	 * @returns {Promise<string|null>}
	 */
	const decryptString = async (b64) => {
		let result;
		try
		{
			const data = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
			const salt = data.slice(0, 16);
			const iv = data.slice(16, 28);
			const cipher = data.slice(28);
			const key = await deriveKey(getKey(), salt);
			result = await crypto.subtle.decrypt(
				{ name: key.algorithm.name, iv },
				key,
				cipher
			);
		}
		catch (err)
		{
			console.log(prefix, err);
			return null;
		}
		return dec.decode(result);
	};

	/**
	 * Set input value via native setter to trigger framework listeners.
	 * @param {HTMLInputElement|HTMLTextAreaElement} element
	 * @param {string} value
	 */
	const setNativeValue = (element, value) =>
	{
		const descriptor = Object.getOwnPropertyDescriptor(element.__proto__, "value");
		const setter = descriptor && descriptor.set;
		if (setter)
			setter.call(element, value);
		else
			element.value = value;
		element.dispatchEvent(new Event("input", { bubbles: true }));
		element.dispatchEvent(new Event("change", { bubbles: true }));
	};
	/**
	 * Persist username/email field value when available.
	 * @param {HTMLFormElement|null|undefined} form
	 */
	const saveEmail = async form =>
	{
		if (!options.autofillEmail)
			return;

		const emailValue = form?.elements?.username?.value || form?.querySelector("input[name=username]")?.value;
		if (!emailValue)
			return;

		GM_setValue("e", await encryptString(emailValue));
		// console.log(prefix, "Stored email:", `"${emailValue}"`);
	};

	let elEmailLoaded = document.querySelector("input[name=username]");
	const formSelector = "form[name=osForm]";
	let elSignInForm = document.querySelector("username-password > " + formSelector);
	/**
	 * Watches for login form nodes, handles autofill and storage updates.
	 */
	const DOM_observer = new MutationObserver(async (mutationList, _observer) =>
	{
		for (const mutation of mutationList)
		{
			if (mutation.type !== "childList")
				continue;

			const node = mutation.target;
			if (!document.body.classList.contains("al-logout"))
			{
				const elLogout = node.querySelector("a[data-automation=sign-out-button]:not(.al-processed)");
				if (elLogout)
				{
					document.body.classList.add("al-logout");
					elLogout.classList.add("al-processed");
					elLogout.addEventListener("click", () =>
					{
						console.log(prefix, "Logged out, clearing data");
						clearData();
					});
					console.log(prefix, "Found logout button, will clear data on click");
				}
			}
			if (!options.autofillEmail && !options.autofillPassword)
				continue;

			//we can't just use found form, because it can be replaced with a new one
			if (node.matches(formSelector))
				elSignInForm = node;

			if (!elSignInForm)
				continue;

			if (options.autofillEmail)
			{
				const elEmail = node.querySelector("input[name=username]:not(.al-processed)") || elEmailLoaded;
				if (elEmail)
				{
					elEmailLoaded = null;
					elEmail.classList.add("al-processed");
					elEmail.addEventListener("input", async evt =>
					{
						await saveEmail(evt.target.form);
					});
					elEmail.addEventListener("change", async evt =>
					{
						await saveEmail(evt.target.form);
					});
					const e = GM_getValue("e");
					const email = e ? await decryptString(e) : null;
					if (email)
					{

						elSignInForm?.classList.add("al-email");
						console.log(prefix, "Autofill email:", `"${email}"`);
						setNativeValue(elEmail, email);
						elEmail.dispatchEvent(new KeyboardEvent("keypress", {
							key: "Enter",
							code: 13,
							keyCode: 13,
							which: 13,
							bubbles: true,
							cancelable: true
						}));
					}
					else
						console.log(prefix, "No email to autofill");

				}
			}
			const elSignInButton = document.querySelector(".os-signin-button[disabled]");
			if (elSignInButton)
			{
				console.log(prefix, "Enabling signin button");
				elSignInButton.disabled = false;
			}
			const elPassword = document.querySelector("input[name=password]:not(.al-processed)");
			if (!elPassword)
				continue;

			console.log(prefix, "Found password input");

			elPassword.classList.add("al-processed");
			elPassword.addEventListener("input", async evt =>
			{
				if (!evt.target.value)
					return;
				await saveEmail(evt.target.form);

				if (options.autofillPassword)
				{
					GM_setValue("p", await encryptString(evt.target.value));
					// console.log(prefix, "Stored password");
					elSignInButton?.click();
					console.log(prefix, "Clicked sign-in button");
				}

			});
			const p = GM_getValue("p");
			const password = options.autofillPassword && p ? await decryptString(p) : null;
			if (options.autofillPassword && !password)
				console.log(prefix, "No password to autofill");
			else
			{
				console.log(prefix, "Autofill password");
				setNativeValue(elPassword, password);
			}

			if (elEmailLoaded || elPassword)
			{
				// elSignInForm.classList.add("al-processed");
				elSignInForm = null;
			}
		}

	});

	DOM_observer.observe(document.body, {
		subtree: true,
		childList: true
	});

	/**
	 * Persists and restores theme attributes on <body>.
	 */
	const BODY_observer = new MutationObserver((mutationList, _observer) =>
	{
		for (const mutation of mutationList)
		{
			if (mutation.type !== "attributes")
				continue;

			const attr = mutation.attributeName;
			const value = mutation.target.getAttribute(attr);
			if (!mutation.oldValue)
			{
				if (attr === "data-os-theme")
					mutation.target.setAttribute(attr, options.isDarkModeOS);
				else if (attr === "data-bs-theme")
					mutation.target.setAttribute(attr, options.isDarkModeBS);
				continue;
			}

			if (value === undefined)
				continue;

			if (attr === "data-os-theme")
				GM_setValue("dos", value);
			else if (attr === "data-bs-theme")
				GM_setValue("dbs", value);

		}
	});

	BODY_observer.observe(document.body, {
		attributes: true,
		attributeFilter: ["data-os-theme", "data-bs-theme"],
		attributeOldValue: true
	});
	if (options.isDarkModeOS)
		document.body.setAttribute("data-os-theme", options.isDarkModeOS);

	if (options.isDarkModeBS)
		document.body.setAttribute("data-bs-theme", options.isDarkModeBS);
	console.log(`OnShape auto-login v${VERSION} loaded`, "https://greatest.deepsurf.us/en/scripts/565664");
}