23c9762cb8
Adds custom claims to the access and ID tokens for the management API. This modification allows the inclusion of specific claims in the tokens, improving the flexibility and security of the authentication process. The claims are added to support better access control and user identification.
453 lines
13 KiB
JavaScript
453 lines
13 KiB
JavaScript
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 = {}
|
|
|
|
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 claim = {}
|
|
claim[adminCustomClaim] = true
|
|
const accessToken = await signToken(
|
|
addCustomClaims('management@example.org', [claim], {
|
|
iss: jwksOrigin,
|
|
aud: [audience],
|
|
sub: 'auth0|management',
|
|
iat: date,
|
|
exp: date + 7200,
|
|
azp: req.body.client_id
|
|
})
|
|
)
|
|
|
|
const idToken = await signToken(
|
|
addCustomClaims('management@example.org', [claim], {
|
|
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)
|
|
|
|
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')
|
|
}
|
|
})
|
|
|
|
// This route can be used to generate a valid jwt-token.
|
|
app.get('/token/:email', (req, res) => {
|
|
if (!req.params.email) {
|
|
debug('No user was given!')
|
|
return res.status(400).send('user is missing')
|
|
}
|
|
const token = jwt.sign(
|
|
{
|
|
user_id: 'auth0|' + req.params.email
|
|
},
|
|
privateKey
|
|
)
|
|
debug('Signed token for ' + req.params.email)
|
|
res.json({ token })
|
|
})
|
|
|
|
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]
|
|
}
|
|
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
|
|
sessions[codeChallenge] = session
|
|
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
|
|
res.send(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<script type="text/javascript">
|
|
(() => {
|
|
const msg = {
|
|
type: 'authorization_response',
|
|
response: {
|
|
code: '${code}',
|
|
state: '${state}'
|
|
}
|
|
}
|
|
parent.postMessage(msg, "*")
|
|
})()
|
|
</script>
|
|
</body>
|
|
</html>`)
|
|
return
|
|
}
|
|
}
|
|
|
|
res.cookie('auth0', codeChallenge, {
|
|
sameSite: 'None',
|
|
secure: true,
|
|
httpOnly: true
|
|
})
|
|
res.send(`
|
|
<html lang='en'>
|
|
<head>
|
|
<meta charset='utf-8'>
|
|
<meta name='viewport' content='width=device-width, initial-scale=1, shrink-to-fit=no'>
|
|
<title>Auth</title>
|
|
<link rel='stylesheet' href='https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css' integrity='sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh' crossorigin='anonymous'>
|
|
</head>
|
|
<body>
|
|
<div class='container'>
|
|
<form method='post' action='/code'>
|
|
<div class='card' style='width: 18rem;'>
|
|
<div class='card-body'>
|
|
<h5 class='card-title'>Login</h5>
|
|
<div class='form-group'>
|
|
<label for='email'>Email</label>
|
|
<input type='text' name='email' id='email' class='form-control'>
|
|
</div>
|
|
<div class='form-group'>
|
|
<label for='password'>Password</label>
|
|
<input type='password' name='password' id='password' class='form-control'>
|
|
</div>
|
|
<div class='form-check'>
|
|
<input class='form-check-input' type='checkbox' name='admin' value='true' id='admin'>
|
|
<label class='form-check-label' for='admin'>
|
|
Admin
|
|
</label>
|
|
</div>
|
|
<button type='submit' class='btn btn-primary'>Login</button>
|
|
<input type='hidden' value='${redirect}' name='redirect'>
|
|
<input type='hidden' value='${state}' name='state'>
|
|
<input type='hidden' value='${nonce}' name='nonce'>
|
|
<input type='hidden' value='${clientId}' name='clientId'>
|
|
<input type='hidden' value='${codeChallenge}' name='codeChallenge'>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`)
|
|
})
|
|
|
|
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!')
|
|
})
|