implemented PKCE auth (#921)
* implemented PKCE auth * removed node-jose * added PKCE tests
This commit is contained in:
parent
20528eb0d1
commit
7ccf462bf8
@ -8,7 +8,7 @@ export default function initialState(state, emitter) {
|
||||
|
||||
Object.assign(state, {
|
||||
prefix: '/android_asset',
|
||||
user: new User(undefined, storage),
|
||||
user: new User(storage),
|
||||
getAsset(name) {
|
||||
return `${state.prefix}/${name}`;
|
||||
},
|
||||
|
@ -5,7 +5,6 @@ import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
|
||||
import * as metrics from './metrics';
|
||||
import Archive from './archive';
|
||||
import { bytes } from './utils';
|
||||
import { prepareWrapKey } from './fxa';
|
||||
|
||||
export default function(state, emitter) {
|
||||
let lastRender = 0;
|
||||
@ -45,9 +44,8 @@ export default function(state, emitter) {
|
||||
lastRender = Date.now();
|
||||
});
|
||||
|
||||
emitter.on('login', async () => {
|
||||
const k = await prepareWrapKey(state.storage);
|
||||
location.assign(`/api/fxa/login?keys_jwk=${k}`);
|
||||
emitter.on('login', () => {
|
||||
state.user.login();
|
||||
});
|
||||
|
||||
emitter.on('logout', () => {
|
||||
|
154
app/fxa.js
154
app/fxa.js
@ -1,21 +1,153 @@
|
||||
import jose from 'node-jose';
|
||||
import { arrayToB64, b64ToArray } from './utils';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export async function prepareWrapKey(storage) {
|
||||
const keystore = jose.JWK.createKeyStore();
|
||||
const keypair = await keystore.generate('EC', 'P-256');
|
||||
storage.set('fxaWrapKey', JSON.stringify(keystore.toJSON(true)));
|
||||
return jose.util.base64url.encode(JSON.stringify(keypair.toJSON()));
|
||||
function getOtherInfo(enc) {
|
||||
const name = encoder.encode(enc);
|
||||
const length = 256;
|
||||
const buffer = new ArrayBuffer(name.length + 16);
|
||||
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) {
|
||||
const keystore = await jose.JWK.asKeyStore(
|
||||
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 jwks = await decryptBundle(storage, bundle);
|
||||
const jwk = jwks['https://identity.mozilla.com/apps/send'];
|
||||
const baseKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
|
@ -1,4 +1,3 @@
|
||||
/* global userInfo */
|
||||
import 'fast-text-encoding'; // MS Edge support
|
||||
import 'fluent-intl-polyfill';
|
||||
import app from './routes';
|
||||
@ -13,7 +12,6 @@ import experiments from './experiments';
|
||||
import Raven from 'raven-js';
|
||||
import './main.css';
|
||||
import User from './user';
|
||||
import { getFileListKey } from './fxa';
|
||||
|
||||
(async function start() {
|
||||
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
|
||||
@ -23,9 +21,7 @@ import { getFileListKey } from './fxa';
|
||||
if (capa.streamDownload) {
|
||||
navigator.serviceWorker.register('/serviceWorker.js');
|
||||
}
|
||||
if (userInfo && userInfo.keys_jwe) {
|
||||
userInfo.fileListKey = await getFileListKey(storage, userInfo.keys_jwe);
|
||||
}
|
||||
|
||||
app.use((state, emitter) => {
|
||||
state.capabilities = capa;
|
||||
state.transfer = null;
|
||||
@ -33,7 +29,7 @@ import { getFileListKey } from './fxa';
|
||||
state.translate = locale.getTranslator();
|
||||
state.storage = storage;
|
||||
state.raven = Raven;
|
||||
state.user = new User(userInfo, storage);
|
||||
state.user = new User(storage);
|
||||
window.appState = state;
|
||||
let unsupportedReason = null;
|
||||
if (
|
||||
|
@ -68,9 +68,14 @@ app.route('/legal', body(require('../pages/legal')));
|
||||
app.route('/error', body(require('../pages/error')));
|
||||
app.route('/blank', body(require('../pages/blank')));
|
||||
app.route('/signin', body(require('../pages/signin')));
|
||||
app.route('/api/fxa/oauth', function(state, emit) {
|
||||
app.route('/api/fxa/oauth', async function(state, emit) {
|
||||
try {
|
||||
await state.user.finishLogin(state.query.code);
|
||||
emit('replaceState', '/');
|
||||
} catch (e) {
|
||||
emit('replaceState', '/error');
|
||||
setTimeout(() => emit('render'));
|
||||
}
|
||||
});
|
||||
app.route('*', body(require('../pages/notFound')));
|
||||
|
||||
|
55
app/user.js
55
app/user.js
@ -1,20 +1,18 @@
|
||||
/* global LIMITS */
|
||||
/* global LIMITS AUTH_CONFIG */
|
||||
import assets from '../common/assets';
|
||||
import { getFileList, setFileList } from './api';
|
||||
import { encryptStream, decryptStream } from './ece';
|
||||
import { b64ToArray, streamToArrayBuffer } from './utils';
|
||||
import { blobStream } from './streams';
|
||||
import { getFileListKey, prepareScopedBundleKey, preparePkce } from './fxa';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export default class User {
|
||||
constructor(info, storage) {
|
||||
if (info && storage) {
|
||||
storage.user = info;
|
||||
}
|
||||
constructor(storage) {
|
||||
this.storage = storage;
|
||||
this.data = info || storage.user || {};
|
||||
this.data = storage.user || {};
|
||||
}
|
||||
|
||||
get avatar() {
|
||||
@ -55,7 +53,50 @@ export default class User {
|
||||
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() {
|
||||
this.storage.user = null;
|
||||
|
91
package-lock.json
generated
91
package-lock.json
generated
@ -3921,12 +3921,6 @@
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.0.tgz",
|
||||
@ -9587,24 +9581,12 @@
|
||||
"integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=",
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=",
|
||||
"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": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
@ -9616,60 +9598,18 @@
|
||||
"integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=",
|
||||
"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": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
|
||||
"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": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
|
||||
"integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=",
|
||||
"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": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.4.0.tgz",
|
||||
@ -10560,37 +10500,6 @@
|
||||
"integrity": "sha512-MmbQJ2MTESTjt3Gi/3yG1wGpIMhUfcIypUCGtTizFR9IiccFwxSpfp0vtIZlkFclEqERemxfnSdZEMR9VqqEFQ==",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.1.0.tgz",
|
||||
|
@ -89,7 +89,6 @@
|
||||
"morgan": "^1.9.0",
|
||||
"nanobus": "^4.3.2",
|
||||
"nanotiming": "^7.3.1",
|
||||
"node-jose": "^1.0.0",
|
||||
"npm-run-all": "^4.1.3",
|
||||
"nyc": "^13.0.1",
|
||||
"postcss-cssnext": "^3.1.0",
|
||||
|
@ -134,11 +134,6 @@ const conf = convict({
|
||||
format: String,
|
||||
default: 'b50ec33d3c9beb6d', // localhost
|
||||
env: 'FXA_CLIENT_ID'
|
||||
},
|
||||
fxa_client_secret: {
|
||||
format: String,
|
||||
default: '05ac76fbe3e739c9effbaea439bc07d265c613c5e0da9070590a2378377c09d8', // localhost
|
||||
env: 'FXA_CLIENT_SECRET'
|
||||
}
|
||||
});
|
||||
|
||||
|
46
server/fxa.js
Normal file
46
server/fxa.js
Normal 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;
|
||||
}
|
||||
};
|
@ -6,9 +6,6 @@ module.exports = function(state) {
|
||||
return state.cspNonce
|
||||
? html`
|
||||
<script nonce="${state.cspNonce}">
|
||||
const userInfo = ${
|
||||
state.user.loggedIn ? raw(JSON.stringify(state.user)) : 'null'
|
||||
};
|
||||
const downloadMetadata = ${
|
||||
state.downloadMetadata ? raw(JSON.stringify(state.downloadMetadata)) : '{}'
|
||||
};
|
||||
|
@ -1,6 +1,6 @@
|
||||
const crypto = require('crypto');
|
||||
const storage = require('../storage');
|
||||
const fxa = require('../routes/fxa');
|
||||
const fxa = require('../fxa');
|
||||
|
||||
module.exports = {
|
||||
hmac: async function(req, res, next) {
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
@ -6,7 +6,6 @@ const config = require('../config');
|
||||
const auth = require('../middleware/auth');
|
||||
const language = require('../middleware/language');
|
||||
const pages = require('./pages');
|
||||
const fxa = require('./fxa');
|
||||
const filelist = require('./filelist');
|
||||
|
||||
const IS_DEV = config.env === 'development';
|
||||
@ -34,6 +33,8 @@ module.exports = function(app) {
|
||||
'wss://*.dev.lcip.org',
|
||||
'wss://*.mozaws.net',
|
||||
'wss://send.firefox.com',
|
||||
'https://*.dev.lcip.org',
|
||||
'https://*.accounts.firefox.com',
|
||||
'https://sentry.prod.mozaws.net',
|
||||
'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/metadata/:id${ID_REGEX}`, auth.hmac, require('./metadata'));
|
||||
app.get('/api/fxa/login', fxa.login);
|
||||
app.get('/api/fxa/oauth', fxa.oauth);
|
||||
app.get('/api/fxa/oauth', pages.blank);
|
||||
app.get('/api/filelist', auth.fxa, filelist.get);
|
||||
app.post('/api/filelist', auth.fxa, filelist.post);
|
||||
app.post('/api/upload', auth.fxa, require('./upload'));
|
||||
|
@ -1,4 +1,5 @@
|
||||
const config = require('../config');
|
||||
const { getFxaConfig } = require('../fxa');
|
||||
|
||||
let sentry = '';
|
||||
if (config.sentry_id) {
|
||||
@ -27,6 +28,9 @@ if (config.analytics_id) {
|
||||
ga = `var GOOGLE_ANALYTICS_ID = '${config.analytics_id}';`;
|
||||
}
|
||||
|
||||
module.exports = async function(req, res) {
|
||||
const fxaConfig = await getFxaConfig();
|
||||
fxaConfig.client_id = config.fxa_client_id;
|
||||
/* eslint-disable no-useless-escape */
|
||||
const jsconfig = `
|
||||
var isIE = /trident\\\/7\.|msie/i.test(navigator.userAgent);
|
||||
@ -49,11 +53,10 @@ var LIMITS = {
|
||||
var DEFAULTS = {
|
||||
EXPIRE_SECONDS: ${config.default_expire_seconds}
|
||||
};
|
||||
var AUTH_CONFIG = ${JSON.stringify(fxaConfig)};
|
||||
${ga}
|
||||
${sentry}
|
||||
`;
|
||||
|
||||
module.exports = function(req, res) {
|
||||
res.set('Content-Type', 'application/javascript');
|
||||
res.send(jsconfig);
|
||||
};
|
||||
|
@ -5,7 +5,7 @@ const mozlog = require('../log');
|
||||
const Limiter = require('../limiter');
|
||||
const Parser = require('../streamparser');
|
||||
const wsStream = require('websocket-stream/stream');
|
||||
const fxa = require('./fxa');
|
||||
const fxa = require('../fxa');
|
||||
|
||||
const log = mozlog('send.upload');
|
||||
|
||||
|
@ -5,8 +5,6 @@ const assets = require('../common/assets');
|
||||
|
||||
module.exports = function(req) {
|
||||
const locale = req.language || 'en-US';
|
||||
const userInfo = req.userInfo || { avatar: assets.get('user.svg') };
|
||||
userInfo.loggedIn = !!userInfo.access_token;
|
||||
return {
|
||||
locale,
|
||||
translate: locales.getTranslator(locale),
|
||||
@ -21,7 +19,7 @@ module.exports = function(req) {
|
||||
fira: false,
|
||||
fileInfo: {},
|
||||
cspNonce: req.cspNonce,
|
||||
user: userInfo,
|
||||
user: { avatar: assets.get('user.svg'), loggedIn: false },
|
||||
layout
|
||||
};
|
||||
};
|
||||
|
45
test/frontend/tests/auth-tests.js
Normal file
45
test/frontend/tests/auth-tests.js
Normal 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'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user