Skip to content

Commit

Permalink
feat: allow to use passwd login along with OpenID login (#12)
Browse files Browse the repository at this point in the history
  • Loading branch information
kuoruan authored Jan 18, 2025
1 parent 42f95d6 commit 068b2b2
Show file tree
Hide file tree
Showing 8 changed files with 123 additions and 29 deletions.
5 changes: 5 additions & 0 deletions src/client/plugin/ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { TemplateUIOptions } from "@verdaccio/types";

export {};

interface OpenIDOptions {
keepPasswdLogin: boolean;
}

declare global {
interface MouseEvent {
// IE and Edge have a `path` property instead of `composedPath()`.
Expand All @@ -12,5 +16,6 @@ declare global {
interface Window {
VERDACCIO_API_URL?: string;
__VERDACCIO_BASENAME_UI_OPTIONS?: TemplateUIOptions;
__VERDACCIO_OPENID_OPTIONS?: OpenIDOptions;
}
}
35 changes: 35 additions & 0 deletions src/client/plugin/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
import { parseJwt } from "./lib";

export interface Credentials {
// The logged in username
username: string;
// UI token is used to authenticate with the UI
uiToken: string;
// NPM token is used to authenticate with the registry
npmToken: string;
}

Expand All @@ -31,14 +34,40 @@ export function clearCredentials() {
}
}

/**
* Check if the user is logged in.
*
* This function checks if the user is logged in with the UI token.
*
* @returns {boolean} True if the user is logged in
*/
export function isLoggedIn(): boolean {
return !!localStorage.getItem(LOCAL_STORAGE_KEYS.USERNAME) && !!localStorage.getItem(LOCAL_STORAGE_KEYS.UI_TOKEN);
}

/**
* Check if the user is logged in with OpenID Connect
*
* @returns {boolean} True if the user is logged in with OpenID Connect
*/
export function isOpenIDLoggedIn(): boolean {
return Object.values(LOCAL_STORAGE_KEYS).every((key) => !!localStorage.getItem(key));
}

/**
* Get the NPM token from local storage
*
* @returns {string | null} The NPM token or null if it doesn't exist
*/
export function getNPMToken(): string | null {
return localStorage.getItem(LOCAL_STORAGE_KEYS.NPM_TOKEN);
}

/**
* Check if the UI token is expired
*
* @returns {boolean} True if the UI token is expired
*/
export function isUITokenExpired() {
const token = localStorage.getItem(LOCAL_STORAGE_KEYS.UI_TOKEN);
if (!token) return true;
Expand All @@ -52,6 +81,12 @@ export function isUITokenExpired() {
return Date.now() >= jsTimestamp;
}

/**
* Validate the credentials object to ensure it has the required fields
*
* @param credentials The credentials object to validate
* @returns {boolean} True if the credentials object is valid
*/
export function validateCredentials(credentials: Partial<Credentials>): credentials is Credentials {
return (["username", "uiToken", "npmToken"] satisfies (keyof Credentials)[]).every((key) => !!credentials[key]);
}
73 changes: 56 additions & 17 deletions src/client/plugin/init.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { loginHref, logoutHref, replacedAttrKey, replacedAttrValue } from "@/constants";
import { loginHref, logoutHref, updatedAttrKey, updatedAttrValue } from "@/constants";
import { parseQueryParams } from "@/query-params";

import {
clearCredentials,
type Credentials,
isLoggedIn,
isOpenIDLoggedIn,
isUITokenExpired,
saveCredentials,
validateCredentials,
Expand Down Expand Up @@ -67,15 +68,22 @@ function removeInvalidCommands(commands: HTMLElement[]): void {
}

function updateUsageTabs(usageTabsSelector: string): void {
const openIDLoggedIn = isOpenIDLoggedIn();

const loggedIn = isLoggedIn();

if (!openIDLoggedIn && loggedIn) {
// If we are logged in but not with OpenID, we don't need to update the usage info
return;
}

const tabs = [...document.querySelectorAll(usageTabsSelector)].filter(
(node) => node.getAttribute(replacedAttrKey) !== replacedAttrValue,
(node) => node.getAttribute(updatedAttrKey) !== updatedAttrValue,
);

if (tabs.length === 0) return;

const loggedIn = isLoggedIn();

const usageInfoLines = getUsageInfo(loggedIn).split("\n").reverse();
const usageInfoLines = getUsageInfo(openIDLoggedIn).split("\n").reverse();

for (const tab of tabs) {
const commands = [...tab.querySelectorAll("button")]
Expand All @@ -85,26 +93,51 @@ function updateUsageTabs(usageTabsSelector: string): void {
if (commands.length === 0) continue;

for (const info of usageInfoLines) {
cloneAndAppendCommand(commands[0], info, loggedIn);
cloneAndAppendCommand(commands[0], info, openIDLoggedIn);
}

removeInvalidCommands(commands);

tab.setAttribute(replacedAttrKey, replacedAttrValue);
tab.setAttribute(updatedAttrKey, updatedAttrValue);
}
}

function addOpenIDLoginButton(loginDialogSelector: string, loginButtonSelector: string, callback: () => void): void {
const loginDialog = document.querySelector(loginDialogSelector);

if (!loginDialog || loginDialog.getAttribute(updatedAttrKey) === updatedAttrValue) return;

const loginButton = document.querySelector(loginButtonSelector)!;

const loginWithOpenIDButton = loginButton.cloneNode(false) as HTMLButtonElement;

loginWithOpenIDButton.textContent = "Login with OpenID Connect";
loginWithOpenIDButton.dataset.testid = "dialogOpenIDLogin";

loginWithOpenIDButton.addEventListener("click", callback);

loginDialog.append(loginWithOpenIDButton);

loginDialog.setAttribute(updatedAttrKey, updatedAttrValue);
}

export interface InitOptions {
loginButton: string;
logoutButton: string;
usageTabs: string;
loginButtonSelector: string;
loginDialogSelector: string;
logoutButtonSelector: string;
usageTabsSelector: 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, usageTabs }: InitOptions): void {
export function init({
loginButtonSelector,
logoutButtonSelector,
usageTabsSelector,
loginDialogSelector,
}: InitOptions): void {
if (parseAndSaveCredentials()) {
// If we are new logged in, reload the page to remove the query params
reloadToPathname();
Expand All @@ -117,19 +150,25 @@ export function init({ loginButton, logoutButton, usageTabs }: InitOptions): voi

const baseUrl = getBaseUrl(true);

interruptClick(loginButton, () => {
const gotoOpenIDLoginUrl = () => {
location.href = baseUrl + loginHref;
});
};

if (window.__VERDACCIO_OPENID_OPTIONS?.keepPasswdLogin) {
const updateLoginDialog = () => addOpenIDLoginButton(loginDialogSelector, loginButtonSelector, gotoOpenIDLoginUrl);

interruptClick(logoutButton, () => {
document.addEventListener("click", () => retry(updateLoginDialog, 2));
} else {
interruptClick(loginButtonSelector, gotoOpenIDLoginUrl);
}

interruptClick(logoutButtonSelector, () => {
clearCredentials();

location.href = baseUrl + logoutHref;
});

const updateUsageInfo = () => updateUsageTabs(usageTabs);
const updateUsageInfo = () => updateUsageTabs(usageTabsSelector);

document.addEventListener("click", () => retry(updateUsageInfo, 2));

retry(updateUsageInfo);
}
8 changes: 5 additions & 3 deletions src/client/verdaccio.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { init } from "./plugin";

const loginButtonSelector = `[data-testid="header--button-login"]`;
const loginDialogSelector = `[data-testid="dialogContentLogin"]`;
const logoutButtonSelector = `[data-testid="header--button-logout"],[data-testid="logOutDialogIcon"]`;
const usageTabsSelector = `[data-testid="tab-content"]`;

init({
loginButton: loginButtonSelector,
logoutButton: logoutButtonSelector,
usageTabs: usageTabsSelector,
loginButtonSelector,
loginDialogSelector,
logoutButtonSelector,
usageTabsSelector,
});
4 changes: 2 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ export const plugin = {

export const pluginKey = name.replace("verdaccio-", "");

export const replacedAttrKey = `data-${pluginKey}`;
export const replacedAttrValue = "1";
export const updatedAttrKey = `data-${pluginKey}`;
export const updatedAttrValue = "1";

export const authorizePath = "/-/oauth/authorize";
export const callbackPath = "/-/oauth/callback";
Expand Down
18 changes: 14 additions & 4 deletions src/server/config/Config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { defaultSecurity } from "@verdaccio/config";
import type { Config, PackageAccess, PackageList, Security } from "@verdaccio/types";
import type { Config, PackageList, Security } from "@verdaccio/types";
import merge from "deepmerge";
import { mixed, object, Schema, string } from "yup";
import { boolean, mixed, object, Schema, string } from "yup";

import { plugin, pluginKey } from "@/constants";
import { CONFIG_ENV_NAME_REGEX } from "@/server/constants";
Expand Down Expand Up @@ -29,11 +29,12 @@ export interface ConfigHolder {
groupUsers?: Record<string, string[]>;
storeType: StoreType;

urlPrefix: string;
secret: string;
security: Security;
packages: Record<string, PackageAccess>;
urlPrefix: string;
packages: PackageList;

keepPasswdLogin: boolean;
getStoreConfig(storeType: StoreType): any;
}

Expand All @@ -53,6 +54,7 @@ export interface OpenIDConfig {
"groups-claim"?: string;
"store-type"?: StoreType;
"store-config"?: Record<string, unknown> | string;
"keep-passwd-login"?: boolean;
"authorized-groups"?: string | string[] | boolean;
"group-users"?: string | Record<string, string[]>;
}
Expand All @@ -74,6 +76,7 @@ export default class ParsedPluginConfig implements ConfigHolder {
public get packages(): PackageList {
return this.verdaccioConfig.packages ?? {};
}

public get urlPrefix(): string {
return this.verdaccioConfig.url_prefix ?? "";
}
Expand Down Expand Up @@ -303,4 +306,11 @@ export default class ParsedPluginConfig implements ConfigHolder {
}
}
}

public get keepPasswdLogin(): boolean {
return (
this.getConfigValue<boolean | undefined>("keep-passwd-login", boolean().optional()) ??
this.verdaccioConfig.auth?.htpasswd?.file !== undefined
);
}
}
4 changes: 2 additions & 2 deletions src/server/plugin/AuthCore.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Auth, buildUser, isAESLegacy, verifyJWTPayload } from "@verdaccio/auth";
import { defaultLoggedUserRoles, defaultNonLoggedUserRoles } from "@verdaccio/config";
import type { JWTSignOptions, PackageAccess, RemoteUser, Security } from "@verdaccio/types";
import type { JWTSignOptions, PackageList, RemoteUser, Security } from "@verdaccio/types";

import type { ConfigHolder } from "@/server/config/Config";
import { debug } from "@/server/debugger";
Expand Down Expand Up @@ -88,7 +88,7 @@ export class AuthCore {
/**
* Returns all permission groups used in the Verdacio config.
*/
private initConfiguredGroups(packages: Record<string, PackageAccess> = {}): string[] {
private initConfiguredGroups(packages: PackageList = {}): string[] {
for (const packageConfig of Object.values(packages)) {
const groups = (["access", "publish", "unpublish"] as const)
.flatMap((key) => packageConfig[key])
Expand Down
5 changes: 4 additions & 1 deletion src/server/plugin/PatchHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export class PatchHtml implements PluginMiddleware {

const scriptSrc = `${baseUrl}${staticPath}/${scriptName}`;

return `<script defer="defer" src="${scriptSrc}"></script>`;
return [
`<script>window.__VERDACCIO_OPENID_OPTIONS={keepPasswdLogin:${this.config.keepPasswdLogin}}</script>`,
`<script defer="defer" src="${scriptSrc}"></script>`,
].join("\n");
}
}

0 comments on commit 068b2b2

Please sign in to comment.