Skip to content

Commit

Permalink
feat: add support for more openid providers
Browse files Browse the repository at this point in the history
This may fix #1
  • Loading branch information
kuoruan committed Mar 10, 2023
1 parent eb95395 commit 519d03a
Show file tree
Hide file tree
Showing 24 changed files with 1,067 additions and 658 deletions.
7 changes: 7 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,20 @@ module.exports = {
"plugin:prettier/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:unicorn/recommended",
],
plugins: ["simple-import-sort"],
rules: {
"simple-import-sort/imports": "error",
"simple-import-sort/exports": "error",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"unicorn/no-null": "off",
"unicorn/no-process-exit": "off",
"unicorn/catch-error-name": "off",
"unicorn/filename-case": "off",
"unicorn/prefer-module": "off",
"unicorn/prevent-abbreviations": "off",
},
settings: {
"import/resolver": ["node", "typescript"],
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,14 @@ auth:
# token-endpoint: https://example.com/oauth/token # optional
# userinfo-endpoint: https://example.com/oauth/userinfo # optional
# jwks-uri: https://example.com/oauth/jwks # optional
# scope: openid email groups # optional. custom scope
# scope: openid email groups # optional. custom scope, default is openid
client-id: CLIENT_ID # required
client-secret: CLIENT_SECRET # required
username-claim: name # optional. default is sub
username-claim: name # optional. username claim in openid, or key to get username in userinfo endpoint response, default is sub
groups-claim: groups # optional. claim to get groups from
# provider-type: gitlab # optional. define this to get groups from gitlab api
# authorized-group: false # optional. user in group is allowed to login, or false to disable
# authorized-groups: # optional. user in array is allowed to login. use true to ensure user have at least one group, false means no groups check
# - access
# group-users: # optional. custom the group users. eg. animal group has user tom and jack. if set, 'groups-claim' and 'provider-type' take no effect
# animal:
# - tom
Expand Down
18 changes: 10 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
],
"scripts": {
"build": "rimraf dist && rollup -c --environment NODE_ENV:production",
"start": "cross-env DEBUG='verdaccio:plugin:openid' verdaccio -c verdaccio/verdaccio.yml",
"start": "cross-env DEBUG='verdaccio:*' verdaccio -c verdaccio/verdaccio.yml",
"preview": "verdaccio -c verdaccio/verdaccio.yml",
"lint": "eslint --fix --ext .js,.ts .",
"link:openid": "yarn link && yarn link verdaccio-openid",
"unlink:openid": "yarn unlink verdaccio-openid && yarn unlink",
Expand All @@ -28,11 +29,11 @@
"dependencies": {
"@gitbeaker/node": "^35.8.0",
"@isaacs/ttlcache": "^1.2.1",
"@verdaccio/auth": "^6.0.0-6-next.42",
"@verdaccio/config": "^6.0.0-6-next.63",
"@verdaccio/core": "^6.0.0-6-next.63",
"@verdaccio/auth": "^6.0.0-6-next.44",
"@verdaccio/config": "^6.0.0-6-next.65",
"@verdaccio/core": "^6.0.0-6-next.65",
"@verdaccio/signature": "^6.0.0-6-next.2",
"@verdaccio/url": "^11.0.0-6-next.29",
"@verdaccio/url": "^11.0.0-6-next.31",
"core-js": "^3.29.0",
"debug": "^4.3.4",
"deepmerge": "^4.3.0",
Expand Down Expand Up @@ -71,13 +72,14 @@
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-simple-import-sort": "^10.0.0",
"eslint-plugin-unicorn": "^46.0.0",
"prettier": "^2.8.4",
"rimraf": "^4.3.1",
"rollup": "^3.18.0",
"rimraf": "^4.4.0",
"rollup": "^3.19.0",
"rollup-plugin-node-externals": "^5.1.2",
"rollup-plugin-shebang-bin": "^0.0.5",
"typescript": "^4.9.5",
"verdaccio": "^5.22.0",
"verdaccio": "^5.22.1",
"verdaccio-htpasswd": "^11.0.0-6-next.13"
},
"peerDependencies": {
Expand Down
9 changes: 6 additions & 3 deletions src/cli/cli-response.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
export function respondWithCliMessage(status: string, message: string) {
switch (status) {
case "success":
case "success": {
console.log("All done! We've updated your npm configuration.");
break;
}

case "denied":
case "denied": {
console.warn("You are not a member of the required access group.");
break;
}

default:
default: {
console.error(message);
break;
}
}
}
1 change: 1 addition & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const server = express()
respondWithCliMessage(status, message);

server.close();
// eslint-disable-next-line unicorn/no-process-exit
process.exit(status === "success" ? 0 : 1);
})
.listen(cliPort, () => {
Expand Down
9 changes: 6 additions & 3 deletions src/cli/npm.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execSync } from "child_process";
import { execSync } from "node:child_process";
import { URL } from "node:url";

import minimist from "minimist";
import { URL } from "url";

import logger from "../server/logger";

Expand Down Expand Up @@ -49,5 +50,7 @@ export function saveNpmToken(token: string) {
const registry = getRegistryUrl();
const commands = getNpmSaveCommands(registry, token);

commands.forEach((command) => runCommand(command));
for (const command of commands) {
runCommand(command);
}
}
4 changes: 3 additions & 1 deletion src/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function getUsageInfo() {
}

export function printUsage() {
getUsageInfo().forEach((line) => console.log(line));
for (const line of getUsageInfo()) {
console.log(line);
}
}

export function validateRegistry() {
Expand Down
9 changes: 6 additions & 3 deletions src/cli/web-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,22 @@ export function respondWithWebPage(status: string, message: string, res: Respons
res.setHeader("Content-Type", "text/html");

switch (status) {
case "success":
case "success": {
res.status(200);
res.send(successPage);
break;
}

case "denied":
case "denied": {
res.status(401);
res.send(buildAccessDeniedPage(withBack));
break;
}

default:
default: {
res.status(500);
res.send(buildErrorPage(message, withBack));
break;
}
}
}
5 changes: 5 additions & 0 deletions src/client/plugin/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* Copy text to the clipboard.
*
* @param text the text to copy to the clipboard
*/
export async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text);
}
18 changes: 18 additions & 0 deletions src/client/plugin/lib.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
/**
* Retry an action multiple times.
*
* @param action
*/
export function retry(action: () => void) {
for (let i = 0; i < 10; i++) {
setTimeout(() => action(), 100 * i);
}
}

