diff --git a/bin/key_server.js b/bin/key_server.js index ea81a4dae..42729203e 100644 --- a/bin/key_server.js +++ b/bin/key_server.js @@ -181,7 +181,9 @@ function main() { }) } }) - .catch(process.exit.bind(null, 8)) + .catch((err) => { + process.exit(8) + }) } if (require.main === module) { diff --git a/docs/api.md b/docs/api.md index 9c2a6cb8f..6b2c8ffc7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -51,6 +51,7 @@ see [`mozilla/fxa-js-client`](https://github.com/mozilla/fxa-js-client). * [Oauth](#oauth) * [GET /oauth/client/{client_id}](#get-oauthclientclient_id) * [POST /account/scoped-key-data (:lock: sessionToken)](#post-accountscoped-key-data) + * [POST /oauth/token](#post-oauthtoken) * [Password](#password) * [POST /password/change/start](#post-passwordchangestart) * [POST /password/change/finish (:lock: passwordChangeToken)](#post-passwordchangefinish) @@ -371,6 +372,7 @@ those common validations are defined here. #### lib/routes/validators * `HEX_STRING`: `/^(?:[a-fA-F0-9]{2})+$/` +* `B64URL_STRING`: `/^[A-Za-z0-9-_]+$/` * `BASE_36`: `/^[a-zA-Z0-9]*$/` * `URL_SAFE_BASE_64`: `/^[A-Za-z0-9_-]+$/` * `DISPLAY_SAFE_UNICODE`: `/^(?:[^\u0000-\u001F\u007F\u0080-\u009F\u2028-\u2029\uD800-\uDFFF\uE000-\uF8FF\uFFF9-\uFFFF])*$/` @@ -378,6 +380,7 @@ those common validations are defined here. * `service`: `string, max(16), regex(/^[a-zA-Z0-9\-]*$/)` * `hexString`: `string, regex(/^(?:[a-fA-F0-9]{2})+$/)` * `clientId`: `module.exports.hexString.length(16)` +* `clientSecret`: `string, length(), regex(/^(?:[a-fA-F0-9]{2})+$/)` * `accessToken`: `module.exports.hexString.length(32)` * `refreshToken`: `module.exports.hexString.length(32)` * `scope`: `string, max(256), regex(/^[a-zA-Z0-9 _\/.:-]+$/)` @@ -392,6 +395,9 @@ those common validations are defined here. * `DIGITS`: `/^[0-9]+$/` * `DEVICE_COMMAND_NAME`: `/^[a-zA-Z0-9._\/\-:]{1,100}$/` * `IP_ADDRESS`: `string, ip` +* `codeVerifier`: `string, min(43), max(128), regex(/^[A-Za-z0-9-_]+$/)` +* `token`: `string, length(), regex(/^(?:[a-fA-F0-9]{2})+$/)` +* `redirectUri`: `string, max(256), regex(/^[a-zA-Z0-9\-_\/.:?=&]+$/)` #### lib/metrics/context @@ -2020,6 +2026,12 @@ requested by the specified OAuth client. +#### POST /oauth/token + + + + + ### Password #### POST /password/change/start diff --git a/lib/oauthdb.js b/lib/oauthdb.js index 238f12a34..da71feb20 100644 --- a/lib/oauthdb.js +++ b/lib/oauthdb.js @@ -26,6 +26,73 @@ const createBackendServiceAPI = require('./backendService') const error = require('./error') const validators = require('./routes/validators') +// TODO: hook it up +const MAX_TTL_S = 604800000 / 1000; // 2 weeks ms? / 1000 +const GRANT_AUTHORIZATION_CODE = 'authorization_code'; +const GRANT_REFRESH_TOKEN = 'refresh_token'; + +const TOKEN_PAYLOAD_SCHEMA = Joi.object({ + + client_id: validators.clientId + .when('$headers.authorization', { + is: Joi.string().required(), + then: Joi.forbidden() + }), + + client_secret: validators.clientSecret + .when('code_verifier', { + is: Joi.string().required(), // if (typeof code_verifier === 'string') { + then: Joi.forbidden() + }) + .when('grant_type', { + is: GRANT_REFRESH_TOKEN, + then: Joi.optional() + }) + .when('$headers.authorization', { + is: Joi.string().required(), + then: Joi.forbidden() + }), + + code_verifier: validators.codeVerifier, + + redirect_uri: validators.redirectUri.optional(), + + grant_type: Joi.string() + .valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN) + .default(GRANT_AUTHORIZATION_CODE) + .optional(), + + ttl: Joi.number() + .positive() + .max(MAX_TTL_S) + .default(MAX_TTL_S) + .optional(), + + scope: validators.scope + .when('grant_type', { + is: GRANT_REFRESH_TOKEN, + otherwise: Joi.forbidden() + }), + + code: Joi.string() + //.length(config.get('unique.code') * 2) + .length(32 * 2) + .regex(validators.HEX_STRING) + .required() + .when('grant_type', { + is: GRANT_AUTHORIZATION_CODE, + otherwise: Joi.forbidden() + }), + + refresh_token: validators.token + .required() + .when('grant_type', { + is: GRANT_REFRESH_TOKEN, + otherwise: Joi.forbidden() + }) + +}); + // The oauth-server's error numbers overlap and conflict // with the auth-server's, so we have to map them to new ones. function mapOAuthError(log, err) { @@ -86,6 +153,40 @@ module.exports = (log, config) => { keyRotationTimestamp: Joi.number().required(), })) } + }, + + postVerifyToken: { + path: '/v1/verify', + method: 'POST', + validate: { + payload: { + token: validators.token.required(), + }, + response: { + user: Joi.string().required(), + client_id: Joi.string().required(), + scope: Joi.array(), + profile_changed_at: Joi.number().min(0) + } + } + }, + + postToken: { + path: '/v1/token', + method: 'POST', + validate: { + payload: TOKEN_PAYLOAD_SCHEMA, + response: Joi.object().keys({ + access_token: validators.token.required(), + refresh_token: validators.token, + id_token: validators.assertion, + scope: validators.scope.required(), + token_type: Joi.string().valid('bearer').required(), + expires_in: Joi.number().max(MAX_TTL_S).required(), + auth_at: Joi.number(), + keys_jwe: validators.jwe.optional() + }) + } } }) @@ -142,6 +243,22 @@ module.exports = (log, config) => { } }, + async postToken(payload) { + try { + return await api.postToken(payload) + } catch (err) { + throw mapOAuthError(log, err) + } + }, + + async postVerifyToken(token) { + try { + return await api.postVerifyToken(token) + } catch (err) { + throw mapOAuthError(log, err) + } + }, + /* As we work through the process of merging oauth-server * into auth-server, future methods we might want to include * here will be things like the following: diff --git a/lib/routes/index.js b/lib/routes/index.js index 90088b984..ef2347e66 100644 --- a/lib/routes/index.js +++ b/lib/routes/index.js @@ -36,7 +36,7 @@ module.exports = function ( signinUtils, push ) - const oauth = require('./oauth')(log, config, oauthdb) + const oauth = require('./oauth')(log, config, oauthdb, db, mailer, push) const devicesSessions = require('./devices-and-sessions')(log, db, config, customs, push, pushbox, devicesImpl) const emails = require('./emails')(log, db, mailer, config, customs, push) const password = require('./password')( diff --git a/lib/routes/oauth.js b/lib/routes/oauth.js index 56f458673..c589e1d21 100644 --- a/lib/routes/oauth.js +++ b/lib/routes/oauth.js @@ -19,7 +19,79 @@ const Joi = require('joi') const validators = require('./validators') -module.exports = (log, config, oauthdb) => { + + +// TODO: hook it up +const MAX_TTL_S = 604800000 / 1000; // 2 weeks ms? / 1000 +const GRANT_AUTHORIZATION_CODE = 'authorization_code'; +const GRANT_REFRESH_TOKEN = 'refresh_token'; + +const TOKEN_PAYLOAD_SCHEMA = Joi.object({ + + client_id: validators.clientId + .when('$headers.authorization', { + is: Joi.string().required(), + then: Joi.forbidden() + }), + + client_secret: validators.clientSecret + .when('code_verifier', { + is: Joi.string().required(), // if (typeof code_verifier === 'string') { + then: Joi.forbidden() + }) + .when('grant_type', { + is: GRANT_REFRESH_TOKEN, + then: Joi.optional() + }) + .when('$headers.authorization', { + is: Joi.string().required(), + then: Joi.forbidden() + }), + + code_verifier: validators.codeVerifier, + + redirect_uri: validators.redirectUri.optional(), + + grant_type: Joi.string() + .valid(GRANT_AUTHORIZATION_CODE, GRANT_REFRESH_TOKEN) + .default(GRANT_AUTHORIZATION_CODE) + .optional(), + + ttl: Joi.number() + .positive() + .max(MAX_TTL_S) + .default(MAX_TTL_S) + .optional(), + + scope: validators.scope + .when('grant_type', { + is: GRANT_REFRESH_TOKEN, + otherwise: Joi.forbidden() + }), + + code: Joi.string() + //.length(config.get('unique.code') * 2) + .length(32 * 2) + .regex(validators.HEX_STRING) + .required() + .when('grant_type', { + is: GRANT_AUTHORIZATION_CODE, + otherwise: Joi.forbidden() + }), + + refresh_token: validators.token + .required() + .when('grant_type', { + is: GRANT_REFRESH_TOKEN, + otherwise: Joi.forbidden() + }) + +}); + + + + +module.exports = (log, config, oauthdb, db, mailer, push) => { const routes = [ { method: 'GET', @@ -68,8 +140,75 @@ module.exports = (log, config, oauthdb) => { handler: async function (request) { const sessionToken = request.auth.credentials return oauthdb.getScopedKeyData(sessionToken, request.payload) - } + }, }, + { + method: 'POST', + path: '/oauth/token', + options: { + validate: { + payload: TOKEN_PAYLOAD_SCHEMA + }, + response: { + schema: Joi.object().keys({ + access_token: validators.token.required(), + refresh_token: validators.token, + id_token: validators.assertion, + scope: validators.scope.required(), + token_type: Joi.string().valid('bearer').required(), + expires_in: Joi.number().max(MAX_TTL_S).required(), + auth_at: Joi.number(), + keys_jwe: validators.jwe.optional() + }) + } + }, + handler: async function (request) { + return oauthdb.postToken(request.payload) + .then(async (resp) => { + + // TODO: is this how we are gonna get the UIDs now? + const tokenVerify = await oauthdb.postVerifyToken({ + token: resp.access_token + }); + + const uid = tokenVerify.user; + const accountRecord = await db.account(uid); + + const devices = db.devices(uid); + push.notifyDeviceConnected(uid, devices, 'Android Reference Browser'); + + try { + await mailer.sendNewDeviceLoginNotification( + accountRecord.emails, + accountRecord, + { + acceptLanguage: request.app.acceptLanguage, + // deviceId, + // flowId, + // flowBeginTime, + //ip, + //location: geoData.location, + //service, + //timeZone: geoData.timeZone, + uaBrowser: request.app.ua.browser, + uaBrowserVersion: request.app.ua.browserVersion, + uaOS: request.app.ua.os, + uaOSVersion: request.app.ua.osVersion, + uaDeviceType: request.app.ua.deviceType, + uid: uid + } + ) + } catch (err) { + log.error({ + op: 'Account.login.sendNewDeviceLoginNotification.error', + error: err + }) + } + + return resp; + }) + } + } ] return routes } diff --git a/lib/routes/validators.js b/lib/routes/validators.js index fed5610f3..be17ef4eb 100644 --- a/lib/routes/validators.js +++ b/lib/routes/validators.js @@ -12,6 +12,8 @@ const isA = require('joi') const HEX_STRING = /^(?:[a-fA-F0-9]{2})+$/ module.exports.HEX_STRING = HEX_STRING +const B64URL_STRING = /^[A-Za-z0-9-_]+$/; +module.exports.B64URL_STRING = B64URL_STRING; module.exports.BASE_36 = /^[a-zA-Z0-9]*$/ // RFC 4648, section 5 @@ -77,6 +79,22 @@ module.exports.email = function() { module.exports.service = isA.string().max(16).regex(/^[a-zA-Z0-9\-]*$/) module.exports.hexString = isA.string().regex(HEX_STRING) module.exports.clientId = module.exports.hexString.length(16) +module.exports.clientSecret = isA.string() + //.length(config.get('unique.clientSecret') * 2) // hex = bytes*2 + .length(32 * 2) // hex = bytes*2 + .regex(exports.HEX_STRING) + //.required(); +exports.codeVerifier = isA.string() + .min(43) + .max(128) + .regex(B64URL_STRING); // https://tools.ietf.org/html/rfc7636#section-4.1 +exports.token = isA.string() + //.length(config.get('unique.token') * 2) + .length(32 * 2) + .regex(exports.HEX_STRING); +exports.redirectUri = isA.string() + .max(256) + .regex(/^[a-zA-Z0-9\-_\/.:?=&]+$/); module.exports.accessToken = module.exports.hexString.length(32) module.exports.refreshToken = module.exports.hexString.length(32) module.exports.scope = isA.string().max(256).regex(/^[a-zA-Z0-9 _\/.:-]+$/)