@cocalc/hub
Version:
CoCalc: Backend webserver component
852 lines (776 loc) • 29 kB
text/typescript
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
// Passport Authentication (oauth, etc.)
//
// Server-side setup
// -----------------
//
// In order to get this running, you have to manually setup each service.
// That requires to register with the authentication provider, telling them about CoCalc,
// the domain you use, the return path for the response, and adding the client identification
// and corresponding secret keys to the database.
// Then, the service is active and will be presented to the user on the sign up page.
// The following is an example for setting up google oauth.
// The other services are similar.
//
// 1. background: https://developers.google.com/identity/sign-in/web/devconsole-project
// 2. https://console.cloud.google.com/apis/credentials/consent
// 3. https://console.developers.google.com/apis/credentials → create credentials → oauth, ...
// 4. The return path for google is https://{DOMAIN_NAME}/auth/google/return
// 5. When done, there should be an entry under "OAuth 2.0 client IDs"
// 6. ... and you have your ID and secret!
//
// Now, connect to the database, where the setup is in the passports_settings table:
//
// In older code, there was a "site_conf". We fix it to be $base_path/auth. There is no need to configure it, and existing configurations are ignored. Besides that, it wasn't properly used for all SSO strategies anyways …
//
// What's important is to configure the individual passport settings:
//
// 2. insert into passport_settings (strategy , conf ) VALUES ( 'google', '{"clientID": "....apps.googleusercontent.com", "clientSecret": "..."}'::JSONB )
//
// Then restart the hubs.
import passwordHash, {
verifyPassword,
} from "@cocalc/backend/auth/password-hash";
import base_path from "@cocalc/backend/base-path";
import type { PostgreSQL } from "@cocalc/database/postgres/types";
import { getLogger } from "@cocalc/hub/logger";
import { getExtraStrategyConstructor } from "@cocalc/server/auth/sso/extra-strategies";
import { loadSSOConf } from "@cocalc/database/postgres/load-sso-conf";
import { addUserProfileCallback } from "@cocalc/server/auth/sso/oauth2-user-profile-callback";
import { PassportLogin } from "@cocalc/server/auth/sso/passport-login";
import {
InitPassport,
isOAuth2,
PassportLoginOpts,
PassportManagerOpts,
PassportStrategyDB,
PassportStrategyDBConfig,
PassportTypes,
StrategyConf,
StrategyInstanceOpts,
} from "@cocalc/server/auth/sso/types";
import { callback2 as cb2 } from "@cocalc/util/async-utils";
import * as misc from "@cocalc/util/misc";
import { DNS } from "@cocalc/util/theme";
import {
PassportStrategyFrontend,
PRIMARY_SSO,
} from "@cocalc/util/types/passport-types";
import Cookies from "cookies";
import * as dot from "dot-object";
import * as express from "express";
import express_session from "express-session";
import * as _ from "lodash";
import ms from "ms";
import passport from "passport";
import { join as path_join } from "path";
import { v4 as uuidv4, v4 } from "uuid";
import {
email_verification_problem,
email_verified_successfully,
welcome_email,
} from "./email";
//import Saml2js from "saml2js";
import {
getOauthCache,
getPassportCache,
} from "@cocalc/database/postgres/passport-store";
import {
API_KEY_COOKIE_NAME,
BLACKLISTED_STRATEGIES,
DEFAULT_LOGIN_INFO,
} from "@cocalc/server/auth/sso/consts";
import {
FacebookStrategyConf,
GithubStrategyConf,
GoogleStrategyConf,
TwitterStrategyConf,
} from "@cocalc/server/auth/sso/public-strategies";
const sign_in = require("./sign-in");
const safeJsonStringify = require("safe-json-stringify");
const logger = getLogger("hub:auth");
// primary strategies -- all other ones are "extra"
const PRIMARY_STRATEGIES = ["email", "site_conf", ...PRIMARY_SSO] as const;
// root for authentication related endpoints -- will be prefixed with the base_path
const AUTH_BASE = "/auth";
const { defaults, required } = misc;
// singleton
let pp_manager: PassportManager | null = null;
export function get_passport_manager() {
return pp_manager;
}
export async function init_passport(opts: InitPassport) {
opts = defaults(opts, {
router: required,
database: required,
host: required,
cb: required,
});
try {
if (pp_manager == null) {
pp_manager = new PassportManager(opts);
await pp_manager.init();
}
opts.cb();
} catch (err) {
opts.cb(err);
}
}
export class PassportManager {
// express js, passed in from hub's main file
private readonly router: express.Router;
// the database, for various server queries
private readonly database: PostgreSQL;
// set in the hub, passed in -- not used by "site_conf", though
private readonly host: string; // e.g. 127.0.0.1
// configured strategies
private passports: { [k: string]: PassportStrategyDB } | undefined =
undefined;
// prefix for those endpoints, where SSO services return back
private auth_url: string | undefined = undefined;
constructor(opts: PassportManagerOpts) {
const { router, database, host } = opts;
this.handle_get_api_key.bind(this);
this.router = router;
this.database = database;
this.host = host;
}
private async init_passport_settings(): Promise<{
[k: string]: PassportStrategyDB;
}> {
if (this.passports != null) {
logger.debug("already initialized -- just returning what we have");
return this.passports;
}
try {
// email is always included, if even email singup is disabled
// use "register tokens" to restrict this method
this.passports = {
email: {
strategy: "email",
conf: { type: "email" },
info: { public: true },
},
};
const settings = await this.database.get_all_passport_settings();
for (const setting of settings) {
const name = setting.strategy;
if (BLACKLISTED_STRATEGIES.includes(name as any)) {
throw new Error(
`It is not allowed to name a strategy endpoint "${name}", because it is used by the next.js /auth/* endpoint. See next/pages/auth/ROUTING.md for more information.`
);
}
// backwards compatibility
const conf = setting.conf as any;
setting.info = setting.info ?? {};
if (setting.info.disabled ?? conf?.disabled ?? false) {
continue;
}
for (const deprecated of [
"public",
"display",
"icon",
"exclusive_domains",
]) {
if (setting.info[deprecated] == null) {
setting.info[deprecated] = conf?.[deprecated];
}
}
this.passports[setting.strategy] = setting;
}
return this.passports;
} catch (err) {
logger.debug(`error getting passport settings -- ${err}`);
throw err;
}
return {};
}
// Define handler for api key cookie setting.
private handle_get_api_key(req, res, next) {
if (req.query.get_api_key) {
logger.debug("handle_get_api_key");
const cookies = new Cookies(req, res);
// maxAge: User gets up to 60 minutes to go through the SSO process...
cookies.set(API_KEY_COOKIE_NAME, req.query.get_api_key, {
maxAge: 30 * 60 * 1000,
});
}
next();
}
// this is for pure backwards compatibility. at some point remove this!
// it only returns a string[] array of the legacy authentication strategies
private strategies_v1(res): void {
const data: string[] = [];
const known = ["email", ...PRIMARY_SSO];
for (const name in this.passports) {
if (name === "site_conf") continue;
if (known.indexOf(name) >= 0) {
data.push(name);
}
}
res.json(data);
}
public get_strategies_v2(): PassportStrategyFrontend[] {
const data: PassportStrategyFrontend[] = [];
// we cast the result of _.pick to get more type saftey
const keys = [
"display",
"type",
"icon",
"public",
"exclusive_domains",
"do_not_hide",
] as const;
for (const name in this.passports) {
if (name === "site_conf") continue;
// this is sent to the web client → do not include any secret info!
const info: PassportStrategyFrontend = {
name,
...(_.pick(this.passports[name].info, keys) as {
[key in typeof keys[number]]: any;
}),
};
data.push(info);
}
return data;
}
// version 2 tells the web client a little bit more.
// the additional info is used to render customizeable SSO icons.
private strategies_v2(res): void {
res.json(this.get_strategies_v2());
}
async init(): Promise<void> {
// Initialize authentication plugins using Passport
logger.debug("init");
// initialize use of middleware
this.router.use(express_session({ secret: v4() })); // secret is totally random and per-hub session
this.router.use(passport.initialize());
this.router.use(passport.session());
// Define user serialization
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((user: Express.User, done) => done(null, user));
await loadSSOConf(this.database);
// this.router endpoints setup
this.init_strategies_endpoint();
this.init_email_verification();
this.init_password_reset_token();
// prerequisite for setting up any SSO endpoints
await this.init_passport_settings();
this.check_exclusive_domains_unique();
const settings = await cb2(this.database.get_server_settings_cached);
const dns = settings.dns || DNS;
this.auth_url = `https://${dns}${path_join(base_path, AUTH_BASE)}`;
logger.debug(`auth_url='${this.auth_url}'`);
await Promise.all([
this.initStrategy(GoogleStrategyConf),
this.initStrategy(GithubStrategyConf),
this.initStrategy(FacebookStrategyConf),
this.initStrategy(TwitterStrategyConf),
this.init_extra_strategies(),
]);
}
// check if exclusive domains are unique
private check_exclusive_domains_unique() {
const ret: { [k: string]: string } = {};
for (const k in this.passports) {
const v = this.passports[k];
for (const domain of v.info?.exclusive_domains ?? []) {
if (ret[domain] != null) {
throw new Error(
`exclusive domain '${domain}' defined by ${ret[domain]} and ${k}: they must be unique`
);
}
ret[domain] = k;
}
}
}
private init_strategies_endpoint(): void {
// Return the configured and supported authentication strategies.
this.router.get(`${AUTH_BASE}/strategies`, (req, res) => {
if (req.query.v === "2") {
this.strategies_v2(res);
} else {
this.strategies_v1(res);
}
});
}
private async init_email_verification(): Promise<void> {
// email verification
this.router.get(`${AUTH_BASE}/verify`, async (req, res) => {
const { DOMAIN_URL } = require("@cocalc/util/theme");
const path = require("path").join(base_path, "app");
const url = `${DOMAIN_URL}${path}`;
res.header("Content-Type", "text/html");
res.header("Cache-Control", "no-cache, no-store");
if (
!(req.query.token && req.query.email) ||
typeof req.query.email !== "string" ||
typeof req.query.token !== "string"
) {
res.send(
"ERROR: I need the email address and the corresponding token data"
);
return;
}
const email = decodeURIComponent(req.query.email);
// .toLowerCase() on purpose: some crazy MTAs transform everything to uppercase!
const token = req.query.token.toLowerCase();
try {
await cb2(this.database.verify_email_check_token, {
email_address: email,
token,
});
res.send(email_verified_successfully(url));
} catch (err) {
res.send(email_verification_problem(url, err));
}
});
}
private init_password_reset_token(): void {
// reset password: user email link contains a token, which we store in a session cookie.
// this prevents leaking that token to 3rd parties as a referrer
// endpoint has to match with @cocalc/hub/password
this.router.get(`${AUTH_BASE}/password_reset`, (req, res) => {
if (typeof req.query.token !== "string") {
res.send("ERROR: reset token must be set");
} else {
const token = req.query.token.toLowerCase();
const cookies = new Cookies(req, res);
// to match @cocalc/frontend/client/password-reset
const name = encodeURIComponent(`${base_path}PWRESET`);
const secure = req.protocol === "https";
cookies.set(name, token, {
maxAge: ms("5 minutes"),
secure: secure,
overwrite: true,
httpOnly: false,
});
res.redirect("../app");
}
});
}
private get_extra_default_opts({
name,
type,
}: {
type: PassportTypes;
name: string;
}) {
switch (type) {
case "saml":
// see https://github.com/node-saml/passport-saml#config-parameter-details
const cachedMS = ms("8 hours");
return {
issuer: this.auth_url,
signatureAlgorithm: "sha256", // better than default sha1
digestAlgorithm: "sha256", // better than default sha1
wantAssertionsSigned: true,
acceptedClockSkewMs: ms("5 minutes"),
// if "*:persistent" doesn't work, use *:emailAddress
identifierFormat:
"urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
requestIdExpirationPeriodMs: cachedMS,
validateInResponseTo: true,
cacheProvider: getPassportCache(name, cachedMS),
};
}
}
private get_extra_opts(name, conf: PassportStrategyDBConfig) {
// "extra_opts" is passed to the passport.js "Strategy" constructor!
// e.g. arbitrary fields like a tokenURL will be extracted here, and then passed to the constructor
const extracted = _.omit(conf, [
"name", // deprecated
"display", // deprecated
"type",
"icon", // deprecated
"login_info", // already extracted, see login_info field above
"clientID",
"clientSecret",
"userinfoURL",
"public", // we don't need that info for initializing them
"auth_opts", // we pass them as a separate parameter
]);
return {
...this.get_extra_default_opts({ name, type: conf.type }),
...extracted,
};
}
// this maps additional strategy configurations to a list of StrategyConf objects
// the overall goal is to support custom OAuth2 and LDAP endpoints, where additional
// info is sent to the webapp client to properly present them. Google&co are "primary" configurations.
//
// here is one example what can be saved in the DB to make this work for a general OAuth2
// if this SSO is not public (e.g. uni campus, company specific, ...) mark it as {"public":false}!
//
// insert into passport_settings (strategy, conf, info ) VALUES ( '[unique, e.g. "wowtech"]', '{"type": "oauth2next", "clientID": "CoCalc_Client", "scope": ["email", "cocalc", "profile", ... depends on the config], "clientSecret": "[a password]", "authorizationURL": "https://domain.edu/.../oauth2/authorize", "userinfoURL" :"https://domain.edu/.../oauth2/userinfo", "tokenURL":"https://domain.edu/.../oauth2/...extras.../access_token", "login_info" : {"emails" :"emails[0].value"}}'::JSONB, {"display": "[user visible, e.g. "WOW Tech"]", "icon": "https://storage.googleapis.com/square.svg", "public": false}::JSONB);
//
// note, the login_info.emails string extracts from the profile object constructed by parse_openid_profile,
// which is only triggered if there is such a "userinfoURL", which is OAuth2 specific.
// other auth mechanisms might already provide the profile in passport.js's structure!
private async init_extra_strategies(): Promise<void> {
if (this.passports == null) throw Error("strategies not initalized!");
const inits: Promise<void>[] = [];
for (const [name, strategy] of Object.entries(this.passports)) {
if (PRIMARY_STRATEGIES.indexOf(name as any) >= 0) {
continue;
}
if (strategy.conf.type == null) {
throw new Error(
`all "extra" strategies must define their type, in particular also "${name}"`
);
}
const type: PassportTypes = strategy.conf.type;
// the constructor
const PassportStrategyConstructor = getExtraStrategyConstructor(type);
const config: StrategyConf = {
name,
type,
PassportStrategyConstructor,
login_info: { ...DEFAULT_LOGIN_INFO, ...strategy.conf.login_info },
userinfoURL: strategy.conf.userinfoURL,
extra_opts: this.get_extra_opts(name, strategy.conf),
update_on_login: strategy.info?.update_on_login ?? false,
cookie_ttl_s: strategy.info?.cookie_ttl_s, // could be undefined, that's OK
auth_opts: strategy.conf.auth_opts ?? {},
} as const;
inits.push(this.initStrategy(config));
}
await Promise.all(inits);
}
// this is the 2nd entry for the strategy, just a basic callback
private getVerify(type: StrategyConf["type"]) {
switch (type) {
case "saml":
return (profile, done) => {
done(undefined, profile);
};
case "azuread":
return (_iss, _sub, profile, _accessToken, _refreshToken, done) => {
if (!profile.oid) {
return done(new Error("No oid found"), null);
}
done(undefined, profile);
};
case "oidc":
return (_issuer, profile, done) => {
return done(undefined, profile);
};
default:
return (_accessToken, _refreshToken, params, profile, done) => {
done(undefined, { params, profile });
};
}
}
private getStrategyInstance(args: StrategyInstanceOpts) {
const { type, opts, userinfoURL, PassportStrategyConstructor } = args;
const L1 = logger.extend("get_strategy_instance");
const L2 = L1.extend("userProfile").debug;
const verify = this.getVerify(type);
const strategy_instance = new PassportStrategyConstructor(opts, verify);
// for OAuth2, set the userinfoURL to get the profile
if (userinfoURL != null) {
addUserProfileCallback({ strategy_instance, userinfoURL, L2, type });
}
return strategy_instance;
}
private getHandleReturn({
Linit,
name,
type,
update_on_login,
cookie_ttl_s,
login_info,
}) {
return async (req, res: express.Response) => {
if (req.user == null) {
throw Error("req.user == null -- that shouldn't happen");
}
const Lret = Linit.extend(`${name}/return`).debug;
// usually, we pick the "profile", but in some cases like SAML this is in "attributes".
// finally, as a fallback, we just take the ".user"
// technically, req.user should never be undefined, though.
const profile = (req.user.profile != null
? req.user.profile
: req.user.attributes != null
? req.user.attributes
: req.user) as any as passport.Profile;
if (type === "saml") {
// the nameID is set via the conf.identifierFormat parameter – even if we set it to
// persistent, we might still just get an email address, though
Lret(`nameID format we actually got is ${req.user.nameIDFormat}`);
profile.id = req.user.nameID;
}
Lret(`profile = ${safeJsonStringify(profile)}`);
const login_opts: PassportLoginOpts = {
passports: this.passports ?? {},
database: this.database,
host: this.host,
record_sign_in: sign_in.record_sign_in,
id: profile.id, // ATTN: not all strategies have an ID → you have to derive the ID from the profile below via the "login_info" mapping (e.g. {id: "email"})
strategyName: name,
profile, // will just get saved in database
update_on_login,
cookie_ttl_s,
req,
res,
};
for (const k in login_info) {
const v = login_info[k];
const param: string | string[] =
typeof v == "function"
? // v is a LoginInfoDerivator<T>
v(profile)
: // v is a string for dot-object
dot.pick(v, profile);
login_opts[k] = param;
}
const passportLogin = new PassportLogin(login_opts);
try {
await passportLogin.login();
} catch (err) {
let err_msg = "";
// due to https://github.com/Microsoft/TypeScript/issues/13965 we have to check on name and can't use instanceof
if (err.name === "PassportLoginError") {
const signInUrl = path_join(base_path, "auth", "sign-in");
err_msg = `Problem signing in using '${name}:<br/><strong>${
err.message ?? `${err}`
}</strong><br/><a href="${signInUrl}">Sign-in again</a>`;
} else {
const helpEmail = await passportLogin.getHelpEmail();
err_msg = `Error trying to login using '${name}' -- if this problem persists please contact ${helpEmail} -- ${err}<br/><pre>${err.stack}</pre>`;
}
Lret(`sending error "${err_msg}"`);
res.send(err_msg);
}
};
}
// right now, we only set this for OAauth2 (SAML knows what to do on its own)
// This does not encode any information for now.
private setState(name, type: PassportTypes, auth_opts) {
return async (_req, _res, next) => {
if (isOAuth2(type)) {
const oauthcache = getOauthCache(name);
const state = uuidv4();
await oauthcache.saveAsync(state, `${Date.now()}`);
auth_opts.state = state;
logger.debug("session: " + auth_opts.state);
}
next();
};
}
// corresponding check to the above. basically checks if the state data is still available.
private checkState(name, type: PassportTypes) {
return async (req, _res, next) => {
if (isOAuth2(type)) {
const oauthcache = getOauthCache(name);
const state = req.query.state;
const saved_state = await oauthcache.getAsync(state);
if (saved_state == null) {
throw Error(`Invalid state: ${state}`);
}
await oauthcache.removeAsync(state);
}
next();
};
}
// a generalized strategy initizalier
private async initStrategy(strategy_config: StrategyConf): Promise<void> {
const {
name, // our "name" of the strategy, set in the DB
type, // the "type", which is the key in the k
PassportStrategyConstructor,
extra_opts,
auth_opts = {},
login_info,
userinfoURL,
cookie_ttl_s,
update_on_login = false,
} = strategy_config;
const Linit = logger.extend("init_strategy");
const L = Linit.debug;
L(`init_strategy ${name}`);
if (this.passports == null) throw Error("strategies not initalized!");
if (name == null) {
L(`strategy name is null -- aborting initialization`);
return;
}
const confDB = this.passports[name];
if (confDB == null) {
L(`no conf for strategy='${name}' in DB -- aborting initialization`);
return;
}
// under the same name, we make it accessible
const strategyUrl = `${AUTH_BASE}/${name}`;
const returnUrl = `${strategyUrl}/return`;
if (confDB.conf == null) {
// This happened on *all* of my dev servers, etc. -- William
L(
`strategy='${name}' is not properly configured -- aborting initialization`
);
return;
}
const opts = {
clientID: confDB.conf.clientID,
clientSecret: confDB.conf.clientSecret,
callbackURL: returnUrl,
...extra_opts,
} as const;
// attn: this log line shows secrets
// logger.debug(`opts = ${safeJsonStringify(opts)}`);
const strategy_instance = this.getStrategyInstance({
type,
opts,
userinfoURL,
PassportStrategyConstructor,
});
// this ties the name (our name set in the DB) to the strategy instance
passport.use(name, strategy_instance);
this.router.get(
strategyUrl,
this.handle_get_api_key,
this.setState(name, type, auth_opts),
passport.authenticate(name, auth_opts)
);
// this will hopefully do new PassportLogin().login()
const handleReturn = this.getHandleReturn({
Linit,
name,
type,
update_on_login,
cookie_ttl_s,
login_info,
});
if (type === "saml") {
this.router.post(
returnUrl,
// the body-parser package is deprecated, using express directly
express.urlencoded({ extended: false }),
express.json(),
passport.authenticate(name),
async (req, res) => {
// block below: boilerplate-code to parse the response from the SAML provider – could become helpful some day!
//const xmlResponse = req.body.SAMLResponse;
//if (xmlResponse == null) {
// throw new Error("SAML xmlResponse is null");
//}
//const samlRes = new Saml2js(xmlResponse);
//if (req.user == null) req.user = {};
//req.user["profile"] = samlRes.toObject();
await handleReturn(req, res);
}
);
} else if (isOAuth2(type)) {
this.router.get(
returnUrl,
this.checkState(name, type),
passport.authenticate(name),
handleReturn
);
} else {
this.router.get(returnUrl, passport.authenticate(name), handleReturn);
}
L(`initialization of '${name}' at '${strategyUrl}' successful`);
}
}
interface IsPasswordCorrect {
database: PostgreSQL;
password: string;
password_hash?: string;
account_id?: string;
email_address?: string;
allow_empty_password?: boolean;
cb: (err?, correct?: boolean) => void;
}
// NOTE: simpler clean replacement for this is in packages/server/auth/is-password-correct.ts
//
// Password checking. opts.cb(undefined, true) if the
// password is correct, opts.cb(error) on error (e.g., loading from
// database), and opts.cb(undefined, false) if password is wrong. You must
// specify exactly one of password_hash, account_id, or email_address.
// In case you specify password_hash, in addition to calling the
// callback (if specified), this function also returns true if the
// password is correct, and false otherwise; it can do this because
// there is no async IO when the password_hash is specified.
export async function is_password_correct(
opts: IsPasswordCorrect
): Promise<void> {
opts = defaults(opts, {
database: required,
password: required,
password_hash: undefined,
account_id: undefined,
email_address: undefined,
// If true and no password set in account, it matches anything.
// this is only used when first changing the email address or password
// in passport-only accounts.
allow_empty_password: false,
// cb(err, true or false)
cb: required,
});
if (opts.password_hash != null) {
const r = verifyPassword(opts.password, opts.password_hash);
opts.cb(undefined, r);
} else if (opts.account_id != null || opts.email_address != null) {
try {
const account = await cb2(opts.database.get_account, {
account_id: opts.account_id,
email_address: opts.email_address,
columns: ["password_hash"],
});
if (opts.allow_empty_password && !account.password_hash) {
if (opts.password && opts.account_id) {
// Set opts.password as the password, since we're actually
// setting the email address and password at the same time.
opts.database.change_password({
account_id: opts.account_id,
password_hash: passwordHash(opts.password),
invalidate_remember_me: false,
cb: (err) => opts.cb(err, true),
});
} else {
opts.cb(undefined, true);
}
} else {
opts.cb(
undefined,
verifyPassword(opts.password, account.password_hash)
);
}
} catch (error) {
opts.cb(error);
}
} else {
opts.cb(
"One of password_hash, account_id, or email_address must be specified."
);
}
}
export async function verify_email_send_token(opts) {
opts = defaults(opts, {
database: required,
account_id: required,
only_verify: false,
cb: required,
});
try {
const { token, email_address } = await cb2(
opts.database.verify_email_create_token,
{
account_id: opts.account_id,
}
);
const settings = await cb2(opts.database.get_server_settings_cached);
await cb2(welcome_email, {
to: email_address,
token,
only_verify: opts.only_verify,
settings,
});
opts.cb();
} catch (err) {
opts.cb(err);
}
}