UNPKG

verdaccio-openid

Version:

A UI for OIDC authentication for Verdaccio, a fork of verdaccio-github-oauth-ui

2,013 lines 62.7 kB
'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

var os = require('node:os');
var path = require('node:path');
var process = require('node:process');
var dotenv = require('dotenv');
var buildDebug = require('debug');
var core = require('@verdaccio/core');
var config = require('@verdaccio/config');
var merge = require('deepmerge');
var yup = require('yup');
var node_url = require('node:url');
var ms = require('ms');
var url = require('@verdaccio/url');
var stableHash = require('stable-hash');
var rest = require('@gitbeaker/rest');
var openidClient = require('openid-client');
var globalAgent = require('global-agent');
var storage = require('node-persist');
var TTLCache = require('@isaacs/ttlcache');
var ioredis = require('ioredis');
var express = require('express');
var auth = require('@verdaccio/auth');

var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
var name = "verdaccio-openid";
var version = "0.15.1";

const plugin = {
  name,
  version
};
const pluginKey = name.replace("verdaccio-", "");
const authorizePath = "/-/oauth/authorize";
const callbackPath = "/-/oauth/callback";
const cliPort = 8239;
const cliProviderId = "cli";
const npmLoginPath = "/-/v1/login";
const npmDonePath = "/-/v1/done";
const webAuthnProviderId = "authn";
const messageGroupRequired = "You are not a member of the required access group.";
const messageLoggedAndCloseWindow = "You have logged in successfully and may close this window.";

const debug = buildDebug(`verdaccio:plugin:${pluginKey}`);

