├── .gitignore ├── client ├── app │ ├── styles │ │ ├── textarea.css │ │ ├── font.css │ │ ├── spacing.css │ │ ├── layout.css │ │ └── color.css │ ├── index.css │ ├── index.html │ ├── components │ │ ├── Response.js │ │ ├── Greeting.js │ │ ├── LogInOut.js │ │ └── UserData.js │ └── index.js ├── webpack.config.js └── package.json ├── server ├── .gitignore ├── routes │ ├── logout.js │ ├── login.js │ ├── oauth-callback.js │ ├── set-user-data.js │ └── user.js ├── package.json ├── helpers │ └── pkce.js ├── index.js └── package-lock.json ├── .env ├── .github └── workflows │ └── main.yaml ├── config.js ├── docker-compose.yml ├── kickstart └── kickstart.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.iws 3 | -------------------------------------------------------------------------------- /client/app/styles/textarea.css: -------------------------------------------------------------------------------- 1 | #UserData textarea { 2 | resize: none; 3 | } 4 | 5 | #UserData textarea:read-only { 6 | cursor: not-allowed; 7 | } 8 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Node 4 | /node_modules 5 | npm-debug.log 6 | yarn-error.log 7 | -------------------------------------------------------------------------------- /client/app/index.css: -------------------------------------------------------------------------------- 1 | @import './styles/color.css'; 2 | @import './styles/font.css'; 3 | @import './styles/layout.css'; 4 | @import './styles/spacing.css'; 5 | @import './styles/textarea.css'; 6 | -------------------------------------------------------------------------------- /client/app/styles/font.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | header h1 { 6 | font-size: 24px; 7 | } 8 | 9 | main, 10 | #UserData textarea { 11 | font-size: 12px; 12 | } 13 | 14 | header a { 15 | text-decoration: none; 16 | } 17 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_USER=postgres 2 | POSTGRES_PASSWORD=postgres 3 | DATABASE_USERNAME=fusionauth 4 | DATABASE_PASSWORD=hkaLBM3RVnyYeYeqE3WI1w2e4Avpy0Wd5O3s3 5 | ES_JAVA_OPTS="-Xms512m -Xmx512m" 6 | FUSIONAUTH_APP_MEMORY=512M 7 | 8 | FUSIONAUTH_APP_KICKSTART_FILE=/usr/local/fusionauth/kickstart/kickstart.json 9 | -------------------------------------------------------------------------------- /client/app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Fusionauth Example React 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /client/app/components/Response.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Response extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | } 7 | 8 | render() { 9 | return ( 10 |
11 |

FusionAuth Response

