├── src ├── config.js ├── assets │ └── logo.png ├── main.js ├── App.vue └── components │ └── FxAForm.vue ├── babel.config.js ├── public ├── favicon.ico ├── login_complete.html ├── index.html └── firefox-logo.svg ├── .gitignore ├── package.json ├── README.md └── server └── index.js /src/config.js: -------------------------------------------------------------------------------- 1 | 2 | export const FXA_CONTENT_ROOT = 'http://127.0.0.1:3030'; 3 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shane-tomlinson/fxa-email-first-oauth-relier/master/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shane-tomlinson/fxa-email-first-oauth-relier/master/src/assets/logo.png -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | 4 | Vue.config.productionTip = false 5 | 6 | new Vue({ 7 | render: h => h(App), 8 | }).$mount('#app') 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /public/login_complete.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Verified! 9 | 10 | 11 |

Get ready for this event!

12 | Sign in again 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | fxa-email-first-oauth-relier 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fxa-email-first-oauth-relier", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "body-parser": "^1.18.3", 12 | "cookie-parser": "^1.4.3", 13 | "express": "^4.16.4", 14 | "request": "^2.88.0", 15 | "request-promise-native": "^1.0.5", 16 | "vue": "^2.5.17" 17 | }, 18 | "devDependencies": { 19 | "@vue/cli-plugin-babel": "^3.1.1", 20 | "@vue/cli-plugin-eslint": "^3.1.1", 21 | "@vue/cli-service": "^3.1.1", 22 | "babel-eslint": "^10.0.1", 23 | "eslint": "^5.8.0", 24 | "eslint-plugin-vue": "^5.0.0-0", 25 | "vue-template-compiler": "^2.5.17" 26 | }, 27 | "eslintConfig": { 28 | "root": true, 29 | "env": { 30 | "node": true 31 | }, 32 | "extends": [ 33 | "plugin:vue/essential", 34 | "eslint:recommended" 35 | ], 36 | "rules": {}, 37 | "parserOptions": { 38 | "parser": "babel-eslint" 39 | } 40 | }, 41 | "postcss": { 42 | "plugins": { 43 | "autoprefixer": {} 44 | } 45 | }, 46 | "browserslist": [ 47 | "> 1%", 48 | "last 2 versions", 49 | "not ie <= 8" 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fxa-email-first-oauth-relier 2 | 3 | This is a proof of concept to show how to integrate Firefox Account's "email-first" flow 4 | into a mozilla.org site. 5 | 6 | The important files are: 7 | 8 | * [src/components/FxAForm.vue](https://github.com/shane-tomlinson/fxa-email-first-oauth-relier/blob/master/src/components/FxAForm.vue) - How to present the form and get the data necessary to redirect to FxA. 9 | * [server/index.js](https://github.com/shane-tomlinson/fxa-email-first-oauth-relier/blob/master/server/index.js) - How to do the server side bits for the OAuth flow, including CSRF protection, getting access to the user's profile data, and subscribing the user to newsletters. 10 | 11 | At a high level, you need to do the following: 12 | 13 | 1. Perform an OAuth flow with FxA. 14 | 2. When the OAuth flow is complete, trade the OAuth code for an access token that can then be used to: 15 | 3. Get the user's profile information 16 | 4. Subscribe to newsletters 17 | 5. Set a local session cookie that allows returning users who have already signed in to not sign in again. 18 | 19 | ## Project setup 20 | ``` 21 | yarn install 22 | ``` 23 | 24 | Running this locally with a full FxA integration currently requires fxa-local-dev. 25 | 26 | Install [fxa-local-dev](https://github.com/mozilla/fxa-local-dev) using the [firefox-special-event](https://github.com/mozilla/fxa-local-dev/tree/firefox-special-event) branch. 27 | 28 | ``` 29 | git clone https://github.com/mozilla/fxa-local-dev.git 30 | cd fxa-local-dev 31 | git pull 32 | git checkout firefox-special-event 33 | npm install 34 | ./pm2 start servers.json 35 | ``` 36 | 37 | I'm going to try to get this running on Monday against https://stable.dev.lcip.org (our test environment) 38 | so that you don't have to do all this, but it's late on a Friday and I'm tired. 39 | 40 | ### Compiles and minifies for production 41 | ``` 42 | yarn run build 43 | ``` 44 | 45 | ## Running 46 | 47 | 1. Install and run fxa-local-dev (see [Project setup](#project-setup)) 48 | 2. Start the fake mozilla.org server 49 | 50 | > node server 51 | 52 | 3. Load up http://127.0.0.1:8082 53 | 54 | ## Running against stable w/o installing fxa-local-dev -------------------------------------------------------------------------------- /src/components/FxAForm.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 81 | ; -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const bodyParser = require('body-parser'); 2 | const cookieParser = require('cookie-parser'); 3 | const express = require('express'); 4 | const path = require('path'); 5 | const request = require('request-promise-native'); 6 | 7 | 8 | const STATIC_DIR = path.join(__dirname, '..', 'dist'); 9 | const sessionCookieToState = {}; 10 | 11 | // Note to mozilla.org team: 12 | // You'll need to get these from the fxa team 13 | const OAUTH_CLIENT_ID = '42c02d0c1e811cd5'; 14 | const OAUTH_CLIENT_SECRET = '57943698ac6e8726e4b114cf30f0488d9f4d9a55abf9b4ce2a3a394ea6e0a736'; 15 | 16 | 17 | // Note to mozilla.org team 18 | // These are local values for working with fxa-local-dev, see https://github.com/mozilla/fxa-local-dev. 19 | // prod values are in comments next to the local dev values. 20 | const FXA_OAUTH_ROOT = 'http://127.0.0.1:9010/v1'; // https://oauth.accounts.firefox.com 21 | const FXA_PROFILE_ROOT = 'http://127.0.0.1:1111/v1'; // https://profile.accounts.firefox.com 22 | 23 | // Note to mozilla.org team 24 | // Basket is the name of the salesforce newsletter integration. With the OAuth 25 | // code, you should be able to subscribe to the correct newsletters yourself. FxA isn't 26 | // set up to subscribe to arbitrary newsletters. :pmac can inform you how to pass utm_params, 27 | // I'm not sure if that currently exists. We don't pass them AFAICT. 28 | // lniolet should be able to tell you which NEWSLETTER_IDS are needed. 29 | const BASKET_ROOT = 'http://127.0.0.1:1114'; // https://basket.mozilla.org/news 30 | const NEWSLETTER_IDS = 'a,b,c'; 31 | 32 | const app = express(); 33 | app.use(cookieParser()); 34 | app.use(bodyParser()); 35 | 36 | // This route is the `redirect_uri` that is set up when provisioning 37 | // OAuth creds. It's responsibilities are: 38 | // 39 | // 1. Get the sessionCookie. 40 | // 2. Get the stored state for the sessionCookie. Call this state `eexpectedState` 41 | // 3. Compare the `expectedState` vs the `state` passed in the URL params. This is CSRF protection. 42 | // 4. If a match, continue, if no match, exit. 43 | // 5. Trade the `code` in the query parameters for an OAuth Access token. 44 | // 6. Use the access token to fetch the user's profile info (if any info is needed from there) 45 | // 6a. If the access token is valid and profile info is returned, you could set a cookie that says "this user is signed in!" 46 | // 7. Use the same access code to sign the user up to the concert newsletters via Basket 47 | app.get('/login_complete', async (req, res) => { 48 | const sessionCookie = req.cookies['_s']; 49 | const expectedState = sessionCookieToState[sessionCookie]; 50 | 51 | console.log('cookie', sessionCookie); 52 | console.log('expected state', expectedState); 53 | console.log('actual state', req.query.state); 54 | console.log('code', req.query.code); 55 | 56 | if (expectedState !== parseFloat(req.query.state)) { 57 | res.status(401).send('state mismatch'); 58 | return 59 | } 60 | 61 | let accessToken; 62 | let profile; 63 | 64 | try { 65 | accessToken = await tradeCodeForToken(req.query.code); 66 | profile = await tradeTokenForProfile(accessToken); 67 | const status = await subscribeToNewsletter(accessToken); 68 | } catch (err) { 69 | res.status(401).send('ixnay ' + String(err)); 70 | return; 71 | } 72 | 73 | res.sendFile(path.join(STATIC_DIR, 'login_complete.html')) 74 | }); 75 | 76 | // This route is called by the front end to get a state 77 | // token that can be sent to FxA when initiating the OAuth 78 | // request. State tokens are a form of CSRF 79 | // protection. Use them. 80 | // 1. Create a state token (opaque, but is passed in a URL). 81 | // 2. Create a random session cookie. 82 | // 3. In a DB somewhere, associate the state token with the session cookie. 83 | // 4. Set the session cookie in the response. 84 | // 5. Send the state token back the the caller. It'll be sent to FxA 85 | // as part of the OAuth process. 86 | app.get('/oauth/state', (req, res) => { 87 | const state = Math.random(); 88 | const sessionCookie = Math.random(); 89 | 90 | sessionCookieToState[sessionCookie] = state; 91 | 92 | res.cookie('_s', sessionCookie, { httpOnly: true, sameSite: true, expires: 0 }); 93 | 94 | res.json({ state }); 95 | }); 96 | 97 | app.get('/', (req, res) => { 98 | res.sendFile(path.join(STATIC_DIR, 'index.html')) 99 | }); 100 | 101 | app.use(express.static(STATIC_DIR)); 102 | 103 | app.listen(8082); 104 | 105 | async function tradeCodeForToken(code) { 106 | // see https://github.com/mozilla/fxa-auth-server/blob/master/fxa-oauth-server/docs/api.md#post-v1token 107 | const tokenResponse = await request.post(`${FXA_OAUTH_ROOT}/token`, { 108 | form: { 109 | grant_type: 'authorization_code', 110 | client_id: OAUTH_CLIENT_ID, 111 | client_secret: OAUTH_CLIENT_SECRET, 112 | code 113 | }, 114 | json: true 115 | }); 116 | // response will be of the form: 117 | // { 118 | // access_token: 'a74a67250768e6220c2faea1e4d3f504dad59eec0eed96ee75cf2f72199b517b', 119 | // token_type: 'bearer', 120 | // scope: 'profile basket', 121 | // auth_at: 1541781367, 122 | // expires_in: 1209600 123 | // } 124 | console.log('tokenResponse', tokenResponse); 125 | 126 | return tokenResponse.access_token; 127 | } 128 | 129 | async function tradeTokenForProfile(token) { 130 | // See https://github.com/mozilla/fxa-profile-server/blob/master/docs/API.md#get-v1profile 131 | 132 | const response = await request.get(`${FXA_PROFILE_ROOT}/profile`, { 133 | headers: { 134 | 'Authorization': `Bearer ${token}` 135 | }, 136 | json: true 137 | }) 138 | 139 | // response will be of the form: 140 | // { 141 | // email: 'stomlinson@mozilla.com', 142 | // locale: 'en-US,en;q=0.5', 143 | // amrValues: [ 'pwd', 'email' ], 144 | // twoFactorAuthentication: false, 145 | // uid: '18c7c6a7ca7a4868a2d0a457da384502', 146 | // avatar: 'http://127.0.0.1:1112/a/00000000000000000000000000000000', 147 | // avatarDefault: true 148 | // } 149 | const profile = response; 150 | console.log('profile', profile); 151 | 152 | return profile; 153 | } 154 | 155 | async function subscribeToNewsletter(token) { 156 | const response = await request.post(`${BASKET_ROOT}/subscribe`, { 157 | form: { 158 | newsletters: NEWSLETTER_IDS 159 | }, 160 | json: true, 161 | headers: { 162 | 'Authorization': `Bearer ${token}` 163 | } 164 | }); 165 | 166 | // response will be of the form: 167 | // { 168 | // status: 'ok' 169 | // } 170 | console.log('basket', response); 171 | 172 | return response.status; 173 | } -------------------------------------------------------------------------------- /public/firefox-logo.svg: -------------------------------------------------------------------------------- 1 | firefox-logo --------------------------------------------------------------------------------