Skip to content

Commit 068b2b2

Browse files
authored
feat: allow to use passwd login along with OpenID login (#12)
1 parent 42f95d6 commit 068b2b2

File tree

8 files changed

+123
-29
lines changed

8 files changed

+123
-29
lines changed

src/client/plugin/ambient.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import type { TemplateUIOptions } from "@verdaccio/types";
22

33
export {};
44

5+
interface OpenIDOptions {
6+
keepPasswdLogin: boolean;
7+
}
8+
59
declare global {
610
interface MouseEvent {
711
// IE and Edge have a `path` property instead of `composedPath()`.
@@ -12,5 +16,6 @@ declare global {
1216
interface Window {
1317
VERDACCIO_API_URL?: string;
1418
__VERDACCIO_BASENAME_UI_OPTIONS?: TemplateUIOptions;
19+
__VERDACCIO_OPENID_OPTIONS?: OpenIDOptions;
1520
}
1621
}

src/client/plugin/credentials.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77
import { parseJwt } from "./lib";
88

99
export interface Credentials {
10+
// The logged in username
1011
username: string;
12+
// UI token is used to authenticate with the UI
1113
uiToken: string;
14+
// NPM token is used to authenticate with the registry
1215
npmToken: string;
1316
}
1417

@@ -31,14 +34,40 @@ export function clearCredentials() {
3134
}
3235
}
3336

37+
/**
38+
* Check if the user is logged in.
39+
*
40+
* This function checks if the user is logged in with the UI token.
41+
*
42+
* @returns {boolean} True if the user is logged in
43+
*/
3444
export function isLoggedIn(): boolean {
45+
return !!localStorage.getItem(LOCAL_STORAGE_KEYS.USERNAME) && !!localStorage.getItem(LOCAL_STORAGE_KEYS.UI_TOKEN);
46+
}
47+
48+
/**
49+
* Check if the user is logged in with OpenID Connect
50+
*
51+
* @returns {boolean} True if the user is logged in with OpenID Connect
52+
*/
53+
export function isOpenIDLoggedIn(): boolean {
3554
return Object.values(LOCAL_STORAGE_KEYS).every((key) => !!localStorage.getItem(key));
3655
}
3756

57+
/**
58+
* Get the NPM token from local storage
59+
*
60+
* @returns {string | null} The NPM token or null if it doesn't exist
61+
*/
3862
export function getNPMToken(): string | null {
3963
return localStorage.getItem(LOCAL_STORAGE_KEYS.NPM_TOKEN);
4064
}
4165

66+
/**
67+
* Check if the UI token is expired
68+
*
69+
* @returns {boolean} True if the UI token is expired
70+
*/
4271
export function isUITokenExpired() {
4372
const token = localStorage.getItem(LOCAL_STORAGE_KEYS.UI_TOKEN);
4473
if (!token) return true;
@@ -52,6 +81,12 @@ export function isUITokenExpired() {
5281
return Date.now() >= jsTimestamp;
5382
}
5483

84+
/**
85+
* Validate the credentials object to ensure it has the required fields
86+
*
87+
* @param credentials The credentials object to validate
88+
* @returns {boolean} True if the credentials object is valid
89+
*/
5590
export function validateCredentials(credentials: Partial<Credentials>): credentials is Credentials {
5691
return (["username", "uiToken", "npmToken"] satisfies (keyof Credentials)[]).every((key) => !!credentials[key]);
5792
}

src/client/plugin/init.ts

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { loginHref, logoutHref, replacedAttrKey, replacedAttrValue } from "@/constants";
1+
import { loginHref, logoutHref, updatedAttrKey, updatedAttrValue } from "@/constants";
22
import { parseQueryParams } from "@/query-params";
33

44
import {
55
clearCredentials,
66
type Credentials,
77
isLoggedIn,
8+
isOpenIDLoggedIn,
89
isUITokenExpired,
910
saveCredentials,
1011
validateCredentials,
@@ -67,15 +68,22 @@ function removeInvalidCommands(commands: HTMLElement[]): void {
6768
}
6869

6970
function updateUsageTabs(usageTabsSelector: string): void {
71+
const openIDLoggedIn = isOpenIDLoggedIn();
72+
73+
const loggedIn = isLoggedIn();
74+
75+
if (!openIDLoggedIn && loggedIn) {
76+
// If we are logged in but not with OpenID, we don't need to update the usage info
77+
return;
78+
}
79+
7080
const tabs = [...document.querySelectorAll(usageTabsSelector)].filter(
71-
(node) => node.getAttribute(replacedAttrKey) !== replacedAttrValue,
81+
(node) => node.getAttribute(updatedAttrKey) !== updatedAttrValue,
7282
);
7383

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

76-
const loggedIn = isLoggedIn();
77-
78-
const usageInfoLines = getUsageInfo(loggedIn).split("\n").reverse();
86+
const usageInfoLines = getUsageInfo(openIDLoggedIn).split("\n").reverse();
7987

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

8795
for (const info of usageInfoLines) {
88-
cloneAndAppendCommand(commands[0], info, loggedIn);
96+
cloneAndAppendCommand(commands[0], info, openIDLoggedIn);
8997
}
9098

9199
removeInvalidCommands(commands);
92100

93-
tab.setAttribute(replacedAttrKey, replacedAttrValue);
101+
tab.setAttribute(updatedAttrKey, updatedAttrValue);
94102
}
95103
}
96104

105+
function addOpenIDLoginButton(loginDialogSelector: string, loginButtonSelector: string, callback: () => void): void {
106+
const loginDialog = document.querySelector(loginDialogSelector);
107+
108+
if (!loginDialog || loginDialog.getAttribute(updatedAttrKey) === updatedAttrValue) return;
109+
110+
const loginButton = document.querySelector(loginButtonSelector)!;
111+
112+
const loginWithOpenIDButton = loginButton.cloneNode(false) as HTMLButtonElement;
113+
114+
loginWithOpenIDButton.textContent = "Login with OpenID Connect";
115+
loginWithOpenIDButton.dataset.testid = "dialogOpenIDLogin";
116+
117+
loginWithOpenIDButton.addEventListener("click", callback);
118+
119+
loginDialog.append(loginWithOpenIDButton);
120+
121+
loginDialog.setAttribute(updatedAttrKey, updatedAttrValue);
122+
}
123+
97124
export interface InitOptions {
98-
loginButton: string;
99-
logoutButton: string;
100-
usageTabs: string;
125+
loginButtonSelector: string;
126+
loginDialogSelector: string;
127+
logoutButtonSelector: string;
128+
usageTabsSelector: string;
101129
}
102130

103131
//
104132
// By default the login button opens a form that asks the user to submit credentials.
105133
// We replace this behaviour and instead redirect to the route that handles OAuth.
106134
//
107-
export function init({ loginButton, logoutButton, usageTabs }: InitOptions): void {
135+
export function init({
136+
loginButtonSelector,
137+
logoutButtonSelector,
138+
usageTabsSelector,
139+
loginDialogSelector,
140+
}: InitOptions): void {
108141
if (parseAndSaveCredentials()) {
109142
// If we are new logged in, reload the page to remove the query params
110143
reloadToPathname();
@@ -117,19 +150,25 @@ export function init({ loginButton, logoutButton, usageTabs }: InitOptions): voi
117150

118151
const baseUrl = getBaseUrl(true);
119152

120-
interruptClick(loginButton, () => {
153+
const gotoOpenIDLoginUrl = () => {
121154
location.href = baseUrl + loginHref;
122-
});
155+
};
156+
157+
if (window.__VERDACCIO_OPENID_OPTIONS?.keepPasswdLogin) {
158+
const updateLoginDialog = () => addOpenIDLoginButton(loginDialogSelector, loginButtonSelector, gotoOpenIDLoginUrl);
123159

124-
interruptClick(logoutButton, () => {
160+
document.addEventListener("click", () => retry(updateLoginDialog, 2));
161+
} else {
162+
interruptClick(loginButtonSelector, gotoOpenIDLoginUrl);
163+
}
164+
165+
interruptClick(logoutButtonSelector, () => {
125166
clearCredentials();
126167

127168
location.href = baseUrl + logoutHref;
128169
});
129170

130-
const updateUsageInfo = () => updateUsageTabs(usageTabs);
171+
const updateUsageInfo = () => updateUsageTabs(usageTabsSelector);
131172

132173
document.addEventListener("click", () => retry(updateUsageInfo, 2));
133-
134-
retry(updateUsageInfo);
135174
}

src/client/verdaccio.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { init } from "./plugin";
22

33
const loginButtonSelector = `[data-testid="header--button-login"]`;
4+
const loginDialogSelector = `[data-testid="dialogContentLogin"]`;
45
const logoutButtonSelector = `[data-testid="header--button-logout"],[data-testid="logOutDialogIcon"]`;
56
const usageTabsSelector = `[data-testid="tab-content"]`;
67

78
init({
8-
loginButton: loginButtonSelector,
9-
logoutButton: logoutButtonSelector,
10-
usageTabs: usageTabsSelector,
9+
loginButtonSelector,
10+
loginDialogSelector,
11+
logoutButtonSelector,
12+
usageTabsSelector,
1113
});

src/constants.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ export const plugin = {
77

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

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

1313
export const authorizePath = "/-/oauth/authorize";
1414
export const callbackPath = "/-/oauth/callback";

src/server/config/Config.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { defaultSecurity } from "@verdaccio/config";
2-
import type { Config, PackageAccess, PackageList, Security } from "@verdaccio/types";
2+
import type { Config, PackageList, Security } from "@verdaccio/types";
33
import merge from "deepmerge";
4-
import { mixed, object, Schema, string } from "yup";
4+
import { boolean, mixed, object, Schema, string } from "yup";
55

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

32-
urlPrefix: string;
3332
secret: string;
3433
security: Security;
35-
packages: Record<string, PackageAccess>;
34+
urlPrefix: string;
35+
packages: PackageList;
3636

37+
keepPasswdLogin: boolean;
3738
getStoreConfig(storeType: StoreType): any;
3839
}
3940

@@ -53,6 +54,7 @@ export interface OpenIDConfig {
5354
"groups-claim"?: string;
5455
"store-type"?: StoreType;
5556
"store-config"?: Record<string, unknown> | string;
57+
"keep-passwd-login"?: boolean;
5658
"authorized-groups"?: string | string[] | boolean;
5759
"group-users"?: string | Record<string, string[]>;
5860
}
@@ -74,6 +76,7 @@ export default class ParsedPluginConfig implements ConfigHolder {
7476
public get packages(): PackageList {
7577
return this.verdaccioConfig.packages ?? {};
7678
}
79+
7780
public get urlPrefix(): string {
7881
return this.verdaccioConfig.url_prefix ?? "";
7982
}
@@ -303,4 +306,11 @@ export default class ParsedPluginConfig implements ConfigHolder {
303306
}
304307
}
305308
}
309+
310+
public get keepPasswdLogin(): boolean {
311+
return (
312+
this.getConfigValue<boolean | undefined>("keep-passwd-login", boolean().optional()) ??
313+
this.verdaccioConfig.auth?.htpasswd?.file !== undefined
314+
);
315+
}
306316
}

src/server/plugin/AuthCore.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type Auth, buildUser, isAESLegacy, verifyJWTPayload } from "@verdaccio/auth";
22
import { defaultLoggedUserRoles, defaultNonLoggedUserRoles } from "@verdaccio/config";
3-
import type { JWTSignOptions, PackageAccess, RemoteUser, Security } from "@verdaccio/types";
3+
import type { JWTSignOptions, PackageList, RemoteUser, Security } from "@verdaccio/types";
44

55
import type { ConfigHolder } from "@/server/config/Config";
66
import { debug } from "@/server/debugger";
@@ -88,7 +88,7 @@ export class AuthCore {
8888
/**
8989
* Returns all permission groups used in the Verdacio config.
9090
*/
91-
private initConfiguredGroups(packages: Record<string, PackageAccess> = {}): string[] {
91+
private initConfiguredGroups(packages: PackageList = {}): string[] {
9292
for (const packageConfig of Object.values(packages)) {
9393
const groups = (["access", "publish", "unpublish"] as const)
9494
.flatMap((key) => packageConfig[key])

src/server/plugin/PatchHtml.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export class PatchHtml implements PluginMiddleware {
5757

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

60-
return `<script defer="defer" src="${scriptSrc}"></script>`;
60+
return [
61+
`<script>window.__VERDACCIO_OPENID_OPTIONS={keepPasswdLogin:${this.config.keepPasswdLogin}}</script>`,
62+
`<script defer="defer" src="${scriptSrc}"></script>`,
63+
].join("\n");
6164
}
6265
}

0 commit comments

Comments
 (0)