12 |
{JSON.stringify(this.props.body, null, '\t')}
13 |
14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/app/components/Greeting.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class Greeting extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | } 7 | 8 | render() { 9 | let message = (this.props.body.tid) 10 | ? `Hi, ${this.props.body.email}!` 11 | : "You're not logged in."; 12 | 13 | return ( 14 | {message} 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/routes/logout.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const config = require('../../config'); 4 | 5 | router.get('/', (req, res) => { 6 | // delete the session 7 | req.session.destroy(); 8 | 9 | // end FusionAuth session 10 | res.redirect(`http://localhost:${config.fusionAuthPort}/oauth2/logout?client_id=${config.clientID}`); 11 | }); 12 | 13 | module.exports = router; 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | # This is a starting workflow for building with GitHub Actions 2 | name: Build 3 | 4 | on: 5 | push: 6 | branches: [ master, main ] 7 | pull_request: 8 | branches: [ master, main ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | # Check out code 16 | - uses: actions/checkout@v3 17 | 18 | # Set up the build environment 19 | 20 | # Build 21 | 22 | # Done! 23 | -------------------------------------------------------------------------------- /client/app/components/LogInOut.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class LogInOut extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | } 7 | 8 | render() { 9 | console.log("this.props.body", this.props.body); 10 | let message = (this.props.body.tid) 11 | ? 'sign out' 12 | : 'sign in'; 13 | 14 | let path = (this.props.body.tid) 15 | ? '/logout' 16 | : '/login'; 17 | 18 | return ( 19 | {message} 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "Secure-React server", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index.js", 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "cors": "^2.8.5", 14 | "express": "^4.18.2", 15 | "express-session": "^1.17.3", 16 | "request": "^2.88.2", 17 | "rxjs": "^7.5.7", 18 | "tslib": "^2.4.1", 19 | "zone.js": "^0.11.8" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/helpers/pkce.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | 3 | function base64URLEncode(str) { 4 | return str 5 | .toString("base64") 6 | .replace(/\+/g, "-") 7 | .replace(/\//g, "_") 8 | .replace(/=/g, "") 9 | } 10 | 11 | function sha256(buffer) { 12 | return crypto.createHash("sha256").update(buffer).digest() 13 | } 14 | 15 | module.exports.generateVerifier = () => { 16 | return base64URLEncode(crypto.randomBytes(32)) 17 | } 18 | 19 | module.exports.generateChallenge = (verifier) => { 20 | return base64URLEncode(sha256(verifier)) 21 | } 22 | -------------------------------------------------------------------------------- /client/app/styles/spacing.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | #App { 6 | height: 100vh; 7 | } 8 | 9 | header, 10 | main, 11 | footer { 12 | padding: 20px 5vw; 13 | } 14 | 15 | header h1 { 16 | margin: 0; 17 | } 18 | 19 | header span { 20 | margin: 10px 20px 10px 0; 21 | } 22 | 23 | header a { 24 | padding: 5px 10px; 25 | } 26 | 27 | footer a { 28 | margin: 0 20px 0 0; 29 | } 30 | 31 | #UserData, 32 | #Response { 33 | height: 100%; 34 | width: 100%; 35 | } 36 | 37 | #Response pre, 38 | #UserData textarea { 39 | padding: 5px 10px; 40 | margin: 0; 41 | } 42 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | // These values must match what's in the kickstart/kickstart.json file 4 | apiKey: 'this_really_should_be_a_long_random_alphanumeric_value_but_this_still_works_dont_use_this_in_prod', 5 | applicationID: 'E9FDB985-9173-4E01-9D73-AC2D60D1DC8E', 6 | clientID: 'E9FDB985-9173-4E01-9D73-AC2D60D1DC8E', 7 | clientSecret: 'super-secret-secret-that-should-be-regenerated-for-production', 8 | redirectURI: 'http://localhost:3000/oauth-callback', 9 | 10 | //ports 11 | clientPort: 4200, 12 | serverPort: 3000, 13 | fusionAuthPort: 9011 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /client/app/styles/layout.css: -------------------------------------------------------------------------------- 1 | #App, 2 | main, 3 | #UserData, 4 | #Response { 5 | display: flex; 6 | flex-direction: column; 7 | } 8 | 9 | header, 10 | main { 11 | display: flex; 12 | align-items: center; 13 | justify-content: space-between; 14 | } 15 | 16 | @media (max-width: 750px) { 17 | header { 18 | flex-direction: column; 19 | align-items: flex-start; 20 | } 21 | } 22 | 23 | #UserData { 24 | flex-basis: 20%; 25 | } 26 | 27 | #Response { 28 | flex-basis: 80%; 29 | } 30 | 31 | header h1, 32 | main, 33 | #UserData, 34 | #Response, 35 | #Response pre, 36 | #UserData textarea { 37 | flex-grow: 1; 38 | } 39 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | 4 | module.exports = { 5 | entry: './app/index.js', 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: 'index_bundle.js' 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.(js)$/, 14 | use: 'babel-loader' 15 | }, 16 | { 17 | test: /\.css$/, 18 | use: ['style-loader', 'css-loader'] 19 | } 20 | ] 21 | }, 22 | mode: 'development', 23 | plugins: [ 24 | new HtmlWebpackPlugin({ 25 | template: 'app/index.html' 26 | }) 27 | ] 28 | }; 29 | -------------------------------------------------------------------------------- /server/routes/login.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const config = require('../../config'); 4 | const pkce = require('../helpers/pkce'); 5 | 6 | router.get('/', (req, res) => { 7 | // Generate and store the PKCE verifier 8 | req.session.verifier = pkce.generateVerifier(); 9 | 10 | // Generate the PKCE challenge 11 | const challenge = pkce.generateChallenge(req.session.verifier); 12 | 13 | res.redirect(`http://localhost:${config.fusionAuthPort}/oauth2/authorize?client_id=${config.clientID}&redirect_uri=${config.redirectURI}&response_type=code&code_challenge=${challenge}&code_challenge_method=S256`); 14 | }); 15 | 16 | module.exports = router; 17 | 18 | 19 | -------------------------------------------------------------------------------- /client/app/styles/color.css: -------------------------------------------------------------------------------- 1 | /* color variables */ 2 | html { 3 | --color-dark: #123; 4 | --color-light: #f6f3f0; 5 | --color-accent: #38c; 6 | } 7 | 8 | /* light on dark */ 9 | header, 10 | footer, 11 | #Response pre, 12 | footer a { 13 | color: var(--color-light); 14 | background-color: var(--color-dark); 15 | } 16 | 17 | /* dark on light */ 18 | header a, 19 | main, 20 | #UserData textarea:read-only { 21 | color: var(--color-dark); 22 | background-color: var(--color-light); 23 | } 24 | 25 | /* light on accent */ 26 | header a:hover, 27 | header a:active { 28 | color: var(--color-light); 29 | background-color: var(--color-accent); 30 | } 31 | 32 | /* accent on dark */ 33 | footer a:hover, 34 | footer a:active { 35 | color: var(--color-accent); 36 | background-color: var(--color-dark); 37 | } 38 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fusionauth-example-react-client", 3 | "version": "0.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "babel": { 7 | "presets": [ 8 | "@babel/preset-env", 9 | "@babel/preset-react" 10 | ] 11 | }, 12 | "scripts": { 13 | "start": "webpack-dev-server --open --port 4200" 14 | }, 15 | "keywords": [], 16 | "author": "Matt Boisseau ", 17 | "license": "ISC", 18 | "dependencies": { 19 | "react": "^16.14.0", 20 | "react-dom": "^16.14.0" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.20.5", 24 | "@babel/preset-env": "^7.20.2", 25 | "@babel/preset-react": "^7.18.6", 26 | "babel-loader": "^9.1.0", 27 | "css-loader": "^6.7.2", 28 | "html-webpack-plugin": "^5.5.0", 29 | "style-loader": "^3.3.1", 30 | "webpack": "^5.75.0", 31 | "webpack-cli": "^5.0.1", 32 | "webpack-dev-server": "^4.11.1" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const session = require('express-session'); 3 | const cors = require('cors'); 4 | const config = require('../config'); 5 | 6 | const app = express(); 7 | 8 | // install the JSON middleware for parsing JSON bodies 9 | app.use(express.json()); 10 | 11 | app.use(cors({ 12 | origin: true, 13 | credentials: true 14 | })); 15 | 16 | // configure sessions 17 | app.use(session( 18 | { 19 | secret: '1234567890', 20 | resave: false, 21 | saveUninitialized: false, 22 | cookie: { 23 | secure: 'auto', 24 | httpOnly: true, 25 | maxAge: 3600000 26 | } 27 | }) 28 | ); 29 | 30 | // routes 31 | app.use('/user', require('./routes/user')); 32 | app.use('/login', require('./routes/login')); 33 | app.use('/oauth-callback', require('./routes/oauth-callback')); 34 | app.use('/logout', require('./routes/logout')); 35 | app.use('/set-user-data', require('./routes/set-user-data')); 36 | 37 | app.listen(config.serverPort, () => console.log(`FusionAuth example app listening on port ${config.serverPort}.`)); 38 | 39 | -------------------------------------------------------------------------------- /server/routes/oauth-callback.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const request = require('request'); 4 | const config = require('../../config'); 5 | 6 | router.get('/', (req, res) => { 7 | request( 8 | // POST request to /token endpoint 9 | { 10 | method: 'POST', 11 | uri: `http://localhost:${config.fusionAuthPort}/oauth2/token`, 12 | form: { 13 | 'client_id': config.clientID, 14 | 'client_secret': config.clientSecret, 15 | 'code': req.query.code, 16 | 'code_verifier': req.session.verifier, 17 | 'grant_type': 'authorization_code', 18 | 'redirect_uri': config.redirectURI 19 | } 20 | }, 21 | 22 | // callback 23 | (error, response, body) => { 24 | if (error !== null) 25 | console.log("error:", error); 26 | else 27 | console.log("body:", body); 28 | 29 | // save token to session 30 | req.session.token = JSON.parse(body).access_token; 31 | 32 | // redirect to the Angular app 33 | res.redirect(`http://localhost:${config.clientPort}`); 34 | } 35 | ); 36 | }); 37 | 38 | module.exports = router; 39 | 40 | -------------------------------------------------------------------------------- /client/app/components/UserData.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class LogInOut extends React.Component { 4 | constructor(props) { 5 | super(props); 6 | } 7 | 8 | render() { 9 | // placeholder text for the textarea 10 | let placeholder = 'Anything you type in here will be saved to your FusionAuth user data.'; 11 | 12 | // the user's data.userData (or an empty string if uninitialized) 13 | let userData = (this.props.body.registration && this.props.body.registration.data) 14 | ? this.props.body.registration.data.userData 15 | : ''; 16 | 17 | // textarea (locked if user not logged in) 18 | let input = (this.props.body.tid) 19 | ? 20 | : ; 21 | 22 | // section title 23 | let title = (this.props.body.tid) 24 | ?

Your User Data

25 | :

Sign In to Edit Your User Data

; 26 | 27 | // JSX return 28 | return ( 29 |
30 | {title} 31 | {input} 32 |
33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/routes/set-user-data.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const request = require('request'); 4 | const config = require('../../config'); 5 | 6 | router.post('/', (req, res) => { 7 | // fetch the user using the token in the session so that we have their ID 8 | request( 9 | { 10 | method: 'GET', 11 | uri: `http://localhost:${config.fusionAuthPort}/oauth2/userinfo`, 12 | headers: { 13 | 'Authorization': 'Bearer ' + req.session.token 14 | } 15 | }, 16 | 17 | // callback 18 | (error, response, body) => { 19 | let userInfoResponse = JSON.parse(body); 20 | request( 21 | // PATCH request to /registration endpoint 22 | { 23 | method: 'PATCH', 24 | uri: `http://localhost:${config.fusionAuthPort}/api/user/registration/${userInfoResponse.sub}/${config.applicationID}`, 25 | headers: { 26 | 'Authorization': config.apiKey 27 | }, 28 | json: true, 29 | body: { 30 | 'registration': { 31 | 'data': req.body 32 | } 33 | } 34 | }, 35 | (err2, response2, body2) => { 36 | if (err2) { 37 | console.log(err2); 38 | } 39 | } 40 | ); 41 | } 42 | ); 43 | }); 44 | 45 | module.exports = router; 46 | 47 | -------------------------------------------------------------------------------- /server/routes/user.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const request = require('request'); 4 | const config = require('../../config'); 5 | 6 | router.get('/', (req, res) => { 7 | // token in session -> get user data and send it back to the Angular app 8 | if (req.session.token) { 9 | request( 10 | { 11 | method: 'GET', 12 | uri: `http://localhost:${config.fusionAuthPort}/oauth2/userinfo`, 13 | headers: { 14 | 'Authorization': 'Bearer ' + req.session.token 15 | } 16 | }, 17 | 18 | // callback 19 | (error, response, body) => { 20 | let userInfoResponse = JSON.parse(body); 21 | 22 | // valid token -> get more user data and send it back to the Angular app 23 | request( 24 | // GET request to /registration endpoint 25 | { 26 | method: 'GET', 27 | uri: `http://localhost:${config.fusionAuthPort}/api/user/registration/${userInfoResponse.sub}/${config.applicationID}`, 28 | json: true, 29 | headers: { 30 | 'Authorization': config.apiKey 31 | } 32 | }, 33 | 34 | // callback 35 | (error, response, body) => { 36 | res.send( 37 | { 38 | ...userInfoResponse, 39 | ...body // body is results from the registration endpoint 40 | 41 | } 42 | ); 43 | } 44 | ); 45 | } 46 | ); 47 | } 48 | 49 | // no token -> send nothing 50 | else { 51 | res.send({}); 52 | } 53 | }); 54 | 55 | module.exports = router; 56 | 57 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | db: 5 | image: postgres:12.9 6 | environment: 7 | PGDATA: /var/lib/postgresql/data/pgdata 8 | POSTGRES_USER: ${POSTGRES_USER} 9 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 10 | healthcheck: 11 | test: [ "CMD-SHELL", "pg_isready -U postgres" ] 12 | interval: 5s 13 | timeout: 5s 14 | retries: 5 15 | networks: 16 | - db_net 17 | restart: unless-stopped 18 | volumes: 19 | - db_data:/var/lib/postgresql/data 20 | 21 | search: 22 | image: docker.elastic.co/elasticsearch/elasticsearch:7.17.0 23 | environment: 24 | cluster.name: fusionauth 25 | bootstrap.memory_lock: "true" 26 | discovery.type: single-node 27 | ES_JAVA_OPTS: ${ES_JAVA_OPTS} 28 | healthcheck: 29 | test: [ "CMD", "curl", "--fail" ,"--write-out", "'HTTP %{http_code}'", "--silent", "--output", "/dev/null", "http://localhost:9200/" ] 30 | interval: 5s 31 | timeout: 5s 32 | retries: 5 33 | networks: 34 | - search_net 35 | restart: unless-stopped 36 | ulimits: 37 | memlock: 38 | soft: -1 39 | hard: -1 40 | volumes: 41 | - search_data:/usr/share/elasticsearch/data 42 | 43 | fusionauth: 44 | image: fusionauth/fusionauth-app:latest 45 | depends_on: 46 | db: 47 | condition: service_healthy 48 | search: 49 | condition: service_healthy 50 | environment: 51 | DATABASE_URL: jdbc:postgresql://db:5432/fusionauth 52 | DATABASE_ROOT_USERNAME: ${POSTGRES_USER} 53 | DATABASE_ROOT_PASSWORD: ${POSTGRES_PASSWORD} 54 | DATABASE_USERNAME: ${DATABASE_USERNAME} 55 | DATABASE_PASSWORD: ${DATABASE_PASSWORD} 56 | FUSIONAUTH_APP_MEMORY: ${FUSIONAUTH_APP_MEMORY} 57 | FUSIONAUTH_APP_RUNTIME_MODE: development 58 | FUSIONAUTH_APP_URL: http://fusionauth:9011 59 | SEARCH_SERVERS: http://search:9200 60 | SEARCH_TYPE: elasticsearch 61 | FUSIONAUTH_APP_KICKSTART_FILE: ${FUSIONAUTH_APP_KICKSTART_FILE} 62 | 63 | networks: 64 | - db_net 65 | - search_net 66 | restart: unless-stopped 67 | ports: 68 | - 9011:9011 69 | volumes: 70 | - fusionauth_config:/usr/local/fusionauth/config 71 | - ./kickstart:/usr/local/fusionauth/kickstart 72 | 73 | networks: 74 | db_net: 75 | driver: bridge 76 | search_net: 77 | driver: bridge 78 | 79 | volumes: 80 | db_data: 81 | fusionauth_config: 82 | search_data: 83 | -------------------------------------------------------------------------------- /client/app/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | 5 | import Greeting from './components/Greeting.js'; 6 | import LogInOut from './components/LogInOut.js'; 7 | import Response from './components/Response.js'; 8 | import UserData from './components/UserData.js'; 9 | 10 | const config = require('../../config'); 11 | 12 | class App extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | body: {} // this is the body from /user 17 | }; 18 | this.handleTextInput = this.handleTextInput.bind(this); 19 | } 20 | 21 | componentDidMount() { 22 | fetch(`http://localhost:${config.serverPort}/user`, { 23 | credentials: 'include' // fetch won't send cookies unless you set credentials 24 | }) 25 | .then(response => response.json()) 26 | .then(data => { console.log("data", data); this.setState({ body: data }); }); 27 | } 28 | 29 | handleTextInput(event) { 30 | // update this.state.body.registration.data.userData 31 | let body = this.state.body; 32 | if (body['registration'] === undefined) 33 | body['registration'] = {}; 34 | 35 | body.registration.data = {userData: event.target.value}; 36 | this.setState( 37 | { 38 | body: body 39 | }); 40 | 41 | // save the change in FusionAuth 42 | fetch(`http://localhost:${config.serverPort}/set-user-data`, 43 | { 44 | credentials: 'include', 45 | method: 'POST', 46 | headers: { 47 | 'Content-Type': 'application/json' 48 | }, 49 | body: JSON.stringify( 50 | { 51 | userData: event.target.value 52 | }) 53 | }); 54 | } 55 | 56 | render() { 57 | return ( 58 |
59 |
60 |

FusionAuth Example: React

61 | 62 | 63 |
64 |
65 | 66 | 67 |
68 | 72 |
73 | ); 74 | } 75 | } 76 | 77 | ReactDOM.render(, document.querySelector('#Container')); 78 | -------------------------------------------------------------------------------- /kickstart/kickstart.json: -------------------------------------------------------------------------------- 1 | { 2 | "variables": { 3 | "applicationId": "E9FDB985-9173-4E01-9D73-AC2D60D1DC8E", 4 | "apiKey": "this_really_should_be_a_long_random_alphanumeric_value_but_this_still_works", 5 | "asymmetricKeyId": "#{UUID()}", 6 | "defaultTenantId": "d7d09513-a3f5-401c-9685-34ab6c552453", 7 | "adminEmail": "admin@example.com", 8 | "adminPassword": "password", 9 | "userEmail": "richard@example.com", 10 | "userPassword": "password", 11 | "userUserId": "00000000-0000-0000-0000-111111111111" 12 | }, 13 | "apiKeys": [ 14 | { 15 | "key": "#{apiKey}", 16 | "description": "Unrestricted API key" 17 | } 18 | ], 19 | "requests": [ 20 | { 21 | "method": "POST", 22 | "url": "/api/key/generate/#{asymmetricKeyId}", 23 | "tenantId": "#{defaultTenantId}", 24 | "body": { 25 | "key": { 26 | "algorithm": "RS256", 27 | "name": "For exampleapp", 28 | "length": 2048 29 | } 30 | } 31 | }, 32 | { 33 | "method": "POST", 34 | "url": "/api/user/registration", 35 | "body": { 36 | "user": { 37 | "email": "#{adminEmail}", 38 | "password": "#{adminPassword}" 39 | }, 40 | "registration": { 41 | "applicationId": "#{FUSIONAUTH_APPLICATION_ID}", 42 | "roles": [ 43 | "admin" 44 | ] 45 | } 46 | } 47 | }, 48 | { 49 | "method": "POST", 50 | "url": "/api/application/#{applicationId}", 51 | "tenantId": "#{defaultTenantId}", 52 | "body": { 53 | "application": { 54 | "name": "exampleapp", 55 | "oauthConfiguration" : { 56 | "authorizedRedirectURLs": ["http://localhost:3000/oauth-callback"], 57 | "clientSecret": "super-secret-secret-that-should-be-regenerated-for-production", 58 | "logoutURL": "http://localhost:4200", 59 | "enabledGrants": ["authorization_code"] 60 | }, 61 | "jwtConfiguration": { 62 | "enabled": true, 63 | "accessTokenKeyId": "#{asymmetricKeyId}", 64 | "idTokenKeyId": "#{asymmetricKeyId}" 65 | } 66 | } 67 | } 68 | }, 69 | { 70 | "method": "POST", 71 | "url": "/api/user/registration/#{userUserId}", 72 | "body": { 73 | "user": { 74 | "birthDate": "1985-11-23", 75 | "email": "#{userEmail}", 76 | "firstName": "Richard", 77 | "lastName": "Hendricks", 78 | "password": "#{userPassword}" 79 | }, 80 | "registration": { 81 | "applicationId": "#{applicationId}" 82 | } 83 | } 84 | } 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **This repo is out of date and is archived. Check out [an updated tutorial on using FusionAuth with React](https://fusionauth.io/docs/quickstarts/quickstart-javascript-react-web) or [the updated GitHub repository](https://github.com/fusionauth/fusionauth-quickstart-javascript-react-web).** 2 | 3 | # Example: Using React with FusionAuth 4 | 5 | ------ 6 | 7 | This project contains an example project that illustrates using FusionAuth with a React front-end and a NodeJS/Express backend. This application will use an OAuth Authorization Code workflow and the PKCE (PKCE stands for Proof Key for Code Exchange, and is often pronounced “pixie”) extension to log users in and a NodeJS backend to store your access token securely. 8 | 9 | You can read the blog post here: https://fusionauth.io/blog/2020/03/10/securely-implement-oauth-in-react/ 10 | 11 | ## React Version 12 | 13 | This project runs React 16. This is an old version. Please see https://github.com/FusionAuth/fusionauth-example-react-2.0/ if you are looking for a React 17 example. 14 | 15 | ## Prerequisites 16 | You will need the following things properly installed on your computer. 17 | 18 | * [Git](http://git-scm.com/): Presumably you already have this on your machine if you are looking at this project locally; if not, use your platform's package manager to install git, and `git clone` this repo. 19 | * [NodeJS](https://nodejs.org): This will install the NodeJS runtime, which includes the package management tool `npm` needed for pulling down the various dependencies. 20 | * [Docker](https://www.docker.com): For standing up FusionAuth from within a Docker container. (You can [install it other ways](https://fusionauth.io/docs/v1/tech/installation-guide/), but for this example you'll need Docker.) 21 | 22 | ## Installation 23 | To install, do the following in a shell/Terminal window: 24 | 25 | * `git clone https://github.com/fusionauth/fusionauth-example-react` or `gh repo clone fusionauth/fusionauth-example-react` 26 | * `cd fusionauth-example-react`: This is the root of the example. 27 | * `cd client; npm install`: This will bring all the node modules onto the machine. 28 | * `cd ../server; npm install`: Likewise. 29 | 30 | ## FusionAuth Configuration 31 | This example assumes that you will run FusionAuth from a Docker container. In the root of this project directory (next to this README) are two files [a Docker compose file](./docker-compose.yml) and an [environment variables configuration file](./.env). Assuming you have Docker installed on your machine, a `docker-compose up` will bring FusionAuth up on your machine. 32 | 33 | The FusionAuth configuration files also make use of a unique feature of FusionAuth, called Kickstart: when FusionAuth comes up for the first time, it will look at the [Kickstart file](./kickstart/kickstart.json) and mimic API calls to configure FusionAuth for use. It will perform all the necessary setup to make this demo work correctly, but if you are curious as to what the setup would look like by hand, the "FusionAuth configuration (by hand)" section of this README describes it in detail. 34 | 35 | For now, get FusionAuth in Docker up and running (via `docker-compose up`) if it is not already running; to see, [click here](http://localhost:9011/) to verify it is up and running. 36 | 37 | > **NOTE**: If you ever want to reset the FusionAuth system, delete the volumes created by docker-compose by executing `docker-compose down -v`. FusionAuth will only apply the Kickstart settings when it is first run (e.g., it has no data configured for it yet). 38 | 39 | ## Running 40 | To run, do the following: 41 | 42 | * In one shell, run `docker-compose up` 43 | * In another shell, `cd server` and `npm run start` 44 | * In a third shell, `cd client` and `npm run start` 45 | 46 | [Open a browser to the React app](http://localhost:4200/). The app will automatically reload if you change any of the source files. 47 | 48 | ## Architecture 49 | The app has three parts, each running on a different `localhost` port: 50 | 51 | - `localhost:4200` is the React app. It has a single route (`/`) and makes calls to the Express app. 52 | - `localhost:3000` is the Express app. It has several routes (like `/login` and `/logout`), which are used by the React front-end. The Express app makes calls to FusionAuth. 53 | - `localhost:9011` is your instance of FusionAuth. It has several endpoints (like `/authorize` and `/introspect`). It accepts calls from the Express app and sends back information, such as access tokens and user registration data. 54 | 55 | So, the parts connect like this: 56 | 57 | `React (4200) <---> Express (3000) <---> FusionAuth (9011)` 58 | 59 | The React app never talks directly to FusionAuth. This is important, because the React app can be easily picked apart by anyone online (it's Javascript, which means the source is directly visible to anyone with a browser), which means you can't keep confidential information there. While some calls directly to FusionAuth are safe, it's usually important to keep things separated like this. 60 | 61 | ### Logging In/Out 62 | 63 | When the user clicks on `sign in`, the React app redirects to the Express server's `/login` route, which redirects to FusionAuth's `authorize` endpoint. FusionAuth renders the username/password form, authenticates the user, and redirects to the configured Redirect URI (`/oauth-redirect` on the Express server) with an Authorization Code. 64 | 65 | The Express server sends the Authorization Code (as well as its Client ID and Secret) to FusionAuth's `/token` endpoint. FusionAuth validates everything and sends back an Access Token. The Express Server saves this token in session storage and redirects back to the React client. 66 | 67 | When the user clicks on `sign out`, the React app sends a request to the Express server's `/logout` route, which sends a request to FusionAuth's `/logout` endpoint, deletes the relevant cookie, and deletes the Access Token from session storage. 68 | 69 | **The presence of the Access Token in session storage is what defines whether or not a user is logged in**, because FusionAuth will not allow retrieval or modification of user data without a valid Access Token. 70 | 71 | ### Rendering the React App 72 | 73 | When the React client mounts, it sends a request to the Express server's `/user` route. If there's an Access Token in session storage, the Express server uses FusionAuth's `/introspect` and `/registration` endpoints to get data for the current user; these give us the `token` and `registration` JSON objects seen in the example app. 74 | 75 | If there is no Access Token (or if it's expired), `/user` will instead return an empty object. The React components use the existence of `token` (or lack thereof) to determine whether to render the page in its logged-in or logged-out state. 76 | 77 | ### Editing User Data 78 | 79 | All of your FusionAuth users have a `registration.data` object for storing arbitrary data related to the user. The example app allows logged-in users to modify `registration.data.userData` by changing its value in the `