UNPKG

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
'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