const staticPath = `/-/static/${pluginKey}`;
const publicRoot = node_url.fileURLToPath(new URL("../client", (typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.js', document.baseURI).href))));
const CONFIG_ENV_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const ERRORS = {
  INVALID_TOKEN: "Invalid token format",
  INVALID_ID_TOKEN: "Invalid id token",
  TOKEN_ENCRYPTION_FAILED_NPM: "Internal server error, failed to encrypt npm token",
  TOKEN_ENCRYPTION_FAILED: "Internal server error, failed to encrypt token",
  AUTH_NOT_INITIALIZED: "Unexpected error, auth is not initialized",
  CLIENT_NOT_DISCOVERED: "Client has not yet been discovered",
  PROVIDER_HOST_NOT_SET: "Provider host is not set",
  NO_STATE: "No state provided in the request",
  STATE_NOT_FOUND: "The state does not match a known state",
  NO_ACCESS_TOKEN_RETURNED: `No "access_token" was returned from the provider`,
  NO_ID_TOKEN_RETURNED: `"openid" scope is requested but no "id_token" was returned from the provider`,
  ID_TOKEN_NOT_FOUND: "No id_token found in the tokens",
  PROVIDER_NOT_FOUND: "Provider not found"
};

/**
 * When token is string, it is a access token
 */

let ProviderType = /*#__PURE__*/function (ProviderType) {
  ProviderType["Gitlab"] = "gitlab";
  return ProviderType;
}({});

class BaseStore {
  /** The State ttl */
  static DefaultStateTTL = 1 * 60 * 1000; // 1 minute;
  /** The other data cache ttl */
  static DefaultDataTTL = 5 * 60 * 1000; // 5 minutes;

  getStateKey(key, providerId) {
    return `${providerId}:state:${key}`;
  }
  getUserInfoKey(key, providerId) {
    return `${providerId}:userinfo:${key}`;
  }
  getUserGroupsKey(key, providerId) {
    return `${providerId}:groups:${key}`;
  }
  getWebAuthnTokenKey(sessionId) {
    return `${webAuthnProviderId}:${sessionId}`;
  }
}
let StoreType = /*#__PURE__*/function (StoreType) {
  StoreType["InMemory"] = "in-memory";
  StoreType["Redis"] = "redis";
  StoreType["File"] = "file";
  return StoreType;
}({});

function noop() {
  /* noop */
}
const dummyLogger = {
  child: () => dummyLogger,
  debug: noop,
  error: noop,
  http: noop,
  trace: noop,
  warn: noop,
  info: noop
};
let logger = dummyLogger;
function setLogger(l) {
  if (!l) return;
  logger = l.child({
    plugin: {
      name: plugin.name
    }
  });
  logger?.info(plugin, "plugin loading: @{name}@@{version}");
}

/**
 * Get the value of an environment variable.
 *
 * @param name - The name of the environment variable.
 * @returns
 */
function getEnvironmentValue(name) {
  const value = process.env[name];
  if (value === undefined || value === null) return value;
  if (value === "true" || value === "false") {
    return value === "true";
  }
  try {
    const v = JSON.parse(value);

    // Only return the parsed value if it is an object.
    if (typeof v === "object" && v !== null) {
      return v;
    }
  } catch {
    // Do nothing
  }
  return value;
}
function handleValidationError(error, ...keyPaths) {
  const message = error.errors ? error.errors[0] : error.message ?? error;
  logger.error({
    key: ["auth", pluginKey, ...keyPaths].join("."),
    message
  }, `invalid configuration at "@{key}": @{message} — Please check your verdaccio config.`);
  process.exit(1);
}

/**
 * Transform a time string or number into a ms number.
 *
 * @param ttl - The time to live value.
 * @returns The time to live value in ms.
 */
function getTTLValue(ttl) {
  if (typeof ttl === "string") {
    return ms(ttl);
  }
  return ttl;
}

/**
 * Get the absolute path of a store file.
 *
 * @param configPath - The path to the config file.
 * @param storePath - The path to the store files.
 * @returns The absolute path of the store file.
 */
function getStoreFilePath(configPath, storePath) {
  return path.isAbsolute(storePath) ? storePath : path.normalize(path.join(path.dirname(configPath), storePath));
}

const portSchema = yup.number().min(1).max(65_535);
const ttlSchema = yup.mixed().test({
  name: "is-time-string-or-integer",
  message: "must be a time string or integer",
  test: value => {
    return value === undefined || typeof value === "string" && value !== "" || typeof value === "number" && value > 1000 // 1 second
    ;
  }
});
const InMemoryConfigSchema = yup.object({
  ttl: ttlSchema,
  max: yup.number().min(1).optional()
});
const nodeObjectSchema = yup.object({
  host: yup.string().optional(),
  port: portSchema.optional()
});
const RedisConfigSchema = yup.object({
  username: yup.string().optional(),
  password: yup.string().optional(),
  port: portSchema.optional(),
  ttl: ttlSchema,
  nodes: yup.array().of(yup.mixed().test({
    name: "is-valid-node",
    message: "must be an object, number, or string.",
    test: value => {
      if (typeof value === "object" && value !== null) {
        return nodeObjectSchema.isValidSync(value);
      }
      return typeof value === "number" || typeof value === "string";
    }
  })).optional()
});
const FileConfigSchema = yup.object({
  ttl: ttlSchema,
  dir: yup.string().required(),
  expiredInterval: yup.number().min(1).optional()
});
class StoreConfig {
  constructor(config, configKey) {
    this.config = config;
    this.configKey = configKey;
  }
  getConfigValue(key, schema) {
    const valueOrEnvironmentName = this.config[key];
    const environmentName = typeof valueOrEnvironmentName === "string" && CONFIG_ENV_NAME_REGEX.test(valueOrEnvironmentName) ? valueOrEnvironmentName : `${plugin.name}-${this.configKey}-${key}`.toUpperCase().replaceAll("-", "_");

    /**
     * Allow environment variables to be used as values.
     */
    const value = getEnvironmentValue(environmentName) ?? valueOrEnvironmentName;
    try {
      schema.validateSync(value);
    } catch (error) {
      handleValidationError(error, this.configKey, key);
    }
    return value;
  }
}
class RedisStoreConfigHolder extends StoreConfig {
  get username() {
    return this.getConfigValue("username", yup.string().optional());
  }
  get password() {
    return this.getConfigValue("password", yup.string().optional());
  }
  get storeType() {
    return StoreType.Redis;
  }
}

class ParsedPluginConfig {
  constructor(config, verdaccioConfig) {
    this.config = config;
    this.verdaccioConfig = verdaccioConfig;
  }
  get secret() {
    return this.verdaccioConfig.secret;
  }
  get security() {
    return merge(config.defaultSecurity, this.verdaccioConfig.security ?? {});
  }
  get packages() {
    return this.verdaccioConfig.packages ?? {};
  }
  get urlPrefix() {
    return this.verdaccioConfig.url_prefix ?? "";
  }
  getConfigValue(key, schema) {
    const valueOrEnvironmentName = this.config[key] ?? this.verdaccioConfig.auth?.[pluginKey]?.[key] ?? this.verdaccioConfig.middlewares?.[pluginKey]?.[key];

    /**
     * If the value is not defined in the config, use the plugin name and key as the environment variable name.
     *
     * eg. client-id -> `VERDACCIO_OPENID_CLIENT_ID`
     */
    const environmentName = typeof valueOrEnvironmentName === "string" && CONFIG_ENV_NAME_REGEX.test(valueOrEnvironmentName) ? valueOrEnvironmentName : `${plugin.name}-${key}`.toUpperCase().replaceAll("-", "_");

    /**
     * Allow environment variables to be used as values.
     */
    const value = getEnvironmentValue(environmentName) ?? valueOrEnvironmentName;
    try {
      schema.validateSync(value);
    } catch (error) {
      handleValidationError(error, key);
    }
    return value;
  }
  get providerHost() {
    return this.getConfigValue("provider-host", yup.string().url().required());
  }
  get providerType() {
    return this.getConfigValue("provider-type", yup.string().oneOf([ProviderType.Gitlab]).optional());
  }
  get configurationUri() {
    return this.getConfigValue("configuration-uri", yup.string().url().optional());
  }
  get issuer() {
    return this.getConfigValue("issuer", yup.string().url().optional());
  }
  get authorizationEndpoint() {
    return this.getConfigValue("authorization-endpoint", yup.string().url().optional());
  }
  get tokenEndpoint() {
    return this.getConfigValue("token-endpoint", yup.string().url().optional());
  }
  get userinfoEndpoint() {
    return this.getConfigValue("userinfo-endpoint", yup.string().url().optional());
  }
  get jwksUri() {
    return this.getConfigValue("jwks-uri", yup.string().url().optional());
  }
  get scope() {
    return this.getConfigValue("scope", yup.string().optional()) ?? "openid";
  }
  get clientId() {
    return this.getConfigValue("client-id", yup.string().required());
  }
  get clientSecret() {
    return this.getConfigValue("client-secret", yup.string().required());
  }
  get usernameClaim() {
    return this.getConfigValue("username-claim", yup.string().optional()) ?? "sub";
  }
  get groupsClaim() {
    return this.getConfigValue("groups-claim", yup.string().optional());
  }
  get authorizedGroups() {
    return this.getConfigValue("authorized-groups", yup.mixed().test({
      name: "is-string-array-or-boolean",
      skipAbsent: true,
      message: "must be a string, string[] or a boolean",
      test: value => {
        if (Array.isArray(value)) {
          return value.every(item => typeof item === "string");
        }
        return typeof value === "string" || typeof value === "boolean";
      }
    }).optional()) ?? false;
  }
  get groupUsers() {
    return this.getConfigValue("group-users", yup.object().test({
      name: "is-record-of-string-arrays",
      skipAbsent: true,
      message: "must be a Record<string, string[]>",
      test: value => {
        if (typeof value !== "object" || value === null) {
          return false;
        }
        return Object.values(value).every(item => Array.isArray(item) && item.every(subItem => typeof subItem === "string"));
      }
    }).optional());
  }
  get storeType() {
    return this.getConfigValue("store-type", yup.string().oneOf([StoreType.InMemory, StoreType.Redis, StoreType.File]).optional()) ?? StoreType.InMemory;
  }
  getStoreConfig(storeType) {
    const configKey = "store-config";
    switch (storeType) {
      case StoreType.InMemory:
        {
          const storeConfig = this.getConfigValue(configKey, InMemoryConfigSchema.optional());
          if (storeConfig === undefined) return;
          return {
            ...storeConfig,
            ttl: getTTLValue(storeConfig.ttl)
          };
        }
      case StoreType.Redis:
        {
          const storeConfig = this.getConfigValue(configKey, yup.mixed().test({
            name: "is-redis-config-or-redis-url",
            message: "must be a RedisConfig or a string",
            test: value => {
              if (value === undefined) return true;
              if (typeof value === "string" && value !== "") {
                return yup.string().matches(/^rediss?:\/\//).isValidSync(value);
              }
              if (typeof value === "object" && value !== null) {
                return RedisConfigSchema.isValidSync(value);
              }
              return false;
            }
          }));
          if (storeConfig === undefined) return;
          if (typeof storeConfig === "string") {
            return storeConfig;
          }
          const configHolder = new RedisStoreConfigHolder(storeConfig, configKey);
          return {
            ...storeConfig,
            username: configHolder.username,
            password: configHolder.password,
            ttl: getTTLValue(storeConfig.ttl)
          };
        }
      case StoreType.File:
        {
          const config = this.getConfigValue(configKey, yup.mixed().test({
            name: "is-file-config-or-string",
            message: "must be a FileConfig or a string",
            test: value => {
              if (typeof value === "string" && value !== "") {
                return true;
              }
              if (typeof value === "object" && value !== null) {
                return FileConfigSchema.isValidSync(value);
              }
              return false;
            }
          }));
          const configPath = this.verdaccioConfig.configPath ?? this.verdaccioConfig.self_path;
          if (typeof config === "string") {
            return getStoreFilePath(configPath, config);
          }
          return {
            ...config,
            dir: getStoreFilePath(configPath, config.dir),
            ttl: getTTLValue(config.ttl)
          };
        }
      default:
        {
          handleValidationError(new Error(`Unsupported store type: ${String(storeType)}`), "store-type");
        }
    }
  }
  get keepPasswdLogin() {
    return this.getConfigValue("keep-passwd-login", yup.boolean().optional()) ?? !!this.verdaccioConfig.auth?.htpasswd?.file;
  }
  get loginButtonText() {
    return this.getConfigValue("login-button-text", yup.string().optional()) ?? "Login with OpenID Connect";
  }
}

/**
 * parse a query string into a key/value object
 *
 * @param search the query string to parse
 * @returns a key/value object
 */

/**
 * stringify a key/value object into a query string
 *
 * @param params the key/value object to stringify
 * @returns stringified query string
 */
function stringifyQueryParams(params) {
  return Object.entries(params).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
}

function getAuthorizePath(id) {
  return `${authorizePath}${id ? `/${id}` : ""}`;
}
function getCallbackPath(id) {
  return `${callbackPath}${id ? `/${id}` : ""}`;
}

/**
 * Get all permission groups used in the Verdacio config.
 *
 * @param packages The package list.
 * @returns The configured groups.
 */
function getAllConfiguredGroups(packages = {}) {
  const groups = Object.values(packages).flatMap(packageConfig => {
    return ["access", "publish", "unpublish"].flatMap(key => packageConfig[key] ?? []).filter(Boolean);
  });
  return [...new Set(groups)];
}

/**
 * Get the authenticated groups configured in the Verdaccio config.
 *
 * @param val The value to check.
 * @returns The authenticated groups.
 */
function getAuthenticatedGroups(val) {
  switch (typeof val) {
    case "boolean":
      {
        return val;
      }
    case "string":
      {
        return [val].filter(Boolean);
      }
    case "object":
      {
        return Array.isArray(val) ? val.filter(Boolean) : false;
      }
    default:
      {
        return false;
      }
  }
}

/**
 * Encode a string to base64
 *
 * @param str
 * @returns
 */
function base64Encode(str) {
  return Buffer.from(str, "utf8").toString("base64url");
}

/**
 * Decode a base64 string
 *
 * @param str
 * @returns
 */
function base64Decode(str) {
  return Buffer.from(str, "base64").toString("utf8");
}

/**
 * Get hash of any object
 *
 * @param obj
 * @returns {string}
 */
function hashObject(obj) {
  if (typeof obj === "string") return obj;
  return stableHash.stableHash(obj);
}

/**
 * Get the claims from the id token
 *
 * @param idToken
 * @returns
 */
function getClaimsFromIdToken(idToken) {
  const splits = idToken.split(".");
  if (splits.length !== 3) {
    throw new TypeError(ERRORS.INVALID_ID_TOKEN);
  }
  return JSON.parse(base64Decode(splits[1]));
}

/**
 * Check if the current time is before the expireAt time
 *
 * @param expireAt
 * @returns
 */
function isNowBefore(expireAt) {
  const now = Math.floor(Date.now() / 1000);
  return now < expireAt;
}

/**
 * Get the base url from the request
 *
 * @param urlPrefix The url prefix.
 * @param req The request.
 * @param noTrailingSlash Whether to include a trailing slash.
 * @returns
 */
function getBaseUrl(urlPrefix, req, noTrailingSlash = false) {
  const base = url.getPublicUrl(urlPrefix, req);
  return noTrailingSlash ? base.replace(/\/$/, "") : base;
}

/**
 * Check if the token is a JWT
 *
 * @param token The token to check.
 * @returns {boolean} True if the token is a JWT token, false otherwise.
 */
function isJWT(token) {
  // A JWT token typically has three parts separated by dots
  return typeof token === "string" && token.split(".").length === 3;
}

var img = "data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 57 49' width='57' height='49'%3e %3cg fill='none' stroke-width='2.4'%3e %3cpath fill='%23405236' stroke='%23405236' d='M46.06 18.8H33.54L28.4 29.08 14.86 2H2.34l22.4 44.8h7.32l14-28Z'/%3e %3cpath fill='%234A5E3F' stroke='%23405236' d='m32.06 46.8 2.58-5.11L14.86 2H2.34l22.4 44.8h7.32Z'/%3e %3cpath fill='%23CD4000' stroke='%23CD4000' d='m50.06 10.8 4.4-8.8H41.94l-4.4 8.8h12.52Z'/%3e %3cg stroke='%23CD4000' stroke-linecap='square'%3e %3cpath d='M37.6 2h15.22M33.6 6h15.22M29.6 10.8h15.22'/%3e %3c/g%3e %3c/g%3e%3c/svg%3e";

const styles = `
html,
body {
  padding: 0;
  margin: 0;
  height: 100%;
  background-color: #e0e0e0;
  color: #24292F;
  font-family: Helvetica, sans-serif;
  position: relative;
  text-align: center;
}
.wrap {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}
.img {
  filter: drop-shadow(0 0.5rem 0.5rem #24292F80);
  width: 114px;
  height: 98px;
}
h1 {
  margin: 20px 0 16px 0;
  font-size: 28px;
  font-weight: 600;
  line-height: 1.2;
}
h1.success {
  color: #27ae60;
}
h1.error {
  color: #e74c3c;
}
h1.warning {
  color: #f39c12;
}
p {
  font-size: 16px;
  line-height: 1.5;
}
.message {
  background: rgba(255, 255, 255, 0.8);
  border-radius: 12px;
  padding: 16px 20px;
  margin-top: 20px;
  border-left: 4px solid transparent;
}
.message.success {
  border-left-color: #27ae60;
  background: rgba(39, 174, 96, 0.1);
}
.message.error {
  border-left-color: #e74c3c;
  background: rgba(231, 76, 60, 0.1);
}
.message.warning {
  border-left-color: #f39c12;
  background: rgba(243, 156, 18, 0.1);
}
.buttons {
  margin-top: 30px;
}
.back {
  color: #3498db;
  text-decoration: none;
  font-weight: 500;
  padding: 10px 16px;
  background: rgba(52, 152, 219, 0.1);
  border-radius: 6px;
  display: inline-block;
  transition: all 0.3s ease;
  border: 1px solid rgba(52, 152, 219, 0.2);
}
.back:hover {
  background: rgba(52, 152, 219, 0.2);
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
`;
const defaultBackUrl = "javascript:history.back()";
function buildStatusPage(body, withBack = false) {
  let backUrl;
  if (typeof withBack === "object") {
    backUrl = withBack.backUrl || defaultBackUrl;
  } else if (withBack) {
    backUrl = defaultBackUrl;
  }
  return `<!DOCTYPE html>
<html lang="en">
  <head>
    <title>${plugin.name} - ${plugin.version}</title>
    <style>${styles}</style>
  </head>
  <body>
    <div class="wrap">
      <img src="${img}" class="img" alt="logo" />
      ${body}
      ${backUrl ? `<div class="buttons">
              <a class="back" href="${backUrl}">Go back</a>
            </div>` : ""}
    </div>
  </body>
</html>`;
}
function buildErrorPage(error, withBack = false) {
  return buildStatusPage(`<h1 class="error">Sorry :(</h1>
    <p class="message error">${error?.message ?? error}</p>`, withBack);
}
function buildSuccessPage(message, withBack = false) {
  return buildStatusPage(`<h1 class="success">Success!</h1>
    <p class="message success">${message}</p>`, withBack);
}
function buildAccessDeniedPage(withBack = false) {
  return buildStatusPage(`<h1 class="warning">Access Denied.</h1>
    <p class="message warning">${messageGroupRequired}</p>`, withBack);
}

const cliAuthorizePath = getAuthorizePath(cliProviderId);
const cliCallbackPath = getCallbackPath(cliProviderId);
class CliFlow {
  constructor(config, core, provider) {
    this.config = config;
    this.core = core;
    this.provider = provider;
  }
  register_middlewares(app) {
    app.get(cliAuthorizePath, this.authorize);
    app.get(cliCallbackPath, this.callback);
  }
  authorize = async (req, res) => {
    const baseUrl = getBaseUrl(this.config.urlPrefix, req, true);
    try {
      const redirectUrl = baseUrl + cliCallbackPath;
      const url = await this.provider.getLoginUrl(redirectUrl);
      res.redirect(url);
    } catch (e) {
      logger.error({
        message: e.message ?? e
      }, "auth error: @{message}");
      res.status(500).send(buildErrorPage(e, false));
    }
  };
  callback = async (req, res) => {
    const params = {};
    try {
      const providerToken = await this.provider.getToken(req);
      debug(`provider auth success, tokens: "%j"`, providerToken);
      const userinfo = await this.provider.getUserinfo(providerToken);
      const groups = this.core.getUserGroups(userinfo.name) ?? userinfo.groups;
      if (this.core.authenticate(userinfo.name, groups)) {
        const realGroups = this.core.filterRealGroups(userinfo.name, groups);
        debug(`user authenticated, name: "%s", groups: %j`, userinfo.name, realGroups);
        const npmToken = await this.core.issueNpmToken(userinfo.name, realGroups, providerToken);
        params.status = "success";
        params.token = npmToken;
      } else {
        params.status = "denied";
      }
    } catch (error) {
      params.status = "error";
      params.message = error.message ?? error;
      logger.error({
        message: params.message
      }, "auth error: @{message}");
    }
    const redirectUrl = `http://localhost:${cliPort}?${stringifyQueryParams(params)}`;
    res.redirect(redirectUrl);
  };
}

const webAuthorizePath = getAuthorizePath();
const webCallbackPath = getCallbackPath();
class WebFlow {
  constructor(config, core, provider) {
    this.config = config;
    this.core = core;
    this.provider = provider;
  }
  register_middlewares(app) {
    app.get(webAuthorizePath, this.authorize);
    app.get(webCallbackPath, this.callback);
  }

  /**
   * Initiates the auth flow by redirecting to the provider's login URL.
   */
  authorize = async (req, res) => {
    const baseUrl = getBaseUrl(this.config.urlPrefix, req, true);
    try {
      const redirectUrl = baseUrl + webCallbackPath;
      const url = await this.provider.getLoginUrl(redirectUrl);
      res.redirect(url);
    } catch (e) {
      logger.error({
        message: e.message ?? e
      }, "auth error: @{message}");
      res.status(500).send(buildErrorPage(e, {
        backUrl: baseUrl
      }));
    }
  };

  /**
   * After successful authentication, the auth provider redirects back to us.
   * We use the code in the query params to get an access token and the username
   * associated with the account.
   *
   * We issue a JWT using these values and pass them back to the frontend as
   * query parameters so they can be stored in the browser.
   *
   * The username and token are encrypted and base64 encoded to form a token for
   * the npm CLI.
   *
   * There is no need to later decode and decrypt the token. This process is
   * automatically reversed by verdaccio before passing it to the plugin.
   */
  callback = async (req, res) => {
    const baseUrl = getBaseUrl(this.config.urlPrefix, req);
    try {
      const providerToken = await this.provider.getToken(req);
      debug(`provider auth success, token: "%s"`, providerToken);
      const userinfo = await this.provider.getUserinfo(providerToken);
      const groups = this.core.getUserGroups(userinfo.name) ?? userinfo.groups;
      if (this.core.authenticate(userinfo.name, groups)) {
        const realGroups = this.core.filterRealGroups(userinfo.name, groups);
        debug(`user authenticated, name: "%s", groups: "%j"`, userinfo.name, realGroups);
        const uiToken = await this.core.issueUiToken(userinfo.name, realGroups);
        const npmToken = await this.core.issueNpmToken(userinfo.name, realGroups, providerToken);
        const params = {
          username: userinfo.name,
          uiToken,
          npmToken
        };
        const redirectUrl = `${baseUrl}?${stringifyQueryParams(params)}`;
        res.redirect(redirectUrl);
      } else {
        res.status(401).send(buildAccessDeniedPage({
          backUrl: baseUrl
        }));
      }
    } catch (e) {
      logger.error({
        message: e.message ?? e
      }, "auth error: @{message}");
      res.status(500).send(buildErrorPage(e, {
        backUrl: baseUrl
      }));
    }
  };
}

const CLIENT_HTTP_TIMEOUT = 30 * 1000; // 30s

class OpenIDConnectAuthProvider {
  constructor(config, store) {
    this.config = config;
    this.store = store;
    this.providerHost = this.config.providerHost;
    this.scope = this.config.scope;
    this.discoverClient().catch(e => {
      if (e instanceof AggregateError) {
        logger.error({
          messages: e.errors.map(e => e.message)
        }, "Could not discover client: @{messages}");
      } else {
        logger.error({
          message: e.message
        }, "Could not discover client: @{message}");
      }
      process.exit(1);
    });
  }
  get discoveredClient() {
    if (!this.client) {
      throw new ReferenceError(ERRORS.CLIENT_NOT_DISCOVERED);
    }
    return this.client;
  }
  async discoverClient() {
    let issuer;
    const configurationUri = this.config.configurationUri;
    openidClient.custom.setHttpOptionsDefaults({
      timeout: CLIENT_HTTP_TIMEOUT
    });
    if (configurationUri) {
      issuer = await openidClient.Issuer.discover(configurationUri);
    } else {
      const providerHost = this.providerHost;
      const authorizationEndpoint = this.config.authorizationEndpoint;
      const tokenEndpoint = this.config.tokenEndpoint;
      const userinfoEndpoint = this.config.userinfoEndpoint;
      const jwksUri = this.config.jwksUri;
      if ([authorizationEndpoint, tokenEndpoint, userinfoEndpoint, jwksUri].some(endpoint => !!endpoint)) {
        issuer = new openidClient.Issuer({
          issuer: this.config.issuer ?? providerHost,
          authorization_endpoint: authorizationEndpoint,
          token_endpoint: tokenEndpoint,
          userinfo_endpoint: userinfoEndpoint,
          jwks_uri: jwksUri
        });
      } else {
        if (!providerHost) {
          throw new ReferenceError(ERRORS.PROVIDER_HOST_NOT_SET);
        }
        issuer = await openidClient.Issuer.discover(providerHost);
      }
    }
    const client = new issuer.Client({
      client_id: this.config.clientId,
      client_secret: this.config.clientSecret,
      response_types: ["code"]
    });
    this.client = client;
  }
  getId() {
    return "openid";
  }
  async getLoginUrl(redirectUrl, customState) {
    const state = customState ?? openidClient.generators.state(32);
    const nonce = openidClient.generators.nonce();
    await this.store.setOpenIDState(state, nonce, this.getId());
    return this.discoveredClient.authorizationUrl({
      scope: this.scope,
      redirect_uri: redirectUrl,
      state: state,
      nonce: nonce
    });
  }

  /**
   * Parse callback request and get the token from provider.
   *
   * @param callbackRequest
   * @returns
   */
  async getToken(callbackRequest) {
    const parameters = this.discoveredClient.callbackParams(callbackRequest.url);
    debug("Receive callback parameters, %j", parameters);
    const state = parameters.state;
    if (!state) {
      throw new URIError(ERRORS.NO_STATE);
    }
    const nonce = await this.store.getOpenIDState(state, this.getId());
    if (!nonce) {
      throw new URIError(ERRORS.STATE_NOT_FOUND);
    }
    await this.store.deleteOpenIDState(state, this.getId());
    const checks = {
      state,
      nonce,
      scope: this.scope
    };
    const baseUrl = getBaseUrl(this.config.urlPrefix, callbackRequest, true);
    const redirectUrl = baseUrl + callbackRequest.path;
    const tokens = await this.discoveredClient.callback(redirectUrl, parameters, checks);
    if (!tokens.access_token) {
      throw new Error(ERRORS.NO_ACCESS_TOKEN_RETURNED);
    }
    if (!tokens.id_token && this.scope.includes("openid")) {
      throw new Error(ERRORS.NO_ID_TOKEN_RETURNED);
    }
    let expiresAt = tokens.expires_at;
    const claims = tokens.claims();
    if (!expiresAt && tokens.expires_in) {
      expiresAt = Math.trunc(Date.now() / 1000) + tokens.expires_in;
    }

    // if expires_at is not set, try to get it from the id_token
    if (!expiresAt && tokens.id_token) {
      expiresAt = claims.exp;
    }
    return {
      subject: claims.sub,
      accessToken: tokens.access_token,
      idToken: tokens.id_token,
      expiresAt: expiresAt
    };
  }

  /**
   * Get the user info from id_token
   *
   * @param tokens
   * @returns
   */
  getUserinfoFromIdToken(tokens) {
    const idToken = tokens.idToken;
    if (!idToken) {
      throw new TypeError(ERRORS.ID_TOKEN_NOT_FOUND);
    }
    return getClaimsFromIdToken(idToken);
  }

  /**
   * Get the user info from the userinfo endpoint or from the cache.
   *
   * @param token
   * @returns
   */
  async getUserinfoFromEndpoint(token) {
    let accessToken;
    let key;
    if (typeof token === "string") {
      accessToken = token;
      key = token;
    } else {
      accessToken = token.accessToken;
      key = token.subject ?? hashObject(token);
    }
    let userinfo;
    try {
      userinfo = await this.store.getUserInfo?.(key, this.getId());
    } catch {
      debug("No userinfo cache found for key: %s", key);
    }
    if (!userinfo) {
      userinfo = await this.discoveredClient.userinfo(accessToken);
      try {
        await this.store.setUserInfo?.(key, userinfo, this.getId());
      } catch (e) {
        logger.warn({
          message: e.message
        }, "Could not set userinfo cache: @{message}");
      }
    }
    return userinfo;
  }

  /**
   * Get the user from the userinfo.
   *
   * @param token
   * @returns
   */
  async getUserinfo(token) {
    let userinfo;
    let username, groups;
    const usernameClaim = this.config.usernameClaim;
    const groupsClaim = this.config.groupsClaim;
    if (typeof token !== "string") {
      /**
       * username and groups can be in the id_token if the scope is openid.
       */
      try {
        userinfo = this.getUserinfoFromIdToken(token);
        username = userinfo[usernameClaim];
        if (groupsClaim) {
          groups = userinfo[groupsClaim];
        }
      } catch {
        debug("Could not get userinfo from id_token. Trying userinfo endpoint...");
      }
    }
    if (!username || !groups) {
      /**
       * or we can get them from the userinfo endpoint.
       */
      try {
        userinfo = await this.getUserinfoFromEndpoint(token);
        username ??= userinfo[usernameClaim];
        if (groupsClaim) {
          groups ??= userinfo[groupsClaim];
        }
      } catch {
        debug("Could not get userinfo from userinfo endpoint.");
      }
    }
    if (!username) {
      throw new Error(`Could not get username with claim: "${usernameClaim}"`);
    }

    // We prefer the groups from the providerType if it is set.
    const providerType = this.config.providerType;
    if (providerType) {
      groups = await this.getGroupsWithProviderType(token, providerType);
    }
    if (groups) {
      groups = Array.isArray(groups) ? groups.map(String) : [String(groups)];
    }
    return {
      name: String(username),
      groups: groups
    };
  }

  /**
   * Get the groups for the user from the provider.
   *
   * @param token
   * @param providerType
   * @returns
   */
  async getGroupsWithProviderType(token, providerType) {
    const key = typeof token === "string" ? token : token.subject ?? hashObject(token);
    let groups;
    try {
      groups = await this.store.getUserGroups?.(key, this.getId());
    } catch {
      debug("No user groups cache found for key: %s", key);
    }
    if (groups) return groups;
    switch (providerType) {
      case ProviderType.Gitlab:
        {
          groups = await this.getGitlabGroups(token);
          break;
        }
      default:
        {
          throw new ReferenceError(ERRORS.PROVIDER_NOT_FOUND);
        }
    }
    try {
      await this.store.setUserGroups?.(key, groups, this.getId());
    } catch (e) {
      logger.warn({
        message: e.message
      }, "Could not set user groups cache: @{message}");
    }
    return groups;
  }

  /**
   * Get the groups for the user from the Gitlab API.
   *
   * @param token
   * @returns {Promise<string[]>} The groups the user is in.
   */
  async getGitlabGroups(token) {
    const group = new rest.Groups({
      host: this.providerHost,
      oauthToken: typeof token === "string" ? token : token.accessToken
    });
    const userGroups = await group.all();
    return userGroups.map(g => g.name);
  }
}

globalAgent.bootstrap({
  environmentVariableNamespace: "",
  socketConnectionTimeout: 60_000
});

/**
 * Set global proxy configuration.
 *
 * https://www.npmjs.com/package/global-agent#globalglobal_agent
 *
 * @param proxyConfig - proxy configuration
 */
function registerGlobalProxy(proxyConfig) {
  for (const [key, value] of Object.entries(proxyConfig)) {
    if (value) {
      const proxyAgentEnvKey = key.toUpperCase();
      global.GLOBAL_AGENT[proxyAgentEnvKey] = value;
      logger.info({
        key: proxyAgentEnvKey,
        value
      }, "setting proxy environment variable: @{key}=@{value}");
    }
  }
}

const defaultOptions$2 = {
  ttl: BaseStore.DefaultStateTTL
};
class FileStore extends BaseStore {
  constructor(opts) {
    super();
    const db = storage.create({
      ...defaultOptions$2,
      ...(typeof opts === "string" ? {
        dir: opts
      } : opts)
    });
    db.init().catch(e => {
      logger.error({
        message: e.message
      }, "Failed to initialize file store: @{message}");
      process.exit(1);
    });
    this.db = db;
  }
  async setOpenIDState(key, nonce, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    await this.db.setItem(stateKey, nonce);
  }
  getOpenIDState(key, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    return this.db.getItem(stateKey);
  }
  async deleteOpenIDState(key, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    await this.db.removeItem(stateKey);
  }
  async setUserInfo(key, data, providerId) {
    const userInfoKey = this.getUserInfoKey(key, providerId);
    await this.db.setItem(userInfoKey, data, {
      ttl: BaseStore.DefaultDataTTL
    });
  }
  getUserInfo(key, providerId) {
    const userInfoKey = this.getUserInfoKey(key, providerId);
    return this.db.getItem(userInfoKey);
  }
  async setUserGroups(key, groups, providerId) {
    const groupsKey = this.getUserGroupsKey(key, providerId);
    await this.db.setItem(groupsKey, groups, {
      ttl: BaseStore.DefaultDataTTL
    });
  }
  getUserGroups(key, providerId) {
    const groupsKey = this.getUserGroupsKey(key, providerId);
    return this.db.getItem(groupsKey);
  }
  async setWebAuthnToken(key, token) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    await this.db.setItem(tokenKey, token);
  }
  getWebAuthnToken(key) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    return this.db.getItem(tokenKey);
  }
  async deleteWebAuthnToken(key) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    await this.db.removeItem(tokenKey);
  }
  close() {
    // ignore
  }
}

const defaultOptions$1 = {
  ttl: BaseStore.DefaultStateTTL
};
class InMemoryStore extends BaseStore {
  constructor(opts = {}) {
    super();
    this.stateCache = new TTLCache({
      ...defaultOptions$1,
      ...opts
    });
    this.dataCache = new TTLCache({
      max: 2000,
      ttl: BaseStore.DefaultDataTTL
    });
  }
  setOpenIDState(key, nonce, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    this.stateCache.set(stateKey, nonce);
  }
  getOpenIDState(key, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    if (!this.stateCache.has(stateKey)) {
      return;
    }
    return this.stateCache.get(stateKey);
  }
  deleteOpenIDState(key, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    this.stateCache.delete(stateKey);
  }
  setUserInfo(key, data, providerId) {
    const userInfoKey = this.getUserInfoKey(key, providerId);
    this.dataCache.set(userInfoKey, data);
  }
  getUserInfo(key, providerId) {
    const userInfoKey = this.getUserInfoKey(key, providerId);
    if (!this.dataCache.has(userInfoKey)) {
      return;
    }
    return this.dataCache.get(userInfoKey);
  }
  setUserGroups(key, groups, providerId) {
    const userGroupsKey = this.getUserGroupsKey(key, providerId);
    this.dataCache.set(userGroupsKey, groups);
  }
  getUserGroups(key, providerId) {
    const userGroupsKey = this.getUserGroupsKey(key, providerId);
    if (!this.dataCache.has(userGroupsKey)) {
      return undefined;
    }
    return this.dataCache.get(userGroupsKey);
  }
  setWebAuthnToken(key, token) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    this.stateCache.set(tokenKey, token);
  }
  getWebAuthnToken(key) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    if (!this.stateCache.has(tokenKey)) {
      return undefined;
    }
    return this.stateCache.get(tokenKey);
  }
  deleteWebAuthnToken(key) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    this.stateCache.delete(tokenKey);
  }
  close() {
    this.stateCache.clear();
    this.dataCache.clear();
  }
}

