Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds user -> account linking #19

Merged
merged 6 commits into from
Apr 19, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# 0.11.0-beta.3
* fix the `requestExtract` handler - allow passing `path` param
* fix the `.asUser()` and `.asAccount()` to return `traits` and `track`
* adds `.asUser().account()` method

# 0.11.0-beta.2
* Reorganize the utils/helpers
* Introduce hull.as() create option
* Upgrade raven API and add default exit handler
* Combine notifHandler and batchHandler
* Automatically filter out users using segment filter on user:update and NOT on batch actions
* Renames `hull().as()` method to `hull().asUser()`
* Adds initial support for accounts

# 0.11.0-beta.1

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hull",
"version": "0.11.0-beta.2",
"version": "0.11.0-beta.3",
"description": "A Node.js client for hull.io",
"main": "lib",
"repository": {
Expand Down
19 changes: 14 additions & 5 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const Client = function Client(config = {}) {
// Check conditions on when to create a "user client" or an "org client".
// When to pass org scret or not

if (config.userId || config.accessToken) {
if (config.userClaim || config.accountClaim || config.accessToken) {
this.traits = (traits, context = {}) => {
// Quick and dirty way to add a source prefix to all traits we want in.
const source = context.source;
Expand Down Expand Up @@ -114,19 +114,28 @@ const Client = function Client(config = {}) {
}
});
};

if (config.userClaim) {
this.account = (accountClaim = {}) => {
if (!accountClaim) {
return new Client({ ...config, subjectType: "account" });
}
return new Client({ ...config, subjectType: "account", accountClaim });
};
}
} else {
this.asUser = (userClaim, additionalClaims) => {
this.asUser = (userClaim, additionalClaims = {}) => {
if (!userClaim) {
throw new Error("User Claims was not defined when calling hull.asUser()");
}
return new Client({ ...config, userClaim, additionalClaims });
return new Client({ ...config, subjectType: "user", userClaim, additionalClaims });
};

this.asAccount = (accountClaim, additionalClaims) => {
this.asAccount = (accountClaim, additionalClaims = {}) => {
if (!accountClaim) {
throw new Error("Account Claims was not defined when calling hull.asAccount()");
}
return new Client({ ...config, accountClaim, additionalClaims });
return new Client({ ...config, subjectType: "account", accountClaim, additionalClaims });
};
}
};
Expand Down
36 changes: 28 additions & 8 deletions src/configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,27 +40,47 @@ const VALID_PROPS = {
protocol: VALID.string,
userClaim: VALID.object,
accountClaim: VALID.object,
subjectType: VALID.string,
additionalClaims: VALID.object,
accessToken: VALID.string,
hostSecret: VALID.string,
flushAt: VALID.number,
flushAfter: VALID.number
};

class Configuration {
/**
* make sure that provided "identity claim" is valid
* @param {String} type "user" or "account"
* @param {String|Object} object identity claim
* @param {Array} requiredFields fields which are required if the passed
* claim is an object
* @throws Error
*/
function assertClaimValidity(type, object, requiredFields) {
if (!_.isEmpty(object)) {
if (_.isString(object)) {
if (!object) {
throw new Error(`Missing ${type} ID`);
}
} else if (!_.isObject(object) || _.intersection(_.keys(object), requiredFields).length === 0) {
throw new Error(`You need to pass an ${type} hash with an ${requiredFields.join(", ")} field`);
}
}
}

class Configuration {
constructor(config) {
if (!_.isObject(config) || !_.size(config)) {
throw new Error("Configuration is invalid, it should be a non-empty object");
}

if (config.userClaim) {
const accessToken = crypto.lookupToken(config, "user", config.userClaim, config.additionalClaims);
config = { ...config, accessToken };
}

if (config.accountClaim) {
const accessToken = crypto.lookupToken(config, "account", config.accountClaim, config.additionalClaims);
if (config.userClaim || config.accountClaim) {
assertClaimValidity("user", config.userClaim, ["id", "email", "external_id", "anonymous_id"]);
assertClaimValidity("account", config.accountClaim, ["id", "external_id", "domain"]);
const accessToken = crypto.lookupToken(config, config.subjectType, {
user: config.userClaim,
account: config.accountClaim
}, config.additionalClaims);
config = { ...config, accessToken };
}

Expand Down
4 changes: 2 additions & 2 deletions src/helpers/request-extract.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* @param {Object} options
* @return {Promise}
*/
export default function requestExtract(ctx, { segment = null, fields = [] } = {}) {
export default function requestExtract(ctx, { segment = null, path, fields = [] } = {}) {
const { client, hostname } = ctx;
return client.utils.extract.request({ hostname, segment, fields });
return client.utils.extract.request({ hostname, segment, path, fields });
}
38 changes: 22 additions & 16 deletions src/lib/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,33 +50,38 @@ module.exports = {
* and saves them as a custom ident claim.
*
* @param {Object} config object
* @param {String} type - "user" or "account"
* @param {String|Object} identClaim main idenditiy claim - object or string
* @param {String} subjectType - "user" or "account"
* @param {String|Object} userClaim main idenditiy claim - object or string
* @param {String|Object} accountClaim main idenditiy claim - object or string
* @param {Object} additionalClaims
* @returns {String} The jwt token to identity the user.
*/
lookupToken(config, type, identClaim, additionalClaims = {}) {
type = _.toLower(type);
if (!_.includes(["user", "account"], type)) {
lookupToken(config, subjectType, objectClaims = {}, additionalClaims = {}) {
subjectType = _.toLower(subjectType);
if (!_.includes(["user", "account"], subjectType)) {
throw new Error("Lookup token supports only `user` and `account` types");
}

checkConfig(config);
const claims = {};
if (_.isString(identClaim)) {
if (!identClaim) { throw new Error(`Missing ${type} ID`); }
claims.sub = identClaim;
} else if (identClaim.id) {
claims.sub = identClaim.id;
} else {
if (type === "user"
&& (!_.isObject(identClaim) || (!identClaim.email && !identClaim.external_id && !identClaim.anonymous_id))) {
throw new Error("You need to pass a user hash with an `email` or `external_id` or `anonymous_id` field");
}

claims[`io.hull.as${_.upperFirst(type)}`] = identClaim;
const subjectClaim = objectClaims[subjectType];

if (_.isString(subjectClaim)) {
claims.sub = subjectClaim;
} else if (subjectClaim.id) {
claims.sub = subjectClaim.id;
}

_.reduce(objectClaims, (c, oClaims, objectType) => {
if (_.isObject(oClaims) && !_.isEmpty(oClaims)) {
c[`io.hull.as${_.upperFirst(objectType)}`] = oClaims;
} else if (_.isString(oClaims) && !_.isEmpty(oClaims) && objectType !== subjectType) {
c[`io.hull.as${_.upperFirst(objectType)}`] = { id: oClaims };
}
return c;
}, claims);

if (_.has(additionalClaims, "create")) {
claims["io.hull.create"] = additionalClaims.create;
}
Expand All @@ -85,6 +90,7 @@ module.exports = {
claims["io.hull.active"] = additionalClaims.active;
}

claims["io.hull.subjectType"] = subjectType;
return buildToken(config, claims);
},

Expand Down
91 changes: 82 additions & 9 deletions tests/client-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ describe("Hull", () => {
});

describe("as", () => {
it("should return scoped client with traits and track methods", () => {
const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" });

const scopedAccount = hull.asAccount({ domain: "hull.io" });
const scopedUser = hull.asUser("1234");

expect(scopedAccount).to.has.property("traits")
.that.is.an("function");
expect(scopedAccount).to.has.property("track")
.that.is.an("function");

expect(scopedUser).to.has.property("traits")
.that.is.an("function");
expect(scopedUser).to.has.property("track")
.that.is.an("function");
});

it("should allow to pass create option", () => {
const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" });

Expand All @@ -50,26 +67,82 @@ describe("Hull", () => {
.that.eql("123456");
});

it("should allow to pass account id as an object property", () => {
it("should allow to pass account domain as an object property", () => {
const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" });

const scoped = hull.asAccount({ id: "123456" });
const scoped = hull.asAccount({ domain: "hull.io" });
const scopedConfig = scoped.configuration();
const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret);
expect(scopedJwtClaims)
.to.have.property("sub")
.that.eql("123456");
.to.have.property("io.hull.asAccount")
.that.eql({ domain: "hull.io" });
expect(scopedJwtClaims)
.to.have.property("io.hull.subjectType")
.that.eql("account");
});

it("should allow to pass account name as an object property", () => {
it("should allow to link user to an account", () => {
const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" });

const scoped = hull.asAccount({ name: "Hull" });
const scopedConfig = scoped.configuration();
const scopedJwtClaims = jwt.decode(scopedConfig.accessToken, scopedConfig.secret);
const scoped = hull.asUser({ email: "foo@bar.com" }).account({ domain: "hull.io" });
const scopedJwtClaims = jwt.decode(scoped.configuration().accessToken, scoped.configuration().secret);

expect(scopedJwtClaims)
.to.have.property("io.hull.subjectType")
.that.eql("account");
expect(scopedJwtClaims)
.to.have.property("io.hull.asAccount")
.that.eql({ domain: "hull.io" });
expect(scopedJwtClaims)
.to.have.property("io.hull.asUser")
.that.eql({ email: "foo@bar.com" });
});

it("should allow to link a user using its id to an account", () => {
const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" });

const scoped = hull.asUser("1234").account({ domain: "hull.io" });
const scopedJwtClaims = jwt.decode(scoped.configuration().accessToken, scoped.configuration().secret);

expect(scopedJwtClaims)
.to.have.property("io.hull.subjectType")
.that.eql("account");
expect(scopedJwtClaims)
.to.have.property("io.hull.asAccount")
.that.eql({ name: "Hull" });
.that.eql({ domain: "hull.io" });
expect(scopedJwtClaims)
.to.have.property("io.hull.asUser")
.that.eql({ id: "1234" });
});

it("should allow to resolve an existing account user is linked to", () => {
const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" });

const scoped = hull.asUser({ email: "foo@bar.com" }).account();
const scopedJwtClaims = jwt.decode(scoped.configuration().accessToken, scoped.configuration().secret);

expect(scopedJwtClaims)
.to.have.property("io.hull.subjectType")
.that.eql("account");
expect(scopedJwtClaims)
.to.not.have.property("io.hull.asAccount");
expect(scopedJwtClaims)
.to.have.property("io.hull.asUser")
.that.eql({ email: "foo@bar.com" });
});

it("should throw an error if any of required field is not passed", () => {
const hull = new Hull({ id: "562123b470df84b740000042", secret: "1234", organization: "test" });

expect(hull.asUser.bind(null, { some_id: "1234" }))
.to.throw(Error);
expect(hull.asAccount.bind(null, { some_other_id: "1234" }))
.to.throw(Error);

expect(hull.asUser.bind(null, { external_id: "1234" }))
.to.not.throw(Error);
expect(hull.asAccount.bind(null, { external_id: "1234" }))
.to.not.throw(Error);
});
});
});