Skip to content

Commit

Permalink
feat: refactor and add token expires check
Browse files Browse the repository at this point in the history
  • Loading branch information
kuoruan committed Sep 24, 2024
1 parent 3bb9c49 commit 66cbe2b
Show file tree
Hide file tree
Showing 8 changed files with 144 additions and 75 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions src/client/plugin/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// thinks we are logged in.
//

import { parseJwt } from "./lib";

export type Credentials = {
username: string;
uiToken: string;
Expand All @@ -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;
}
Expand All @@ -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>): credentials is Credentials {
for (const key of ["username", "uiToken", "npmToken"]) {
for (const key of ["username", "uiToken", "npmToken"] as const) {
if (!credentials[key]) {
return false;
}
Expand Down
91 changes: 81 additions & 10 deletions src/client/plugin/init.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -16,8 +25,9 @@ function reloadToPathname() {
location.reload();
}

function saveAndRemoveQueryParams(): boolean {
function parseAndSaveCredentials(): boolean {
const credentials: Partial<Credentials> = parseQueryParams(location.search);

if (!validateCredentials(credentials)) {
return false;
}
Expand All @@ -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, () => {
Expand All @@ -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);
}
35 changes: 33 additions & 2 deletions src/client/plugin/lib.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> | 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<string, any>;
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);
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/client/plugin/usage-info.ts
Original file line number Diff line number Diff line change
@@ -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.";
}

Expand Down
60 changes: 4 additions & 56 deletions src/client/verdaccio.ts
Original file line number Diff line number Diff line change
@@ -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,
});
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit 66cbe2b

Please sign in to comment.