const defaultOptions = {
  ttl: BaseStore.DefaultStateTTL
};
class RedisStore extends BaseStore {
  constructor(opts) {
    super();
    if (!opts) {
      const {
        ttl,
        ...restOpts
      } = defaultOptions;
      this.redis = new ioredis.Redis(restOpts);
      this.ttl = ttl;
    } else if (typeof opts === "string") {
      const {
        ttl,
        ...restOpts
      } = defaultOptions;
      this.redis = new ioredis.Redis(opts, restOpts);
      this.ttl = ttl;
    } else {
      if (opts?.nodes) {
        const {
          ttl: defaultTTL,
          ...restDefaultOpts
        } = defaultOptions;
        const {
          ttl = defaultTTL,
          nodes,
          username,
          password,
          redisOptions,
          ...restOpts
        } = opts;
        this.redis = new ioredis.Redis.Cluster(nodes, {
          redisOptions: {
            ...restDefaultOpts,
            username,
            password,
            ...redisOptions
          },
          ...restOpts
        });
        this.ttl = ttl;
      } else {
        const {
          ttl,
          nodes: _,
          ...restOpts
        } = {
          ...defaultOptions,
          ...opts
        };
        this.redis = new ioredis.Redis(restOpts);
        this.ttl = ttl;
      }
    }
  }
  async isKeyExists(key) {
    const times = await this.redis.exists(key);
    return times > 0;
  }
  async setOpenIDState(key, nonce, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    await this.redis.set(stateKey, nonce, "PX", this.ttl);
  }
  async getOpenIDState(key, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    const exists = await this.isKeyExists(stateKey);
    if (!exists) return null;
    return this.redis.get(stateKey);
  }
  async deleteOpenIDState(key, providerId) {
    const stateKey = this.getStateKey(key, providerId);
    await this.redis.del(stateKey);
  }
  async setUserInfo(key, data, providerId) {
    const userInfoKey = this.getUserInfoKey(key, providerId);
    await this.redis.hset(userInfoKey, data);
    await this.redis.pexpire(userInfoKey, BaseStore.DefaultDataTTL);
  }
  async getUserInfo(key, providerId) {
    const userInfoKey = this.getUserInfoKey(key, providerId);
    const exists = await this.redis.exists(userInfoKey);
    if (!exists) return null;
    return this.redis.hgetall(userInfoKey);
  }
  async setUserGroups(key, groups, providerId) {
    const groupsKey = this.getUserGroupsKey(key, providerId);
    await this.redis.lpush(groupsKey, ...groups);
    await this.redis.pexpire(groupsKey, BaseStore.DefaultDataTTL);
  }
  async getUserGroups(key, providerId) {
    const groupsKey = this.getUserGroupsKey(key, providerId);
    const exists = await this.redis.exists(groupsKey);
    if (!exists) return null;
    return this.redis.lrange(groupsKey, 0, -1);
  }
  async setWebAuthnToken(key, token) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    await this.redis.set(tokenKey, token);
  }
  async getWebAuthnToken(key) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    return this.redis.get(tokenKey);
  }
  async deleteWebAuthnToken(key) {
    const tokenKey = this.getWebAuthnTokenKey(key);
    await this.redis.del(tokenKey);
  }
  async close() {
    await this.redis.quit();
  }
}

