implemented PKCE auth (#921)

* implemented PKCE auth

* removed node-jose

* added PKCE tests
This commit is contained in:
Danny Coates 2018-09-14 08:00:33 -07:00 committed by Donovan Preston
parent 20528eb0d1
commit 7ccf462bf8
18 changed files with 331 additions and 263 deletions

View File

@ -8,7 +8,7 @@ export default function initialState(state, emitter) {
Object.assign(state, { Object.assign(state, {
prefix: '/android_asset', prefix: '/android_asset',
user: new User(undefined, storage), user: new User(storage),
getAsset(name) { getAsset(name) {
return `${state.prefix}/${name}`; return `${state.prefix}/${name}`;
}, },

View File

@ -5,7 +5,6 @@ import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
import * as metrics from './metrics'; import * as metrics from './metrics';
import Archive from './archive'; import Archive from './archive';
import { bytes } from './utils'; import { bytes } from './utils';
import { prepareWrapKey } from './fxa';
export default function(state, emitter) { export default function(state, emitter) {
let lastRender = 0; let lastRender = 0;
@ -45,9 +44,8 @@ export default function(state, emitter) {
lastRender = Date.now(); lastRender = Date.now();
}); });
emitter.on('login', async () => { emitter.on('login', () => {
const k = await prepareWrapKey(state.storage); state.user.login();
location.assign(`/api/fxa/login?keys_jwk=${k}`);
}); });
emitter.on('logout', () => { emitter.on('logout', () => {

View File

@ -1,21 +1,153 @@
import jose from 'node-jose';
import { arrayToB64, b64ToArray } from './utils'; import { arrayToB64, b64ToArray } from './utils';
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const decoder = new TextDecoder();
export async function prepareWrapKey(storage) { function getOtherInfo(enc) {
const keystore = jose.JWK.createKeyStore(); const name = encoder.encode(enc);
const keypair = await keystore.generate('EC', 'P-256'); const length = 256;
storage.set('fxaWrapKey', JSON.stringify(keystore.toJSON(true))); const buffer = new ArrayBuffer(name.length + 16);
return jose.util.base64url.encode(JSON.stringify(keypair.toJSON())); const dv = new DataView(buffer);
const result = new Uint8Array(buffer);
let i = 0;
dv.setUint32(i, name.length);
i += 4;
result.set(name, i);
i += name.length;
dv.setUint32(i, 0);
i += 4;
dv.setUint32(i, 0);
i += 4;
dv.setUint32(i, length);
return result;
}
function concat(b1, b2) {
const result = new Uint8Array(b1.length + b2.length);
result.set(b1, 0);
result.set(b2, b1.length);
return result;
}
async function concatKdf(key, enc) {
if (key.length !== 32) {
throw new Error('unsupported key length');
}
const otherInfo = getOtherInfo(enc);
const buffer = new ArrayBuffer(4 + key.length + otherInfo.length);
const dv = new DataView(buffer);
const concat = new Uint8Array(buffer);
dv.setUint32(0, 1);
concat.set(key, 4);
concat.set(otherInfo, key.length + 4);
const result = await crypto.subtle.digest('SHA-256', concat);
return new Uint8Array(result);
}
export async function prepareScopedBundleKey(storage) {
const keys = await crypto.subtle.generateKey(
{
name: 'ECDH',
namedCurve: 'P-256'
},
true,
['deriveBits']
);
const privateJwk = await crypto.subtle.exportKey('jwk', keys.privateKey);
const publicJwk = await crypto.subtle.exportKey('jwk', keys.publicKey);
const kid = await crypto.subtle.digest(
'SHA-256',
encoder.encode(JSON.stringify(publicJwk))
);
privateJwk.kid = kid;
publicJwk.kid = kid;
storage.set('scopedBundlePrivateKey', JSON.stringify(privateJwk));
return arrayToB64(encoder.encode(JSON.stringify(publicJwk)));
}
export async function decryptBundle(storage, bundle) {
const privateJwk = JSON.parse(storage.get('scopedBundlePrivateKey'));
storage.remove('scopedBundlePrivateKey');
const privateKey = await crypto.subtle.importKey(
'jwk',
privateJwk,
{
name: 'ECDH',
namedCurve: 'P-256'
},
false,
['deriveBits']
);
const jweParts = bundle.split('.');
if (jweParts.length !== 5) {
throw new Error('invalid jwe');
}
const header = JSON.parse(decoder.decode(b64ToArray(jweParts[0])));
const additionalData = encoder.encode(jweParts[0]);
const iv = b64ToArray(jweParts[2]);
const ciphertext = b64ToArray(jweParts[3]);
const tag = b64ToArray(jweParts[4]);
if (header.alg !== 'ECDH-ES' || header.enc !== 'A256GCM') {
throw new Error('unsupported jwe type');
}
const publicKey = await crypto.subtle.importKey(
'jwk',
header.epk,
{
name: 'ECDH',
namedCurve: 'P-256'
},
false,
[]
);
const sharedBits = await crypto.subtle.deriveBits(
{
name: 'ECDH',
public: publicKey
},
privateKey,
256
);
const rawSharedKey = await concatKdf(new Uint8Array(sharedBits), header.enc);
const sharedKey = await crypto.subtle.importKey(
'raw',
rawSharedKey,
{
name: 'AES-GCM'
},
false,
['decrypt']
);
const plaintext = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
additionalData: additionalData,
tagLength: tag.length * 8
},
sharedKey,
concat(ciphertext, tag)
);
return JSON.parse(decoder.decode(plaintext));
}
export async function preparePkce(storage) {
const verifier = arrayToB64(crypto.getRandomValues(new Uint8Array(64)));
storage.set('pkceVerifier', verifier);
const challenge = await crypto.subtle.digest(
'SHA-256',
encoder.encode(verifier)
);
return arrayToB64(new Uint8Array(challenge));
} }
export async function getFileListKey(storage, bundle) { export async function getFileListKey(storage, bundle) {
const keystore = await jose.JWK.asKeyStore( const jwks = await decryptBundle(storage, bundle);
JSON.parse(storage.get('fxaWrapKey'))
);
const result = await jose.JWE.createDecrypt(keystore).decrypt(bundle);
const jwks = JSON.parse(jose.util.utf8.encode(result.plaintext));
const jwk = jwks['https://identity.mozilla.com/apps/send']; const jwk = jwks['https://identity.mozilla.com/apps/send'];
const baseKey = await crypto.subtle.importKey( const baseKey = await crypto.subtle.importKey(
'raw', 'raw',

View File

@ -1,4 +1,3 @@
/* global userInfo */
import 'fast-text-encoding'; // MS Edge support import 'fast-text-encoding'; // MS Edge support
import 'fluent-intl-polyfill'; import 'fluent-intl-polyfill';
import app from './routes'; import app from './routes';
@ -13,7 +12,6 @@ import experiments from './experiments';
import Raven from 'raven-js'; import Raven from 'raven-js';
import './main.css'; import './main.css';
import User from './user'; import User from './user';
import { getFileListKey } from './fxa';
(async function start() { (async function start() {
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) { if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
@ -23,9 +21,7 @@ import { getFileListKey } from './fxa';
if (capa.streamDownload) { if (capa.streamDownload) {
navigator.serviceWorker.register('/serviceWorker.js'); navigator.serviceWorker.register('/serviceWorker.js');
} }
if (userInfo && userInfo.keys_jwe) {
userInfo.fileListKey = await getFileListKey(storage, userInfo.keys_jwe);
}
app.use((state, emitter) => { app.use((state, emitter) => {
state.capabilities = capa; state.capabilities = capa;
state.transfer = null; state.transfer = null;
@ -33,7 +29,7 @@ import { getFileListKey } from './fxa';
state.translate = locale.getTranslator(); state.translate = locale.getTranslator();
state.storage = storage; state.storage = storage;
state.raven = Raven; state.raven = Raven;
state.user = new User(userInfo, storage); state.user = new User(storage);
window.appState = state; window.appState = state;
let unsupportedReason = null; let unsupportedReason = null;
if ( if (

View File

@ -68,9 +68,14 @@ app.route('/legal', body(require('../pages/legal')));
app.route('/error', body(require('../pages/error'))); app.route('/error', body(require('../pages/error')));
app.route('/blank', body(require('../pages/blank'))); app.route('/blank', body(require('../pages/blank')));
app.route('/signin', body(require('../pages/signin'))); app.route('/signin', body(require('../pages/signin')));
app.route('/api/fxa/oauth', function(state, emit) { app.route('/api/fxa/oauth', async function(state, emit) {
emit('replaceState', '/'); try {
setTimeout(() => emit('render')); await state.user.finishLogin(state.query.code);
emit('replaceState', '/');
} catch (e) {
emit('replaceState', '/error');
setTimeout(() => emit('render'));
}
}); });
app.route('*', body(require('../pages/notFound'))); app.route('*', body(require('../pages/notFound')));

View File

@ -1,20 +1,18 @@
/* global LIMITS */ /* global LIMITS AUTH_CONFIG */
import assets from '../common/assets'; import assets from '../common/assets';
import { getFileList, setFileList } from './api'; import { getFileList, setFileList } from './api';
import { encryptStream, decryptStream } from './ece'; import { encryptStream, decryptStream } from './ece';
import { b64ToArray, streamToArrayBuffer } from './utils'; import { b64ToArray, streamToArrayBuffer } from './utils';
import { blobStream } from './streams'; import { blobStream } from './streams';
import { getFileListKey, prepareScopedBundleKey, preparePkce } from './fxa';
const textEncoder = new TextEncoder(); const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder(); const textDecoder = new TextDecoder();
export default class User { export default class User {
constructor(info, storage) { constructor(storage) {
if (info && storage) {
storage.user = info;
}
this.storage = storage; this.storage = storage;
this.data = info || storage.user || {}; this.data = storage.user || {};
} }
get avatar() { get avatar() {
@ -55,7 +53,50 @@ export default class User {
return this.loggedIn ? LIMITS.MAX_DOWNLOADS : LIMITS.ANON.MAX_DOWNLOADS; return this.loggedIn ? LIMITS.MAX_DOWNLOADS : LIMITS.ANON.MAX_DOWNLOADS;
} }
login() {} async login() {
const keys_jwk = await prepareScopedBundleKey(this.storage);
const code_challenge = await preparePkce(this.storage);
const params = new URLSearchParams({
client_id: AUTH_CONFIG.client_id,
code_challenge,
code_challenge_method: 'S256',
response_type: 'code',
scope: 'profile https://identity.mozilla.com/apps/send', //TODO param
state: 'todo',
keys_jwk
});
location.assign(
`${AUTH_CONFIG.authorization_endpoint}?${params.toString()}`
);
}
async finishLogin(code) {
const tokenResponse = await fetch(AUTH_CONFIG.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code,
client_id: AUTH_CONFIG.client_id,
code_verifier: this.storage.get('pkceVerifier')
})
});
const auth = await tokenResponse.json();
const infoResponse = await fetch(AUTH_CONFIG.userinfo_endpoint, {
method: 'GET',
headers: {
Authorization: `Bearer ${auth.access_token}`
}
});
const userInfo = await infoResponse.json();
userInfo.keys_jwe = auth.keys_jwe;
userInfo.access_token = auth.access_token;
userInfo.fileListKey = await getFileListKey(this.storage, auth.keys_jwe);
this.storage.user = userInfo;
this.data = userInfo;
this.storage.remove('pkceVerifier');
}
logout() { logout() {
this.storage.user = null; this.storage.user = null;

91
package-lock.json generated
View File

@ -3921,12 +3921,6 @@
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
"integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw=="
}, },
"base64url": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.0.tgz",
"integrity": "sha512-LIVmqIrIWuiqTvn4RzcrwCOuHo2DD6tKmKBPXXlr4p4n4l6BZBkwFTIa3zu1XkX5MbZgro4a6BvPi+n2Mns5Gg==",
"dev": true
},
"basic-auth": { "basic-auth": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz",
@ -9587,24 +9581,12 @@
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
"dev": true "dev": true
}, },
"lodash.assign": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz",
"integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=",
"dev": true
},
"lodash.camelcase": { "lodash.camelcase": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=", "integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=",
"dev": true "dev": true
}, },
"lodash.clone": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz",
"integrity": "sha1-GVhwRQ9aExkkeN9Lw9I9LeoZB7Y=",
"dev": true
},
"lodash.clonedeep": { "lodash.clonedeep": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
@ -9616,60 +9598,18 @@
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
"dev": true "dev": true
}, },
"lodash.fill": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/lodash.fill/-/lodash.fill-3.4.0.tgz",
"integrity": "sha1-o8dK5kDQU63w3CB5+HIHiOi/74U=",
"dev": true
},
"lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=",
"dev": true
},
"lodash.get": { "lodash.get": {
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
"dev": true "dev": true
}, },
"lodash.intersection": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.intersection/-/lodash.intersection-4.4.0.tgz",
"integrity": "sha1-ChG6Yx0OlcI8fy9Mu5ppLtF45wU=",
"dev": true
},
"lodash.memoize": { "lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=",
"dev": true "dev": true
}, },
"lodash.merge": {
"version": "4.6.1",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz",
"integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==",
"dev": true
},
"lodash.omit": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.omit/-/lodash.omit-4.5.0.tgz",
"integrity": "sha1-brGa5aHuHdnfC5aeZs4Lf6MLXmA=",
"dev": true
},
"lodash.partialright": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/lodash.partialright/-/lodash.partialright-4.2.1.tgz",
"integrity": "sha1-ATDYDoM2MmTUAHTzKbij56ihzEs=",
"dev": true
},
"lodash.pick": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.pick/-/lodash.pick-4.4.0.tgz",
"integrity": "sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=",
"dev": true
},
"lodash.template": { "lodash.template": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz",
@ -10560,37 +10500,6 @@
"integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==", "integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==",
"dev": true "dev": true
}, },
"node-jose": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-jose/-/node-jose-1.0.0.tgz",
"integrity": "sha512-RE3P8l60Rj9ELrpPmvw6sOQ1hSyYfmQdNUMCa4EN7nCE1ux5JVX+GfXv+mfUTEMhZwNMwxBtI0+X1CKKeukSVQ==",
"dev": true,
"requires": {
"base64url": "^3.0.0",
"es6-promise": "^4.0.5",
"lodash.assign": "^4.0.8",
"lodash.clone": "^4.3.2",
"lodash.fill": "^3.2.2",
"lodash.flatten": "^4.2.0",
"lodash.intersection": "^4.1.2",
"lodash.merge": "^4.3.5",
"lodash.omit": "^4.2.1",
"lodash.partialright": "^4.1.3",
"lodash.pick": "^4.2.0",
"lodash.uniq": "^4.2.1",
"long": "^4.0.0",
"node-forge": "^0.7.1",
"uuid": "^3.0.1"
},
"dependencies": {
"long": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
"integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
"dev": true
}
}
},
"node-libs-browser": { "node-libs-browser": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.1.0.tgz", "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.1.0.tgz",

View File

@ -89,7 +89,6 @@
"morgan": "^1.9.0", "morgan": "^1.9.0",
"nanobus": "^4.3.2", "nanobus": "^4.3.2",
"nanotiming": "^7.3.1", "nanotiming": "^7.3.1",
"node-jose": "^1.0.0",
"npm-run-all": "^4.1.3", "npm-run-all": "^4.1.3",
"nyc": "^13.0.1", "nyc": "^13.0.1",
"postcss-cssnext": "^3.1.0", "postcss-cssnext": "^3.1.0",

View File

@ -134,11 +134,6 @@ const conf = convict({
format: String, format: String,
default: 'b50ec33d3c9beb6d', // localhost default: 'b50ec33d3c9beb6d', // localhost
env: 'FXA_CLIENT_ID' env: 'FXA_CLIENT_ID'
},
fxa_client_secret: {
format: String,
default: '05ac76fbe3e739c9effbaea439bc07d265c613c5e0da9070590a2378377c09d8', // localhost
env: 'FXA_CLIENT_SECRET'
} }
}); });

46
server/fxa.js Normal file
View File

@ -0,0 +1,46 @@
const fetch = require('node-fetch');
const config = require('./config');
const KEY_SCOPE = 'https://identity.mozilla.com/apps/send';
let fxaConfig = null;
let lastConfigRefresh = 0;
async function getFxaConfig() {
if (fxaConfig && Date.now() - lastConfigRefresh < 1000 * 60 * 5) {
return fxaConfig;
}
const res = await fetch(`${config.fxa_url}/.well-known/openid-configuration`);
fxaConfig = await res.json();
lastConfigRefresh = Date.now();
return fxaConfig;
}
module.exports = {
getFxaConfig,
verify: async function(token) {
if (!token) {
return null;
}
const c = await getFxaConfig();
try {
const verifyUrl = c.jwks_uri.replace('jwks', 'verify'); //HACK
const result = await fetch(verifyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const info = await result.json();
if (
info.scope &&
Array.isArray(info.scope) &&
info.scope.includes(KEY_SCOPE)
) {
return info.user;
}
} catch (e) {
// gulp
}
return null;
}
};

View File

@ -6,9 +6,6 @@ module.exports = function(state) {
return state.cspNonce return state.cspNonce
? html` ? html`
<script nonce="${state.cspNonce}"> <script nonce="${state.cspNonce}">
const userInfo = ${
state.user.loggedIn ? raw(JSON.stringify(state.user)) : 'null'
};
const downloadMetadata = ${ const downloadMetadata = ${
state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}' state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}'
}; };

View File

@ -1,6 +1,6 @@
const crypto = require('crypto'); const crypto = require('crypto');
const storage = require('../storage'); const storage = require('../storage');
const fxa = require('../routes/fxa'); const fxa = require('../fxa');
module.exports = { module.exports = {
hmac: async function(req, res, next) { hmac: async function(req, res, next) {

View File

@ -1,96 +0,0 @@
const { URLSearchParams } = require('url');
const fetch = require('node-fetch');
const config = require('../config');
const pages = require('./pages');
const KEY_SCOPE = 'https://identity.mozilla.com/apps/send';
let fxaConfig = null;
let lastConfigRefresh = 0;
async function getFxaConfig() {
if (fxaConfig && Date.now() - lastConfigRefresh < 1000 * 60 * 5) {
return fxaConfig;
}
const res = await fetch(`${config.fxa_url}/.well-known/openid-configuration`);
fxaConfig = await res.json();
lastConfigRefresh = Date.now();
return fxaConfig;
}
module.exports = {
login: async function(req, res) {
const query = req.query;
if (!query || !query.keys_jwk) {
return res.sendStatus(400);
}
const c = await getFxaConfig();
const params = new URLSearchParams({
client_id: config.fxa_client_id,
redirect_uri: `${config.base_url}/api/fxa/oauth`,
state: 'todo',
scope: `profile ${KEY_SCOPE}`,
action: 'email',
keys_jwk: query.keys_jwk
});
res.redirect(`${c.authorization_endpoint}?${params.toString()}`);
},
oauth: async function(req, res) {
const query = req.query;
if (!query || !query.code || !query.state || !query.action) {
return res.sendStatus(400);
}
const c = await getFxaConfig();
const x = await fetch(c.token_endpoint, {
method: 'POST',
body: JSON.stringify({
code: query.code,
client_id: config.fxa_client_id,
client_secret: config.fxa_client_secret
}),
headers: {
'content-type': 'application/json'
}
});
const zzz = await x.json();
console.error(zzz);
const p = await fetch(c.userinfo_endpoint, {
method: 'GET',
headers: {
authorization: `Bearer ${zzz.access_token}`
}
});
const userInfo = await p.json();
userInfo.keys_jwe = zzz.keys_jwe;
userInfo.access_token = zzz.access_token;
req.userInfo = userInfo;
pages.index(req, res);
},
verify: async function(token) {
if (!token) {
return null;
}
const c = await getFxaConfig();
try {
const verifyUrl = c.jwks_uri.replace('jwks', 'verify');
const result = await fetch(verifyUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token })
});
const info = await result.json();
if (
info.scope &&
Array.isArray(info.scope) &&
info.scope.includes(KEY_SCOPE)
) {
return info.user;
}
} catch (e) {
// gulp
}
return null;
}
};

View File

@ -6,7 +6,6 @@ const config = require('../config');
const auth = require('../middleware/auth'); const auth = require('../middleware/auth');
const language = require('../middleware/language'); const language = require('../middleware/language');
const pages = require('./pages'); const pages = require('./pages');
const fxa = require('./fxa');
const filelist = require('./filelist'); const filelist = require('./filelist');
const IS_DEV = config.env === 'development'; const IS_DEV = config.env === 'development';
@ -34,6 +33,8 @@ module.exports = function(app) {
'wss://*.dev.lcip.org', 'wss://*.dev.lcip.org',
'wss://*.mozaws.net', 'wss://*.mozaws.net',
'wss://send.firefox.com', 'wss://send.firefox.com',
'https://*.dev.lcip.org',
'https://*.accounts.firefox.com',
'https://sentry.prod.mozaws.net', 'https://sentry.prod.mozaws.net',
'https://www.google-analytics.com' 'https://www.google-analytics.com'
], ],
@ -80,8 +81,7 @@ module.exports = function(app) {
); );
app.get(`/api/exists/:id${ID_REGEX}`, require('./exists')); app.get(`/api/exists/:id${ID_REGEX}`, require('./exists'));
app.get(`/api/metadata/:id${ID_REGEX}`, auth.hmac, require('./metadata')); app.get(`/api/metadata/:id${ID_REGEX}`, auth.hmac, require('./metadata'));
app.get('/api/fxa/login', fxa.login); app.get('/api/fxa/oauth', pages.blank);
app.get('/api/fxa/oauth', fxa.oauth);
app.get('/api/filelist', auth.fxa, filelist.get); app.get('/api/filelist', auth.fxa, filelist.get);
app.post('/api/filelist', auth.fxa, filelist.post); app.post('/api/filelist', auth.fxa, filelist.post);
app.post('/api/upload', auth.fxa, require('./upload')); app.post('/api/upload', auth.fxa, require('./upload'));

View File

@ -1,4 +1,5 @@
const config = require('../config'); const config = require('../config');
const { getFxaConfig } = require('../fxa');
let sentry = ''; let sentry = '';
if (config.sentry_id) { if (config.sentry_id) {
@ -27,33 +28,35 @@ if (config.analytics_id) {
ga = `var GOOGLE_ANALYTICS_ID = '${config.analytics_id}';`; ga = `var GOOGLE_ANALYTICS_ID = '${config.analytics_id}';`;
} }
/* eslint-disable no-useless-escape */ module.exports = async function(req, res) {
const jsconfig = ` const fxaConfig = await getFxaConfig();
var isIE = /trident\\\/7\.|msie/i.test(navigator.userAgent); fxaConfig.client_id = config.fxa_client_id;
var isUnsupportedPage = /\\\/unsupported/.test(location.pathname); /* eslint-disable no-useless-escape */
if (isIE && !isUnsupportedPage) { const jsconfig = `
window.location.replace('/unsupported/ie'); var isIE = /trident\\\/7\.|msie/i.test(navigator.userAgent);
} var isUnsupportedPage = /\\\/unsupported/.test(location.pathname);
var LIMITS = { if (isIE && !isUnsupportedPage) {
ANON: { window.location.replace('/unsupported/ie');
MAX_FILE_SIZE: ${config.anon_max_file_size}, }
MAX_DOWNLOADS: ${config.anon_max_downloads}, var LIMITS = {
MAX_EXPIRE_SECONDS: ${config.anon_max_expire_seconds}, ANON: {
}, MAX_FILE_SIZE: ${config.anon_max_file_size},
MAX_FILE_SIZE: ${config.max_file_size}, MAX_DOWNLOADS: ${config.anon_max_downloads},
MAX_DOWNLOADS: ${config.max_downloads}, MAX_EXPIRE_SECONDS: ${config.anon_max_expire_seconds},
MAX_EXPIRE_SECONDS: ${config.max_expire_seconds}, },
MAX_FILES_PER_ARCHIVE: ${config.max_files_per_archive}, MAX_FILE_SIZE: ${config.max_file_size},
MAX_ARCHIVES_PER_USER: ${config.max_archives_per_user} MAX_DOWNLOADS: ${config.max_downloads},
}; MAX_EXPIRE_SECONDS: ${config.max_expire_seconds},
var DEFAULTS = { MAX_FILES_PER_ARCHIVE: ${config.max_files_per_archive},
EXPIRE_SECONDS: ${config.default_expire_seconds} MAX_ARCHIVES_PER_USER: ${config.max_archives_per_user}
}; };
${ga} var DEFAULTS = {
${sentry} EXPIRE_SECONDS: ${config.default_expire_seconds}
`; };
var AUTH_CONFIG = ${JSON.stringify(fxaConfig)};
module.exports = function(req, res) { ${ga}
${sentry}
`;
res.set('Content-Type', 'application/javascript'); res.set('Content-Type', 'application/javascript');
res.send(jsconfig); res.send(jsconfig);
}; };

View File

@ -5,7 +5,7 @@ const mozlog = require('../log');
const Limiter = require('../limiter'); const Limiter = require('../limiter');
const Parser = require('../streamparser'); const Parser = require('../streamparser');
const wsStream = require('websocket-stream/stream'); const wsStream = require('websocket-stream/stream');
const fxa = require('./fxa'); const fxa = require('../fxa');
const log = mozlog('send.upload'); const log = mozlog('send.upload');

View File

@ -5,8 +5,6 @@ const assets = require('../common/assets');
module.exports = function(req) { module.exports = function(req) {
const locale = req.language || 'en-US'; const locale = req.language || 'en-US';
const userInfo = req.userInfo || { avatar: assets.get('user.svg') };
userInfo.loggedIn = !!userInfo.access_token;
return { return {
locale, locale,
translate: locales.getTranslator(locale), translate: locales.getTranslator(locale),
@ -21,7 +19,7 @@ module.exports = function(req) {
fira: false, fira: false,
fileInfo: {}, fileInfo: {},
cspNonce: req.cspNonce, cspNonce: req.cspNonce,
user: userInfo, user: { avatar: assets.get('user.svg'), loggedIn: false },
layout layout
}; };
}; };

View File

@ -0,0 +1,45 @@
import assert from 'assert';
import storage from '../../../app/storage';
import { decryptBundle, prepareScopedBundleKey } from '../../../app/fxa';
import { b64ToArray } from '../../../app/utils';
const decoder = new TextDecoder();
describe('user auth', function() {
it('prepares ECDH keys for PKCE auth', async function() {
const empty = storage.get('scopedBundlePrivateKey');
assert.equal(empty, undefined);
const publicKeyB64 = await prepareScopedBundleKey(storage);
const publicKey = JSON.parse(decoder.decode(b64ToArray(publicKeyB64)));
assert(!publicKey.d, 'not a public key');
assert(publicKey.x);
assert(publicKey.y);
assert.equal(publicKey.kty, 'EC');
assert.equal(publicKey.crv, 'P-256');
const privateKey = JSON.parse(storage.get('scopedBundlePrivateKey'));
storage.remove('scopedBundlePrivateKey');
assert.equal(privateKey.kty, 'EC');
assert.equal(privateKey.crv, 'P-256');
assert(privateKey.d, 'not a private key');
});
it('decrypts the PKCE auth bundle', async function() {
storage.set(
'scopedBundlePrivateKey',
'{"kty":"EC","kid":"cV9_thVX9XRa-R2nVZF9rFdwrcR_eST4UZuUCx03ebI","crv":"P-256","x":"-0OOb6SPdYBz0CkQLWRu8ojDUhRe-VoKnwLEBi97KAk","y":"U3fXgj1LV7KhiO5O60niMjPpDqToh15-R6C22NnmNXY","d":"KfIQCxZrqSI6j69rAC6fEiGIYKwYv2buQG9NTcKOiGc"}'
);
const jwks = await decryptBundle(
storage,
'eyJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiRUNESC1FUyIsImtpZCI6ImNWOV90aFZYOVhSYS1SMm5WWkY5ckZkd3JjUl9lU1Q0VVp1VUN4MDNlYkkiLCJlcGsiOnsia3R5IjoiRUMiLCJjcnYiOiJQLTI1NiIsIngiOiJqckcwajNFODNodDZJcDE1YmtuZWRUV3kwZmR1WnR0V3NtMkFybUNoQU5rIiwieSI6Ijl3SmNQUDRrQmQ5amtCbEJJcWRhclQ2NjVIQU00SndUX0FSSFc0aTN4QUUifX0..Dkf-FXtakCiPuXjW.-KfVQEntYjUe3f5OxslSQwjLFauc50RurLQHDV75sUixNTlsjTIldCZVb6WUKpQkpOdFHOUYFX9_Cvk2ENKdfcVm2eTuyomlKklHF3q5209KwJz8lDK3gOQuAlz79eDou0k_Z3JNGu-qZ8IiDhZZ9iNSgBrsq0BZwVXZ9ViSFEW-YzJBQlKmildscXhp_-Lf6-qiJJrPbZCXFD3PZmzcule3kyBOarg_fjjHLFlIpdjP1lI5wBETqdjk7iBKeO2isSQO7-8.q5EzqP6OPg9yb5BcJH2oFg'
);
assert.deepEqual(jwks, {
'https://identity.mozilla.com/apps/send': {
kty: 'oct',
scope: 'https://identity.mozilla.com/apps/send',
k: '5_jrbS76RzJ4EwlKSl527vqz3BDqf5DM4sNsoEK_hoA',
kid: '1414456160-n6yE-eL-ADvnsJo_huq3DA'
}
});
});
});