/**
* Check if the path of a mouse event contains an element.
*
* @param selector the selector of the element to check for
* @param e the mouse event
* @returns
*/
function pathContainsElement(selector: string, e: MouseEvent): boolean {
const path = e.path || e.composedPath?.();
const element = document.querySelector(selector)!;

return path.includes(element);
}

/**
* Interrupt a click event on an element.
*
* @param selector the selector of the element to interrupt the click event for
* @param callback new callback to run instead of the original click event
*/
export function interruptClick(selector: string, callback: () => void) {
const handleClick = (e: MouseEvent) => {
if (pathContainsElement(selector, e)) {
Expand Down
32 changes: 16 additions & 16 deletions src/client/verdaccio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,40 +13,40 @@ function updateUsageInfo(): void {

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

tabs.forEach((tab) => {
for (const tab of tabs) {
const alreadyReplaced = tab.getAttribute("replaced") === "true";
if (alreadyReplaced) return;
if (alreadyReplaced) continue;

const commands = Array.from<HTMLElement>(tab.querySelectorAll("button"))
const commands = [...tab.querySelectorAll("button")]
.map((node) => node.parentElement!)
.filter((node) => !!node.innerText.match(/^(npm|pnpm|yarn)/));
if (!commands.length) return;
.filter((node) => !!/^(npm|pnpm|yarn)/.test(node.textContent || ""));
if (commands.length === 0) continue;

usageInfoLines.forEach((info) => {
for (const info of usageInfoLines) {
const cloned = commands[0].cloneNode(true) as HTMLElement;
const textEl = cloned.querySelector("span")!;
textEl.innerText = info;
textEl.textContent = info;

const copyEl = cloned.querySelector("button")!;
copyEl.style.visibility = loggedIn ? "visible" : "hidden";
copyEl.onclick = (e) => {
copyEl.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard(info);
};
});

commands[0].parentElement!.appendChild(cloned);
commands[0].parentElement!.append(cloned);
tab.setAttribute("replaced", "true");
});
}

// Remove commands that don't work with oauth
commands.forEach((node) => {
if (node.innerText.includes("adduser") || node.innerText.includes("set password")) {
node.parentElement!.removeChild(node);
for (const node of commands) {
if (node.textContent?.includes("adduser") || node.textContent?.includes("set password")) {
node.remove();
tab.setAttribute("replaced", "true");
}
});
});
}
}
}

init({
Expand Down
17 changes: 10 additions & 7 deletions src/query-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@
* @returns a key/value object
*/
export function parseQueryParams(search: string): Record<string, string> {
if (!search) return {};
const params = {};

if (!search) return params;

if (search.startsWith("?")) {
search = search.substring(1);
search = search.slice(1);
}

for (const str of search.split("&")) {
const [key, value] = str.split("=");
params[key] = decodeURIComponent(value);
}

return search.split("&").reduce((acc, pair) => {
const [key, value] = pair.split("=");
acc[key] = decodeURIComponent(value);
return acc;
}, {});
return params;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/server/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fileURLToPath } from "url";
import { fileURLToPath } from "node:url";

import { pluginKey } from "@/constants";

Expand Down
18 changes: 8 additions & 10 deletions src/server/flows/CliFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,21 @@ export class CliFlow implements IPluginMiddleware<any> {
try {
const providerToken = await this.provider.getToken(req);

debug(`provider auth success, token: "%s"`, providerToken);
debug(`provider auth success, tokens: "%j"`, providerToken);

const username = await this.provider.getUsername(providerToken);

let groups = this.core.getUserGroups(username);
const userinfo = await this.provider.getUserinfo(providerToken);

let groups = this.core.getUserGroups(userinfo.name);
if (!groups) {
groups = await this.provider.getGroups(username);
groups = userinfo.groups;
}

if (this.core.authenticate(username, groups)) {
const realGroups = this.core.filterRealGroups(username, groups);
if (this.core.authenticate(userinfo.name, groups)) {
const realGroups = this.core.filterRealGroups(userinfo.name, groups);

debug(`user authenticated, name: "%s", groups: "%o"`, username, realGroups);
debug(`user authenticated, name: "%s", groups: %j`, userinfo.name, realGroups);

const user = await this.core.createAuthenticatedUser(username, realGroups);
const npmToken = await this.core.issueNpmToken(user, providerToken);
const npmToken = await this.core.issueNpmToken(userinfo.name, realGroups, providerToken);

params.status = "success";
params.token = npmToken;
Expand Down
20 changes: 9 additions & 11 deletions src/server/flows/WebFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,24 +54,22 @@ export class WebFlow implements IPluginMiddleware<any> {
const providerToken = await this.provider.getToken(req);
debug(`provider auth success, token: "%s"`, providerToken);

const username = await this.provider.getUsername(providerToken);
const userinfo = await this.provider.getUserinfo(providerToken);

let groups = this.core.getUserGroups(username);
let groups = this.core.getUserGroups(userinfo.name);
if (!groups) {
groups = await this.provider.getGroups(providerToken);
groups = userinfo.groups;
}

if (this.core.authenticate(username, groups)) {
const realGroups = this.core.filterRealGroups(username, groups);
if (this.core.authenticate(userinfo.name, groups)) {
const realGroups = this.core.filterRealGroups(userinfo.name, groups);

debug(`user authenticated, name: "%s", groups: "%o"`, username, realGroups);
debug(`user authenticated, name: "%s", groups: "%j"`, userinfo.name, realGroups);

const user = this.core.createAuthenticatedUser(username, realGroups);
const uiToken = await this.core.issueUiToken(userinfo.name, realGroups);
const npmToken = await this.core.issueNpmToken(userinfo.name, realGroups, providerToken);

const uiToken = await this.core.issueUiToken(user, providerToken);
const npmToken = await this.core.issueNpmToken(user, providerToken);

const params = { username: user.name!, uiToken, npmToken };
const params = { username: userinfo.name, uiToken, npmToken };

const redirectUrl = `/?${stringifyQueryParams(params)}`;

Expand Down
4 changes: 1 addition & 3 deletions src/server/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import dotenv from "dotenv";

import { Plugin } from "./plugin/Plugin";

dotenv.config();

// plugins must be a default export
export default Plugin;
export { Plugin as default } from "./plugin/Plugin";
Loading

0 comments on commit 519d03a

Please sign in to comment.