function createStore(config) {
  const storeType = config.storeType;
  const storeConfig = config.getStoreConfig(storeType);
  switch (storeType) {
    case StoreType.Redis:
      {
        return new RedisStore(storeConfig);
      }
    case StoreType.File:
      {
        return new FileStore(storeConfig);
      }
    default:
      {
        return new InMemoryStore(storeConfig);
      }
  }
}

/**
 * This is the npm WebAuth login flow.
 *
 * Known as the npm `--auth-type=web` command line argument.
 *
 * see: https://docs.npmjs.com/accessing-npm-using-2fa#sign-in-from-the-command-line-using---auth-typeweb
 *
 * This flow is described in verdaccio's discussions and issues:
 *   - https://github.com/orgs/verdaccio/discussions/1515
 *   - https://github.com/verdaccio/verdaccio/issues/3413
 *
 * First, npm login will make a POST request to your registry at endpoint `/-/v1/login` (e.g. https://registry.npmjs.org/-/v1/login).
 *
 * It expects in return a JSON body shaped like this:
 *
 *   ```json
 *   {
 *     "loginUrl": "https://www.npmjs.com/login?next=/login/cli/82737ae6-7557-4e7d-b3cb-edcc195aa34a",
 *     "doneUrl": "https://registry.npmjs.org/-/v1/done?sessionId=82737ae6-7557-4e7d-b3cb-edcc195aa34a"
 *   }
 *   ```
 *
 * Second, the NPM CLI will periodically call the `doneUrl`, which is responsible for letting it know when the user is successfully authenticated,
 * and returning the user's token afterward.
 *
 * It expects the server to return either:
 *   - A HTTP code `202`, along with an HTTP header `retry-after`, as long as the token is not available
 *   - A HTTP code `200` response, along with the token, once the login is successful
 *
 * The token must be put inside the response body as JSON:
 *   ```json
 *   {
 *     "token": "npm_token0123456789abcdef=="
 *   }
 *   ```
 *
 * Most likely for security reasons, once the token is successfully retrieved, the session matching the `sessionId` gets destroyed,
 * and the `doneUrl` is no longer available. Hence, one can use the `loginUrl` to fetch the token only once.
 *
 * Third, while the NPM CLI is waiting for the `doneUrl` to return a token, it offers to open up a web browser to the `loginUrl`.
 *
 * When the `adduser` command is used, the `/-/v1/login` endpoint is called with the `{"create":true}` body,
 * the response should be the same as above.
 */

