Skip to content
This repository has been archived by the owner on Apr 3, 2019. It is now read-only.

Commit

Permalink
feat(oauth): Expose /account/scoped-key-data endpoint, by making back…
Browse files Browse the repository at this point in the history
…end calls to oauth-server.
  • Loading branch information
rfk committed Dec 17, 2018
1 parent fdf0b1d commit 7f13766
Show file tree
Hide file tree
Showing 30 changed files with 1,584 additions and 599 deletions.
20 changes: 12 additions & 8 deletions bin/key_server.js
Expand Up @@ -61,12 +61,13 @@ function run(config) {

var Customs = require('../lib/customs')(log, error)

var Server = require('../lib/server')
var server = null
var senders = null
var statsInterval = null
var database = null
var customs = null
const Server = require('../lib/server')
let server = null
let senders = null
let statsInterval = null
let database = null
let customs = null
let oauthdb = null

function logStatInfo() {
log.stat(server.stat())
Expand All @@ -88,8 +89,9 @@ function run(config) {
(db, translator) => {
database = db
const bounces = require('../lib/bounces')(config, db)
oauthdb = require('../lib/oauthdb')(log, config)

return require('../lib/senders')(log, config, error, bounces, translator)
return require('../lib/senders')(log, config, error, bounces, translator, oauthdb)
.then(result => {
senders = result
customs = new Customs(config.customsUrl)
Expand All @@ -98,6 +100,7 @@ function run(config) {
serverPublicKeys,
signer,
db,
oauthdb,
senders.email,
senders.sms,
Password,
Expand All @@ -108,7 +111,7 @@ function run(config) {
statsInterval = setInterval(logStatInfo, 15000)

async function init() {
server = await Server.create(log, error, config, routes, db, translator)
server = await Server.create(log, error, config, routes, db, oauthdb, translator)
try {
await server.start()
log.info({op: 'server.start.1', msg: 'running on ' + server.info.uri})
Expand Down Expand Up @@ -140,6 +143,7 @@ function run(config) {
clearInterval(statsInterval)
server.stop().then(() => {
customs.close()
oauthdb.close()
try {
senders.email.stop()
} catch (e) {
Expand Down
21 changes: 21 additions & 0 deletions config/index.js
Expand Up @@ -619,6 +619,26 @@ var conf = convict({
format: 'duration',
default: '3 days',
env: 'OAUTH_CLIENT_INFO_CACHE_TTL'
},
secretKey: {
doc: 'Shared secret for signing server-to-server JWT assertions',
env: 'OAUTH_SERVER_SECRET_KEY',
format: String,
default: 'megaz0rd'
},
poolee: {
timeout: {
default: '30 seconds',
format: 'duration',
env: 'OAUTH_POOLEE_TIMEOUT',
doc: 'Time in milliseconds to wait for oauth query completion'
},
maxPending: {
default: 1000,
format: 'int',
env: 'OAUTH_POOLEE_MAX_PENDING',
doc: 'Number of pending requests to fxa-oauth-server to allow'
}
}
},
metrics: {
Expand Down Expand Up @@ -933,6 +953,7 @@ if (conf.get('isProduction')) {
const SECRET_SETTINGS = [
'pushbox.key',
'metrics.flow_id_key',
'oauth.secretKey',
]
for (const key of SECRET_SETTINGS) {
if (conf.get(key) === conf.default(key)) {
Expand Down
81 changes: 81 additions & 0 deletions docs/api.md
Expand Up @@ -48,6 +48,9 @@ see [`mozilla/fxa-js-client`](https://github.com/mozilla/fxa-js-client).
* [POST /recovery_email (:lock: sessionToken)](#post-recovery_email)
* [POST /recovery_email/destroy (:lock: sessionToken)](#post-recovery_emaildestroy)
* [POST /recovery_email/set_primary (:lock: sessionToken)](#post-recovery_emailset_primary)
* [Oauth](#oauth)
* [GET /oauth/client/{client_id}](#get-oauthclientclient_id)
* [POST /account/scoped-key-data (:lock: sessionToken)](#post-accountscoped-key-data)
* [Password](#password)
* [POST /password/change/start](#post-passwordchangestart)
* [POST /password/change/finish (:lock: passwordChangeToken)](#post-passwordchangefinish)
Expand Down Expand Up @@ -297,6 +300,10 @@ for `code` and `errno` are:
This request requires two step authentication enabled on your account.
* `code: 400, errno: 161`:
Recovery key already exists.
* `code: 400, errno: 162`:
Unknown client_id
* `code: 400, errno: 164`:
Stale auth timestamp
* `code: 503, errno: 201`:
Service unavailable
* `code: 503, errno: 202`:
Expand Down Expand Up @@ -331,6 +338,8 @@ include additional response properties:
* `errno: 135`: bouncedAt
* `errno: 152`
* `errno: 153`
* `errno: 162`: clientId
* `errno: 164`: authAt
* `errno: 201`: retryAfter
* `errno: 202`: retryAfter
* `errno: 203`: service, operation
Expand Down Expand Up @@ -368,6 +377,13 @@ those common validations are defined here.
* `DISPLAY_SAFE_UNICODE`: `/^(?:[^\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uD800-\uDFFF\uE000-\uF8FF\uFFF9-\uFFFF])*$/`
* `DISPLAY_SAFE_UNICODE_WITH_NON_BMP`: `/^(?:[^\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uE000-\uF8FF\uFFF9-\uFFFF])*$/`
* `service`: `string, max(16), regex(/^[a-zA-Z0-9\-]*$/)`
* `hexString`: `string, regex(/^(?:[a-fA-F0-9]{2})+$/)`
* `clientId`: `module.exports.hexString.length(16)`
* `accessToken`: `module.exports.hexString.length(32)`
* `refreshToken`: `module.exports.hexString.length(32)`
* `scope`: `string, max(256), regex(/^[a-zA-Z0-9 _\/.:-]+$/)`
* `assertion`: `string, min(50), max(10240), regex(/^[a-zA-Z0-9_\-\.~=]+$/)`
* `jwe`: `string, max(1024), regex(/^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/)`
* `verificationMethod`: `string, valid()`
* `authPW`: `string, length(64), regex(HEX_STRING), required`
* `wrapKb`: `string, length(64), regex(/^(?:[a-fA-F0-9]{2})+$/)`
Expand Down Expand Up @@ -1939,6 +1955,71 @@ by the following errors
Can not change primary email to an email that does not belong to this account


### Oauth

#### GET /oauth/client/{client_id}
<!--begin-route-get-oauthclientclient_id-->
Retrieve metadata about the specified OAuth client,
such as its display-name and redirect URI.
<!--end-route-get-oauthclientclient_id-->

##### Response body

* `id`: *validators.clientId.required*

<!--begin-response-body-get-oauthclientclient_id-id-->

<!--end-response-body-get-oauthclientclient_id-id-->

* `name`: *Joi.string.max(25).regex(validators.DISPLAY_SAFE_UNICODE).required*

<!--begin-response-body-get-oauthclientclient_id-name-->

<!--end-response-body-get-oauthclientclient_id-name-->

* `trusted`: *Joi.boolean.required*

<!--begin-response-body-get-oauthclientclient_id-trusted-->

<!--end-response-body-get-oauthclientclient_id-trusted-->

* `image_uri`: *Joi.string.optional.allow('')*

<!--begin-response-body-get-oauthclientclient_id-image_uri-->

<!--end-response-body-get-oauthclientclient_id-image_uri-->

* `redirect_uri`: *Joi.string.required.allow('')*

<!--begin-response-body-get-oauthclientclient_id-redirect_uri-->

<!--end-response-body-get-oauthclientclient_id-redirect_uri-->


#### POST /account/scoped-key-data

:lock: HAWK-authenticated with session token
<!--begin-route-post-accountscoped-key-data-->
Query for the information required
to derive scoped encryption keys
requested by the specified OAuth client.
<!--end-route-post-accountscoped-key-data-->

##### Request body

* `client_id`: *validators.clientId.required*

<!--begin-request-body-post-accountscoped-key-data-client_id-->

<!--end-request-body-post-accountscoped-key-data-client_id-->

* `scope`: *validators.scope.required*

<!--begin-request-body-post-accountscoped-key-data-scope-->

<!--end-request-body-post-accountscoped-key-data-scope-->


### Password

#### POST /password/change/start
Expand Down
1 change: 1 addition & 0 deletions fxa-oauth-server/config/test.json
Expand Up @@ -102,6 +102,7 @@
}
],
"allowHttpRedirects": true,
"authServerSecrets": ["meg@z0rd", "v0ltr0n"],
"scopes": [
{
"scope": "https://identity.mozilla.com/apps/sample-scope",
Expand Down
145 changes: 145 additions & 0 deletions fxa-oauth-server/lib/assertion.js
@@ -0,0 +1,145 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

'use stict';

/* Utilities for verifing signed identity assertions.
*
* This service accepts two different kinds of identity assertions
* for authenticating the caller:
*
* - A JWT, signed by one of a fixed set of trusted server-side secret
* HMAC keys.
* - A BrowserID assertion bundle, signed via BrowserID's public key
* discovery mechanisms.
*
* The former is much simpler and easier to verify, so much so that
* we do it inline in the server process. The later is much more
* complicated and we need to call out to an external verifier process.
* We hope to eventually phase out support for BrowserID assertions.
*
*/

const P = require('./promise');

const Joi = require('joi');
const jwt = P.promisifyAll(require('jsonwebtoken'));

const AppError = require('./error');
const config = require('./config');
const logger = require('./logging')('assertion');

const HEX_STRING = /^[0-9a-f]+$/;
const CLAIMS_SCHEMA = Joi.object({
'uid': Joi.string().length(32).regex(HEX_STRING).required(),
'fxa-generation': Joi.number().integer().min(0).required(),
'fxa-verifiedEmail': Joi.string().max(255).required(),
'fxa-lastAuthAt': Joi.number().integer().min(0).required(),
'iat': Joi.number().integer().min(0).optional(),
'fxa-tokenVerified': Joi.boolean().optional(),
'fxa-amr': Joi.array().items(Joi.string().alphanum()).optional(),
'fxa-aal': Joi.number().integer().min(0).max(3).optional(),
'fxa-profileChangedAt': Joi.number().integer().min(0).optional()
}).options({ stripUnknown: true });
const validateClaims = P.promisify(CLAIMS_SCHEMA.validate, {
context: CLAIMS_SCHEMA
});

const AUDIENCE = config.get('publicUrl');
const ALLOWED_ISSUER = config.get('browserid.issuer');

const request = P.promisify(require('request').defaults({
url: config.get('browserid.verificationUrl'),
pool: {
maxSockets: config.get('browserid.maxSockets')
}
}), { multiArgs: true });


function error(assertion, msg, val) {
logger.info('invalidAssertion', { assertion, msg, val });
throw AppError.invalidAssertion();
}

// Verify a BrowserID assertion,
// by posting to an external verifier service.

async function verifyBrowserID(assertion) {

let res, body;
try {
[res, body] = await request({
method: 'POST',
json: {
assertion: assertion,
audience: AUDIENCE
}
});
} catch (err) {
logger.error('verify.error', err);
throw err;
}
if (! res || ! body || body.status !== 'okay') {
return error(assertion, 'non-okay response', body);
}

const email = body.email;
const parts = email.split('@');
if (parts.length !== 2 || parts[1] !== ALLOWED_ISSUER) {
return error(assertion, 'invalid email', email);
}
if (body.issuer !== ALLOWED_ISSUER) {
return error(assertion, 'invalid issuer', body.issuer);
}
const uid = parts[0];

const claims = body.idpClaims || {};
claims.uid = uid;
return claims;
}

// Verify a JWT assertion.
// Since it's just a symmetric HMAC signature,
// this should be safe and performant enough to do in-proces.
async function verifyJWT(assertion) {
const opts = {
algorithms: ['HS256'],
audience: AUDIENCE,
issuer: ALLOWED_ISSUER,
};
// To allow for key rotation, we may have
// several valid shared secret keys in-flight.
const keys = config.get('authServerSecrets');
for (const key of keys) {
try {
const claims = jwt.verify(assertion, key, opts);
claims.uid = claims.sub;
return claims;
} catch (err) {
// Any error other than 'invalid signature' will not
// be resolved by trying the remaining keys.
if (err.message !== 'invalid signature') {
return error(assertion, err.message);
}
}
}
// None of the keys worked, clearly invalid.
return error(assertion, 'unknown signing key');
}

module.exports = async function verifyAssertion(assertion) {
// We can differentiate between JWTs and BrowserID assertions
// because the former cannot contain "~" while the later always do.
let claims;
if (/~/.test(assertion)) {
claims = await verifyBrowserID(assertion);
} else {
claims = await verifyJWT(assertion);
}
try {
return await validateClaims(claims);
} catch (err) {
return error(assertion, err, claims);
}
};

0 comments on commit 7f13766

Please sign in to comment.