Skip to content
Permalink
Browse files

feat(accounts): add APIs to manage subscriptions and report capabilities

  • Loading branch information...
lmorchard committed Apr 10, 2019
1 parent ae38a86 commit eace43609fe65e57d80d1e084011eed52e2d7899
@@ -35,4 +35,46 @@
"key": "Correct_Horse_Battery_Staple_1",
"maxTTL": "28 days"
},
"subhub": {
"useStubs": true,
"stubs": {
"plans": [
{
"plan_id": "123doneProMonthly",
"product_id": "123doneProProduct",
"interval": "month",
"amount": 50,
"currency": "usd"
},
{
"plan_id": "321doneProMonthly",
"product_id": "321doneProProduct",
"interval": "month",
"amount": 50,
"currency": "usd"
},
{
"plan_id": "allDoneProMonthly",
"product_id": "allDoneProProduct",
"interval": "month",
"amount": 50,
"currency": "usd"
}
]
}
},
"subscriptions": {
"productCapabilities": {
"123doneProProduct": [ "123donePro" ],
"321doneProProduct": [ "321donePro" ],
"allDoneProProduct": [ "123donePro", "321donePro" ],
"exampleProd1": [ "exampleCap1", "exampleCap2", "exampleCap3" ],
"exampleProd2": [ "exampleCap4", "exampleCap5" ],
"exampleProd3": [ "exampleCap1", "exampleCap2", "exampleCap3", "exampleCap4", "exampleCap5" ]
},
"clientCapabilities": {
"dcdb5ae7add825d2": [ "123donePro", "exampleCap1", "exampleCap3", "exampleCap5" ],
"325b4083e32fe8e7": [ "321donePro", "exampleCap2", "exampleCap4", "exampleCap6" ]
}
}
}
@@ -569,6 +569,54 @@ const conf = convict({
env: 'DEVICE_NOTIFICATIONS_ENABLED',
default: true
},
subhub: {
enabled: {
doc: 'Indicates whether talking to the SubHub server is enabled',
format: Boolean,
default: false,
env: 'SUBHUB_ENABLED'
},
useStubs: {
doc: 'Indicates whether to use stub methods for SubHub instead of talking to the server',
format: Boolean,
default: false,
env: 'SUBHUB_USE_STUBS'
},
stubs: {
plans: {
doc: 'Stub data used for plans',
format: Array,
env: 'SUBHUB_STUB_PLANS',
default: []
}
},
url: {
doc: 'SubHub Server URL',
format: 'url',
default: 'https://subhub.services.mozilla.com/',
env: 'SUBHUB_URL'
},
key: {
doc: 'Authentication key to use when accessing SubHub server',
format: String,
default: 'Correct_Horse_Battery_Staple_1',
env: 'SUBHUB_KEY'
},
},
subscriptions: {
productCapabilities: {
doc: 'Mappings from product names to subscription capability names',
format: Object,
env: 'SUBSCRIPTION_PRODUCT_CAPABILITIES',
default: {}
},
clientCapabilities: {
doc: 'Mappings from OAuth client IDs to relevant subscription capabilities',
format: Object,
env: 'SUBSCRIPTION_CLIENT_CAPABILITIES',
default: {}
}
},
oauth: {
url: {
format: 'url',
@@ -1358,6 +1358,47 @@ module.exports = (
return this.pool.del(SAFE_URLS.deleteRecoveryKey, { uid });
};

SAFE_URLS.createAccountSubscription = new SafeUrl(
'/account/:uid/subscriptions/:subscriptionId',
'db.createAccountSubscription'
);
DB.prototype.createAccountSubscription = function (data) {
const { uid, subscriptionId, productName, createdAt } = data;
log.trace('DB.createAccountSubscription', data);
return this.pool.put(
SAFE_URLS.createAccountSubscription,
{ uid, subscriptionId },
{ productName, createdAt }
);
};

SAFE_URLS.getAccountSubscription = new SafeUrl(
'/account/:uid/subscriptions/:subscriptionId',
'db.getAccountSubscription'
);
DB.prototype.getAccountSubscription = function (uid, subscriptionId) {
log.trace('DB.getAccountSubscription', { uid, subscriptionId });
return this.pool.get(SAFE_URLS.getAccountSubscription, { uid, subscriptionId });
};

SAFE_URLS.deleteAccountSubscription = new SafeUrl(
'/account/:uid/subscriptions/:subscriptionId',
'db.deleteAccountSubscription'
);
DB.prototype.deleteAccountSubscription = function (uid, subscriptionId) {
log.trace('DB.deleteAccountSubscription', { uid, subscriptionId });
return this.pool.del(SAFE_URLS.deleteAccountSubscription, { uid, subscriptionId });
};

SAFE_URLS.fetchAccountSubscriptions = new SafeUrl(
'/account/:uid/subscriptions',
'db.fetchAccountSubscriptions'
);
DB.prototype.fetchAccountSubscriptions = function (uid) {
log.trace('DB.fetchAccountSubscriptions', { uid });
return this.pool.get(SAFE_URLS.fetchAccountSubscriptions, { uid });
};

DB.prototype.safeRedisGet = function (key) {
return this.redis.get(key)
.catch(err => {
@@ -85,6 +85,9 @@ const ERRNO = {
MISMATCH_AUTHORIZATION_CODE: 173,
EXPIRED_AUTHORIZATION_CODE: 174,
INVALID_PKCE_CHALLENGE: 175,
UNKNOWN_SUBSCRIPTION: 176,
UNKNOWN_SUBSCRIPTION_PLAN: 177,
REJECTED_SUBSCRIPTION_PAYMENT_TOKEN: 178,

SERVER_BUSY: 201,
FEATURE_NOT_ENABLED: 202,
@@ -1033,6 +1036,39 @@ AppError.invalidPkceChallenge = (pkceHashValue) => {
});
};

AppError.unknownSubscription = (subscriptionId) => {
return new AppError({
code: 404,
error: 'Not Found',
errno: ERRNO.UNKNOWN_SUBSCRIPTION,
message: 'Unknown subscription'
}, {
subscriptionId
});
};

AppError.unknownSubscriptionPlan = (planId) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.UNKNOWN_SUBSCRIPTION_PLAN,
message: 'Unknown subscription plan'
}, {
planId
});
};

AppError.rejectedSubscriptionPaymentToken = (token) => {
return new AppError({
code: 400,
error: 'Bad Request',
errno: ERRNO.REJECTED_SUBSCRIPTION_PAYMENT_TOKEN,
message: 'Rejected subscription payment token'
}, {
token
});
};

AppError.insufficientACRValues = (foundValue) => {
return new AppError({
code: 400,
@@ -816,48 +816,80 @@ module.exports = (log, db, mailer, Password, config, customs, signinUtils, push,
locale: isA.string().optional().allow(null),
authenticationMethods: isA.array().items(isA.string().required()).optional(),
authenticatorAssuranceLevel: isA.number().min(0),
subscriptions: isA.array().items(isA.string().required()).optional(),
profileChangedAt: isA.number().min(0)
}
}
},
handler: async function (request) {
const auth = request.auth;
let uid, scope, account;
let uid, scope, client_id;
if (auth.strategy === 'sessionToken') {
uid = auth.credentials.uid;
scope = { contains: () => true };
client_id = null;
} else {
uid = auth.credentials.user;
scope = ScopeSet.fromArray(auth.credentials.scope);
client_id = auth.credentials.client_id;
}

const res = {};
return db.account(uid)
.then(result => {
account = result;
if (scope.contains('profile:email')) {
res.email = account.primaryEmail.email;
}
if (scope.contains('profile:locale')) {
res.locale = account.locale;
}
if (scope.contains('profile:amr')) {
return authMethods.availableAuthenticationMethods(db, account)
.then(amrValues => {
res.authenticationMethods = Array.from(amrValues);
res.authenticatorAssuranceLevel = authMethods.maximumAssuranceLevel(amrValues);
});
const account = await db.account(uid);

if (scope.contains('profile:email')) {
res.email = account.primaryEmail.email;
}
if (scope.contains('profile:locale')) {
res.locale = account.locale;
}
if (scope.contains('profile:amr')) {
const amrValues = await authMethods.availableAuthenticationMethods(db, account);
res.authenticationMethods = Array.from(amrValues);
res.authenticatorAssuranceLevel = authMethods.maximumAssuranceLevel(amrValues);
}

// TODO: email for now, but someday we may change scope for subscrption status
if (
auth.strategy === 'sessionToken'
|| scope.contains('profile:email')
) {
const {
subscriptions: {
productCapabilities = {},
clientCapabilities = {}
} = {}
} = config;

const subscriptions = await db.fetchAccountSubscriptions(uid) || [];
if (subscriptions.length > 0) {
const capabilitiesToReveal = new Set();
const clientVisibleCapabilities = clientCapabilities[client_id] || [];
for (const subscription of subscriptions) {
const capabilitiesFromProduct =
productCapabilities[subscription.productName] || [];
for (const capability of capabilitiesFromProduct) {
if (
auth.strategy === 'sessionToken'
|| clientVisibleCapabilities.includes(capability)
) {
capabilitiesToReveal.add(capability);
}
}
}
})
.then(() => {
// If no keys set on the response, there was no valid profile scope found. We only
// want to return `profileChangedAt` if a valid scope was found and set.
if (Object.keys(res).length !== 0) {
res.profileChangedAt = account.profileChangedAt;
if (capabilitiesToReveal.size > 0) {
res.subscriptions = Array.from(capabilitiesToReveal);
}
}
}

return res;
});
// If no keys set on the response, there was no valid profile scope found. We only
// want to return `profileChangedAt` if a valid scope was found and set.
if (Object.keys(res).length !== 0) {
res.profileChangedAt = account.profileChangedAt;
}

return res;
}
},
{
@@ -21,6 +21,7 @@ module.exports = function (
// Various extra helpers.
const push = require('../push')(log, db, config);
const pushbox = require('../pushbox')(log, config);
const subhub = require('../subhub')(log, config);
const devicesImpl = require('../devices')(log, db, push);
const signinUtils = require('./utils/signin')(log, config, customs, db, mailer);
const verificationReminders = require('../verification-reminders')(log, config);
@@ -62,6 +63,7 @@ module.exports = function (
const totp = require('./totp')(log, db, mailer, customs, config.totp);
const recoveryCodes = require('./recovery-codes')(log, db, config.totp, customs, mailer);
const recoveryKey = require('./recovery-key')(log, db, Password, config.verifierVersion, customs, mailer);
const subscriptions = require('./subscriptions')(log, db, config, customs, oauthdb, subhub);
const util = require('./util')(
log,
config,
@@ -86,7 +88,8 @@ module.exports = function (
totp,
unblockCodes,
util,
recoveryKey
recoveryKey,
subscriptions
);
v1Routes.forEach(r => { r.path = `${basePath }/v1${ r.path}`; });
defaults.forEach(r => { r.path = basePath + r.path; });
Oops, something went wrong.

0 comments on commit eace436

Please sign in to comment.
You can’t perform that action at this time.