const PENDING_TOKEN = "__pending__";
const webAuthnAuthorizePath = getAuthorizePath(webAuthnProviderId);
const webAuthnCallbackPath = getCallbackPath(webAuthnProviderId);
const SESSION_ID_BYTES = 32;
const SESSION_ID_LENGTH = Math.trunc((SESSION_ID_BYTES * 8 + 5) / 6); // base64url encoding unpadded

class WebAuthFlow {
  constructor(config, core, provider, store) {
    this.config = config;
    this.core = core;
    this.provider = provider;
    this.store = store;
  }
  register_middlewares(app) {
    app.post(npmLoginPath, express.json(), this.login);
    app.get(npmDonePath, this.done);
    app.get(webAuthnAuthorizePath, this.authorize);
    app.get(webAuthnCallbackPath, this.callback);
  }
  login = async (req, res, next) => {
    try {
      const sessionId = openidClient.generators.random(SESSION_ID_BYTES);
      await this.store.setWebAuthnToken(sessionId, PENDING_TOKEN);
      const baseUrl = getBaseUrl(this.config.urlPrefix, req, true);
      res.json({
        loginUrl: baseUrl + webAuthnAuthorizePath + `?sessionId=${sessionId}`,
        doneUrl: baseUrl + npmDonePath + `?sessionId=${sessionId}`
      });
    } catch (e) {
      logger.error({
        message: e.message ?? e
      }, "auth error: @{message}");
      next(core.errorUtils.getInternalError(e.message ?? e));
    }
  };
  done = async (req, res, next) => {
    const sessionId = req.query.sessionId;
    if (!sessionId) {
      next(core.errorUtils.getBadRequest("missing sessionId"));
      return;
    }
    if (sessionId.length !== SESSION_ID_LENGTH) {
      next(core.errorUtils.getBadRequest("invalid sessionId"));
      return;
    }
    try {
      const token = await this.store.getWebAuthnToken(sessionId);
      if (!token) {
        next(core.errorUtils.getUnauthorized("invalid or expired session"));
        return;
      }
      if (token === PENDING_TOKEN) {
        res.header("Retry-After", "3").status(202).json({});
        return;
      }
      await this.store.deleteWebAuthnToken(sessionId);
      res.json({
        token
      });
    } catch (e) {
      logger.error({
        message: e.message ?? e
      }, "auth error: @{message}");
      void this.store.deleteWebAuthnToken(sessionId);
      return next(core.errorUtils.getInternalError(e.message ?? e));
    }
  };
  authorize = async (req, res) => {
    const sessionId = req.query.sessionId;
    if (!sessionId) {
      res.status(400).send(buildErrorPage(new Error("missing sessionId")));
      return;
    }
    try {
      const baseUrl = getBaseUrl(this.config.urlPrefix, req, true);
      const redirectUrl = baseUrl + webAuthnCallbackPath;
      const url = await this.provider.getLoginUrl(redirectUrl, sessionId);
      res.redirect(url);
    } catch (e) {
      logger.error({
        message: e.message ?? e
      }, "auth error: @{message}");
      void this.store.deleteWebAuthnToken(sessionId);
      res.status(500).send(buildErrorPage(e));
    }
  };
  callback = async (req, res) => {
    // The query parameter `state` is the sessionId, added by authorize api
    const sessionId = req.query.state;
    if (!sessionId) {
      res.status(400).send(buildErrorPage(new Error("missing sessionId")));
      return;
    }
    try {
      const providerToken = await this.provider.getToken(req);
      debug(`provider auth success, token: "%s"`, providerToken);
      const userinfo = await this.provider.getUserinfo(providerToken);
      const groups = this.core.getUserGroups(userinfo.name) ?? userinfo.groups;
      if (this.core.authenticate(userinfo.name, groups)) {
        const realGroups = this.core.filterRealGroups(userinfo.name, groups);
        debug(`user authenticated, name: "%s", groups: "%j"`, userinfo.name, realGroups);
        const npmToken = await this.core.issueNpmToken(userinfo.name, realGroups, providerToken);
        await this.store.setWebAuthnToken(sessionId, npmToken);
        res.status(200).send(buildSuccessPage(messageLoggedAndCloseWindow));
      } else {
        void this.store.deleteWebAuthnToken(sessionId);
        res.status(401).send(buildAccessDeniedPage());
      }
    } catch (e) {
      logger.error({
        message: e.message ?? e
      }, "auth error: @{message}");
      void this.store.deleteWebAuthnToken(sessionId);
      res.status(500).send(buildErrorPage(e));
    }
  };
}

