verdaccio-openid
Version:
A UI for OIDC authentication for Verdaccio, a fork of verdaccio-github-oauth-ui
1,637 lines (1,576 loc) • 62.7 kB
JavaScript
'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 serveStatic = require('serve-static');
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
var name = "verdaccio-openid";
var version = "0.15.2";
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