process.env.DEBUG = 'app*' const express = require('express') const cookieParser = require('cookie-parser') const app = express() const jwt = require('jsonwebtoken') const Debug = require('debug') const path = require('path') const cors = require('cors') const bodyParser = require('body-parser') const jose = require('node-jose') const favicon = require('serve-favicon') const initialUsers = require('./users') const issuer = process.env.ISSUER || 'localhost:3333' const jwksOrigin = `https://${issuer}/` const audience = process.env.AUDIENCE || 'https://generic-audience' const adminCustomClaim = process.env.ADMIN_CUSTOM_CLAIM || 'https://unbound.se/admin' const emailCustomClaim = process.env.EMAIL_CUSTOM_CLAIM || 'https://unbound.se/email' const debug = Debug('app') const keyStore = jose.JWK.createKeyStore() keyStore.generate('RSA', 2048, { alg: 'RS256', use: 'sig' }) // let { privateKey, certDer, thumbprint, exponent, modulus } = cert(jwksOrigin) const users = initialUsers(process.env.USERS_FILE || './users.json') const sessions = {} const challenges = {} // Session TTL in milliseconds (5 minutes) const SESSION_TTL_MS = 5 * 60 * 1000 // Periodically clean up old sessions to prevent memory leaks setInterval(() => { const now = Date.now() let cleaned = 0 for (const [key, session] of Object.entries(sessions)) { if (session.createdAt && now - session.createdAt > SESSION_TTL_MS) { delete sessions[key] delete challenges[key] cleaned++ } } if (cleaned > 0) { debug(`Cleaned up ${cleaned} expired sessions`) } }, 60000) // Run every minute const corsOpts = (req, cb) => { cb(null, { origin: req.headers.origin }) } const addCustomClaims = (email, customClaims, token) => { const emailClaim = {} emailClaim[emailCustomClaim] = email return [...customClaims, emailClaim].reduce((acc, claim) => { return { ...acc, ...claim } }, token) } const signToken = async (token) => { const [key] = keyStore.all({ use: 'sig' }) const opt = { compact: true, jwk: key, fields: { typ: 'jwt' } } return await jose.JWS.createSign(opt, key) .update(JSON.stringify(token)) .final() } // Configure our small auth0-mock-server app .options('*all', cors(corsOpts)) .use(cors()) .use(bodyParser.json({ strict: false })) .use(bodyParser.urlencoded({ extended: true })) .use(cookieParser()) .use(express.static(`${__dirname}/public`)) .use(favicon(path.join(__dirname, 'public', 'favicon.ico'))) // This route can be used to generate a valid jwt-token. app.post('/oauth/token', async (req, res) => { const date = Math.floor(Date.now() / 1000) if (req.body.grant_type === 'client_credentials' && req.body.client_id) { const accessToken = await signToken({ iss: jwksOrigin, aud: [audience], sub: 'auth0|management', iat: date, exp: date + 7200, azp: req.body.client_id }) const idToken = await signToken({ iss: jwksOrigin, aud: req.body.client_id, sub: 'auth0|management', iat: date, exp: date + 7200, azp: req.body.client_id, name: 'Management API' }) debug('Signed token for management API') res.json({ access_token: accessToken, id_token: idToken, scope: 'openid%20profile%20email', expires_in: 7200, token_type: 'Bearer' }) } else if (req.body.code) { const code = req.body.code const session = sessions[code] const accessToken = await signToken( addCustomClaims(session.email, session.customClaims, { iss: jwksOrigin, aud: [audience], sub: 'auth0|' + session.email, iat: date, exp: date + 7200, azp: session.clientId }) ) const idToken = await signToken( addCustomClaims(session.email, session.customClaims, { iss: jwksOrigin, aud: session.clientId, nonce: session.nonce, sub: 'auth0|' + session.email, iat: date, exp: date + 7200, azp: session.clientId, name: 'Example Person', given_name: 'Example', family_name: 'Person', email: session.email, picture: 'https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg' }) ) debug('Signed token for ' + session.email) // Clean up session and challenge after successful token exchange delete sessions[code] delete challenges[code] res.json({ access_token: accessToken, id_token: idToken, scope: 'openid%20profile%20email', expires_in: 7200, token_type: 'Bearer' }) } else { res.status(401) res.send('Missing client_id or client_secret') } }) app.post('/code', (req, res) => { if (!req.body.email || !req.body.password || !req.body.codeChallenge) { debug('Body is invalid!', req.body) return res.status(400).send('Email or password is missing!') } const code = req.body.codeChallenge challenges[req.body.codeChallenge] = code const state = req.body.state const claim = {} claim[adminCustomClaim] = req.body.admin === 'true' sessions[code] = { email: req.body.email, password: req.body.password, state: req.body.state, nonce: req.body.nonce, clientId: req.body.clientId, codeChallenge: req.body.codeChallenge, customClaims: [claim], createdAt: Date.now() } res.redirect( `${req.body.redirect}?code=${code}&state=${encodeURIComponent(state)}` ) }) app.get('/authorize', (req, res) => { const redirect = req.query.redirect_uri const state = req.query.state const nonce = req.query.nonce const clientId = req.query.client_id const codeChallenge = req.query.code_challenge const prompt = req.query.prompt const responseMode = req.query.response_mode if (responseMode === 'query') { const code = req.cookies['auth0'] const session = sessions[code] if (session) { session.nonce = nonce session.state = state session.codeChallenge = codeChallenge session.createdAt = Date.now() // Refresh timestamp sessions[codeChallenge] = session // Clean up old session entry if different key if (code !== codeChallenge) { delete sessions[code] delete challenges[code] } res.redirect(`${redirect}?code=${codeChallenge}&state=${state}`) return } } if (prompt === 'none' && responseMode === 'web_message') { const code = req.cookies['auth0'] const session = sessions[code] if (session) { session.nonce = nonce session.state = state session.codeChallenge = codeChallenge session.createdAt = Date.now() // Refresh timestamp res.send(` `) return } } res.cookie('auth0', codeChallenge, { sameSite: 'None', secure: true, httpOnly: true }) res.send(` Auth
Login
`) }) app.get('/userinfo', (req, res) => { res.contentType('application/json').send( JSON.stringify({ picture: 'https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg' }) ) }) app.get('/v2/logout', (req, res) => { const code = req.cookies['auth0'] const session = sessions[code] if (session) { delete sessions[code] } res.redirect(req.query.returnTo) }) app.get('/.well-known/openid-configuration', (req, res) => { debug('Fetching OpenID configuration') res.contentType('application/json').send( JSON.stringify({ issuer: `${jwksOrigin}`, authorization_endpoint: `${jwksOrigin}authorize`, token_endpoint: `${jwksOrigin}oauth/token`, token_endpoint_auth_methods_supported: [ 'client_secret_basic', 'private_key_jwt' ], token_endpoint_auth_signing_alg_values_supported: ['RS256'], userinfo_endpoint: `${jwksOrigin}userinfo`, check_session_iframe: `${jwksOrigin}check_session`, end_session_endpoint: `${jwksOrigin}end_session`, jwks_uri: `${jwksOrigin}.well-known/jwks.json`, registration_endpoint: `${jwksOrigin}register`, scopes_supported: [ 'openid', 'profile', 'email', 'address', 'phone', 'offline_access' ], response_types_supported: [ 'code', 'code id_token', 'id_token', 'id_token token' ], acr_values_supported: [], subject_types_supported: ['public', 'pairwise'], userinfo_signing_alg_values_supported: ['RS256', 'ES256', 'HS256'], userinfo_encryption_alg_values_supported: ['RSA-OAEP-256', 'A128KW'], userinfo_encryption_enc_values_supported: ['A128CBC-HS256', 'A128GCM'], id_token_signing_alg_values_supported: ['RS256', 'ES256', 'HS256'], id_token_encryption_alg_values_supported: ['RSA-OAEP-256', 'A128KW'], id_token_encryption_enc_values_supported: ['A128CBC-HS256', 'A128GCM'], request_object_signing_alg_values_supported: ['none', 'RS256', 'ES256'], display_values_supported: ['page', 'popup'], claim_types_supported: ['normal', 'distributed'], claims_supported: [ 'sub', 'iss', 'auth_time', 'acr', 'name', 'given_name', 'family_name', 'nickname', 'profile', 'picture', 'website', 'email', 'email_verified', 'locale', 'zoneinfo', 'https://unbound.se/email', 'https://unbound.se/admin' ], claims_parameter_supported: true, service_documentation: 'http://auth0/', ui_locales_supported: ['en-US'] }) ) }) app.get('/.well-known/jwks.json', (req, res) => { debug('Fetching JWKS') res.contentType('application/json').send(keyStore.toJSON()) }) // This route returns the inside of a jwt-token. Your main application // should use this route to keep the auth0-flow app.post('/tokeninfo', (req, res) => { if (!req.body.id_token) { debug('No token given in the body!') return res.status(401).send('missing id_token') } const data = jwt.decode(req.body.id_token) if (data) { debug('Return token data from ' + data.user_id) res.json(data) } else { debug('The token was invalid and could not be decoded!') res.status(401).send('invalid id_token') } }) app.get('/api/v2/users-by-email', (req, res) => { const email = req.query.email console.log('users', users) const user = users[email] if (user === undefined) { res.json([]) } else { res.json([user]) } }) app.patch('/api/v2/users/:userid', (req, res) => { const email = req.params.userid.slice(6) console.log('patching user with id', email) const user = users[email] if (!user) { res.sendStatus(404) return } users[email] = { email: email, given_name: req.body.given_name || user.given_name, family_name: req.body.family_name || user.family_name, user_id: email, picture: req.body.picture || user.picture } res.json({ user_id: `auth0|${email}` }) }) app.post('/api/v2/users', (req, res) => { const email = req.body.email users[email] = { email: email, given_name: 'Given', family_name: 'Last', user_id: email } res.json({ user_id: `auth0|${email}` }) }) app.post('/api/v2/tickets/password-change', (req, res) => { res.json({ ticket: `https://some-url` }) }) app.use(function (req, res, next) { console.log('404', req.path) res.status(404).send('error: 404 Not Found ' + req.path) }) app.listen(3333, () => { debug('Auth0-Mock-Server listening on port 3333!') })