function isAccessTokenPayload(u) {
  return !!u.at;
}
class AuthCore {
  constructor(config, provider) {
    this.provider = provider;
    this.configSecret = config.secret;
    this.security = config.security;
    this.groupUsers = config.groupUsers;
    this.configuredGroups = getAllConfiguredGroups(config.packages);
    this.authenticatedGroups = getAuthenticatedGroups(config.authorizedGroups);
  }
  setAuth(auth) {
    this.auth = auth;
  }
  get secret() {
    return this.auth?.secret ?? this.configSecret;
  }

  /**
   * Get the logged user's full groups
   *
   * Our JWT do not contain the user's full groups, so we need to get them from the config.
   *
   * @param user
   * @returns
   */
  getLoggedUserGroups(user) {
    return [...user.real_groups, ...config.defaultLoggedUserRoles];
  }

  /**
   * Get the non-logged user's full groups
   *
   * @returns
   */
  getNonLoggedUserGroups() {
    return [...config.defaultNonLoggedUserRoles];
  }

  /**
   * Get the user groups from the config
   *
   * @param username
   * @returns groups or undefined
   */
  getUserGroups(username) {
    if (!this.groupUsers) return undefined;
    const groupUsers = this.groupUsers;
    return Object.keys(groupUsers).filter(group => {
      return groupUsers[group].includes(username);
    });
  }

