UNPKG

verdaccio-doreamon-oauth2

Version:

A GitLab OAuth Plugin for [Verdaccio](https://www.verdaccio.org)

547 lines (435 loc) 16.2 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; } var lodash = require('lodash'); var querystring = require('querystring'); var got = _interopDefault(require('got')); var doreamon = _interopDefault(require('@zodash/doreamon')); var qs = require('qs'); var chalk = _interopDefault(require('chalk')); var express = require('express'); var fs = require('fs'); var globalAgent = require('global-agent'); function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } class DoreamonClient { constructor(host) { this.host = host; _defineProperty(this, "defaultOptions", { json: true }); _defineProperty(this, "getAuthorizationUrl", (clientId, redirectUri) => { const query = { client_id: clientId, redirect_uri: redirectUri, response_type: "code", scope: "openid", state: "doreamon" }; return this.webBaseUrl + "/v2/authorize?" + qs.stringify(query).replace("%20", "+"); }); _defineProperty(this, "requestAccessToken", async (code, clientId, clientSecret, redirectUri) => { const url = this.webBaseUrl + "/token"; const options = { headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, body: { client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri, grant_type: "authorization_code", scope: "openid" } }; // return this.request<DoreamonToken>(url, options) const data = await doreamon.request.post(url, options).json(); return data; }); _defineProperty(this, "requestUser", async accessToken => { const url = this.webBaseUrl + "/user"; const options = { headers: { Authorization: "Bearer " + accessToken, "Accept": "application/json" } }; return this.request(url, options); }); } get webBaseUrl() { return this.host || "https://login.zcorky.com"; } get apiBaseUrl() { return this.webBaseUrl + "/api"; } async request(url, additionalOptions) { const options = lodash.merge({}, this.defaultOptions, additionalOptions); const response = await got(url, options); return response.body; } } // This is duplicated here because it is unfortunately not availabel via API. const TIME_EXPIRATION_7D = "7d"; const defaultWebTokenOptions = { sign: { // The expiration token for the website is 7 days expiresIn: TIME_EXPIRATION_7D }, verify: {} }; const defaultApiTokenConf = { legacy: true }; const defaultSecurity = { web: defaultWebTokenOptions, api: defaultApiTokenConf }; function getSecurity(config) { if (lodash.isNil(config.security) === false) { return lodash.merge(defaultSecurity, config.security); } return defaultSecurity; } const pluginName = "doreamon-oauth2"; function getConfig(config, key) { const value = lodash.get(config, `middlewares[${pluginName}][${key}]`) || lodash.get(config, `auth[${pluginName}][${key}]`); return process.env[value] || value; } /** * user_agent: e.g. "verdaccio/4.3.4" --> 4 */ function getMajorVersion(config) { return +config.user_agent[10]; } function ensurePropExists(config, key) { const value = getConfig(config, key); if (!value) { console.error(chalk.red(`[${pluginName}] ERR: Missing configuration "auth[${pluginName}][${key}]". Please check your verdaccio config.`)); process.exit(1); } } function validateConfig(config) { // ensurePropExists(config, "group") ensurePropExists(config, "client_id"); ensurePropExists(config, "client_secret"); } class Callback { /** * This is where itLab should redirect back to. */ static getCallbackUrl(req, config) { return Callback.getRegistryUrl(req, config) + Callback.path; } /** * This is the same as what `npm config get registry` returns. */ static getRegistryUrl(req, config) { const prefix = lodash.get(config, "root_url", "") || lodash.get(config, "redirect_url_prefix", ""); if (prefix) { return prefix.replace(/\/?$/, ""); // Remove potential trailing slash } const protocol = req.get("X-Forwarded-Proto") || req.protocol; return protocol + "://" + req.get("host"); } constructor(config, auth) { this.config = config; this.auth = auth; _defineProperty(this, "requiredGroup", getConfig(this.config, "group") || '$authenticated'); _defineProperty(this, "clientId", getConfig(this.config, "client_id")); _defineProperty(this, "clientSecret", getConfig(this.config, "client_secret")); _defineProperty(this, "host", getConfig(this.config, "host")); _defineProperty(this, "rootURL", getConfig(this.config, "root_url")); _defineProperty(this, "doreamon", new DoreamonClient(this.host)); _defineProperty(this, "version", getMajorVersion(this.config)); _defineProperty(this, "middleware", async (req, res, next) => { try { const code = req.query.code; const callbackUrl = Callback.getCallbackUrl(req, this.config); const token = await this.doreamon.requestAccessToken(code, this.clientId, this.clientSecret, callbackUrl); const user = await this.doreamon.requestUser(token.access_token); if (this.requiredGroup === "*" || this.requiredGroup === '$authenticated') { await this.grantAccess(res, token, user); } else { await this.denyAccess(res); } } catch (error) { next(error); } }); } /** * After a successful OAuth authentication, gitLab redirects back to us. * We use the OAuth code to get an OAuth access token and the username associated * with the GitLab account. * * The token and username are encryped and base64 encoded and configured as npm * credentials for this registry. There is no need to later decode and decrypt the token. * This process is automatically reversed before the token is passed to the authenticate * middleware. * * We then issue a JWT token using these values and pass them back to the frontend * as query parameters so they can be stored in the browser. */ async grantAccess(res, token, user) { const npmAuth = user.nickname + ":" + token.access_token; // const encryptedNpmToken = this.encrypt(npmAuth) const _user = { name: user.nickname, groups: [this.requiredGroup], real_groups: [this.requiredGroup] }; const encryptedJWT = this.version === 3 ? await this.issueJWTVerdaccio3(_user) : await this.issueJWTVerdaccio4(_user); const encryptedNpmToken = await this.encryptAPIJWT(_user); const frontendUrl = "/?" + querystring.stringify({ username: user.nickname, jwtToken: encryptedJWT, npmToken: encryptedNpmToken }); res.redirect(frontendUrl); } denyAccess(res) { res.send(` Access denied: you are not a member of "${this.requiredGroup}"<br> <a href="/">Go back</a> `); } // https://github.com/verdaccio/verdaccio/blob/3.x/src/api/web/endpoint/user.js#L15 async issueJWTVerdaccio3(user) { return this.auth.issueUIjwt(user, "24h"); } // https://github.com/verdaccio/verdaccio/blob/master/src/api/web/endpoint/user.ts#L31 async issueJWTVerdaccio4(user) { const jWTSignOptions = getSecurity(this.config).web.sign; return this.auth.jwtEncrypt(user, jWTSignOptions); } async encryptAPIJWT(user) { var _getSecurity$api, _getSecurity$api$jwt; const jWTSignOptions = (_getSecurity$api = getSecurity(this.config).api) === null || _getSecurity$api === void 0 ? void 0 : (_getSecurity$api$jwt = _getSecurity$api.jwt) === null || _getSecurity$api$jwt === void 0 ? void 0 : _getSecurity$api$jwt.sign; return this.auth.jwtEncrypt(user, jWTSignOptions); } encrypt(text) { return this.auth.aesEncrypt(new Buffer(text)).toString("base64"); } } _defineProperty(Callback, "path", "/-/doreamon/callback"); class Authorization { constructor(config) { this.config = config; _defineProperty(this, "clientId", getConfig(this.config, "client_id")); _defineProperty(this, "host", getConfig(this.config, "host")); _defineProperty(this, "doreamon", new DoreamonClient(this.host)); _defineProperty(this, "middleware", (req, res, next) => { try { const id = req.params.id || ""; const callbackUrl = Callback.getCallbackUrl(req, this.config) + (id ? `/${id}` : ""); const url = this.doreamon.getAuthorizationUrl(this.clientId, callbackUrl); res.redirect(url); } catch (error) { next(error); } }); } /** * Initiates the GitLab OAuth flow by redirecting to GitLab. * The callback URL can be customized by subpathing the request. * * Example: * A request to `/-/oauth/authorize/cheese-cake` will be called back at * `/-/doreamon/callback/cheese-cake`. */ } _defineProperty(Authorization, "path", "/-/oauth/authorize/:id?"); const pluginOAuthId = "/doreamon-oauth-cli"; const cliAuthorizeUrl = "/oauth/authorize"; const cliCallbackUrl = "http://localhost:8239"; class DoreamonOAuthCliSupport { constructor(config, auth) { this.config = config; this.auth = auth; _defineProperty(this, "clientId", getConfig(this.config, "client_id")); _defineProperty(this, "clientSecret", getConfig(this.config, "client_secret")); _defineProperty(this, "host", getConfig(this.config, "host")); _defineProperty(this, "rootURL", getConfig(this.config, "root_url")); _defineProperty(this, "doreamon", new DoreamonClient(this.host)); } /** * Implements the middleware plugin interface. */ register_middlewares(app) { app.get(cliAuthorizeUrl, (req, res) => { res.redirect(Authorization.path.replace("/:id?", pluginOAuthId)); }); app.get(Callback.path + pluginOAuthId, async (req, res, next) => { try { const code = req.query.code; const id = req.params.id || ""; const callbackUrl = Callback.getCallbackUrl(req, this.config) + (id ? `/${id}` : ""); const token = await this.doreamon.requestAccessToken(code, this.clientId, this.clientSecret, callbackUrl); const user = await this.doreamon.requestUser(token.access_token); const npmAuth = user.nickname + ":" + token.access_token; const encryptedNpmToken = this.encrypt(npmAuth); const query = { token: encodeURIComponent(encryptedNpmToken) }; const url = cliCallbackUrl + "?" + querystring.stringify(query); res.redirect(url); } catch (error) { next(error); } }); } encrypt(text) { return this.auth.aesEncrypt(new Buffer(text)).toString("base64"); } } const publicRoot = __dirname + "/public"; /** * Injects additional tags into the DOM that modify the login button. */ class InjectHtml { /** * Serves the injected style and script imports. */ constructor(config) { this.config = config; _defineProperty(this, "serveAssetsMiddleware", express.static(publicRoot)); _defineProperty(this, "version", getMajorVersion(this.config)); _defineProperty(this, "scriptTag", `<script src="${InjectHtml.path}/verdaccio-${this.version}.js"></script>`); _defineProperty(this, "styleTag", `<style>${fs.readFileSync(`${publicRoot}/verdaccio-${this.version}.css`)}</style>`); _defineProperty(this, "headWithStyle", [this.styleTag, "</head>"].join("")); _defineProperty(this, "bodyWithScript", [this.scriptTag, "</body>"].join("")); _defineProperty(this, "injectAssetsMiddleware", (req, res, next) => { const send = res.send; res.send = html => { html = this.insertImportTags(html); return send.call(res, html); }; next(); }); _defineProperty(this, "insertImportTags", html => { html = String(html); if (!html.includes("VERDACCIO_API_URL")) { return html; } return html.replace(/<\/head>/, this.headWithStyle).replace(/<\/body>/, this.bodyWithScript); }); } /** * Monkey-patches `res.send` in order to inject style and script imports. */ } _defineProperty(InjectHtml, "path", "/-/static/" + pluginName); function registerGlobalProxyAgent() { globalAgent.bootstrap(); console.log(`${[pluginName]}] Proxy config:`, JSON.stringify(GLOBAL_AGENT || {})); } function log(...args) { console.log(`${[pluginName]}`, ...args); } /** * Implements the verdaccio plugin interfaces. */ class DoreamonOauthUiPlugin { // getConfig(this.config, "group") constructor(config) { this.config = config; _defineProperty(this, "requiredGroup", '*'); _defineProperty(this, "host", getConfig(this.config, "host")); _defineProperty(this, "doreamon", new DoreamonClient(this.host)); _defineProperty(this, "cache", {}); validateConfig(config); registerGlobalProxyAgent(); } /** * Implements the middleware plugin interface. */ register_middlewares(app, auth) { if (lodash.get(this.config, "web.enable", true)) { const injectHtml = new InjectHtml(this.config); app.use(injectHtml.injectAssetsMiddleware); app.use(InjectHtml.path, injectHtml.serveAssetsMiddleware); } const cliSupport = new DoreamonOAuthCliSupport(this.config, auth); cliSupport.register_middlewares(app); const authorization = new Authorization(this.config); app.get(Authorization.path, authorization.middleware); const callback = new Callback(this.config, auth); app.get(Callback.path, callback.middleware); } /** * Implements the auth plugin interface. */ async authenticate(username, authToken, cb) { const userOrgs = ["*"]; // await this.getGroupNames(username, authToken) if (this.requiredGroup === "*" || userOrgs.includes(this.requiredGroup)) { cb(null, [this.requiredGroup]); } else { log(`Unauthenticated: user "${username}" is not a member of "${this.requiredGroup}"`); cb(null, false); } } // @ts-ignore allow_access(user, pkg, cb) { const requiredAccess = [...(pkg.access || [])]; if (requiredAccess.includes("$authenticated")) { // @TODO if (this.config.auth[pluginName].group) { requiredAccess.push(this.config.auth[pluginName].group); } } const grantedAccess = lodash.intersection(user.groups, requiredAccess); if (grantedAccess.length === requiredAccess.length) { cb(null, user.groups); } else { log(`Access denied: user "${user.name}" is not a member of "${this.config.group}"`); cb(null, false); } } /** * IPluginAuth */ allow_publish(user, config, callback) { console.log('doreamon allow_publish:', config, user); if (config.publish) { const grant = config.publish.some(group => user.groups.includes(group)); callback(null, grant); } else { this.allow_access(user, config, callback); } } // private async getGroupNames(username: string, authToken: string): Promise<string[]> { // const invalidate = () => delete this.cache[username] // const cached = () => this.cache[username] || {} // const nearFuture = () => Date.now() + cacheTTLms // if (cached().authToken !== authToken) { // invalidate() // } // if (cached().expires < Date.now()) { // invalidate() // } else { // cached().expires = nearFuture() // } // if (!cached().groupNames) { // try { // const user = await this.gitlab.requestUser(authToken) // this.cache[username] = { // authToken, // groupNames: user.groups, // expires: nearFuture(), // } // } catch (error) { // log((error as any).message) // } // } // return cached().groupNames || [] // } } exports.default = DoreamonOauthUiPlugin;