From 66cbe2b946ca3cf7934c24497d6d06abb9ddaed7 Mon Sep 17 00:00:00 2001 From: Xingwang Liao Date: Tue, 24 Sep 2024 11:28:52 +0800 Subject: [PATCH] feat: refactor and add token expires check --- package.json | 2 +- pnpm-lock.yaml | 2 +- src/client/plugin/credentials.ts | 19 ++++++- src/client/plugin/init.ts | 91 ++++++++++++++++++++++++++++---- src/client/plugin/lib.ts | 35 +++++++++++- src/client/plugin/usage-info.ts | 6 +-- src/client/verdaccio.ts | 60 ++------------------- src/constants.ts | 4 ++ 8 files changed, 144 insertions(+), 75 deletions(-) diff --git a/package.json b/package.json index c8a0bb7..b00e7be 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,7 @@ "typescript": "^5.6.2", "verdaccio": "^6.0.0", "verdaccio-htpasswd": "13.0.0-next-8.1", - "verdaccio-openid": "file:.", + "verdaccio-openid": "file:", "verdaccio5": "npm:verdaccio@5.0.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4639cc6..24c50a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,7 +163,7 @@ importers: specifier: 13.0.0-next-8.1 version: 13.0.0-next-8.1 verdaccio-openid: - specifier: file:. + specifier: 'file:' version: file:(verdaccio@6.0.0(typanion@3.14.0)) verdaccio5: specifier: npm:verdaccio@5.0.0 diff --git a/src/client/plugin/credentials.ts b/src/client/plugin/credentials.ts index 45ebda4..4f78b2f 100644 --- a/src/client/plugin/credentials.ts +++ b/src/client/plugin/credentials.ts @@ -4,6 +4,8 @@ // thinks we are logged in. // +import { parseJwt } from "./lib"; + export type Credentials = { username: string; uiToken: string; @@ -24,7 +26,7 @@ export function clearCredentials() { } export function isLoggedIn(): boolean { - for (const key of ["username", "token", "npm"]) { + for (const key of ["username", "token", "npm"] as const) { if (!localStorage.getItem(key)) { return false; } @@ -33,8 +35,21 @@ export function isLoggedIn(): boolean { return true; } +export function isTokenExpired() { + const token = localStorage.getItem("token"); + if (!token) return true; + + const payload = parseJwt(token); + if (!payload) return true; + + // Report as expired before (real expiry - 30s) + const jsTimestamp = payload.exp * 1000 - 30_000; + + return Date.now() >= jsTimestamp; +} + export function validateCredentials(credentials: Partial): credentials is Credentials { - for (const key of ["username", "uiToken", "npmToken"]) { + for (const key of ["username", "uiToken", "npmToken"] as const) { if (!credentials[key]) { return false; } diff --git a/src/client/plugin/init.ts b/src/client/plugin/init.ts index cac91ea..e910190 100644 --- a/src/client/plugin/init.ts +++ b/src/client/plugin/init.ts @@ -1,8 +1,17 @@ -import { loginHref, logoutHref } from "@/constants"; +import { loginHref, logoutHref, replacedAttrKey, replacedAttrValue } from "@/constants"; import { parseQueryParams } from "@/query-params"; -import { clearCredentials, type Credentials, saveCredentials, validateCredentials } from "./credentials"; +import { copyToClipboard } from "./clipboard"; +import { + clearCredentials, + type Credentials, + isLoggedIn, + isTokenExpired, + saveCredentials, + validateCredentials, +} from "./credentials"; import { getBaseUrl, interruptClick, retry } from "./lib"; +import { getUsageInfo } from "./usage-info"; /** * Change the current URL to only the current pathname and reload. @@ -16,8 +25,9 @@ function reloadToPathname() { location.reload(); } -function saveAndRemoveQueryParams(): boolean { +function parseAndSaveCredentials(): boolean { const credentials: Partial = parseQueryParams(location.search); + if (!validateCredentials(credentials)) { return false; } @@ -27,26 +37,85 @@ function saveAndRemoveQueryParams(): boolean { return true; } -// -// Shared API -// +function cloneAndAppendCommand(command: HTMLElement, info: string, isLoggedIn: boolean): void { + const cloned = command.cloneNode(true) as HTMLElement; + + const textEl = cloned.querySelector("span")!; + textEl.textContent = info; + + const copyEl = cloned.querySelector("button")!; + + copyEl.style.visibility = isLoggedIn ? "visible" : "hidden"; + copyEl.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + + copyToClipboard(info); + }); + + command.parentElement!.append(cloned); +} + +// Remove commands that don't work with oauth +function removeInvalidCommands(commands: HTMLElement[]): void { + for (const node of commands) { + const content = node.textContent || ""; + + if (content && (content.includes("adduser") || content.includes("set password"))) { + node.remove(); + } + } +} + +function updateUsageTabs(usageTabsSelector: string): void { + const tabs = [...document.querySelectorAll(usageTabsSelector)].filter( + (node) => node.getAttribute(replacedAttrKey) !== replacedAttrValue, + ); + + if (tabs.length === 0) return; + + const loggedIn = isLoggedIn(); + + const usageInfoLines = getUsageInfo(loggedIn).split("\n").reverse(); + + for (const tab of tabs) { + const commands = [...tab.querySelectorAll("button")] + .map((node) => node.parentElement!) + .filter((node) => !!/^(npm|pnpm|yarn)/.test(node.textContent || "")); + + if (commands.length === 0) continue; + + for (const info of usageInfoLines) { + cloneAndAppendCommand(commands[0], info, loggedIn); + } + + removeInvalidCommands(commands); + + tab.setAttribute(replacedAttrKey, replacedAttrValue); + } +} + export interface InitOptions { loginButton: string; logoutButton: string; - updateUsageInfo: () => void; + usageTabs: string; } // // By default the login button opens a form that asks the user to submit credentials. // We replace this behaviour and instead redirect to the route that handles OAuth. // -export function init({ loginButton, logoutButton, updateUsageInfo }: InitOptions): void { - if (saveAndRemoveQueryParams()) { +export function init({ loginButton, logoutButton, usageTabs }: InitOptions): void { + if (parseAndSaveCredentials()) { // If we are new logged in, reload the page to remove the query params reloadToPathname(); return; } + if (isTokenExpired()) { + clearCredentials(); + } + const baseUrl = getBaseUrl(true); interruptClick(loginButton, () => { @@ -59,7 +128,9 @@ export function init({ loginButton, logoutButton, updateUsageInfo }: InitOptions location.href = baseUrl + logoutHref; }); - document.addEventListener("click", () => retry(updateUsageInfo)); + const updateUsageInfo = () => updateUsageTabs(usageTabs); + + document.addEventListener("click", () => retry(updateUsageInfo, 2)); retry(updateUsageInfo); } diff --git a/src/client/plugin/lib.ts b/src/client/plugin/lib.ts index 94289ee..28d3faa 100644 --- a/src/client/plugin/lib.ts +++ b/src/client/plugin/lib.ts @@ -1,10 +1,41 @@ +/* eslint-disable unicorn/prefer-spread, unicorn/prefer-code-point */ + +// This parseJWT implementation is taken from https://stackoverflow.com/a/38552302/1935971 +export function parseJwt(token: string): Record | null { + // JWT has 3 parts separated by ".", the payload is the base64url-encoded part in the middle + const base64Url = token.split(".")[1]; + // base64url replaced '+' and '/' with '-' and '_', so we undo it here + const base64 = base64Url.replaceAll("-", "+").replaceAll("_", "/"); + const jsonPayload = decodeURIComponent( + window + .atob(base64) + .split("") + // atob decoded the base64 string, but multi-byte characters (emojis for example) + // are not decoded properly. For example, "🍀" looks like "ð\x9F\x8D\x80". The next + // line converts bytes into URI-percent-encoded format, for example "%20" for space. + // Lastly, the decodeURIComponent wrapping this can correctly get a UTF-8 string. + .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2)) + .join(""), + ); + + let payload: Record; + try { + payload = JSON.parse(jsonPayload); + } catch { + return null; + } + + return payload; +} + /** * Retry an action multiple times. * * @param action + * @param times */ -export function retry(action: () => void): void { - for (let i = 0; i < 10; i++) { +export function retry(action: () => void, times = 5): void { + for (let i = 0; i < times; i++) { setTimeout(() => action(), 100 * i); } } diff --git a/src/client/plugin/usage-info.ts b/src/client/plugin/usage-info.ts index 902307f..81c0706 100644 --- a/src/client/plugin/usage-info.ts +++ b/src/client/plugin/usage-info.ts @@ -1,9 +1,9 @@ // // Replace the default npm usage info and displays the authToken that needs // to be configured. -// -export function getUsageInfo(isLoggedIn: boolean): string { - if (!isLoggedIn) { + +export function getUsageInfo(loggedIn: boolean): string { + if (!loggedIn) { return "Click the login button to authenticate with OIDC."; } diff --git a/src/client/verdaccio.ts b/src/client/verdaccio.ts index fb95439..88fd7c4 100644 --- a/src/client/verdaccio.ts +++ b/src/client/verdaccio.ts @@ -1,63 +1,11 @@ -import { plugin } from "@/constants"; - -import { copyToClipboard, getUsageInfo, init, isLoggedIn } from "./plugin"; +import { init } from "./plugin"; const loginButtonSelector = `[data-testid="header--button-login"]`; -const logoutButtonSelector = `[data-testid="logOutDialogIcon"]`; -const tabSelector = `[data-testid="tab-content"]`; - -const attrKey = `${plugin.name}-replaced`; -const attrValue = "1"; - -function updateUsageInfo(): void { - const loggedIn = isLoggedIn(); - - const tabs = document.querySelectorAll(tabSelector); - if (!tabs) return; - - const usageInfoLines = getUsageInfo(loggedIn).split("\n").reverse(); - - for (const tab of tabs) { - const alreadyReplaced = tab.getAttribute(attrKey) === attrValue; - if (alreadyReplaced) continue; - - const commands = [...tab.querySelectorAll("button")] - .map((node) => node.parentElement!) - .filter((node) => !!/^(npm|pnpm|yarn)/.test(node.textContent || "")); - - if (commands.length === 0) continue; - - for (const info of usageInfoLines) { - const cloned = commands[0].cloneNode(true) as HTMLElement; - - const textEl = cloned.querySelector("span")!; - textEl.textContent = info; - - const copyEl = cloned.querySelector("button")!; - - copyEl.style.visibility = loggedIn ? "visible" : "hidden"; - copyEl.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - copyToClipboard(info); - }); - - commands[0].parentElement!.append(cloned); - tab.setAttribute(attrKey, attrValue); - } - - // Remove commands that don't work with oauth - for (const node of commands) { - if (node.textContent?.includes("adduser") || node.textContent?.includes("set password")) { - node.remove(); - tab.setAttribute(attrKey, attrValue); - } - } - } -} +const logoutButtonSelector = `[data-testid="header--button-logout"],[data-testid="logOutDialogIcon"]`; +const usageTabsSelector = `[data-testid="tab-content"]`; init({ loginButton: loginButtonSelector, logoutButton: logoutButtonSelector, - updateUsageInfo, + usageTabs: usageTabsSelector, }); diff --git a/src/constants.ts b/src/constants.ts index 1363946..5d6a853 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -6,6 +6,10 @@ export const plugin = { }; export const pluginKey = plugin.name.replace("verdaccio-", ""); + +export const replacedAttrKey = `data-${pluginKey}`; +export const replacedAttrValue = "1"; + export const authorizePath = "/-/oauth/authorize"; export const callbackPath = "/-/oauth/callback"; export const loginHref = authorizePath;