  // get unique and sorted groups
  filterRealGroups(username, groups = []) {
    const authenticatedGroups = typeof this.authenticatedGroups === "boolean" ? [] : this.authenticatedGroups;
    const relevantGroups = groups.filter(group => this.configuredGroups.includes(group) || authenticatedGroups.includes(group));

    /**
     * add the user to the groups
     */
    relevantGroups.push(username);
    return [...new Set(relevantGroups)].sort();
  }

  /**
   * Check if the user is allowed to access the registry
   *
   * @param username
   * @param groups
   * @returns true if the user is allowed to access the registry
   */
  authenticate(username, groups = []) {
    if (!username) return false;
    debug("authenticate user %s with groups %j, required groups: %j", username, groups, this.authenticatedGroups);
    let authenticated;

    /**
     * - if authenticatedGroups is true, the user must be in at least one group
     * - if authenticatedGroups is false, no group authentication is required
     */
    if (this.authenticatedGroups === true) {
      authenticated = groups.length > 0;
    } else if (this.authenticatedGroups === false) {
      authenticated = true;
    } else {
      /**
       * if authenticatedGroups is an array, the user must be in one of the groups
       */
      authenticated = this.authenticatedGroups.some(group => username === group || groups.includes(group));
    }
    return authenticated;
  }
  issueNpmToken(username, realGroups, providerToken) {
    if (auth.isAESLegacy(this.security)) {
      debug("using legacy encryption for npm token");
      const npmToken = this.legacyEncrypt(username, realGroups, providerToken);
      if (!npmToken) {
        throw new Error(ERRORS.TOKEN_ENCRYPTION_FAILED_NPM);
      }
      return Promise.resolve(npmToken);
    } else {
      return this.signJWT(username, realGroups, this.security.api.jwt.sign);
    }
  }

  /**
   * Verify the npm token
   *
   * @param token
   * @returns
   */
  async verifyNpmToken(token) {
    // if jwt is not enabled, use legacy encryption
    // the token is the password in basic auth
    // decode it and get the token from the payload
    if (auth.isAESLegacy(this.security)) {
      debug("verifying npm token using legacy encryption");
      const legacyPayload = this.legacyDecode(token);
      debug("legacy payload: %j", legacyPayload);
      let name;
      let userGroups = [];
      let realGroups = [];
      if (isAccessTokenPayload(legacyPayload)) {
        const res = await this.provider.getUserinfo(legacyPayload.at);
        name = res.name;
        userGroups = res.groups ?? [];
        realGroups = this.filterRealGroups(name, userGroups);
      } else {
        if (!isNowBefore(legacyPayload.exp)) {
          return false;
        }
        name = legacyPayload.n;
        userGroups = [...legacyPayload.g];
        realGroups = [...legacyPayload.g];
      }
      if (!this.authenticate(name, userGroups)) {
        return false;
      }
      return {
        name,
        real_groups: realGroups
      };
    } else if (isJWT(token)) {
      debug("verifying npm token using jwt");
      return this.verifyJWT(token);
    } else {
      debug("npm token is not a valid jwt");
      throw new TypeError(ERRORS.INVALID_TOKEN);
    }
  }

  /**
   * The ui token of verdaccio is always a JWT token.
   *
   * @param username
   * @param realGroups
   * @returns
   */
  issueUiToken(username, realGroups) {
    const jwtSignOptions = this.security.web.sign;
    return this.signJWT(username, realGroups, jwtSignOptions);
  }
  verifyUiToken(token) {
    return this.verifyJWT(token);
  }
  signJWT(username, realGroups, jwtSignOptions) {
    if (!this.auth) {
      throw new ReferenceError(ERRORS.AUTH_NOT_INITIALIZED);
    }
    const remoteUser = config.createRemoteUser(username, realGroups);
    return this.auth.jwtEncrypt(remoteUser, jwtSignOptions);
  }
  verifyJWT(token) {
    return auth.verifyJWTPayload(token, this.secret, this.security);
  }
  legacyEncrypt(username, realGroups, providerToken) {
    if (!this.auth) {
      throw new ReferenceError(ERRORS.AUTH_NOT_INITIALIZED);
    }

    // encode the user info as a token, save it to the final token.
    let u;
    if (typeof providerToken === "string") {
      u = {
        at: providerToken
      };
    } else {
      // legacy token does not have a expiration time
      // we use the provider expire time or token to check if the token is still valid
      u = providerToken.expiresAt ? {
        sub: providerToken.subject,
        n: username,
        g: [...realGroups],
        exp: providerToken.expiresAt
      } : {
        sub: providerToken.subject,
        at: providerToken.accessToken
      };
    }
    const payloadToken = this.legacyEncode(u);

    // use internal function to encrypt token
    const token = this.auth.aesEncrypt(auth.buildUser(username, payloadToken));
    if (!token) {
      throw new Error(ERRORS.TOKEN_ENCRYPTION_FAILED);
    }

    // the return value in verdaccio 5 is a buffer, we need to convert it to a string
    return typeof token === "string" ? token : base64Encode(token);
  }
  legacyEncode(payload) {
    return base64Encode(JSON.stringify(payload));
  }
  legacyDecode(payloadToken) {
    return JSON.parse(base64Decode(payloadToken));
  }
}

