├── .firebaserc.dist ├── src ├── images │ └── favicon.png ├── index.js ├── database │ ├── update-vault.js │ ├── remove-vault.js │ ├── sync-vault.js │ ├── extract-records-from-firestore-snapshot.js │ ├── get-vaults.js │ ├── add-vault.js │ ├── encrypt-vault.js │ └── decrypt-vault.js ├── utilities │ └── debounce-async.js ├── components │ ├── vaults.js │ ├── app.js │ ├── login.js │ ├── logged-in.js │ └── vault.js ├── index.html └── index.css ├── .babelrc ├── functions ├── environments │ ├── environment.json │ └── environment.test.json ├── jest.config.js ├── utilities │ ├── prod-context.js │ ├── test-context.js │ ├── decrypt-encrypted-with-password.js │ └── encrypt-secret-with-password.js └── src │ ├── decrypt.spec.js │ ├── encrypt.spec.js │ ├── decrypt.js │ └── encrypt.js ├── .vscode └── launch.json ├── LICENSE ├── package.json ├── .gitignore └── README.md /.firebaserc.dist: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "how-to-firebase-secrets" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/how-to-firebase/secrets/HEAD/src/images/favicon.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": ["@babel/transform-runtime"] 4 | } 5 | -------------------------------------------------------------------------------- /functions/environments/environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "firebase": { 3 | "databaseURL": "https://how-to-firebase-secrets.firebaseio.com" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /functions/environments/environment.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "firebase": { 3 | "databaseURL": "https://how-to-firebase-secrets.firebaseio.com" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/app'; 4 | 5 | const rootEl = document.getElementById('root'); 6 | 7 | ReactDOM.render(, rootEl); 8 | -------------------------------------------------------------------------------- /src/database/update-vault.js: -------------------------------------------------------------------------------- 1 | export default (firebase, uid, vaultId, updates) => { 2 | /** 3 | * Task 8: Update the vault 4 | * Subject: Firestore 5 | * Docs: https://firebase.google.com/docs/firestore/manage-data/add-data#update-data 6 | * 7 | * 1. Call `.update(updates)` on the user's `vault` doc. 8 | * 9 | * The `.update(updates)` function 10 | */ 11 | 12 | // TODO: Implement Task 13 | }; 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Secrets CF Tests", 11 | "program": "${workspaceFolder}\\functions\\node_modules\\jest\\bin\\jest.js", 12 | "cwd": "${workspaceFolder}\\functions" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/database/remove-vault.js: -------------------------------------------------------------------------------- 1 | export default async (firebase, { uid, vaultId }) => { 2 | /** 3 | * Task 17: Delete a Firestore record 4 | * Subject: Firestore 5 | * Docs: https://firebase.google.com/docs/firestore/manage-data/delete-data#delete_documents 6 | * 7 | * 1. Delete the vault doc 8 | * Hint: /user-owned/{uid}/vaults/{vaultId} 9 | */ 10 | 11 | return firebase 12 | .firestore() 13 | .collection('user-owned') 14 | .doc(uid) 15 | .collection('vaults') 16 | .doc(vaultId) 17 | .delete(); 18 | }; 19 | -------------------------------------------------------------------------------- /src/database/sync-vault.js: -------------------------------------------------------------------------------- 1 | export default (firebase, uid, vaultId, callback) => { 2 | /** 3 | * Task 7: Listen to vault changes 4 | * Subject: Firestore 5 | * Docs: https://firebase.google.com/docs/firestore/query-data/listen 6 | * 7 | * 1. Call `.onSnapshot()` on the user's `vault` doc. 8 | * 2. Call the callback function with the record. 9 | * 3. Return the `unsubscribe` function 10 | * 11 | * The `.onSnapshot` function takes a callback only. It returns an unsubscribe function. 12 | * 13 | */ 14 | 15 | // TODO: Implement Task 16 | }; 17 | -------------------------------------------------------------------------------- /functions/jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * `roots` tells Jest where to look for tests 3 | * `transformIgnorePatterns` tells Jest to not attempt Babel transforms on our files 4 | * 5 | * Jest is built for front-end React tests, so it assumes that every test needs to 6 | * be compiled with Babel. Babel throws all sorts of errors when testing Node.js code, 7 | * so we're telling Jest to not attempt to run Babel transforms on our files. 8 | * 9 | * You'll need to add folder 10 | */ 11 | 12 | module.exports = { 13 | roots: ['src', 'utilities'], 14 | transformIgnorePatterns: ['/'], 15 | }; 16 | -------------------------------------------------------------------------------- /functions/utilities/prod-context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Initializes a Firebase admin app without arguments 3 | * `admin.initializeApp()` can be called without arguments in the production runtime 4 | * Sets Firestore settings on the admin app 5 | * Exports the context, consisting of the admin app and the environment 6 | */ 7 | const admin = require('firebase-admin'); 8 | 9 | const environment = require('../environments/environment.json'); 10 | 11 | admin.initializeApp(); 12 | 13 | admin.firestore().settings({ timestampsInSnapshots: true }); 14 | 15 | module.exports = { admin, environment }; 16 | -------------------------------------------------------------------------------- /src/database/extract-records-from-firestore-snapshot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Firestore collection snapshots have a `.docs` field that contains all of their docs. 3 | * Firestore docs have a `doc.id` property and store the rest of their data in `doc.data()`. 4 | * It's very useful to extract these docs into an easier-to-use structure for use in React. 5 | * 6 | * This app uses a convention where the `doc.id` value is assigned to `{ __id: doc.id }` 7 | * for each extracted record. It makes looping in React much easier. 8 | */ 9 | export default snapshot => snapshot.docs.map(doc => ({ __id: doc.id, ...doc.data() })); 10 | -------------------------------------------------------------------------------- /src/utilities/debounce-async.js: -------------------------------------------------------------------------------- 1 | export default (fn, millis = 0) => { 2 | const resolves = []; 3 | let timer; 4 | 5 | return async (...args) => { 6 | clearTimeout(timer); 7 | 8 | return new Promise(resolve => { 9 | resolves.push(resolve); 10 | 11 | timer = setTimeout(async () => { 12 | const result = await fn.apply(this, args); 13 | let i = resolves.length; 14 | 15 | while (i--) { 16 | const resolve = resolves.pop(); 17 | 18 | i == 0 ? resolve(result || true) : resolve(); 19 | } 20 | }, millis); 21 | }); 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /functions/utilities/test-context.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Pulls in a service account and an environment file 3 | * Initializes a Firebase admin app 4 | * Sets Firestore settings on the admin app 5 | * Exports the context, consisting of the admin app and the environment 6 | */ 7 | const admin = require('firebase-admin'); 8 | 9 | const serviceAccount = require('../service-account.json'); 10 | 11 | const environment = require('../environments/environment.test.json'); 12 | 13 | admin.initializeApp({ 14 | credential: admin.credential.cert(serviceAccount), 15 | databaseURL: environment.firebase.databaseURL, 16 | }); 17 | 18 | admin.firestore().settings({ timestampsInSnapshots: true }); 19 | 20 | module.exports = { admin, environment }; 21 | -------------------------------------------------------------------------------- /src/database/get-vaults.js: -------------------------------------------------------------------------------- 1 | import extractRecordsFromFirestoreSnapshot from './extract-records-from-firestore-snapshot'; 2 | 3 | export default async (firebase, uid) => { 4 | /** 5 | * Task 6: Query your vaults 6 | * Subject: Firestore 7 | * Docs: https://firebase.google.com/docs/firestore/query-data/get-data 8 | * 9 | * 1. Call `.get()` on the user's `vaults` collection. 10 | * 2. Don't forget to await the result of `.get()` 11 | * 3. Pass the snapshot through `extractRecordsFromFirestoreSnapshot(snapshot)` 12 | * 4. Return the extracted records 13 | * 5. Review `src/database/extract-records-from-firestore-snapshot.js` 14 | * 15 | * The easiest error to make here is to forget the `await` keyword. 16 | * You can use a promise-based pattern here if you don't like async/await 17 | */ 18 | 19 | // TODO: Implement Task 20 | }; 21 | -------------------------------------------------------------------------------- /src/database/add-vault.js: -------------------------------------------------------------------------------- 1 | export default async (firebase, uid) => { 2 | /** 3 | * Task 4: Add a Firestore record 4 | * Subject: Firestore 5 | * Docs: https://firebase.google.com/docs/firestore/manage-data/add-data 6 | * 7 | * 1. Enable Firestore in your Console at Database > Create Database. Start in "locked mode" 8 | * 2. Add the vault to /user-owned/{uid}/vaults/. 9 | * 3. Click your the "+" fab button to test. 10 | * YOU WILL GET AN ERROR IN YOUR CONSOLE. THIS IS EXPECTED. 11 | * 12 | * Firestore follows a collection/doc/collection/doc pattern. 13 | * You can only call .add on a collection, which will create a new doc. 14 | * We're expecting a `Missing or insufficient permissions` error. 15 | */ 16 | const vault = { name: `New Vault: ${new Date().toISOString()}`, created: Date.now() }; 17 | 18 | // TODO: Implement Task 19 | }; 20 | -------------------------------------------------------------------------------- /src/database/encrypt-vault.js: -------------------------------------------------------------------------------- 1 | export default (firebase, { password, secret, vaultId }) => { 2 | /** 3 | * Task 9: Call the `encrypt` Cloud Function 4 | * Subject: Cloud Functions 5 | * Docs: https://firebase.google.com/docs/functions/callable#call_the_function 6 | * 7 | * 1. Use `.httpsCallable` to get a callable function named `encrypt`. 8 | * 2. Call the 'encrypt' function with `payload` 9 | * 3. Make sure to `await` the `encrypt` function 10 | * 4. Wrap the `encrypt` function call in a try/catch for error handling. 11 | * 5. Log any caught errors with console.log 12 | * 13 | * We haven't written the Cloud Function yet, so this will throw a bunch of errors. 14 | * We'll write the Cloud Function next. 15 | */ 16 | const payload = { 17 | password, 18 | secret, 19 | vaultId, 20 | }; 21 | 22 | // TODO: Implement Task 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/vaults.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List, SimpleListItem } from '@rmwc/list'; 3 | 4 | import '@material/list/dist/mdc.list.css'; 5 | 6 | export default ({ vaults, onVaultSelected }) => { 7 | return ( 8 |
9 | {!vaults.length ? ( 10 |
11 |

Click to add Vaults ☝

12 |
13 | ) : ( 14 | 15 | {vaults.map(vault => ( 16 | 17 | ))} 18 | 19 | )} 20 |
21 | ); 22 | }; 23 | 24 | function VaultListItem({ vault, onClick }) { 25 | return ( 26 | onClick(vault.__id)} 30 | /> 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /functions/utilities/decrypt-encrypted-with-password.js: -------------------------------------------------------------------------------- 1 | /** 2 | * See https://lollyrock.com/articles/nodejs-encryption/ 3 | */ 4 | const { promisify } = require('util'); 5 | const crypto = require('crypto'); 6 | const algorithm = 'aes-256-gcm'; 7 | 8 | module.exports = async ({ encrypted, iv, password, tag }) => { 9 | const ivBuffer = new Buffer(iv, 'hex'); 10 | const tagBuffer = new Buffer(tag, 'hex'); 11 | const key = crypto 12 | .createHash('md5') 13 | .update(password) 14 | .digest('hex'); 15 | let decipher; 16 | let decrypted; 17 | 18 | try { 19 | decipher = crypto.createDecipheriv(algorithm, key, ivBuffer); 20 | decipher.setAuthTag(tagBuffer); 21 | decrypted = decipher.update(encrypted, 'hex', 'utf8'); 22 | decrypted += decipher.final('utf8'); 23 | } catch (error) { 24 | console.error('error', error); 25 | } 26 | 27 | return decrypted; 28 | }; 29 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Secrets 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | #login { 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | 10 | height: 100vh; 11 | } 12 | 13 | #logged-in .content { 14 | padding-top: 5rem; 15 | min-height: calc(100vh - 5rem); 16 | } 17 | 18 | #logged-in .fab { 19 | position: fixed; 20 | right: 4rem; 21 | top: 2rem; 22 | z-index: 4; 23 | } 24 | 25 | #vaults .empty-state { 26 | display: flex; 27 | justify-content: center; 28 | align-items: center; 29 | 30 | padding-top: 25vh; 31 | } 32 | 33 | #vaults .mdc-list { 34 | margin: 0 1rem; 35 | } 36 | 37 | #vault { 38 | padding: 1rem 2rem; 39 | } 40 | 41 | form, 42 | form.form-row { 43 | display: flex; 44 | flex-direction: column; 45 | justify-content: start; 46 | align-items: center; 47 | 48 | overflow: hidden; 49 | } 50 | 51 | form > *, 52 | form .form-row > * { 53 | margin: 1rem 0; 54 | width: 100% !important; 55 | } 56 | -------------------------------------------------------------------------------- /functions/utilities/encrypt-secret-with-password.js: -------------------------------------------------------------------------------- 1 | /** 2 | * See https://lollyrock.com/articles/nodejs-encryption/ 3 | */ 4 | const { promisify } = require('util'); 5 | const crypto = require('crypto'); 6 | const pseudoRandomBytes = promisify(crypto.pseudoRandomBytes); 7 | const algorithm = 'aes-256-gcm'; 8 | 9 | module.exports = async ({ password, secret }) => { 10 | const ivBuffer = await pseudoRandomBytes(16); 11 | const key = crypto 12 | .createHash('md5') 13 | .update(password) 14 | .digest('hex'); 15 | let cipher; 16 | let encrypted; 17 | let tagBuffer; 18 | 19 | try { 20 | cipher = crypto.createCipheriv(algorithm, key, ivBuffer); 21 | encrypted = cipher.update(secret, 'utf8', 'hex'); 22 | encrypted += cipher.final('hex'); 23 | tagBuffer = cipher.getAuthTag(); 24 | } catch (error) { 25 | console.error('error', error); 26 | } 27 | 28 | return { encrypted, iv: ivBuffer.toString('hex'), tag: tagBuffer.toString('hex') }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/database/decrypt-vault.js: -------------------------------------------------------------------------------- 1 | export default (firebase, { password, vaultId }) => { 2 | /** 3 | * Task 14: Call the `decrypt` Cloud Function 4 | * Subject: Cloud Functions 5 | * Docs: https://firebase.google.com/docs/functions/callable#call_the_function 6 | * 7 | * 1. Use `.httpsCallable` to get a callable function named `decrypt`. 8 | * 2. Call the 'decrypt' function with `payload` 9 | * 3. Make sure to `await` the `decrypt` function 10 | * 4. Wrap the `decrypt` function call in a try/catch for error handling. 11 | * 5. Log any caught errors with console.log 12 | * 13 | * We haven't written the Cloud Function yet, so this will throw a bunch of errors. 14 | * We'll write the Cloud Function next. 15 | */ 16 | const payload = { 17 | password, 18 | vaultId, 19 | }; 20 | const decrypt = firebase.functions().httpsCallable('decrypt'); 21 | 22 | try { 23 | return decrypt(payload); 24 | } catch (error) { 25 | console.error(error); 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Login from './login'; 3 | import LoggedIn from './logged-in'; 4 | 5 | export default class App extends React.Component { 6 | constructor() { 7 | super(); 8 | 9 | this.state = { 10 | currentUser: null, 11 | }; 12 | } 13 | 14 | get firebase() { 15 | return window.firebase; 16 | } 17 | 18 | componentDidMount() { 19 | this.syncCurrentUser(); 20 | } 21 | 22 | syncCurrentUser() { 23 | /** 24 | * Task 2: Implement onAuthStateChange listener 25 | * Subject: Authentication 26 | * Docs: https://firebase.google.com/docs/auth/web/start 27 | * 28 | * 1. Use `onAuthStateChanged` to call this.setState({ currentUser }) 29 | * 2. You'll know you've logged in when you see the "Log Out" 30 | */ 31 | 32 | // TODO: Implement Task 33 | } 34 | 35 | render() { 36 | const { currentUser } = this.state; 37 | 38 | return currentUser ? : ; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 How To Firebase 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "secrets", 3 | "version": "1.0.0", 4 | "description": "We're going to workshop our way through to a functional web application built on the Firebase platform.", 5 | "main": "src/index.js", 6 | "dependencies": {}, 7 | "devDependencies": { 8 | "@babel/core": "^7.1.2", 9 | "@babel/plugin-transform-runtime": "^7.1.0", 10 | "@babel/preset-react": "^7.0.0", 11 | "firebase-tools": "^6.0.0", 12 | "parcel-bundler": "^1.10.3", 13 | "parcel-plugin-clean-dist": "0.0.6", 14 | "react": "^16.6.0", 15 | "react-dom": "^16.6.0", 16 | "rmwc": "^3.0.9" 17 | }, 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1", 20 | "start": "npm run-script serve", 21 | "serve": "parcel src/index.html", 22 | "build": "parcel build src/index.html --out-dir public" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/how-to-firebase/secrets.git" 27 | }, 28 | "author": "Chris Esplin ", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/how-to-firebase/secrets/issues" 32 | }, 33 | "homepage": "https://github.com/how-to-firebase/secrets#readme" 34 | } 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Firebase 64 | .firebase 65 | .firebaserc 66 | public 67 | service-account.json 68 | 69 | # Parcel 70 | .cache 71 | dist -------------------------------------------------------------------------------- /src/components/login.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '@rmwc/Button'; 3 | 4 | import '@material/button/dist/mdc.button.css'; 5 | 6 | export default class Login extends React.Component { 7 | get firebase() { 8 | return window.firebase; 9 | } 10 | 11 | signIn() { 12 | /** 13 | * Task 1: Implement signInWithPopup 14 | * Subject: Authentication 15 | * Docs: https://firebase.google.com/docs/auth/web/google-signin 16 | * Scopes: https://developers.google.com/identity/protocols/googlescopes#google_sign-in 17 | * 18 | * 1. Create a new GoogleAuthProvider 19 | * 2. Add the 'email' scope to the provider 20 | * 3. Call signInWithPopup using the new provider 21 | * 4. Attempt to sign in with the button and you'll likely get an 22 | * `auth/operation-not-allowed` error. 23 | * 5. Visit your Firebase Console an navigate to Authentication > Sign-in Method. 24 | * 6. Enable the Google sign-in provider. 25 | * 7. Attempt another login 26 | * 8. Call `firebase.auth().currentUser` in DevToolsto see your currentUser object 27 | */ 28 | 29 | // TODO: Implement Task 30 | } 31 | 32 | render() { 33 | return ( 34 |
35 | 38 |
39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /functions/src/decrypt.spec.js: -------------------------------------------------------------------------------- 1 | const testContext = require('../utilities/test-context'); 2 | const Decrypt = require('./decrypt'); 3 | const uuidv4 = require('uuid/v4'); 4 | 5 | describe('#decrypt', () => { 6 | describe('setup', () => { 7 | it('should start with a valid testContext ', () => { 8 | expect(Object.keys(testContext)).toEqual(['admin', 'environment']); 9 | }); 10 | }); 11 | 12 | describe('function call', () => { 13 | const encrypted = '6745818dbec53e324928ffffc55d3b6d5ba76ee31ae321be08'; 14 | const tag = '5702fe4235553668640571f96e2842dd'; 15 | const iv = '75316872421c63c5c9a1f7db4d5887f3'; 16 | const password = 'password123'; 17 | const vaultId = 'test-vault'; 18 | const secret = 'a super secret text block'; 19 | const uid = `test-uuid-${uuidv4()}`; 20 | 21 | const { admin } = testContext; 22 | const vaultDocRef = admin 23 | .firestore() 24 | .collection('user-owned') 25 | .doc(uid) 26 | .collection('vaults') 27 | .doc(vaultId); 28 | 29 | let result; 30 | 31 | beforeAll(async () => { 32 | const decrypt = Decrypt(testContext); 33 | 34 | await vaultDocRef.set({ encrypted, tag, iv }); 35 | 36 | result = await decrypt({ password, vaultId }, { auth: { uid } }); 37 | }); 38 | 39 | afterAll(async () => { 40 | await vaultDocRef.remove(); 41 | }); 42 | 43 | it('should have the decrypted attribute', () => { 44 | expect(result.decrypted).toEqual(secret); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /functions/src/encrypt.spec.js: -------------------------------------------------------------------------------- 1 | const testContext = require('../utilities/test-context'); 2 | const Encrypt = require('./encrypt'); 3 | const uuidv4 = require('uuid/v4'); 4 | 5 | describe('#encrypt', () => { 6 | describe('setup', () => { 7 | it('should start with a valid testContext ', () => { 8 | expect(Object.keys(testContext)).toEqual(['admin', 'environment']); 9 | }); 10 | }); 11 | 12 | describe('function call', () => { 13 | const password = 'password123'; 14 | const secret = 'a super secret text block'; 15 | const vaultId = 'test-vault'; 16 | const uid = `test-uuid-${uuidv4()}`; 17 | 18 | const { admin } = testContext; 19 | const vaultDocRef = admin 20 | .firestore() 21 | .collection('user-owned') 22 | .doc(uid) 23 | .collection('vaults') 24 | .doc(vaultId); 25 | 26 | let result; 27 | 28 | beforeAll(async () => { 29 | const encrypt = Encrypt(testContext); 30 | 31 | await encrypt({ password, secret, vaultId }, { auth: { uid } }); 32 | 33 | const doc = await vaultDocRef.get(); 34 | 35 | result = doc.data(); 36 | }); 37 | 38 | afterAll(async () => { 39 | await vaultDocRef.remove(); 40 | }); 41 | 42 | it('should have the encrypted attribute', () => { 43 | console.log('result', result); 44 | console.log('result', result); 45 | console.log('result', result); 46 | console.log('result', result); 47 | 48 | 49 | expect(result.encrypted.length).toEqual(50); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /functions/src/decrypt.js: -------------------------------------------------------------------------------- 1 | const decryptEncryptedWithPassword = require('../utilities/decrypt-encrypted-with-password'); 2 | 3 | module.exports = context => async (data, functionContext) => { 4 | /** 5 | * Task 15: Decrypt the vault 6 | * Subject: Cloud Functions 7 | * Docs: 8 | * https://firebase.google.com/docs/functions/callable#write_and_deploy_the_callable_function 9 | * 10 | * 1. Pull `admin` off of `context`. 11 | * 2. Pull `password` and `vaultId` off of `data` 12 | * 3. Pull `uid` off of `functionContext.auth` 13 | * 4. Pull `password` and 'vaultId` off of `data` 14 | * 5. Create a `vaultDocRef` by chaining off of `admin.firestore().collection('user-owned')` 15 | * Hint: /user-owned/{uid}/vaults/{vaultId} 16 | * 6. Get the `vaultDoc` by awaiting `vaultDocRef.get()` 17 | * 7. Extract `encrypted`, `iv`, and `tag` off of `vaultDoc.data()` 18 | * 8. Call `await decryptEncryptedWithPassword({ encrypted, iv, password, tag })`. 19 | * Save the result as `decrypted`. 20 | * 3. Return the `decrypted` value as { decrypted: 'the decrypted string' } 21 | */ 22 | 23 | // TODO: Implement the function! 24 | 25 | const { admin } = context; 26 | const { password, vaultId } = data; 27 | const { uid } = functionContext.auth; 28 | const vaultDocRef = admin 29 | .firestore() 30 | .collection('user-owned') 31 | .doc(uid) 32 | .collection('vaults') 33 | .doc(vaultId); 34 | 35 | const vaultDoc = await vaultDocRef.get(); 36 | 37 | const { encrypted, iv, tag } = vaultDoc.data(); 38 | const decrypted = await decryptEncryptedWithPassword({ encrypted, iv, password, tag }); 39 | 40 | return { decrypted }; 41 | }; 42 | -------------------------------------------------------------------------------- /functions/src/encrypt.js: -------------------------------------------------------------------------------- 1 | const encryptSecretWithPassword = require('../utilities/encrypt-secret-with-password'); 2 | 3 | module.exports = context => async (data, functionContext) => { 4 | /** 5 | * Task 12: Encrypt the secret 6 | * Subject: Cloud Functions 7 | * Docs: 8 | * https://firebase.google.com/docs/functions/callable#write_and_deploy_the_callable_function 9 | * 10 | * 1. Pull `admin` off of `context`. 11 | * 2. Pull `password`, 'secret` and `vaultId` off of `data` 12 | * 3. Pull `uid` off of `functionContext.auth` 13 | * 4. Call `await encryptSecretWithPassword({ password, secret})`. Save the result as `encrypted`. 14 | * 5. Create a `vaultDocRef` object by chaining off of `admin.firestore().collection('user-owned'). 15 | * 6. Return `vaultDocRef.set({encrypted, encryptedDate: Date.now()}, { merge: true })` 16 | * 7. Make sure you've `cd`ed into the `functions` directory and run `npm test`. All should pass. 17 | * 18 | * This file exports a higher-order function that takes `context` as an argument. 19 | * `context` represents whatever `{ admin, environment }` values we want to work with. 20 | * This architecture enables use to use a different `context` for test and prod environments. 21 | * 22 | * The function that will be exported to the Cloud Functions runtime is async, 23 | * it also takes `data` and `functionContext` as arguments. 24 | * The `data` argument contains whatever we passed into the function when we called it, 25 | * in this case it's `{ password, secret, vaultId }`. 26 | * The `functionContext` argument contains an `auth` attribute with the user's JWT/currentUser. 27 | * We're pulling the `uid` variable off of the JWT for security purposes. You can't fake the JWT. 28 | * 29 | * `encrypteSecretWithPassword` takes the arguments `{ password, secret }` and returns a string. 30 | * We're going to set that string as the `encrypted` attribute of our `vaultDocRef`. 31 | * We could use `vaultDocRef.update(updates)` or `vaultDocRef.set(updates, { merge: true })`. 32 | * The second `{ merge: true }` argument turns `vaultDocRef.set` into an upsert. The upsert is 33 | * great for testing, because we don't have to see the `vaultDocRef` with data to run an upsert. 34 | * `vaultDocRef.update` will fail if a record does not already exist. 35 | * 36 | * Note: Cloud Functions requires that every function returns a promise. The easy way to do that 37 | * is to make sure that you make your function async. The `async` and `await` keywords aren't 38 | * available in Node v6, so we need to make sure that our function runs in Node v8. 39 | */ 40 | 41 | // TODO: Implement the function! 42 | 43 | const { admin } = context; 44 | const { password, secret, vaultId } = data; 45 | const { uid } = functionContext.auth; 46 | const vaultDocRef = admin 47 | .firestore() 48 | .collection('user-owned') 49 | .doc(uid) 50 | .collection('vaults') 51 | .doc(vaultId); 52 | 53 | const { encrypted, iv, tag } = await encryptSecretWithPassword({ password, secret }); 54 | 55 | const update = { encrypted, encryptedDate: Date.now(), iv, tag }; 56 | 57 | return vaultDocRef.set(update, { merge: true }); 58 | }; 59 | -------------------------------------------------------------------------------- /src/components/logged-in.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Fab } from '@rmwc/fab'; 3 | import { Snackbar } from '@rmwc/snackbar'; 4 | import { SimpleTopAppBar } from '@rmwc/top-app-bar'; 5 | 6 | import Vault from './vault'; 7 | import Vaults from './vaults'; 8 | import addVault from '../database/add-vault'; 9 | import removeVault from '../database/remove-vault'; 10 | import getVaults from '../database/get-vaults'; 11 | 12 | import '@material/fab/dist/mdc.fab.css'; 13 | import '@material/snackbar/dist/mdc.snackbar.css'; 14 | import '@material/top-app-bar/dist/mdc.top-app-bar.css'; 15 | 16 | export default class LoggedIn extends React.Component { 17 | constructor() { 18 | super(); 19 | 20 | this.state = this.localStorageState || { 21 | selectedVaultId: '', 22 | snackbarText: '', 23 | vaults: [], 24 | }; 25 | } 26 | 27 | get firebase() { 28 | return window.firebase; 29 | } 30 | 31 | get localStorageStateName() { 32 | return 'logged-in-state'; 33 | } 34 | 35 | get localStorageState() { 36 | return JSON.parse(window.localStorage.getItem(this.localStorageStateName)); 37 | } 38 | 39 | set localStorageState(state) { 40 | window.localStorage.setItem(this.localStorageStateName, JSON.stringify(this.state)); 41 | } 42 | 43 | async componentDidMount() { 44 | await this.getUserVaults(); 45 | } 46 | 47 | componentDidUpdate() { 48 | this.localStorageState = this.state; 49 | } 50 | 51 | handleSignOutClick() { 52 | /** 53 | * Task 3: Implement signOut 54 | * Subject: Authentication 55 | * Docs: https://firebase.google.com/docs/auth/web/google-signin#next_steps 56 | * 57 | * 1. Call `signOut` to sign out of Firebase Authentication 58 | * 59 | * Try using `this.firebase` to access `window.firebase` via this component's `firebase` getter 60 | */ 61 | 62 | // TODO: Implement Task 63 | } 64 | 65 | async handleAddVaultClick() { 66 | const { currentUser } = this.props; 67 | 68 | await addVault(this.firebase, currentUser.uid); 69 | 70 | await this.getUserVaults(); 71 | 72 | this.setSnackbarText('Vault added'); 73 | } 74 | 75 | async handleRemoveVaultClick() { 76 | const { currentUser } = this.props; 77 | const { selectedVaultId } = this.state; 78 | 79 | await removeVault(this.firebase, { vaultId: selectedVaultId, uid: currentUser.uid }); 80 | 81 | this.handleBack(); 82 | 83 | this.setSnackbarText('Vault removed'); 84 | } 85 | 86 | async getUserVaults() { 87 | const { currentUser } = this.props; 88 | 89 | const vaults = await getVaults(this.firebase, currentUser.uid); 90 | 91 | this.setState({ vaults }); 92 | } 93 | 94 | setSnackbarText(snackbarText) { 95 | this.setState({ snackbarText }); 96 | } 97 | 98 | async handleBack() { 99 | await this.getUserVaults(); 100 | 101 | this.setState({ selectedVaultId: '' }); 102 | } 103 | 104 | render() { 105 | const { currentUser } = this.props; 106 | const { selectedVaultId, snackbarText, vaults } = this.state; 107 | 108 | return ( 109 |
110 | console.log('Navigate') }} 113 | actionItems={[{ onClick: this.handleSignOutClick.bind(this), icon: 'power_off' }]} 114 | /> 115 |
116 | {selectedVaultId ? ( 117 | 122 | ) : ( 123 | this.setState({ selectedVaultId })} 126 | /> 127 | )} 128 |
129 | {selectedVaultId ? ( 130 | 135 | ) : ( 136 | 137 | )} 138 | this.setSnackbarText('')} 142 | /> 143 |
144 | ); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/components/vault.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from '@rmwc/button'; 3 | import { IconButton } from '@rmwc/icon-button'; 4 | import { Snackbar } from '@rmwc/snackbar'; 5 | import { TextField } from '@rmwc/textfield'; 6 | 7 | import syncVault from '../database/sync-vault'; 8 | import updateVault from '../database/update-vault'; 9 | import encryptVault from '../database/encrypt-vault'; 10 | import decryptVault from '../database/decrypt-vault'; 11 | import debounceAsync from '../utilities/debounce-async'; 12 | 13 | import '@material/button/dist/mdc.button.css'; 14 | import '@material/snackbar/dist/mdc.snackbar.css'; 15 | import '@material/icon-button/dist/mdc.icon-button.css'; 16 | import '@material/textfield/dist/mdc.textfield.css'; 17 | import '@material/floating-label/dist/mdc.floating-label.css'; 18 | import '@material/notched-outline/dist/mdc.notched-outline.css'; 19 | import '@material/line-ripple/dist/mdc.line-ripple.css'; 20 | 21 | export default class Vault extends React.Component { 22 | constructor() { 23 | super(); 24 | 25 | this.state = { 26 | decrypted: '', 27 | draftVault: null, 28 | password: '', 29 | secret: '', 30 | snackbarText: '', 31 | operationInProgress: false, 32 | vault: null, 33 | }; 34 | 35 | this.__updateVault = debounceAsync(updateVault, 1000 * 1); 36 | } 37 | 38 | get firebase() { 39 | return window.firebase; 40 | } 41 | 42 | componentDidMount() { 43 | this.startSync(); 44 | } 45 | 46 | componentWillUnmount() { 47 | this.stopSync(); 48 | } 49 | 50 | startSync() { 51 | const { uid, vaultId } = this.props; 52 | 53 | this.unsubscribe = syncVault(this.firebase, uid, vaultId, this.setVault.bind(this)); 54 | } 55 | 56 | stopSync() { 57 | this.unsubscribe && this.unsubscribe(); 58 | } 59 | 60 | setVault(vault) { 61 | this.setState({ draftVault: vault, vault }); 62 | } 63 | 64 | setSnackbarText(snackbarText) { 65 | this.setState({ snackbarText }); 66 | } 67 | 68 | async updateName(e) { 69 | const { uid, vaultId } = this.props; 70 | const { draftVault } = this.state; 71 | const name = e.target.value; 72 | 73 | this.setState({ draftVault: { ...draftVault, name } }); 74 | 75 | const result = await this.__updateVault(this.firebase, uid, vaultId, { name }); 76 | 77 | result && this.setSnackbarText('Saved'); 78 | } 79 | 80 | updatePassword(e) { 81 | this.setState({ password: e.target.value }); 82 | } 83 | 84 | updateSecret(e) { 85 | this.setState({ secret: e.target.value }); 86 | } 87 | 88 | async handleSubmit(e) { 89 | e.preventDefault(); 90 | 91 | const { vault } = this.state; 92 | 93 | vault.encrypted ? await this.decryptVault() : await this.encryptVault(); 94 | 95 | return false; 96 | } 97 | 98 | async encryptVault() { 99 | const { vaultId } = this.props; 100 | const { password, secret } = this.state; 101 | 102 | this.setState({ operationInProgress: true }); 103 | 104 | await encryptVault(this.firebase, { password, secret, vaultId }); 105 | 106 | this.setState({ operationInProgress: false }); 107 | 108 | this.setSnackbarText('encrypted'); 109 | } 110 | 111 | async decryptVault() { 112 | const { vaultId } = this.props; 113 | const { password } = this.state; 114 | 115 | this.setState({ operationInProgress: true }); 116 | 117 | const { 118 | data: { decrypted }, 119 | } = await decryptVault(this.firebase, { password, vaultId }); 120 | 121 | this.setState({ operationInProgress: false, decrypted }); 122 | 123 | this.setSnackbarText('decrypted'); 124 | } 125 | 126 | render() { 127 | const { vaultId, onBack } = this.props; 128 | const { 129 | decrypted, 130 | draftVault, 131 | operationInProgress, 132 | password, 133 | secret, 134 | snackbarText, 135 | vault, 136 | } = this.state; 137 | 138 | return vault ? ( 139 |
140 | 141 |

Vault {vaultId}

142 | 143 |
144 | 150 | 156 | {vault.encrypted ? ( 157 |
158 | 165 | 168 |
169 | ) : ( 170 |
171 | 178 | 181 |
182 | )} 183 | 184 | this.setSnackbarText('')} 188 | /> 189 |
190 | ) : null; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Secrets: A Firebase feature teacher 2 | 3 | We're going to workshop our way through to a functional web application built on the Firebase platform. 4 | 5 | We'll try to abstract away as much of the non-Firebase code as possible so that we can focus on the Firebase. 6 | 7 | ## Dependencies 8 | 9 | You'll need to [install node](https://nodejs.org/en/download/), which comes bundled with the [npm](https://docs.npmjs.com/getting-started/what-is-npm) package manager for Node.js dependencies. Node version 8.12.0 is ideal, but any Node version 8.x should work. Run `node --version` to verify that you're on the right version of Node. You can use [nvm for Unix](https://github.com/creationix/nvm) or [nvm for Windows](https://github.com/coreybutler/nvm-windows) to manage your local Node version. 10 | 11 | We'll use a Node package named [npx](https://github.com/zkat/npx) to run some of our Node.js executables. 12 | 13 | `npx` is Node.js package runner written with--of course!--Node.js. `npx` can find an executable from either the local or the global `node_modules` folder. It will attempt to find the executable dependency locally, then globally. If that fails, npx will install it into a global cache and run it from there. 14 | 15 | ## Create a Firebase project 16 | 17 | - Visit `https://console.firebase.google.com/` and create a project. 18 | - Name the project whatever you'd like. The options here don't matter much. 19 | 20 | ## Enable the Firebase Tools CLI 21 | 22 | - Install `npx` with `npm install --global npx` 23 | - `npx firebase login` to log in with the same Google account that you used for your Firebase project 24 | 25 | The `firebase-tools` CLI can now be accessed by running `npx firebase`. 26 | 27 | ## Installation for completed app 28 | 29 | - `npm install --global npx` to install npx 30 | - `git checkout complete` to check out the completed branch 31 | - `npm install` to install the front-end dependencies 32 | - `cd functions && npm install` to install Cloud Functions dependencies 33 | - `cd functions && npm test` to run Cloud Functions tests 34 | - 35 | - `npm run-script deploy` to deploy the Firebase app 36 | 37 | ## Installation for workshop 38 | 39 | - `npm install --global npx` to install npx 40 | - `git checkout master` to check out the workshop starting point 41 | - `npm install` to install the front-end dependencies 42 | - `npx firebase init` 43 | - Use the arrow keys to install all of the Firebase CLI features 44 | - Select the project that you're using for this workshop 45 | - Accept the defaults 46 | - You'll know you're done when you see `+ Firebase initialization complete!` in your terminal 47 | 48 | ## firebase init 49 | 50 | If you accepted the defaults, then `npx firebase init` will have installed the following files: 51 | 52 | - `/functions`: Your Cloud Functions code 53 | - `/public`: The public files to be deployed on Firebase Hosting 54 | - `.firebaserc`: Firebase project definition 55 | - `database.rules.json`: Realtime Database (aka the RTDB) security rules 56 | - `firebase.json`: Firebase project config 57 | - `firestore.indexes.json`: Firestore indexes 58 | - `firestore.rules`: Firestore security rules 59 | - `storage.rules`: Firebase Storage security rules 60 | 61 | We'll start the workshop with these files in their default states. The only project-specific file in here is `.firebaserc`, which you can edit to point the `firebase-tools` CLI to any Firebase project for which you have access. 62 | 63 | Check out `.firebaserc.dist` for an example of the file. 64 | 65 | ### Check your installation 66 | 67 | Run `npx firebase serve` to run a local Firebase Hosting emulator. Open [http://localhost:5000/](http://localhost:5000/) to see what you have so far. 68 | 69 | The page you see is served from the `/public` folder. We'll be overwriting these files soon, so don't get attached. 70 | 71 | ## Run local dev server 72 | 73 | We're using the [Parcel](https://parceljs.org/getting_started.html) app bundler and dev server. 74 | 75 | Parcel is mostly automated, so there isn't much to manage yourself. The Parcel commands that we'll use are within the `package.json` scripts and can be called with `npm run-script serve` and `npm run-script build`. 76 | 77 | Run `npm run-script serve` to get your local dev server running. The terminal will tell you which port on `localhost` to use to view your page. The default url is [http://localhost:1234/](http://localhost:1234/) 78 | 79 | ## Test your Firebase Hosting deploy 80 | 81 | 1. Run `npm run-script build` top populate your `/public` folder with the build app files. 82 | 2. Run `npx firebase deploy --only hosting` to deploy only Firebase Hosting. 83 | 3. See the "Hosting URL" output by your terminal and follow that URL to test your deploy. 84 | 4. Add `/__/firebase/init.js` to your hosting URL and open that page to see your Firebase Hosting initialization. Example: `https://how-to-firebase-secrets.firebaseapp.com/__/firebase/init.js`. 85 | 86 | The `/__/firebase/init.js` file is available after your first Firebase Hosting deploy. This allows for a very handy pattern where you merely reference the `init.js` file in a script tag on your page and you're automatically initialized wherever you deploy your app on Firebase Hosting. 87 | 88 | This is a bit of an advanced tricky, but let's do it! 89 | 90 | ## Add Firebase to your project 91 | 92 | Open up the [Firebase web setup docs](https://firebase.google.com/docs/web/setup) and scroll down to the CDN script tags. Copy the entire block and paste it into your `src/index.html` file at the bottom of the `` tag. 93 | 94 | We'll use `firebase-app.js`, `firebase-auth.js`, `firebase-firestore.js` and `firebase-functions.js` scripts. So go ahead and comment out or delete the other script tags. 95 | 96 | Finally, notice the script tag that contains `firebase.initializeApp(config)`. Replace the guts of that ` 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 131 | 132 | ``` 133 | 134 | Check your setup by opening up Chrome DevTools on your dev page and typing `firebase.app().options`. This will output the config for your Firebase app. Just make sure that it looks right, and you're good. 135 | 136 | # Firebase Authentication 137 | 138 | Now that we have the Firebase SDK on our page, we can implement auth in just three easy steps. 139 | 140 | ## Task 1: Implement signInWithPopup 141 | 142 | **File:** `src/components/login.js` 143 | 144 | We're going to wire up the "Log in with Google" button to Firebase Authentication. 145 | 146 | You can find the completed code on the `complete` branch of this repo: [login.js](https://github.com/how-to-firebase/secrets/blob/complete/src/components/login.js) 147 | 148 | ## Task 2: Implement onAuthStateChange listener 149 | 150 | **File:** `src/components/app.js` 151 | 152 | Firebase has a `currentUser` object that represents the logged-in user's [JWT](https://jwt.io/). 153 | 154 | We'll need to sync the `currentUser` JWT to our app's state. 155 | 156 | You can find the completed code on the `complete` branch of this repo: [app.js](https://github.com/how-to-firebase/secrets/blob/complete/src/components/app.js) 157 | 158 | ## Task 3: Implement signOut 159 | 160 | **File:** `src/components/logged-in.js` 161 | 162 | Signing out with Firebase Authentication is EASY! 163 | 164 | You can find the completed code on the `complete` branch of this repo: [logged-in.js](https://github.com/how-to-firebase/secrets/blob/complete/src/components/logged-in.js) 165 | 166 | ## Task 4: Prepare to add a Firestore record 167 | 168 | **File:** `src/database/add-vault.js` 169 | 170 | You won't be able to actually add a record until you've completed Task 5. This step succeeds when you get your first `Missing or insufficient permissions` error. 171 | 172 | You can find the completed code on the `complete` branch of this repo: [add-vault.js](https://github.com/how-to-firebase/secrets/blob/complete/src/database/add-vault.js) 173 | 174 | ## Task 5: Add your first security rule 175 | 176 | **File:** `firestore.rules` 177 | 178 | You created `firestore.rules` earlier when calling `npx firebase init`. `firestore.rules` contains the security rules that you'll need to secure your Firestore database. 179 | 180 | Review [Firestore security rules](https://firebase.google.com/docs/firestore/security/get-started) to see how flexible they can be. 181 | 182 | We're not going to go deep into security rules. That would be a different workshop. So just make sure that your `firestore.rules` file looks like this: 183 | 184 | ``` 185 | service cloud.firestore { 186 | match /databases/{database}/documents { 187 | match /user-owned/{uid}/vaults/{vaultId} { 188 | allow read, write: if request.auth.uid == uid 189 | } 190 | } 191 | } 192 | ``` 193 | 194 | Then call `npx firebase deploy --only firestore` to deploy your Firestore rules. 195 | 196 | Now you can add the vaults that you wanted in Task 4! 197 | 198 | ## Task 6: Query your vaults 199 | 200 | **File:** `firestore.rules` 201 | 202 | You created `firestore.rules` earlier when calling `npx firebase init`. `firestore.rules` contains the security rules that you'll need to secure your Firestore database. 203 | 204 | Review [Firestore security rules](https://firebase.google.com/docs/firestore/security/get-started) to see how flexible they can be. 205 | 206 | ## Task 7: Listen to vault changes 207 | 208 | **File:** `src/database/sync-vault.js` 209 | 210 | You can find the completed code on the `complete` branch of this repo: [sync-vault.js](https://github.com/how-to-firebase/secrets/blob/complete/src/database/sync-vault.js) 211 | 212 | ## Task 8: Update the vault 213 | 214 | **File:** `src/database/update-vault.js` 215 | 216 | You can find the completed code on the `complete` branch of this repo: [update-vault.js](https://github.com/how-to-firebase/secrets/blob/complete/src/database/update-vault.js) 217 | 218 | ## Task 9: Call the `encrypt` Cloud Function 219 | 220 | **File:** `src/database/encrypt-vault.js` 221 | 222 | You can find the completed code on the `complete` branch of this repo: [encrypt-vault.js](https://github.com/how-to-firebase/secrets/blob/complete/src/database/encrypt-vault.js) 223 | 224 | ## Task 10: Configure Jest 225 | 226 | **File:** `functions/package.json` 227 | 228 | All of the Cloud Functions code lives in this folder, which is a separate NPM project with its own `package.json`. We'll need to make sure that Jest is installed, and we'll want to configure a test command. 229 | 230 | 1. Run `cd functions` to begin work on your Cloud Functions. 231 | 2. `npm install --save-dev jest` to get the Jest test runner. 232 | 3. Add a `"scripts"` attribute to `package.json` and add `"test": "jest --watchAll"` to scripts. 233 | 4. Add an `"engines"` attribute with `"node": "8"` to make sure that our functions run in the Node v8 runtime instead of the default v6. 234 | 235 | The result should look like this: 236 | 237 | ```json 238 | { 239 | "name": "functions", 240 | "description": "Cloud Functions for Firebase", 241 | "scripts": { 242 | "test": "jest --watchAll", 243 | "serve": "firebase serve --only functions", 244 | "shell": "firebase functions:shell", 245 | "start": "npm run shell", 246 | "deploy": "firebase deploy --only functions", 247 | "logs": "firebase functions:log" 248 | }, 249 | "dependencies": { 250 | "firebase-admin": "~6.0.0", 251 | "firebase-functions": "^2.1.0" 252 | }, 253 | "engines": { 254 | "node": "8" 255 | }, 256 | "private": true, 257 | "devDependencies": { 258 | "jest": "^23.6.0" 259 | } 260 | } 261 | ``` 262 | 263 | The rest of this file is auto-generated by `npx firebase init`, so don't worry about it. The other "scripts" commands are useful but outside the scope of this workshop. 264 | 265 | 4. Run `npm test` to verify that Jest gets called. Type `q` to quit. 266 | 267 | You can find the completed code on the `complete` branch of this repo: [package.json](https://github.com/how-to-firebase/secrets/blob/complete/functions/package.json) 268 | 269 | ## Task 11: Initialize test environment 270 | 271 | **File:** `functions/src/encrypt.spec.js` 272 | 273 | 1. Download a service account for your project: [service account download instructions](https://firebase.google.com/docs/admin/setup#add_firebase_to_your_app) 274 | 2. Move the service account file to `functions/service-account.json`. This file will be ignored by `git`, so it won't ever make it into your source control. Guard this file carefully, because it grants admin rights to your Firebase project. 275 | 3. Copy the `databaseURL` value from the Firebase Admin SDK screen where you just downloaded your service account. 276 | 4. Update the `databaseURL` values in `functions/environments/environment.js` and `functions/environments/environment.test.js`. 277 | 5. Run `npm test` to verify that the `#encrypt -> setup` tests pass. 278 | 6. Open up `functions/utilities/test-context.js` and `functions/jest.config.js` and read the comments at the top of each file. 279 | 280 | The Cloud Functions runtime provides an initialized Firebase `admin` app, but we don't have that in our testing environment. Therefore we need to create our own `admin` app with a service account. 281 | 282 | We're also setting up basic environment files to add to our `context` object. This `context` object is arbitrary. We just made it up. But most use cases of Cloud Functions will need environment variables and an `admin` app, so we're starting with a robust architecture. 283 | 284 | ## Task 12: Encrypt the secret 285 | 286 | **File:** `functions/encrypt.js` 287 | 288 | You can find the completed code on the `complete` branch of this repo: [encrypt.js](https://github.com/how-to-firebase/secrets/blob/complete/functions/src/encrypt.js) 289 | 290 | ## Task 13: Export `encrypt` to the Cloud Functions runtime 291 | 292 | **File:** `functions/index.js` 293 | 294 | 1. Import `../utilities/prod-context` as your production `context`. 295 | 2. Import `Encrypt` from `./src/encrypt` 296 | 3. Instantiate an instance of our `encrypt` function using `Encrypt(context)`. 297 | 4. Export a callable Cloud Function to `exports.encrypt` using [the docs](https://firebase.google.com/docs/functions/callable#write_and_deploy_the_callable_function) as a guide. 298 | 5. Run `npm install` and `npx firebase deploy --only functions` to deploy. 299 | 6. Open up the Functions logs in your Firebase Console to confirm that the deploy succeeded. 300 | 7. Use the running `localhost` version of the app to attempt to encrypt a secret. 301 | 8. Verify that the `encrypted` string was saved to Firestore. 302 | 9. Watch the Functions logs to see each call to `encrypt` succeed. 303 | 304 | You can find the completed code on the `complete` branch of this repo: [index.js](https://github.com/how-to-firebase/secrets/blob/complete/functions/index.js) 305 | 306 | ## Task 14: Call the `decrypt` Cloud Function 307 | 308 | **File:** `src/database/decrypt-vault.js` 309 | 310 | You can find the completed code on the `complete` branch of this repo: [decrypt-vault.js](https://github.com/how-to-firebase/secrets/blob/complete/src/database/decrypt-vault.js) 311 | 312 | ## Task 15: Decrypt the vault 313 | 314 | **File:** `functions/src/decrypt.js` 315 | 316 | You can find the completed code on the `complete` branch of this repo: [decrypt.js](https://github.com/how-to-firebase/secrets/blob/complete/functions/src/decrypt.js) 317 | 318 | ## Task 16: Export `decrypt` to the Cloud Functions runtime 319 | 320 | **File:** `functions/index.js` 321 | 322 | 2. Import `Decrypt` from `./src/encrypt` 323 | 3. Instantiate an instance of our `decrypt` function using `Decrypt(context)`. 324 | 4. Export a callable Cloud Function to `exports.decrypt` using [the docs](https://firebase.google.com/docs/functions/callable#write_and_deploy_the_callable_function) as a guide. 325 | 5. Run `npm install` and `npx firebase deploy --only functions` to deploy. 326 | 6. Open up the Functions logs in your Firebase Console to confirm that the deploy succeeded. 327 | 7. Use the running `localhost` version of the app to attempt to encrypt a secret. 328 | 8. That clicking the 'DECRYPT' button in the UI will decrypt a record. 329 | 9. Watch the Functions logs to see each call to `decrypt` succeed. 330 | 331 | You can find the completed code on the `complete` branch of this repo: [index.js](https://github.com/how-to-firebase/secrets/blob/complete/functions/index.js) 332 | 333 | ## Task 17: Delete a Firestore record 334 | 335 | **File:** `src/database/remove-vault.js` 336 | 337 | You can find the completed code on the `complete` branch of this repo: [remove-vault.js](https://github.com/how-to-firebase/secrets/blob/complete/src/database/remove-vault.js) 338 | --------------------------------------------------------------------------------