Enables auto-login to OnShape
// ==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");
}