/**
 * Injects additional static imports into the DOM with code from the client folder
 * that modifies the login button.
 */
class PatchHtml {
  constructor(config) {
    this.urlPrefix = config.urlPrefix;
    this.keepPasswdLogin = config.keepPasswdLogin;
    this.loginButtonText = config.loginButtonText;
  }
  register_middlewares(app) {
    app.use(this.patchResponse);
  }

  /**
   * Patches `res.send` in order to inject style and script tags.
   */
  patchResponse = (req, res, next) => {
    const originalSend = res.send;
    res.send = (html, ...args) => {
      try {
        const patchedHtml = this.insertTags(html, req);
        return originalSend.call(res, patchedHtml, ...args);
      } catch (err) {
        logger.error({
          message: err.message
        }, "Failed to patch HTML: @{message}");
        return originalSend.call(res, html, ...args);
      }
    };
    next();
  };
  insertTags(html, req) {
    const htmlString = Buffer.isBuffer(html) ? html.toString() : html;
    if (typeof htmlString !== "string" || !htmlString.includes("__VERDACCIO_BASENAME_UI_OPTIONS")) {
      return html;
    }
    const bodyLineRegex = /^(\s*)<\/body>/m;
    const indent = bodyLineRegex.exec(htmlString)?.[1] ?? "";
    const scriptTag = this.generateScriptTag(req, indent);
    return htmlString.replace(bodyLineRegex, `${scriptTag}</body>`);
  }
  generateScriptTag(req, indent) {
    const baseUrl = getBaseUrl(this.urlPrefix, req, true);
    const scriptName = `${plugin.name}-${plugin.version}.js`;
    const scriptSrc = `${baseUrl}${staticPath}/${scriptName}`;
    return [`<script>`, `    window.__VERDACCIO_OPENID_OPTIONS={"keepPasswdLogin":${this.keepPasswdLogin},"loginButtonText":"${this.loginButtonText}"}`, `</script>`, `<script defer="defer" src="${scriptSrc}"></script>`, ""].map(line => `${indent}${line}`).join("");
  }
}

/**
 * Serves additional static assets required to modify the login button.
 */
class ServeStatic {
  register_middlewares(app) {
    app.use(staticPath, express.static(publicRoot));
  }
}

/**
 * Implements the verdaccio plugin interfaces.
 */
class Plugin {
  storeClosing = false;
  constructor(config, options) {
    this.config = config;
    this.options = options;
    setLogger(options.logger);
    const verdaccioConfig = options.config;
    registerGlobalProxy({
      http_proxy: verdaccioConfig.http_proxy,
      https_proxy: verdaccioConfig.https_proxy,
      no_proxy: verdaccioConfig.no_proxy
    });
    const parsedConfig = new ParsedPluginConfig(config, verdaccioConfig);
    const store = createStore(parsedConfig);

    // close store on process termination
    for (const signal of ["SIGINT", "SIGQUIT", "SIGTERM", "SIGHUP"]) {
      process.once(signal, async () => {
        if (this.storeClosing) return;
        this.storeClosing = true;
        try {
          debug("Received signal %s, closing store...", signal);
          await store.close();
          debug("Store closed, good bye!");
        } catch (e) {
          debug("Error closing store: %s", e.message);
        }
      });
    }
    const provider = new OpenIDConnectAuthProvider(parsedConfig, store);
    const core = new AuthCore(parsedConfig, provider);
    this.parsedConfig = parsedConfig;
    this.provider = provider;
    this.core = core;
    this.store = store;
  }
  get version() {
    return +plugin.version;
  }
  getVersion() {
    return this.version;
  }
  register_middlewares(app, auth, _storage) {
    this.core.setAuth(auth);
    const children = [new ServeStatic(), new PatchHtml(this.parsedConfig), new WebFlow(this.parsedConfig, this.core, this.provider), new CliFlow(this.parsedConfig, this.core, this.provider), new WebAuthFlow(this.parsedConfig, this.core, this.provider, this.store)];
    for (const child of children) {
      child.register_middlewares(app);
    }
  }
  async authenticate(username, token, callback) {
    if (!username || !token) {
      debug("username or token is empty, skip authentication");

      // set error to null, next auth plugin will be called
      callback(null, false);
      return;
    }
    debug("authenticating user, username: %s, token: %s", username, token);
    let user;
    try {
      user = await this.core.verifyNpmToken(token);
    } catch (e) {
      debug(`%s. user: "%s", token: "%s", falling back to legacy auth`, e.message, username, token);

      // the token is not valid by us, let the next auth plugin to handle it
      callback(null, false);
      return;
    }

    /**
     * the result is false, means the token is not authenticated
     */
    if (user === false || !user.name) {
      callback(core.errorUtils.getUnauthorized(`user ${username} is not authenticated`), false);
      return;
    }
    debug("user: %j", user);
    if (username !== user.name) {
      logger.warn({
        expected: user.name,
        actual: username
      }, `invalid username: expected "@{expected}", actual "@{actual}"`);
      callback(core.errorUtils.getUnauthorized("invalid username"), false);
      return;
    }
    callback(null, user.real_groups);
  }
  allow_access = this.allow_action("access");
  allow_publish = this.allow_action("publish");
  allow_unpublish = this.allow_action(["unpublish", "publish"]);
  allow_action(action) {
    const actions = Array.isArray(action) ? action : [action];
    const mainAction = actions[0];
    return (user, pkg, callback) => {
      let requiredGroups;
      for (const action of actions) {
        const groups = pkg[action];
        if (groups) {
          requiredGroups = groups;
          break;
        }
      }
      if (!requiredGroups?.length || !user.name) {
        // let next auth plugin to handle it
        callback(null, false);
        return;
      }
      debug("check %s: %s (%j) -> %s, required: %j", mainAction, user.name, user.real_groups, pkg.name, requiredGroups);
      let userGroups;
      /**
       * Check if user is authenticated
       * The authenticated groups may change after user login.
       * If the user is authenticated, we use the logged user groups.
       * If the user is not authenticated, we use the non-logged user groups.
       * This is to ensure that the user has the correct groups to access the package.
       */
      if (this.core.authenticate(user.name, user.real_groups)) {
        userGroups = this.core.getLoggedUserGroups(user);
      } else {
        logger.info({
          user: user.name,
          groups: JSON.stringify(user.real_groups)
        }, `User "@{user}" with groups @{groups} is not authenticated now, treating as non-logged user`);
        userGroups = this.core.getNonLoggedUserGroups();
      }
      const hasPermission = requiredGroups.some(group => user.name === group || userGroups.includes(group));
      if (hasPermission) {
        callback(null, true);
        return;
      }
      callback(core.errorUtils.getForbidden(`user ${user.name} is not allowed to ${mainAction} package ${pkg.name}`));
    };
  }
}

const env = dotenv.config({
  path: [os.homedir(), process.cwd()].flatMap(dir => [path.join(dir, ".env"), path.join(dir, `.env.${pluginKey}`)]),
  quiet: true,
  override: false
});
debug("Loaded environment variables", env.parsed);

exports.default = Plugin;