├── Makefile ├── public ├── custom.css ├── callback.js └── main.css ├── lib ├── handlers │ ├── index.js │ ├── configuration.js │ ├── invite.js │ ├── auth_api.js │ ├── login.js │ └── bootstrap.js ├── pkce.js ├── jar.js ├── api2.js ├── env.js ├── constants.js └── client_authentication.js ├── .env.example ├── views ├── error.jade ├── samluser.jade ├── menusaml.jade ├── callback.jade ├── layout.jade ├── menu.jade ├── user.jade └── index.jade ├── .vscode └── launch.json ├── README.md ├── package.json ├── scripts └── reset_tenant.js ├── .gitignore ├── server.js ├── app.js ├── bin └── www └── routes ├── user.js └── index.js /Makefile: -------------------------------------------------------------------------------- 1 | start-local: 2 | npm run start -------------------------------------------------------------------------------- /public/custom.css: -------------------------------------------------------------------------------- 1 | img.user-picture { 2 | width: 25px; 3 | height: 25px; 4 | vertical-align: middle; 5 | } 6 | -------------------------------------------------------------------------------- /lib/handlers/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | auth_api: require('./auth_api'), 3 | invite: require('./invite'), 4 | login: require('./login'), 5 | configuration: require('./configuration') 6 | }; 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | AUTH0_MGMT_CLIENT_ID=some-other-client-id 2 | AUTH0_MGMT_CLIENT_SECRET=some-other-client-secret 3 | AUTH0_DOMAIN=your-tenant.auth0.com 4 | APP_JAR_KEY_ALG=RS256 5 | APP_CLIENT_AUTHENTICATION_METHOD=client_secret_post 6 | AUTH0_MGMT_CLIENT_AUTHENTICATION_METHOD=client_secret_post 7 | ENABLE_JAR=false -------------------------------------------------------------------------------- /views/error.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | div(class="row mt-3") 5 | div(class="col-sm-12") 6 | div(class="card") 7 | h5(class="card-header") An error occurred during login. 8 | div(class="card-body") 9 | h5(class="card-title") #{error} 10 | p(class="card-text") #{error_description} 11 | a(href="/") 12 | button(id="apiCallButton" class="btn btn-danger") Home 13 | -------------------------------------------------------------------------------- /views/samluser.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | block content 3 | include menusaml 4 | p   5 | h1 Welcome #{user.profile.email}! 6 | div(class="card") 7 | div(class="card-header" id="headingOne") 8 | h5(class="mb-0") 9 | User Profile SAML Response 10 | div(id="collapseOne" class="collapse show" aria-labelledby="headingOne" data-parent="#accordion") 11 | div(class="card-body") 12 | pre 13 | code #{JSON.stringify(samlProfile, null, 4)} 14 | -------------------------------------------------------------------------------- /views/menusaml.jade: -------------------------------------------------------------------------------- 1 | nav(class="navbar navbar-expand-sm navbar-dark bg-primary") 2 | a(class="navbar-brand") Fake SaaS SAML App Demo 3 | ul(class="navbar-nav") 4 | li(class="nav-item active") 5 | a(href="/" class="nav-link") Go Home Without Logout 6 | li(class="nav-item active") 7 | a(href='https://#{config.AUTH0_DOMAIN}/v2/logout?returnTo=#{config.APP_LOGOUT_URL}&client_id=#{config.APP_CLIENT_ID}' class="nav-link") Logout 8 | |   9 | |   10 | span(class="navbar-text") Logged in as #{user.profile.email}   11 | -------------------------------------------------------------------------------- /lib/pkce.js: -------------------------------------------------------------------------------- 1 | const crypto = require("crypto"); 2 | const base64url = require("base64url"); 3 | 4 | module.exports = { 5 | generateCodeVerifier() { 6 | const unencodedVerifier = crypto.randomBytes(32); 7 | return base64url.encode(unencodedVerifier); 8 | }, 9 | 10 | generateCodeChallengeFromVerifier(codeChallengeMethod, codeChallenge) { 11 | if (codeChallengeMethod === "plain") { 12 | return codeChallenge; 13 | } 14 | return base64url.encode(crypto.createHash("sha256").update(codeChallenge, "utf-8").digest()); 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /views/callback.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | div(class="jumbotron") 5 | h1(class="display-4")= title 6 | form(name="callback_form" method="post" action="/callback") 7 | input(type="text", name="error") 8 | input(type="text", name="error_description") 9 | input(type="text", name="code") 10 | input(type="text", name="state") 11 | input(type="text", name="access_token") 12 | input(type="text", name="id_token") 13 | input(type="text", name="response") 14 | input(type="submit" value="Redirect" class="btn btn-primary") 15 | -------------------------------------------------------------------------------- /views/layout.jade: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | body 13 | div(class="container") 14 | block content 15 | -------------------------------------------------------------------------------- /views/menu.jade: -------------------------------------------------------------------------------- 1 | nav(class="navbar navbar-expand-sm navbar-dark bg-primary") 2 | a(class="navbar-brand") Fake SaaS App Demo 3 | ul(class="navbar-nav") 4 | li(class="nav-item active") 5 | a(href="/" class="nav-link") Go Home Without Logout 6 | li(class="nav-item active") 7 | a(href='https://#{config.AUTH0_DOMAIN}/v2/logout?returnTo=#{config.APP_LOGOUT_URL}&client_id=#{config.APP_CLIENT_ID}' class="nav-link") Logout 8 | li(class="nav-item active") 9 | a(href="/user/refresh" class="nav-link") Refresh Tokens 10 | li(class="nav-item active") 11 | a(href="/user/userinfo" class="nav-link") Call Userinfo 12 | |   13 | |   14 | span(class="navbar-text") Logged in as #{user.profile.nickname}   15 | img(id="userPicture" class="user-picture" src="#{user.profile.picture}") 16 | -------------------------------------------------------------------------------- /lib/jar.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const { getEnv } = require("./env"); 3 | 4 | const createJARPayload = (params) => { 5 | const { 6 | APP_JAR_KEY_ID, 7 | APP_JAR_PRIVATE_KEY, 8 | AUTH0_DOMAIN, 9 | } = getEnv(); 10 | const client_id = params.client_id; 11 | 12 | const assertion = jwt.sign( 13 | { 14 | ...params, 15 | jti: "" + Date.now(), 16 | iat: Math.floor(Date.now() / 1000), 17 | nbf: Math.floor(Date.now() / 1000), 18 | }, 19 | APP_JAR_PRIVATE_KEY, 20 | { 21 | audience: `https://${AUTH0_DOMAIN}/`, 22 | issuer: client_id, 23 | subject: client_id, 24 | keyid: APP_JAR_KEY_ID, 25 | algorithm: "PS256", 26 | expiresIn: "1m", 27 | } 28 | ); 29 | return { request: assertion, client_id: client_id }; 30 | }; 31 | 32 | exports.createJARPayload = createJARPayload; -------------------------------------------------------------------------------- /.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 | "name": "Attach Local", 9 | "type": "node", 10 | "request": "attach", 11 | "port": 7370, 12 | "address": "127.0.0.1", 13 | "restart": false, 14 | "sourceMaps": false, 15 | "outFiles": [], 16 | "remoteRoot": "${workspaceRoot}", 17 | "localRoot": "${workspaceRoot}", 18 | "protocol": "inspector", 19 | "skipFiles": [ 20 | "!**/node_modules/**", 21 | "**/$KNOWN_TOOLS$/**", 22 | "/**", 23 | "/internal/async_hooks.js", 24 | "/internal/inspector_async_hook.js" 25 | ] 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # demozero-token-demo 2 | 3 | ## Setup 4 | 5 | - `npm install` 6 | - Copy .env.example to .env and set the appropriate values. 7 | - The Management API client (M2M app) that you use must have at minimum read:client_grants and update:client_grants scopes assigned via a client grant for the API2 resource server in your tenant. This is the minimum setup that is required. The app will automatically bootstrap the rest. 8 | - Add myapp.com to your /etc/hosts, mapped to 127.0.0.1. 9 | - Create self-signed cert as described here https://bit.ly/3oj6t9u. Save as server.key and server.cert in the root directory of the application. 10 | - Now you can navigate to the app at https://myapp.com:4040/ 11 | ## Running the example 12 | 13 | Use `npm start` to run the project. 14 | 15 | ## Reset Tenant 16 | 17 | You can reset the tenant used with this app, which will delete the client, resource server, etc. that are created during the bootstrap process. To do this, run `npm run reset-tenant`. 18 | -------------------------------------------------------------------------------- /public/callback.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const OAUTH_PARAM_NAMES = [ 4 | "error", 5 | "error_description", 6 | "code", 7 | "state", 8 | "access_token", 9 | "id_token", 10 | "response" 11 | ]; 12 | 13 | function hasImplicitOAuthParams(hashParams) { 14 | return OAUTH_PARAM_NAMES.find((paramName) => hashParams.has(paramName)); 15 | } 16 | 17 | document.addEventListener("DOMContentLoaded", async () => { 18 | const url = new URL(document.location); 19 | if (url.pathname === "/callback") { 20 | const hashParams = new URLSearchParams( 21 | document.location.hash.replace("#", "") 22 | ); 23 | 24 | if (hasImplicitOAuthParams(hashParams)) { 25 | // This path is followed when an implicit/hybrid flow is used. Take the 26 | // parameters from the hash, put them into a form, and submit 27 | 28 | OAUTH_PARAM_NAMES.forEach((paramName) => { 29 | if (hashParams.has(paramName)) { 30 | document 31 | .querySelector(`[name=${paramName}]`) 32 | .setAttribute("value", hashParams.get(paramName)); 33 | } 34 | }); 35 | 36 | document.callback_form.submit(); 37 | } else { 38 | document.location = "/"; 39 | } 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /lib/handlers/configuration.js: -------------------------------------------------------------------------------- 1 | const { getEnv, setEnv } = require("../env"); 2 | const _ = require("lodash"); 3 | 4 | const ALLOWED_CONFIGURATION_PARAMS = [ 5 | "acr_values", 6 | "app_client_authentication_method", 7 | "audience", 8 | "authorization_details", 9 | "claims", 10 | "jar_enabled", 11 | "login_hint", 12 | "owp", 13 | "par_enabled", 14 | "pkce_code_challenge_method", 15 | "pkce", 16 | "redirect_uri", 17 | "response_mode", 18 | "response_type", 19 | "scope", 20 | "send_authorization_details", 21 | "prompt", 22 | ]; 23 | 24 | const saveConfiguration = (req, res) => { 25 | const updatedConfiguration = _.pick(req.body, ALLOWED_CONFIGURATION_PARAMS); 26 | Object.keys(updatedConfiguration).forEach((configurationKey) => 27 | setEnv(configurationKey, updatedConfiguration[configurationKey], { 28 | logging: false, 29 | }) 30 | ); 31 | 32 | setEnv("jar_enabled", !!updatedConfiguration.jar_enabled); 33 | setEnv("owp", !!updatedConfiguration.owp); 34 | setEnv("par_enabled", !!updatedConfiguration.par_enabled); 35 | setEnv("pkce", !!updatedConfiguration.pkce); 36 | setEnv( 37 | "send_authorization_details", 38 | !!updatedConfiguration.send_authorization_details 39 | ); 40 | 41 | console.log("current environment", JSON.stringify(getEnv(), null, 2)); 42 | 43 | res.redirect("/"); 44 | }; 45 | 46 | module.exports = { 47 | saveConfiguration, 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-quickstart", 3 | "version": "1.0.0", 4 | "description": "This is a prototype nodejs quickstart project to demonstrate new OAuth as a service features with Auth0.", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "nodemon --inspect=7370 server.js", 8 | "start:debug": "nodemon --inspect-brk server.js", 9 | "reset-tenant": "node scripts/reset_tenant.js" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "axios": "^0.21.1", 15 | "base64url": "^3.0.1", 16 | "body-parser": "^1.15.2", 17 | "cookie-parser": "^1.4.3", 18 | "dotenv": "^2.0.0", 19 | "express": "^4.17.1", 20 | "express-openid-connect": "^2.0.0", 21 | "express-session": "^1.14.1", 22 | "jade": "^1.11.0", 23 | "jose": "^4.13.2", 24 | "jsonwebtoken": "^9.0.0", 25 | "jwt-decode": "^2.2.0", 26 | "lodash": "^4.17.11", 27 | "morgan": "^1.7.0", 28 | "pem-jwk": "^2.0.0", 29 | "req-flash": "0.0.3", 30 | "update-dotenv": "^1.1.1", 31 | "uuid": "^9.0.0" 32 | }, 33 | "now": { 34 | "files": [ 35 | ".env", 36 | "./env_map.js", 37 | "./public", 38 | "./views", 39 | "app.js", 40 | "server.js", 41 | "./routes", 42 | "./app_passport.js" 43 | ], 44 | "name": "node-hosted-demo", 45 | "alias": "node-hosted-demo" 46 | }, 47 | "devDependencies": { 48 | "nodemon": "^3.0.1" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /scripts/reset_tenant.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const { APP_RESOURCE_SERVER_IDENTIFIER, CLIENT_NAME_FOR_DEMO_APP } = require('../lib/constants'); 3 | const { makeApi2Request } = require('../lib/api2'); 4 | 5 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; 6 | 7 | dotenv.config(); 8 | 9 | const resetTenant = async () => { 10 | console.log('This script will remove artifacts from the tenant used for this demo app.'); 11 | console.log('The next time the app boots, these artifacts will be re-created.'); 12 | console.log('>>> DELETING CLIENT'); 13 | await deleteAppClient(); 14 | console.log('>>> DELETING RESOURCE SERVER'); 15 | await deleteAppResourceServer(); 16 | }; 17 | 18 | const deleteAppClient = async () => { 19 | const getClientsRequest = { 20 | path: 'clients?page=0&per_page=100', 21 | }; 22 | 23 | const clients = await makeApi2Request(getClientsRequest); 24 | const appClient = clients.filter((client) => client.name === CLIENT_NAME_FOR_DEMO_APP); 25 | if (appClient.length < 1) { 26 | return; 27 | } 28 | 29 | const request = { 30 | method: 'delete', 31 | path: `clients/${appClient[0].client_id}`, 32 | }; 33 | await makeApi2Request(request); 34 | }; 35 | 36 | const deleteAppResourceServer = async () => { 37 | const request = { 38 | method: 'delete', 39 | path: `resource-servers/${APP_RESOURCE_SERVER_IDENTIFIER}`, 40 | }; 41 | await makeApi2Request(request); 42 | }; 43 | 44 | resetTenant(); 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | server.cert 3 | server.key 4 | 5 | # Created by https://www.gitignore.io/api/node,osx 6 | 7 | ### Node ### 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules 39 | jspm_packages 40 | 41 | # Optional npm cache directory 42 | .npm 43 | 44 | # Optional eslint cache 45 | .eslintcache 46 | 47 | # Optional REPL history 48 | .node_repl_history 49 | 50 | # Output of 'npm pack' 51 | *.tgz 52 | 53 | 54 | ### OSX ### 55 | *.DS_Store 56 | .AppleDouble 57 | .LSOverride 58 | 59 | # Icon must end with two \r 60 | Icon 61 | 62 | 63 | # Thumbnails 64 | ._* 65 | 66 | # Files that might appear in the root of a volume 67 | .DocumentRevisions-V100 68 | .fseventsd 69 | .Spotlight-V100 70 | .TemporaryItems 71 | .Trashes 72 | .VolumeIcon.icns 73 | .com.apple.timemachine.donotpresent 74 | 75 | # Directories potentially created on remote AFP share 76 | .AppleDB 77 | .AppleDesktop 78 | Network Trash Folder 79 | Temporary Items 80 | .apdisk 81 | 82 | envs/ 83 | .env 84 | set_env 85 | set_env.js 86 | 87 | keys/ -------------------------------------------------------------------------------- /lib/api2.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | const { API2_BASE_URL, TOKEN_ENDPOINT, API2_AUDIENCE } = require("./constants"); 4 | const { getManagementClientAuthentication } = require('./client_authentication'); 5 | const { getEnv } = require('./env'); 6 | 7 | let api2Token; 8 | 9 | const getToken = async (force = false) => { 10 | if (!api2Token || force) { 11 | const tokenRequest = { 12 | client_id: getEnv("AUTH0_MGMT_CLIENT_ID"), 13 | grant_type: "client_credentials", 14 | audience: API2_AUDIENCE, 15 | ...getManagementClientAuthentication() 16 | }; 17 | const config = { 18 | method: "post", 19 | url: TOKEN_ENDPOINT, 20 | headers: { 21 | "Content-Type": "application/json", 22 | }, 23 | data: JSON.stringify(tokenRequest), 24 | }; 25 | 26 | const response = await axios(config); 27 | api2Token = response.data.access_token; 28 | } 29 | return api2Token; 30 | }; 31 | 32 | const makeApi2Request = async (options) => { 33 | try { 34 | const api2Token = await getToken(); 35 | const method = 36 | (options && options.method && options.method.toLowerCase()) || "get"; 37 | 38 | const url = `${API2_BASE_URL}${options.path}`; 39 | const config = { 40 | method, 41 | url, 42 | headers: { 43 | Authorization: `Bearer ${api2Token}`, 44 | "Content-Type": "application/json", 45 | }, 46 | data: JSON.stringify(options.data), 47 | }; 48 | 49 | const response = await axios(config); 50 | return response.data; 51 | } catch (error) { 52 | const api2Error = new Error(error.message); 53 | if (error.response) { 54 | api2Error.data = error.response.data; 55 | } 56 | 57 | throw api2Error; 58 | } 59 | }; 60 | 61 | module.exports = { 62 | getToken, 63 | makeApi2Request, 64 | }; 65 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv"); 2 | const debug = require("debug")("nodejs-regular-webapp2:server"); 3 | const https = require("https"); 4 | const fs = require("fs"); 5 | dotenv.config(); 6 | 7 | const env = require("./lib/env"); 8 | const boostrap = require("./lib/handlers/bootstrap"); 9 | 10 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; 11 | 12 | const init = async () => { 13 | await boostrap.bootstrapProcess(); 14 | const app = require("./app"); 15 | 16 | const port = normalizePort(process.env.PORT || "4040"); 17 | app.set("port", port); 18 | 19 | console.log(">>> Using env:"); 20 | console.log(env.getEnv()); 21 | 22 | const server = https.createServer( 23 | { 24 | key: fs.readFileSync("server.key"), 25 | cert: fs.readFileSync("server.cert"), 26 | }, 27 | app 28 | ); 29 | 30 | server.listen(port); 31 | 32 | server.on("error", (error) => { 33 | if (error.syscall !== "listen") { 34 | throw error; 35 | } 36 | 37 | const bind = typeof port === "string" ? "Pipe " + port : "Port " + port; 38 | 39 | switch (error.code) { 40 | case "EACCES": 41 | console.error(bind + " requires elevated privileges"); 42 | process.exit(1); 43 | break; 44 | case "EADDRINUSE": 45 | console.error(bind + " is already in use"); 46 | process.exit(1); 47 | break; 48 | default: 49 | throw error; 50 | } 51 | }); 52 | 53 | server.on("listening", () => { 54 | const addr = server.address(); 55 | const bind = 56 | typeof addr === "string" ? "pipe " + addr : "port " + addr.port; 57 | debug("Listening on " + bind); 58 | console.log("Listening on " + bind); 59 | }); 60 | }; 61 | 62 | const normalizePort = (portValue) => { 63 | const port = parseInt(portValue, 10); 64 | 65 | if (isNaN(port)) { 66 | return portValue; 67 | } 68 | 69 | if (port >= 0) { 70 | return port; 71 | } 72 | 73 | return false; 74 | }; 75 | 76 | init(); 77 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const logger = require('morgan'); 4 | const session = require('express-session'); 5 | const flash = require('req-flash'); 6 | const bodyParser = require('body-parser'); 7 | const routes = require('./routes/index'); 8 | const user = require('./routes/user'); 9 | 10 | const app = express(); 11 | 12 | app.use(bodyParser.urlencoded({ extended: false })); 13 | 14 | app.set('views', path.join(__dirname, 'views')); 15 | app.set('view engine', 'jade'); 16 | app.set('view options', { pretty: true }); 17 | 18 | app.use(logger('dev')); 19 | app.use( 20 | session({ 21 | secret: 'yourSessionSecret', 22 | resave: true, 23 | saveUninitialized: true, 24 | }) 25 | ); 26 | app.use(flash()); 27 | app.use(express.static(path.join(__dirname, 'public'))); 28 | 29 | app.use(function authErrorHandler(req, res, next) { 30 | if (req && req.query && req.query.error) { 31 | req.flash('error', req.query.error); 32 | } 33 | if (req && req.query && req.query.error_description) { 34 | req.flash('error_description', req.query.error_description); 35 | } 36 | next(); 37 | }); 38 | 39 | app.use('/', routes); 40 | app.use('/user', user); 41 | 42 | app.use(function catch404Error(req, res, next) { 43 | var err = new Error('Not Found'); 44 | err.status = 404; 45 | next(err); 46 | }); 47 | 48 | if (app.get('env') === 'development') { 49 | app.use(function devErrorHandler(err, req, res, next) { 50 | // TODO: A better way to output diagnostic info in the console 51 | console.log(err.data); 52 | res.status(err.status || 500); 53 | res.render('error', { 54 | message: err.message, 55 | error: err, 56 | }); 57 | }); 58 | } 59 | 60 | app.use(function prodErrorHandler(err, req, res, next) { 61 | // TODO: What makes to log in production? 62 | res.status(err.status || 500); 63 | res.render('error', { 64 | message: err.message, 65 | error: {}, 66 | }); 67 | }); 68 | 69 | module.exports = app; 70 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Module dependencies. 5 | */ 6 | 7 | var app = require('../app'); 8 | var debug = require('debug')('nodejs-regular-webapp2:server'); 9 | var http = require('http'); 10 | 11 | /** 12 | * Get port from environment and store in Express. 13 | */ 14 | 15 | var port = normalizePort(process.env.PORT || "4040"); 16 | app.set('port', port); 17 | 18 | /** 19 | * Create HTTP server. 20 | */ 21 | 22 | var server = http.createServer(app); 23 | 24 | /** 25 | * Listen on provided port, on all network interfaces. 26 | */ 27 | 28 | server.listen(port); 29 | server.on('error', onError); 30 | server.on('listening', onListening); 31 | 32 | /** 33 | * Normalize a port into a number, string, or false. 34 | */ 35 | 36 | function normalizePort(val) { 37 | var port = parseInt(val, 10); 38 | 39 | if (isNaN(port)) { 40 | // named pipe 41 | return val; 42 | } 43 | 44 | if (port >= 0) { 45 | // port number 46 | return port; 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Event listener for HTTP server "error" event. 54 | */ 55 | 56 | function onError(error) { 57 | if (error.syscall !== 'listen') { 58 | throw error; 59 | } 60 | 61 | var bind = typeof port === 'string' 62 | ? 'Pipe ' + port 63 | : 'Port ' + port; 64 | 65 | // handle specific listen errors with friendly messages 66 | switch (error.code) { 67 | case 'EACCES': 68 | console.error(bind + ' requires elevated privileges'); 69 | process.exit(1); 70 | break; 71 | case 'EADDRINUSE': 72 | console.error(bind + ' is already in use'); 73 | process.exit(1); 74 | break; 75 | default: 76 | throw error; 77 | } 78 | } 79 | 80 | /** 81 | * Event listener for HTTP server "listening" event. 82 | */ 83 | 84 | function onListening() { 85 | var addr = server.address(); 86 | var bind = typeof addr === 'string' 87 | ? 'pipe ' + addr 88 | : 'port ' + addr.port; 89 | debug('Listening on ' + bind); 90 | console.log(`Listening on ${addr} ${bind}`); 91 | } 92 | -------------------------------------------------------------------------------- /lib/handlers/invite.js: -------------------------------------------------------------------------------- 1 | const { makeApi2Request } = require('../api2'); 2 | const { getEnv } = require('../env'); 3 | 4 | const deleteTestUsers = async (email) => { 5 | const requestOptions = { 6 | path: `users?q=email:"${email}"&search_engine=v3`, 7 | }; 8 | 9 | const response = await makeApi2Request(requestOptions); 10 | const userIds = response.map((user) => user.user_id); 11 | for (let userId of userIds) { 12 | await deleteSingleUser(userId); 13 | } 14 | }; 15 | 16 | const deleteSingleUser = async (userId) => { 17 | const requestOptions = { 18 | method: 'delete', 19 | path: `users/${userId}`, 20 | }; 21 | 22 | await makeApi2Request(requestOptions); 23 | }; 24 | 25 | const inviteFlow = async (req, res, next) => { 26 | // TODO: We should validate these instead of directly passing them to the backend 27 | const email = req.body.email; 28 | const organizationId = req.body.organization_id; 29 | const roleId = req.body.role_id; 30 | const connectionId = req.body.connection_id; 31 | 32 | try { 33 | const inviteRequest = { 34 | client_id: getEnv("APP_CLIENT_ID"), 35 | invitee: { email }, 36 | inviter: { name: 'John Doe' }, 37 | app_metadata: { 38 | source: 'Invited via test app', 39 | }, 40 | roles: [roleId], 41 | }; 42 | 43 | if (connectionId !== 'not-specified') { 44 | inviteRequest.connection_id = connectionId; 45 | } 46 | 47 | await deleteTestUsers(email); 48 | 49 | const requestOptions = { 50 | method: 'post', 51 | path: `organizations/${organizationId}/invitations`, 52 | data: inviteRequest, 53 | }; 54 | 55 | const response = await makeApi2Request(requestOptions); 56 | 57 | const invitationAppUrl = response.invitation_url; 58 | console.log(`Will redirect to ${invitationAppUrl}`); 59 | res.redirect(invitationAppUrl); 60 | } catch (error) { 61 | console.log('Error while creating invitation: '); 62 | console.log(error); 63 | return next(error); 64 | } 65 | }; 66 | 67 | module.exports = { 68 | inviteFlow, 69 | }; 70 | -------------------------------------------------------------------------------- /lib/handlers/auth_api.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | 3 | const { 4 | USERINFO_ENDPOINT, 5 | TOKEN_ENDPOINT, 6 | AUTH_REQUESTED_SCOPES, 7 | APP_CALLBACK_URL, 8 | } = require("../constants"); 9 | const { getEnv } = require("../env"); 10 | const { setAppClientAuthentication } = require("../client_authentication"); 11 | 12 | const getUserInfo = async (accessToken) => { 13 | const config = { 14 | method: "GET", 15 | url: USERINFO_ENDPOINT, 16 | headers: { 17 | "content-type": "application/json", 18 | Authorization: `Bearer ${accessToken}`, 19 | }, 20 | json: true, 21 | }; 22 | 23 | try { 24 | const response = await axios(config); 25 | return response.data; 26 | } catch (error) { 27 | console.error("error fetching userinfo", { 28 | status: error.response.status, 29 | data: error.response.data, 30 | statusText: error.response.statusText, 31 | errorText: 32 | error.response.headers["www-authenticate"] || error.response.body, 33 | }); 34 | throw error; 35 | } 36 | }; 37 | 38 | const getAccessTokenFromCode = async (authorizationCode, params = {}) => { 39 | const APP_CLIENT_ID = getEnv("APP_CLIENT_ID"); 40 | 41 | const config = setAppClientAuthentication({ 42 | method: "POST", 43 | url: TOKEN_ENDPOINT, 44 | headers: { "content-type": "application/json" }, 45 | data: { 46 | ...params, 47 | grant_type: "authorization_code", 48 | client_id: APP_CLIENT_ID, 49 | code: authorizationCode, 50 | redirect_uri: APP_CALLBACK_URL, 51 | }, 52 | json: true, 53 | }); 54 | 55 | const response = await axios(config); 56 | return response.data; 57 | }; 58 | 59 | const getRefreshToken = async (refreshToken) => { 60 | const config = setAppClientAuthentication({ 61 | method: "POST", 62 | url: TOKEN_ENDPOINT, 63 | headers: { "content-type": "application/json" }, 64 | data: { 65 | grant_type: "refresh_token", 66 | client_id: getEnv("APP_CLIENT_ID"), 67 | refresh_token: refreshToken, 68 | scope: AUTH_REQUESTED_SCOPES, 69 | }, 70 | json: true, 71 | }); 72 | 73 | const response = await axios(config); 74 | return response.data; 75 | }; 76 | 77 | module.exports = { 78 | getUserInfo, 79 | getRefreshToken, 80 | getAccessTokenFromCode, 81 | }; 82 | -------------------------------------------------------------------------------- /views/user.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | block content 3 | include menu 4 | p   5 | h1 Welcome #{user.profile.displayName}! 6 | if userinfoResponse 7 | p   8 | strong Userinfo result   9 | pre#api-call-result #{JSON.stringify(userinfoResponse, null, 4)} 10 | div(id="accordion") 11 | div(class="card") 12 | div(class="card-header" id="headingOne") 13 | h5(class="mb-0") 14 | button(class="btn btn-link" data-toggle="collapse" data-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne") Detached Signature 15 | div(id="collapseOne" class="collapse show" aria-labelledby="headingOne" data-parent="#accordion") 16 | div(class="card-body") 17 | pre 18 | h6 header 19 | code #{JSON.stringify(decodedDetachedSignature.header, null, 4)} 20 | pre 21 | h6 payload 22 | code #{JSON.stringify(decodedDetachedSignature.payload, null, 4)} 23 | div(class="card-header" id="headingTwo") 24 | h5(class="mb-0") 25 | button(class="btn btn-link" data-toggle="collapse" data-target="#collapseTwo" aria-expanded="true" aria-controls="collapseTwo") ID Token Payload 26 | div(id="collapseTwo" class="collapse" aria-labelledby="headingTwo" data-parent="#accordion") 27 | div(class="card-body") 28 | pre 29 | h6 header 30 | code #{JSON.stringify(decodedIDToken.header, null, 4)} 31 | pre 32 | h6 payload 33 | code #{JSON.stringify(decodedIDToken.payload, null, 4)} 34 | div(class="card") 35 | div(class="card-header" id="headingThree") 36 | h5(class="mb-0") 37 | button(class="btn btn-link" data-toggle="collapse" data-target="#collapseThree" aria-expanded="true" aria-controls="collapseThree") Access Token Payload 38 | div(id="collapseThree" class="collapse" aria-labelledby="headingThree" data-parent="#accordion") 39 | div(class="card-body") 40 | pre 41 | h6 header 42 | code #{JSON.stringify(decodedAccessToken.header, null, 4)} 43 | pre 44 | h6 payload 45 | code #{JSON.stringify(decodedAccessToken.payload, null, 4)} 46 | div(class="card") 47 | div(class="card-header" id="headingFour") 48 | h5(class="mb-0") 49 | button(class="btn btn-link" data-toggle="collapse" data-target="#collapseFour" aria-expanded="true" aria-controls="collapseFour") Tokens 50 | div(id="collapseFour" class="collapse" aria-labelledby="headingFour" data-parent="#accordion") 51 | div(class="card-body") 52 | pre Detached Signature ID Token: 53 | pre #{tokens.detached_signature} 54 | pre Access Token: 55 | pre #{tokens.access_token} 56 | pre ID Token: 57 | pre #{tokens.id_token} 58 | pre Refresh Token: 59 | pre #{tokens.refresh_token} 60 | -------------------------------------------------------------------------------- /lib/env.js: -------------------------------------------------------------------------------- 1 | const writeDotEnv = require("update-dotenv"); 2 | 3 | const { 4 | AUTH_REQUESTED_SCOPES, 5 | APP_CALLBACK_URL, 6 | APP_RESOURCE_SERVER_IDENTIFIER, 7 | PKCE_CODE_CHALLENGE_METHODS, 8 | CLIENT_AUTHENTICATION_METHODS, 9 | PROMPT_TYPES, 10 | } = require("../lib/constants"); 11 | 12 | function importKey(envName) { 13 | return process.env[envName]?.replace(/\\n/g, "\n") || ""; 14 | } 15 | 16 | const resolvedEnv = { 17 | AUTH0_DOMAIN: process.env.AUTH0_DOMAIN, 18 | AUTH0_MGMT_CLIENT_ID: process.env.AUTH0_MGMT_CLIENT_ID, 19 | AUTH0_MGMT_CLIENT_SECRET: process.env.AUTH0_MGMT_CLIENT_SECRET, 20 | AUTH0_MGMT_CLIENT_AUTHENTICATION_METHOD: 21 | process.env.AUTH0_MGMT_CLIENT_AUTHENTICATION_METHOD, 22 | AUTH0_MGMT_JWTCA_PRIVATE_KEY: importKey("AUTH0_MGMT_JWTCA_PRIVATE_KEY"), 23 | AUTH0_MGMT_JWTCA_PUBLIC_KEY: importKey("AUTH0_MGMT_JWTCA_PUBLIC_KEY"), 24 | AUTH0_MGMT_JWTCA_KEY_ID: process.env.AUTH0_MGMT_JWTCA_KEY_ID, 25 | AUTH0_MGMT_JWTCA_CREDENTIAL_ID: process.env.AUTH0_MGMT_JWTCA_CREDENTIAL_ID, 26 | APP_CLIENT_ID: process.env.APP_CLIENT_ID, 27 | APP_CLIENT_SECRET: process.env.APP_CLIENT_SECRET, 28 | app_client_authentication_method: 29 | process.env.APP_CLIENT_AUTHENTICATION_METHOD, 30 | APP_CLIENT_AUTHENTICATION_METHOD: 31 | process.env.APP_CLIENT_AUTHENTICATION_METHOD, 32 | APP_JAR_PRIVATE_KEY: importKey("APP_JAR_PRIVATE_KEY"), 33 | APP_JAR_PUBLIC_KEY: importKey("APP_JAR_PUBLIC_KEY"), 34 | APP_JAR_KEY_ALG: process.env.APP_JAR_KEY_ALG, 35 | APP_JAR_KEY_ID: process.env.APP_JAR_KEY_ID, 36 | APP_JAR_CREDENTIAL_ID: process.env.APP_JAR_CREDENTIAL_ID, 37 | APP_JWTCA_PRIVATE_KEY: importKey("APP_JWTCA_PRIVATE_KEY"), 38 | APP_JWTCA_PUBLIC_KEY: importKey("APP_JWTCA_PUBLIC_KEY"), 39 | APP_JWTCA_KEY_ID: process.env.APP_JWTCA_KEY_ID, 40 | APP_JWTCA_CREDENTIAL_ID: process.env.APP_JWTCA_CREDENTIAL_ID, 41 | APP_MTLS_CERTIFICATE: importKey("APP_MTLS_CERTIFICATE"), 42 | APP_MTLS_PRIVATE_KEY: importKey("APP_MTLS_PRIVATE_KEY"), 43 | APP_MTLS_PUBLIC_KEY: importKey("APP_MTLS_PUBLIC_KEY"), 44 | acr_values: process.env.ACR_VALUES, 45 | claims: process.env.CLAIMS, 46 | audience: APP_RESOURCE_SERVER_IDENTIFIER, 47 | authorization_details: '[{"type":"urn:auth0:temp:sca"}]', 48 | client_authentication_methods_list: Object.values( 49 | CLIENT_AUTHENTICATION_METHODS 50 | ), 51 | jar_enabled: process.env.APP_JAR_ENABLED === "true", 52 | login_hint: '', 53 | owp: false, 54 | par_enabled: process.env.APP_PAR_ENABLED === "true", 55 | pkce_code_challenge_method_list: PKCE_CODE_CHALLENGE_METHODS, 56 | pkce_code_challenge_method: "S256", 57 | pkce: true, 58 | prompt: [], 59 | prompt_list: PROMPT_TYPES, 60 | redirect_uri: APP_CALLBACK_URL, 61 | response_mode: "", 62 | response_mode_list: [ 63 | "", 64 | "query", 65 | "fragment", 66 | "form_post", 67 | "jwt", 68 | "query.jwt", 69 | "fragment.jwt", 70 | "form_post.jwt", 71 | "auth0_owp", 72 | ], 73 | response_type: "code id_token", 74 | scope: AUTH_REQUESTED_SCOPES, 75 | send_authorization_details: false, 76 | }; 77 | 78 | const getEnv = (envVariableName) => { 79 | if (typeof envVariableName === "string") { 80 | return resolvedEnv[envVariableName]; 81 | } else { 82 | return resolvedEnv; 83 | } 84 | }; 85 | 86 | const setEnv = async (envVariableName, value, { write = false } = {}) => { 87 | resolvedEnv[envVariableName] = value; 88 | if (write) { 89 | await writeDotEnv({ [envVariableName]: value }); 90 | } 91 | }; 92 | 93 | module.exports = { 94 | getEnv, 95 | setEnv, 96 | }; 97 | -------------------------------------------------------------------------------- /routes/user.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const router = express.Router(); 3 | const jsonwebtoken = require("jsonwebtoken"); 4 | 5 | const { getUserInfo, getRefreshToken } = require("../lib/handlers/auth_api"); 6 | const { getEnv } = require("../lib/env"); 7 | const { APP_LOGOUT_URL } = require("../lib/constants"); 8 | 9 | const ensureLoggedIn = (req, res, next) => { 10 | if (req.session.user) { 11 | req.user = req.session.user; 12 | } else { 13 | return res.redirect("/"); 14 | } 15 | 16 | next(); 17 | }; 18 | 19 | router.get("/", ensureLoggedIn, (req, res, next) => { 20 | renderUserPage(req, res); 21 | }); 22 | 23 | router.get("/refresh", ensureLoggedIn, async (req, res, next) => { 24 | try { 25 | const refreshTokenResponse = await getRefreshToken( 26 | req.user.extraParams.refresh_token 27 | ); 28 | 29 | renderUserPage(req, res, { 30 | idToken: refreshTokenResponse.id_token, 31 | accessToken: refreshTokenResponse.access_token, 32 | }); 33 | } catch (error) { 34 | next(error); 35 | } 36 | }); 37 | 38 | router.get("/userinfo", ensureLoggedIn, async (req, res, next) => { 39 | try { 40 | const userinfoResponse = await getUserInfo( 41 | req.user.extraParams.access_token 42 | ); 43 | 44 | renderUserPage(req, res, { userinfoResponse }); 45 | } catch (error) { 46 | next(error); 47 | } 48 | }); 49 | 50 | const renderUserPage = (req, res, data = {}) => { 51 | const detachedSignature = 52 | data.detachedSignature || req.user.extraParams.detached_signature; 53 | const idToken = data.idToken || req.user.extraParams.id_token; 54 | const accessToken = data.accessToken || req.user.extraParams.access_token; 55 | let decodedDetachedSignature = ""; 56 | let decodedIDToken = ""; 57 | let decodedAccessToken = ""; 58 | 59 | try { 60 | // TODO rather than just decoding, verify JWT. 61 | decodedDetachedSignature = jsonwebtoken.decode(detachedSignature, { 62 | complete: true, 63 | }); 64 | } catch (error) { 65 | decodedDetachedSignature = "Unable to decode"; 66 | } 67 | 68 | try { 69 | decodedIDToken = jsonwebtoken.decode(idToken, { complete: true }); 70 | } catch (error) { 71 | decodedIDToken = "Unable to decode"; 72 | } 73 | 74 | try { 75 | decodedAccessToken = jsonwebtoken.decode(accessToken, { complete: true }); 76 | } catch (error) { 77 | decodedAccessToken = "Unable to decode"; 78 | } 79 | 80 | res.render("user", { 81 | user: req.user, 82 | decodedDetachedSignature, 83 | decodedIDToken, 84 | decodedAccessToken, 85 | userinfoResponse: data.userinfoResponse, 86 | title: "Fake SaaS App", 87 | tokens: { 88 | detached_signature: detachedSignature, 89 | refresh_token: req.user.extraParams.refresh_token, 90 | id_token: idToken, 91 | access_token: accessToken, 92 | }, 93 | config: { 94 | APP_LOGOUT_URL, 95 | AUTH0_DOMAIN: getEnv("AUTH0_DOMAIN"), 96 | APP_CLIENT_ID: getEnv("APP_CLIENT_ID"), 97 | }, 98 | }); 99 | }; 100 | 101 | router.get("/saml", ensureLoggedIn, (req, res, next) => { 102 | renderUserPageWithSAML(req, res); 103 | }); 104 | 105 | const renderUserPageWithSAML = (req, res) => { 106 | const samlProfile = req.user.profile; 107 | 108 | res.render("samluser", { 109 | user: req.user, 110 | samlProfile, 111 | title: "Fake SAML SaaS App", 112 | config: { 113 | APP_LOGOUT_URL, 114 | AUTH0_DOMAIN: getEnv("AUTH0_DOMAIN"), 115 | APP_CLIENT_ID: getEnv("SAML_APP_CLIENT_ID"), 116 | }, 117 | }); 118 | }; 119 | 120 | module.exports = router; 121 | -------------------------------------------------------------------------------- /lib/constants.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv"); 2 | 3 | dotenv.config(); 4 | 5 | const API2_BASE_URL = `https://${process.env.AUTH0_DOMAIN}/api/v2/`; 6 | const API2_AUDIENCE = API2_BASE_URL; 7 | const MFA_AUDIENCE = `https://${process.env.AUTH0_DOMAIN}/mfa`; 8 | const AUTH_REQUESTED_SCOPES = 9 | "openid email profile create:foo read:foo update:foo delete:foo offline_access"; 10 | const USERINFO_ENDPOINT = `https://${process.env.AUTH0_DOMAIN}/userinfo`; 11 | const USERINFO_AUDIENCE = USERINFO_ENDPOINT; 12 | const TOKEN_ENDPOINT = `https://${process.env.AUTH0_DOMAIN}/oauth/token`; 13 | const AUTHORIZE_ENDPOINT = `https://${process.env.AUTH0_DOMAIN}/authorize`; 14 | const PAR_ENDPOINT = `https://${process.env.AUTH0_DOMAIN}/oauth/par`; 15 | 16 | const REQUIRED_SCOPES_FOR_BACKEND_CLIENT = [ 17 | "create:clients", 18 | "read:client_grants", 19 | "update:client_grants", 20 | "read:clients", 21 | "delete:clients", 22 | "read:client_keys", 23 | "create:connections", 24 | "read:connections", 25 | "delete:connections", 26 | "create:roles", 27 | "delete:roles", 28 | "update:roles", 29 | "read:roles", 30 | "read:users", 31 | "delete:users", 32 | "update:prompts", 33 | "create:resource_servers", 34 | "read:resource_servers", 35 | "delete:resource_servers", 36 | ]; 37 | const APP_RESOURCE_SERVER_IDENTIFIER = "urn:demo-saas-api"; 38 | // CLIENT SETTINGS 39 | // TODO: Make these dynamic e.g. retrieve port 40 | const APP_CALLBACK_URL = "https://myapp.com:4040/callback"; 41 | const APP_LOGOUT_URL = "https://myapp.com:4040/logout"; 42 | const APP_INITIATE_LOGIN_URL = "https://myapp.com:4040/login"; 43 | const CLIENT_NAME_FOR_DEMO_APP = "Demozero"; 44 | const APP_LOGO_URI = "https://static.thenounproject.com/png/66350-200.png"; 45 | 46 | const CLIENT_SECRET_BASIC = "client_secret_basic"; 47 | const CLIENT_SECRET_POST = "client_secret_post"; 48 | const PRIVATE_KEY_JWT = "jwtca"; 49 | const CA_MTLS = "ca_mtls"; 50 | const SELF_SIGNED_MTLS = "self_signed_mtls"; 51 | const CA_NONE = "none"; 52 | 53 | // Response Types 54 | const AUTHORIZATION_CODE_RESPONSE = "code"; 55 | const TOKEN_RESPONSE = "token"; 56 | const ID_TOKEN_RESPONSE = "id_token"; 57 | const ID_TOKEN_TOKEN_RESPONSE = "id_token token"; 58 | const CODE_ID_TOKEN = "code id_token"; 59 | const CODE_ID_TOKEN_TOKEN = "code id_token token"; 60 | 61 | const PKCE_CODE_CHALLENGE_METHOD_PLAIN = "plain"; 62 | const PKCE_CODE_CHALLENGE_METHOD_S256 = "S256"; 63 | 64 | module.exports = { 65 | API2_BASE_URL, 66 | API2_AUDIENCE, 67 | AUTH_REQUESTED_SCOPES, 68 | AUTHORIZE_ENDPOINT, 69 | PAR_ENDPOINT, 70 | USERINFO_ENDPOINT, 71 | TOKEN_ENDPOINT, 72 | REQUIRED_SCOPES_FOR_BACKEND_CLIENT, 73 | APP_RESOURCE_SERVER_IDENTIFIER, 74 | APP_CALLBACK_URL, 75 | APP_LOGOUT_URL, 76 | APP_INITIATE_LOGIN_URL, 77 | CLIENT_NAME_FOR_DEMO_APP, 78 | APP_LOGO_URI, 79 | 80 | AUTHORIZATION_CODE_RESPONSE, 81 | TOKEN_RESPONSE, 82 | ID_TOKEN_RESPONSE, 83 | ID_TOKEN_TOKEN_RESPONSE, 84 | 85 | AUDIENCES: [API2_AUDIENCE, USERINFO_AUDIENCE, MFA_AUDIENCE], 86 | 87 | RESPONSE_TYPES: [ 88 | AUTHORIZATION_CODE_RESPONSE, 89 | TOKEN_RESPONSE, 90 | ID_TOKEN_RESPONSE, 91 | ID_TOKEN_TOKEN_RESPONSE, 92 | CODE_ID_TOKEN, 93 | CODE_ID_TOKEN_TOKEN, 94 | ], 95 | 96 | PKCE_CODE_CHALLENGE_METHODS: [ 97 | PKCE_CODE_CHALLENGE_METHOD_PLAIN, 98 | PKCE_CODE_CHALLENGE_METHOD_S256, 99 | ], 100 | 101 | CLIENT_AUTHENTICATION_METHODS: { 102 | CLIENT_SECRET_BASIC, 103 | CLIENT_SECRET_POST, 104 | PRIVATE_KEY_JWT, 105 | CA_MTLS, 106 | SELF_SIGNED_MTLS, 107 | CA_NONE, 108 | }, 109 | 110 | PROMPT_TYPES: [ 111 | "", 112 | "none", 113 | "consent", 114 | "login", 115 | "select_account" 116 | ] 117 | }; 118 | -------------------------------------------------------------------------------- /public/main.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/handlers/login.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const url = require("url"); 3 | const querystring = require("node:querystring"); 4 | const uuid = require("uuid"); 5 | 6 | const { getEnv } = require("../env"); 7 | const { 8 | APP_CALLBACK_URL, 9 | AUTHORIZE_ENDPOINT, 10 | PAR_ENDPOINT, 11 | } = require("../constants"); 12 | const { setAppClientAuthentication } = require("../client_authentication"); 13 | const { createJARPayload } = require("../jar"); 14 | const pkce = require("../pkce"); 15 | 16 | function maybeValue(value, name) { 17 | if (value) { 18 | return { [name]: value }; 19 | } 20 | return {}; 21 | } 22 | 23 | function maybeArrayMaybeValue(value, name) { 24 | if (Array.isArray(value)) { 25 | return { [name]: value.join(" ") }; 26 | } 27 | return maybeValue(value, name); 28 | } 29 | 30 | function maybeJSONValue(value, name) { 31 | if (value) { 32 | /* let parsedValue = value; 33 | try { 34 | parsedValue = JSON.parse(value); 35 | } catch { 36 | // ignore 37 | }*/ 38 | return { [name]: value }; 39 | } 40 | return {}; 41 | } 42 | 43 | const getAuthorizeParams = (req, extras = {}) => { 44 | const { 45 | APP_CLIENT_ID, 46 | acr_values, 47 | audience, 48 | authorization_details, 49 | claims, 50 | login_hint, 51 | owp, 52 | pkce_code_challenge_method, 53 | pkce: isPKCEEnabled, 54 | prompt, 55 | redirect_uri, 56 | response_mode, 57 | response_type, 58 | scope, 59 | send_authorization_details: isRAREnabled, 60 | } = getEnv(); 61 | 62 | let pkceParams = {}; 63 | 64 | if (isPKCEEnabled) { 65 | pkceParams.code_challenge_method = pkce_code_challenge_method; 66 | const codeVerifier = pkce.generateCodeVerifier(); 67 | req.session.code_verifier = codeVerifier; 68 | 69 | pkceParams.code_challenge = pkce.generateCodeChallengeFromVerifier( 70 | pkce_code_challenge_method, 71 | codeVerifier 72 | ); 73 | } 74 | 75 | const state = uuid.v4(); 76 | req.session.state = state; 77 | 78 | let nonce; 79 | if ((response_type || "").indexOf("id_token") > -1) { 80 | nonce = uuid.v4(); 81 | req.session.nonce = nonce; 82 | } 83 | 84 | let authorizationDetailsParams = {}; 85 | if (isRAREnabled) { 86 | authorizationDetailsParams = { 87 | authorization_details, 88 | }; 89 | } 90 | 91 | const authorizeParams = { 92 | ...(acr_values ? { acr_values: acr_values.split(/,/g) } : {}), 93 | ...maybeValue(audience, "audience"), 94 | ...maybeValue(nonce, "nonce"), 95 | ...maybeValue(!!owp, "owp"), 96 | ...maybeValue(req.query.invitation, "invitation"), 97 | ...maybeValue(req.query.organization, "organization"), 98 | ...maybeValue(response_mode, "response_mode"), 99 | ...maybeValue(login_hint, "login_hint"), 100 | ...maybeValue(scope, "scope"), 101 | ...authorizationDetailsParams, 102 | ...pkceParams, 103 | ...maybeJSONValue(claims, "claims"), 104 | ...maybeArrayMaybeValue(prompt, "prompt"), 105 | client_id: APP_CLIENT_ID, 106 | redirect_uri, 107 | response_type, 108 | state, 109 | ...extras, 110 | }; 111 | 112 | if (getEnv("jar_enabled")) { 113 | return createJARPayload(authorizeParams); 114 | } 115 | 116 | return authorizeParams; 117 | }; 118 | 119 | const authenticate = (req, res) => { 120 | const authorizeUrl = url.parse(AUTHORIZE_ENDPOINT); 121 | authorizeUrl.query = getAuthorizeParams(req); 122 | console.log( 123 | `Calling ${AUTHORIZE_ENDPOINT} with ${JSON.stringify(authorizeUrl.query)}` 124 | ); 125 | 126 | res.redirect(url.format(authorizeUrl)); 127 | }; 128 | 129 | const authenticateWithPar = async (req, res) => { 130 | console.log( 131 | `Calling ${PAR_ENDPOINT} with ${JSON.stringify( 132 | getAuthorizeParams(req), 133 | null, 134 | 2 135 | )}` 136 | ); 137 | 138 | let response; 139 | try { 140 | const authorizeParams = getAuthorizeParams(req); 141 | const config = setAppClientAuthentication({ 142 | method: "POST", 143 | url: PAR_ENDPOINT, 144 | data: { 145 | ...authorizeParams, 146 | }, 147 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 148 | json: false, 149 | }); 150 | config.data = querystring.stringify(config.data); 151 | response = await axios(config); 152 | } catch (error) { 153 | const callbackUrl = url.parse(APP_CALLBACK_URL); 154 | const body = error.response?.data || error; 155 | console.log({ statusCode: error.response?.status, response: body }); 156 | callbackUrl.query = { 157 | error: body.error, 158 | error_description: body.error_description, 159 | state: req.session.state, 160 | }; 161 | 162 | return res.redirect(url.format(callbackUrl)); 163 | } 164 | const authorizeUrl = url.parse(AUTHORIZE_ENDPOINT); 165 | authorizeUrl.query = { 166 | client_id: getEnv("APP_CLIENT_ID"), 167 | request_uri: response.data.request_uri, 168 | }; 169 | 170 | res.redirect(url.format(authorizeUrl)); 171 | }; 172 | 173 | module.exports = { 174 | authenticate, 175 | authenticateWithPar, 176 | }; 177 | -------------------------------------------------------------------------------- /views/index.jade: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | div(class="jumbotron") 5 | h1(class="display-4")= title 6 | p(class="lead") Welcome to the demo-zero. 7 | #msg 8 | div(class="row mt-3") 9 | div(class="col-sm-6") 10 | div(class="card") 11 | h5(class="card-header") Login 12 | div(class="card-body") 13 | p(class="card-text") Standard OAuth/OIDC flow 14 | form(name="login" method="post" action="login") 15 | input(type="submit" value="Login" class="btn btn-primary" ) 16 | div(class="row mt-3") 17 | div(class="col-sm-12") 18 | div(class="card") 19 | h5(class="card-header") Runtime Configuration 20 | div(class="card-body") 21 | form(name="configuration" method="post" action="saveconfiguration") 22 | div(class="form-group") 23 | label 24 | input(type="checkbox" name="par_enabled" id="par_enabled" checked=(par_enabled)) 25 | = " Use Pushed Authorization Requests (PAR)" 26 | div(class="form-group") 27 | label 28 | input(type="checkbox" name="jar_enabled" id="jar_enabled" checked=(jar_enabled)) 29 | = " Use JWT-Secured Authorization Request (JAR)" 30 | div(class="form-group") 31 | 32 | select(id="app_client_authentication_method" name="app_client_authentication_method" class="form-control" multiple) 33 | each clientAuthenticationMethod in clientAuthenticationMethods 34 | if (selectedClientAuthenticationMethod.includes(clientAuthenticationMethod)) 35 | option(value=clientAuthenticationMethod selected) #{clientAuthenticationMethod} 36 | else 37 | option(value=clientAuthenticationMethod ) #{clientAuthenticationMethod} 38 | div(class="form-group") 39 | 40 | select(id="audience" name="audience" class="form-control") 41 | option 42 | each audience in audienceList 43 | if (selectedAudience === audience) 44 | option(value=audience selected) #{audience} 45 | else 46 | option(value=audience ) #{audience} 47 | div(class="form-group") 48 | 49 | input(id="scope" name="scope" value="#{scope}" class="form-control" size="20") 50 | div(class="form-group") 51 | 52 | select(id="response_type" name="response_type" class="form-control") 53 | each responseType in responseTypeList 54 | if (selectedResponseType === responseType) 55 | option(value=responseType selected) #{responseType} 56 | else 57 | option(value=responseType) #{responseType} 58 | div(class="form-group") 59 | 60 | select(id="response_mode" name="response_mode" class="form-control") 61 | each responseMode in responseModeList 62 | if (selectedResponseMode === responseMode) 63 | option(value=responseMode selected) #{responseMode} 64 | else 65 | option(value=responseMode) #{responseMode} 66 | div(class="form-group") 67 | label 68 | input(type="checkbox" name="owp" id="owp" checked=(owp)) 69 | = " Use owp=true" 70 | div(class="form-group") 71 | label 72 | input(type="checkbox" name="pkce" id="pkce" checked=(pkce)) 73 | = " Use Proof Key for Code Exchange (PKCE)" 74 | div(class="form-group") 75 | 76 | select(id="pkce_code_challenge_method" name="pkce_code_challenge_method" class="form-control") 77 | each pkceCodeChallengeMethod in pkceCodeChallengeMethodList 78 | if (selectedPkceCodeChallengeMethod === pkceCodeChallengeMethod) 79 | option(value=pkceCodeChallengeMethod selected) #{pkceCodeChallengeMethod} 80 | else 81 | option(value=pkceCodeChallengeMethod) #{pkceCodeChallengeMethod} 82 | div(class="form-group") 83 | 84 | select(id="prompt" name="prompt" class="form-control" multiple) 85 | each prompt in promptList 86 | if (selectedPrompt.includes(prompt)) 87 | option(value=prompt selected) #{prompt} 88 | else 89 | option(value=prompt) #{prompt} 90 | div(class="form-group") 91 | label(for="redirect_uri") Redirect URI 92 | input(type="text", name="redirect_uri", value=(redirectURI) class="form-control") 93 | div(class="form-group") 94 | label(for="acr_values") acr_values 95 | input(type="text", name="acr_values", value=(acrValues) class="form-control") 96 | div(class="form-group") 97 | label(for="claims") claims 98 | input(type="text", name="claims", value=(claims) class="form-control") 99 | div(class="form-group") 100 | label 101 | input(type="checkbox", name="send_authorization_details", checked=(sendAuthorizationDetails)) 102 | = " Send authorization_details" 103 | textarea(id="authorization_details" name="authorization_details" class="form-control" placeholder='#{authorizationDetails}') #{authorizationDetails} 104 | div(class="form-group") 105 | label(for="login_hint") login_hint 106 | input(type="text", name="login_hint", value=(login_hint) class="form-control") 107 | 108 | input(type="submit" value="Save Configuration" class="btn btn-primary") 109 | small(id="configurationHelp" class="form-text text-muted") Future calls to /authorize or /oauth/par will use these values. They will be reset when the app restarts. 110 | 111 | div(class="col-sm-6")   112 | -------------------------------------------------------------------------------- /lib/client_authentication.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const https = require("node:https"); 3 | 4 | const { getEnv } = require("./env"); 5 | const { CLIENT_AUTHENTICATION_METHODS } = require("./constants"); 6 | const setAppClientAuthentication = (reqConfig) => { 7 | if (!reqConfig) { 8 | throw new Error("missing reqConfig"); 9 | } 10 | const { 11 | APP_CLIENT_ID, 12 | app_client_authentication_method, 13 | APP_CLIENT_SECRET, 14 | APP_JWTCA_KEY_ID, 15 | APP_JWTCA_PRIVATE_KEY, 16 | APP_MTLS_CERTIFICATE, 17 | APP_MTLS_PRIVATE_KEY, 18 | } = getEnv(); 19 | 20 | const { data, headers, httpsAgent } = clientAuthentication( 21 | APP_CLIENT_ID, 22 | app_client_authentication_method, 23 | { 24 | privateKeyPEM: APP_JWTCA_PRIVATE_KEY, 25 | clientSecret: APP_CLIENT_SECRET, 26 | keyid: APP_JWTCA_KEY_ID, 27 | mtlsClientCertificate: APP_MTLS_CERTIFICATE, 28 | mtlsPrivateKey: APP_MTLS_PRIVATE_KEY, 29 | } 30 | ); 31 | 32 | console.log({ data, headers }); 33 | reqConfig.data = { 34 | ...reqConfig.data, 35 | ...data, 36 | }; 37 | 38 | reqConfig.headers = { 39 | ...reqConfig.headers, 40 | ...headers, 41 | }; 42 | 43 | if (httpsAgent) { 44 | reqConfig.httpsAgent = httpsAgent; 45 | } else { 46 | delete reqConfig; 47 | } 48 | 49 | return reqConfig; 50 | }; 51 | 52 | const getManagementClientAuthentication = () => { 53 | const { 54 | AUTH0_MGMT_CLIENT_ID, 55 | AUTH0_MGMT_CLIENT_AUTHENTICATION_METHOD, 56 | AUTH0_MGMT_CLIENT_SECRET, 57 | AUTH0_MGMT_JWTCA_KEY_ID, 58 | AUTH0_MGMT_JWTCA_PRIVATE_KEY, 59 | } = getEnv(); 60 | 61 | const { data } = clientAuthentication( 62 | AUTH0_MGMT_CLIENT_ID, 63 | [AUTH0_MGMT_CLIENT_AUTHENTICATION_METHOD], 64 | { 65 | privateKeyPEM: AUTH0_MGMT_JWTCA_PRIVATE_KEY, 66 | clientSecret: AUTH0_MGMT_CLIENT_SECRET, 67 | keyid: AUTH0_MGMT_JWTCA_KEY_ID, 68 | } 69 | ); 70 | 71 | return data; 72 | }; 73 | 74 | const clientAuthentication = ( 75 | clientID, 76 | clientAuthenticationMethods, 77 | clientAuthenticationOptions 78 | ) => { 79 | const request = {}; 80 | 81 | function addHeaders(headers) { 82 | for (const [headerName, headerValue] of Object.entries(headers)) { 83 | request.headers ??= {}; 84 | request.headers[headerName] = headerValue; 85 | } 86 | } 87 | 88 | function addBody(params) { 89 | for (const [paramName, paramValue] of Object.entries(params)) { 90 | request.data ??= {}; 91 | request.data[paramName] = paramValue; 92 | } 93 | } 94 | const AUTH0_DOMAIN = getEnv("AUTH0_DOMAIN"); 95 | if (!Array.isArray(clientAuthenticationMethods)) { 96 | clientAuthenticationMethods = [clientAuthenticationMethods]; 97 | } 98 | clientAuthenticationMethods.forEach((clientAuthenticationMethod) => { 99 | if (clientAuthenticationMethod === CLIENT_AUTHENTICATION_METHODS.CA_NONE) { 100 | return {}; 101 | } else if ( 102 | clientAuthenticationMethod === 103 | CLIENT_AUTHENTICATION_METHODS.PRIVATE_KEY_JWT 104 | ) { 105 | console.log("using JWT client authentication"); 106 | if (!clientID) { 107 | throw new Error("missing clientID"); 108 | } 109 | const assertion = jwt.sign( 110 | { 111 | jti: "" + Date.now(), 112 | iat: Math.floor(Date.now() / 1000), 113 | }, 114 | clientAuthenticationOptions.privateKeyPEM, 115 | { 116 | audience: `https://${AUTH0_DOMAIN}/`, 117 | issuer: clientID, 118 | subject: clientID, 119 | keyid: clientAuthenticationOptions.keyid, 120 | algorithm: "RS256", 121 | expiresIn: "1m", 122 | } 123 | ); 124 | 125 | addBody({ 126 | client_assertion_type: 127 | "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", 128 | client_assertion: assertion, 129 | }); 130 | } else if ( 131 | clientAuthenticationMethod === 132 | CLIENT_AUTHENTICATION_METHODS.CLIENT_SECRET_POST 133 | ) { 134 | console.log("using client_secret_post client authentication"); 135 | 136 | addBody({ 137 | client_secret: clientAuthenticationOptions.clientSecret, 138 | }); 139 | } else if ( 140 | clientAuthenticationMethod === 141 | CLIENT_AUTHENTICATION_METHODS.CLIENT_SECRET_BASIC 142 | ) { 143 | console.log("using client_secret_basic client authentication"); 144 | const credentials = Buffer.from( 145 | `${clientID}:${clientAuthenticationOptions.clientSecret}`, 146 | "utf-8" 147 | ).toString("base64"); 148 | const header = `Basic ${credentials};`; 149 | addHeaders({ 150 | authorization: header, 151 | }); 152 | } else if ( 153 | clientAuthenticationMethod === CLIENT_AUTHENTICATION_METHODS.CA_MTLS 154 | ) { 155 | console.log("using CA signed mTLS Client Authentication"); 156 | /* 157 | const httpsAgent = new https.Agent({ 158 | rejectUnauthorized: false, 159 | cert: clientAuthenticationOptions.mtlsClientCertificate, 160 | key: clientAuthenticationOptions.mtlsPrivateKey, 161 | }); 162 | 163 | return { httpsAgent };*/ 164 | addHeaders({ 165 | "Client-Certificate": encodeURIComponent(clientAuthenticationOptions.mtlsClientCertificate), 166 | "Client-Certificate-CA-Verified": "SUCCESS", 167 | }); 168 | } else if ( 169 | clientAuthenticationMethod === 170 | CLIENT_AUTHENTICATION_METHODS.SELF_SIGNED_MTLS 171 | ) { 172 | console.log("using self-signed signed mTLS Client Authentication"); 173 | /* 174 | const httpsAgent = new https.Agent({ 175 | rejectUnauthorized: false, 176 | cert: clientAuthenticationOptions.mtlsClientCertificate, 177 | key: clientAuthenticationOptions.mtlsPrivateKey, 178 | }); 179 | 180 | return { httpsAgent };*/ 181 | 182 | addHeaders({ 183 | "Client-Certificate": encodeURIComponent(clientAuthenticationOptions.mtlsClientCertificate), 184 | "Client-Certificate-CA-Verified": "FAILED: this is a multi word reason", 185 | }); 186 | } else { 187 | throw new Error("invalid client authentication config"); 188 | } 189 | }); 190 | console.log({ clientAuthenticationMethods, request }); 191 | 192 | return request; 193 | }; 194 | 195 | exports.clientAuthentication = clientAuthentication; 196 | exports.setAppClientAuthentication = setAppClientAuthentication; 197 | exports.getManagementClientAuthentication = getManagementClientAuthentication; 198 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const jose = require("jose"); 2 | const express = require("express"); 3 | const jwtDecode = require("jwt-decode"); 4 | const pemToJwk = require("pem-jwk").pem2jwk; 5 | 6 | const router = express.Router(); 7 | const handlers = require("../lib/handlers"); 8 | const authAPI = require("../lib/handlers/auth_api"); 9 | 10 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; 11 | 12 | const { authenticate, authenticateWithPar } = handlers.login; 13 | const { inviteFlow } = handlers.invite; 14 | const { getEnv } = require("../lib/env"); 15 | const { 16 | APP_RESOURCE_SERVER_IDENTIFIER, 17 | AUDIENCES, 18 | RESPONSE_TYPES, 19 | } = require("../lib/constants"); 20 | 21 | const { saveConfiguration } = handlers.configuration; 22 | 23 | router.get("/", async function (req, res, next) { 24 | try { 25 | const audienceList = [APP_RESOURCE_SERVER_IDENTIFIER, ...AUDIENCES]; 26 | 27 | res.render("index", { 28 | acrValues: getEnv("acr_values"), 29 | audienceList, 30 | authorizationDetails: getEnv("authorization_details"), 31 | claims: getEnv("claims"), 32 | clientAuthenticationMethods: getEnv("client_authentication_methods_list"), 33 | jar_enabled: getEnv("jar_enabled"), 34 | login_hint: getEnv("login_hint"), 35 | owp: getEnv("owp"), 36 | par_enabled: getEnv("par_enabled"), 37 | pkce: getEnv("pkce"), 38 | pkceCodeChallengeMethodList: getEnv("pkce_code_challenge_method_list"), 39 | promptList: getEnv("prompt_list"), 40 | redirectURI: getEnv("redirect_uri"), 41 | responseModeList: getEnv("response_mode_list"), 42 | responseTypeList: RESPONSE_TYPES, 43 | scope: getEnv("scope"), 44 | selectedAudience: getEnv("audience"), 45 | selectedPkceCodeChallengeMethod: getEnv("pkce_code_challenge_method"), 46 | selectedPrompt: getEnv("prompt"), 47 | selectedResponseMode: getEnv("response_mode"), 48 | selectedResponseType: getEnv("response_type"), 49 | sendAuthorizationDetails: getEnv("send_authorization_details"), 50 | selectedClientAuthenticationMethod: getEnv( 51 | "app_client_authentication_method" 52 | ), 53 | title: "Fake SaaS App", 54 | }); 55 | } catch (error) { 56 | return next(error); 57 | } 58 | }); 59 | 60 | router.post("/login", (req, res) => { 61 | if (getEnv("par_enabled")) { 62 | return authenticateWithPar(req, res); 63 | } 64 | authenticate(req, res); 65 | }); 66 | router.post("/invite", inviteFlow); 67 | 68 | router.get("/diag", (req, res) => { 69 | res.json({ 70 | AUTH0_CLIENT_ID: process.env.AUTH0_CLIENT_ID, 71 | AUTH0_CLIENT_SECRET: process.env.AUTH0_CLIENT_SECRET.substr(0, 3) + "...", 72 | AUTH0_DOMAIN: process.env.AUTH0_DOMAIN, 73 | AUTH0_CALLBACK_URL: process.env.AUTH0_CALLBACK_URL, 74 | LOGOUT_URL: process.env.LOGOUT_URL, 75 | }); 76 | }); 77 | 78 | router.get("/logout", (req, res) => { 79 | req.session.user = null; 80 | delete req.session.user; 81 | res.redirect("/"); 82 | }); 83 | 84 | router.get("/loggedOut", (req, res) => { 85 | res.json({ status: "logged out" }); 86 | }); 87 | 88 | router.post( 89 | "/callback", 90 | (req, res, next) => { 91 | const { 92 | error, 93 | error_description, 94 | code, 95 | state, 96 | id_token, 97 | access_token, 98 | response, 99 | } = req.body; 100 | 101 | if ( 102 | !state && 103 | !code && 104 | !error && 105 | !error_description && 106 | !id_token && 107 | !access_token && 108 | !response 109 | ) { 110 | res.redirect("/"); 111 | } 112 | 113 | next(); 114 | }, 115 | callbackHandler, 116 | (req, res) => { 117 | res.redirect(req.session.returnTo || "/user"); 118 | } 119 | ); 120 | 121 | router.get( 122 | "/callback", 123 | (req, res, next) => { 124 | const { error, error_description, code, state, response } = req.query; 125 | 126 | if (!state && !code && !error && !error_description && !response) { 127 | // assume this is an implicit flow and the parameters are on the URL. 128 | // The front end will copy the params from the URL into a form and 129 | // POST them to /callback 130 | return res.render("callback", { title: "callback" }); 131 | } 132 | 133 | next(); 134 | }, 135 | callbackHandler, 136 | (req, res) => { 137 | res.redirect(req.session.returnTo || "/user"); 138 | } 139 | ); 140 | 141 | router.get("/error", (req, res) => { 142 | const error = req.flash("error"); 143 | const error_description = req.flash("error_description"); 144 | 145 | delete req.session.user; 146 | delete req.session.code_verifier; 147 | delete req.session.state; 148 | delete req.session.returnTo; 149 | 150 | res.render("error", { 151 | error: error, 152 | error_description: error_description, 153 | }); 154 | }); 155 | 156 | router.get("/unauthorized", (req, res) => { 157 | res.render("unauthorized"); 158 | }); 159 | 160 | router.post("/saveconfiguration", saveConfiguration); 161 | 162 | router.get("/.well-known/jwks.json", async (req, res) => { 163 | const jwtcaJWK = await jose.exportJWK( 164 | await jose.importSPKI(getEnv("APP_JWTCA_PUBLIC_KEY")) 165 | ); 166 | const jarJWK = await jose.exportJWK( 167 | await jose.importSPKI(getEnv("APP_JAR_PUBLIC_KEY")) 168 | ); 169 | const keys = [ 170 | { 171 | ...jarJWK, 172 | kid: getEnv("APP_JAR_KEY_ID"), 173 | }, 174 | { 175 | ...jwtcaJWK, 176 | kid: getEnv("APP_JWTCA_KEY_ID"), 177 | }, 178 | ]; 179 | console.log("serving jwks.json", keys); 180 | res.status(200).setHeader("content-type", "application/jwk-set+json").json({ 181 | keys, 182 | }); 183 | }); 184 | 185 | async function callbackHandler(req, res, next) { 186 | let source = Object.keys(req.body).length ? req.body : req.query; 187 | 188 | const response = source.response; 189 | if (response) { 190 | // we have a JWT response. Decode 191 | source = jwtDecode(response); 192 | } 193 | 194 | const { 195 | error, 196 | error_description, 197 | code, 198 | state, 199 | access_token, 200 | id_token: detached_signature, 201 | } = source; 202 | 203 | if (req.session.state && state !== req.session.state) { 204 | req.flash("error", "state mismatch"); 205 | return res.redirect("/error"); 206 | } 207 | 208 | req.session.state = null; 209 | delete req.session.state; 210 | 211 | if (error || error_description) { 212 | req.flash("error", error); 213 | req.flash("error_description", error_description); 214 | return res.redirect("/error"); 215 | } 216 | 217 | try { 218 | let userData = {}; 219 | 220 | let atData = { 221 | access_token, 222 | id_token: detached_signature, 223 | }; 224 | 225 | if (detached_signature) { 226 | // TODO - detached signature, check s_hash, c_hash 227 | } 228 | 229 | if (code) { 230 | const params = {}; 231 | if (req.session.code_verifier) { 232 | params.code_verifier = req.session.code_verifier; 233 | req.session.code_verifier = null; 234 | delete req.session.code_verifier; 235 | } 236 | 237 | atData = await authAPI.getAccessTokenFromCode(code, params); 238 | console.log({ atData }); 239 | } 240 | 241 | if (atData.access_token) { 242 | userData = await authAPI.getUserInfo(atData.access_token); 243 | } 244 | 245 | req.session.user = { 246 | profile: userData, 247 | extraParams: { 248 | detached_signature: detached_signature, 249 | access_token: atData.access_token, 250 | refresh_token: atData.refresh_token, 251 | id_token: atData.id_token, 252 | }, 253 | }; 254 | 255 | next(); 256 | } catch (error) { 257 | next(error); 258 | } 259 | } 260 | 261 | module.exports = router; 262 | -------------------------------------------------------------------------------- /lib/handlers/bootstrap.js: -------------------------------------------------------------------------------- 1 | const { generateKeyPair: generateKeyPairCallback } = require("node:crypto"); 2 | const { promisify } = require("node:util"); 3 | const { getEnv, setEnv } = require("../env"); 4 | 5 | const generateKeyPair = promisify(generateKeyPairCallback); 6 | 7 | const { makeApi2Request } = require("../api2"); 8 | const { 9 | CLIENT_NAME_FOR_DEMO_APP, 10 | REQUIRED_SCOPES_FOR_BACKEND_CLIENT, 11 | APP_RESOURCE_SERVER_IDENTIFIER, 12 | APP_CALLBACK_URL, 13 | APP_LOGOUT_URL, 14 | APP_INITIATE_LOGIN_URL, 15 | APP_LOGO_URI, 16 | } = require("../constants"); 17 | 18 | const getClientGrantId = async () => { 19 | const requestOptions = { 20 | path: `client-grants?client_id=${process.env.AUTH0_MGMT_CLIENT_ID}&audience=https://${process.env.AUTH0_DOMAIN}/api/v2/`, 21 | }; 22 | 23 | try { 24 | const response = await makeApi2Request(requestOptions); 25 | if (response && response.length === 1) { 26 | return response[0].id; 27 | } 28 | 29 | // TODO: Better error message 30 | throw new Error("Could not find client grant"); 31 | } catch (error) { 32 | console.error(error); 33 | // TODO: Better error, e.g. "make sure you setup the right client grant" 34 | console.error(`Error while getting client grant: ${error.message}`); 35 | } 36 | }; 37 | 38 | const getClientGrant = async (clientGrantId) => { 39 | const requestOptions = { 40 | method: "get", 41 | path: `client-grants`, 42 | }; 43 | 44 | try { 45 | const resp = await makeApi2Request(requestOptions); 46 | return resp.find((item) => item.id === clientGrantId); 47 | } catch (error) { 48 | console.error(error); 49 | // TODO: Better error, e.g. "make sure you setup the right client grant" 50 | console.error(`Error while getting client grants: ${error.message}`); 51 | } 52 | }; 53 | 54 | const setRequiredClientGrant = async (clientGrantId) => { 55 | const grant = (await getClientGrant(clientGrantId)) || { scope: [] }; 56 | 57 | const requestOptions = { 58 | method: "patch", 59 | path: `client-grants/${clientGrantId}`, 60 | data: { 61 | scope: [...REQUIRED_SCOPES_FOR_BACKEND_CLIENT, ...grant.scope].reduce( 62 | (scopes, currValue) => { 63 | if (scopes.indexOf(currValue) === -1) { 64 | scopes.push(currValue); 65 | } 66 | return scopes; 67 | }, 68 | [] 69 | ), 70 | }, 71 | }; 72 | 73 | try { 74 | await makeApi2Request(requestOptions); 75 | } catch (error) { 76 | console.error(error); 77 | // TODO: Better error, e.g. "make sure you setup the right client grant" 78 | console.error(`Error while updating client grant: ${error.message}`); 79 | } 80 | }; 81 | 82 | const getAppClient = async () => { 83 | // TODO: Only works if less than 100 clients in a tenant 84 | const requestOptions = { 85 | path: "clients?page=0&per_page=100", 86 | }; 87 | 88 | try { 89 | const response = await makeApi2Request(requestOptions); 90 | console.log({ response }); 91 | const matchingClient = response.filter( 92 | (client) => client.name === CLIENT_NAME_FOR_DEMO_APP 93 | ); 94 | 95 | if (matchingClient.length === 0) { 96 | return; 97 | } 98 | 99 | return matchingClient[0]; 100 | } catch (error) { 101 | console.error(error); 102 | // TODO: Better error, e.g. "make sure you setup the right client grant" 103 | console.error(`Error while getting client grant: ${error.message}`); 104 | } 105 | }; 106 | 107 | const generateJWTCAKeypair = async () => { 108 | const { publicKey, privateKey } = await generateKeyPair("rsa", { 109 | modulusLength: 4096, 110 | publicKeyEncoding: { 111 | type: "spki", 112 | format: "pem", 113 | }, 114 | privateKeyEncoding: { 115 | type: "pkcs8", 116 | format: "pem", 117 | }, 118 | }); 119 | 120 | return { publicKey, privateKey }; 121 | }; 122 | 123 | const addKeyToClientCredentials = async ( 124 | clientId, 125 | publicKey, 126 | alg = "RS256" 127 | ) => { 128 | const requestOptions = { 129 | method: "post", 130 | path: `clients/${clientId}/credentials`, 131 | data: { 132 | name: `key-${new Date().toISOString()}`, 133 | credential_type: "public_key", 134 | pem: publicKey, 135 | alg, 136 | }, 137 | }; 138 | try { 139 | const response = await makeApi2Request(requestOptions); 140 | return { kid: response.kid, id: response.id }; 141 | } catch (error) { 142 | console.error(error); 143 | console.error(`Error while posting client credential: ${error.message}`); 144 | } 145 | }; 146 | 147 | const patchClientToUseJWTCAKey = async (clientId, credentialId) => { 148 | const requestOptions = { 149 | method: "PATCH", 150 | path: `clients/${clientId}`, 151 | data: { 152 | token_endpoint_auth_method: null, 153 | client_authentication_methods: { 154 | private_key_jwt: { 155 | credentials: [{ id: credentialId }], 156 | }, 157 | }, 158 | jwt_configuration: { 159 | alg: "RS256" 160 | } 161 | }, 162 | }; 163 | try { 164 | await makeApi2Request(requestOptions); 165 | } catch (error) { 166 | console.error(error); 167 | console.error(`Error while patching client for JWTCA: ${error.message}`); 168 | } 169 | }; 170 | 171 | const patchClientToUseClientSecret = async (clientId) => { 172 | const requestOptions = { 173 | method: "PATCH", 174 | path: `clients/${clientId}`, 175 | data: { 176 | token_endpoint_auth_method: "client_secret_post", 177 | client_authentication_methods: null, 178 | }, 179 | }; 180 | try { 181 | await makeApi2Request(requestOptions); 182 | } catch (error) { 183 | console.error(error); 184 | console.error(`Error while patching client for JWTCA: ${error.message}`); 185 | } 186 | }; 187 | 188 | const generateNewAppClient = async () => { 189 | const requestOptions = { 190 | method: "post", 191 | path: "clients", 192 | data: { 193 | name: CLIENT_NAME_FOR_DEMO_APP, 194 | description: "Created by orgs demo", 195 | callbacks: [APP_CALLBACK_URL], 196 | allowed_logout_urls: [APP_LOGOUT_URL], 197 | initiate_login_uri: APP_INITIATE_LOGIN_URL, 198 | logo_uri: APP_LOGO_URI, 199 | organization_usage: "allow", 200 | oidc_conformant: true, 201 | }, 202 | }; 203 | 204 | try { 205 | const response = await makeApi2Request(requestOptions); 206 | return response; 207 | } catch (error) { 208 | console.error(error); 209 | // TODO: Better error, e.g. "make sure you setup the right client grant" 210 | console.error(`Error while getting client grant: ${error.message}`); 211 | } 212 | }; 213 | 214 | const createAppClient = async () => { 215 | let appClient = await getAppClient(); 216 | if (!appClient) { 217 | console.log("Demo app client does not exist. Creating..."); 218 | appClient = await generateNewAppClient(); 219 | } 220 | return appClient; 221 | }; 222 | 223 | const getAppResourceServer = async () => { 224 | const requestOptions = { 225 | path: `resource-servers/${APP_RESOURCE_SERVER_IDENTIFIER}`, 226 | }; 227 | 228 | try { 229 | const response = await makeApi2Request(requestOptions); 230 | return response; 231 | } catch (error) { 232 | if (error.data.statusCode === 404) { 233 | // expected if the demo resource server is not already created 234 | return; 235 | } 236 | console.error(error); 237 | // TODO: Better error, e.g. "make sure you setup the right client grant" 238 | console.error(`Error while getting resource server: ${error.message}`); 239 | } 240 | }; 241 | 242 | const generateNewAppResourceServer = async () => { 243 | const requestOptions = { 244 | method: "post", 245 | path: "resource-servers", 246 | data: { 247 | name: APP_RESOURCE_SERVER_IDENTIFIER, 248 | identifier: APP_RESOURCE_SERVER_IDENTIFIER, 249 | scopes: [ 250 | { value: "create:foo", description: "create:foo" }, 251 | { value: "read:foo", description: "read:foo" }, 252 | { value: "update:foo", description: "update:foo" }, 253 | { value: "delete:foo", description: "delete:foo" }, 254 | ], 255 | enforce_policies: true, 256 | token_dialect: "access_token_authz", 257 | skip_consent_for_verifiable_first_party_clients: true, 258 | allow_offline_access: true, 259 | }, 260 | }; 261 | 262 | try { 263 | const response = await makeApi2Request(requestOptions); 264 | return response; 265 | } catch (error) { 266 | console.error(error); 267 | // TODO: Better error, e.g. "make sure you setup the right client grant" 268 | console.error(`Error while getting client grant: ${error.message}`); 269 | } 270 | }; 271 | 272 | const createAppResourceServer = async () => { 273 | let appResourceServer = await getAppResourceServer(); 274 | if (!appResourceServer) { 275 | console.log("Demo app resource server does not exist. Creating..."); 276 | appResourceServer = await generateNewAppResourceServer(); 277 | } 278 | return appResourceServer; 279 | }; 280 | 281 | const bootstrapProcess = async () => { 282 | console.log(">>> Bootstrapping Demo <<<"); 283 | console.log("----- Get Client Grant"); 284 | const clientGrantId = await getClientGrantId(); 285 | 286 | console.log("----- Update Client Grant"); 287 | await setRequiredClientGrant(clientGrantId); 288 | 289 | if (!getEnv("APP_CLIENT_ID")) { 290 | console.log("----- Create App Client"); 291 | const appClient = await createAppClient(); 292 | await setEnv("APP_CLIENT_ID", appClient.client_id, { write: true }); 293 | if (appClient.client_secret) { 294 | await setEnv("APP_CLIENT_SECRET", appClient.client_secret, { 295 | write: true, 296 | }); 297 | } 298 | } 299 | 300 | console.log("----- Create Resource Server"); 301 | await createAppResourceServer(); 302 | 303 | if (!getEnv("APP_JWTCA_PUBLIC_KEY")) { 304 | console.log("--- Generating JWTCA keys"); 305 | const { publicKey, privateKey } = await generateJWTCAKeypair(); 306 | const { kid, id } = await addKeyToClientCredentials( 307 | getEnv("APP_CLIENT_ID"), 308 | publicKey 309 | ); 310 | await setEnv("APP_JWTCA_PUBLIC_KEY", publicKey, { write: true }); 311 | await setEnv("APP_JWTCA_PRIVATE_KEY", privateKey, { write: true }); 312 | await setEnv("APP_JWTCA_KEY_ID", kid, { write: true }); 313 | await setEnv("APP_JWTCA_CREDENTIAL_ID", id, { write: true }); 314 | } 315 | 316 | const appClientAuthMethod = getEnv("APP_CLIENT_AUTHENTICATION_METHOD"); 317 | console.log({appClientAuthMethod}) 318 | if (appClientAuthMethod === "jwtca") { 319 | await patchClientToUseJWTCAKey( 320 | getEnv("APP_CLIENT_ID"), 321 | getEnv("APP_JWTCA_CREDENTIAL_ID") 322 | ); 323 | } else if (appClientAuthMethod === "client_secret_post") { 324 | await patchClientToUseClientSecret(getEnv("APP_CLIENT_ID")); 325 | } 326 | 327 | if (!getEnv("APP_JAR_PUBLIC_KEY")) { 328 | console.log("--- Generating JAR keys"); 329 | const { publicKey, privateKey } = await generateJWTCAKeypair(); 330 | const { kid, id } = await addKeyToClientCredentials( 331 | getEnv("APP_CLIENT_ID"), 332 | publicKey, 333 | getEnv("APP_JAR_KEY_ALG") || "PS256" 334 | ); 335 | await setEnv("APP_JAR_PUBLIC_KEY", publicKey, { write: true }); 336 | await setEnv("APP_JAR_PRIVATE_KEY", privateKey, { write: true }); 337 | await setEnv("APP_JAR_KEY_ID", kid, { write: true }); 338 | await setEnv("APP_JAR_CREDENTIAL_ID", id, { write: true }); 339 | } 340 | 341 | console.log("!!! REMEMBER TO ADD 127.0.0.1 myapp.com to /etc/hosts !!!"); 342 | console.log(">>> Bootstrap Complete <<<"); 343 | }; 344 | 345 | module.exports = { 346 | addKeyToClientCredentials, 347 | createAppClient, 348 | createAppResourceServer, 349 | generateJWTCAKeypair, 350 | getClientGrantId, 351 | patchClientToUseClientSecret, 352 | patchClientToUseJWTCAKey, 353 | setRequiredClientGrant, 354 | bootstrapProcess, 355 | }; 356 | --------------------------------------------------------------------------------