verdaccio-doreamon-oauth2
Version:
A GitLab OAuth Plugin for [Verdaccio](https://www.verdaccio.org)
547 lines (435 loc) • 16.2 kB
JavaScript
'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;