Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ server.register({
clientId: 'foobar',
secret: '1234-bar-4321-foo'
},
cache: {}
cache: {},
userInfo: ['name', 'email']
}
}, function(err) {
if (err) {
Expand Down Expand Up @@ -112,7 +113,10 @@ Required.

- `cache {Object|false}`: The configuration of the [hapi.js cache](https://hapijs.com/api#servercacheoptions) powered by [catbox][catbox].<br/>
If `false` the cache is disabled. Use an empty object to use the built-in default cache.<br/>
Optional. Default: `false`.<br/>
Optional. Default: `false`.

- `userInfo {Array.<?string>}`: List of properties which should be included in the `request.auth.credentials` object besides `scope` and `sub`.<br/>
Optional. Default: `[]`.<br/>

#### `server.kjwt.validate(field {string}, done {Function})`
Uses internally [`GrantManager.prototype.validateAccessToken()`][keycloak-auth-utils-gm-validate].
Expand Down Expand Up @@ -166,7 +170,8 @@ server.register({
clientId: 'foobar',
secret: '1234-bar-4321-foo'
},
cache: {}
cache: {},
userInfo: ['name', 'email']
}
}).then(() => {
server.auth.strategy('keycloak-jwt', 'keycloak-jwt');
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"json web token",
"plugin"
],
"version": "0.1.1",
"version": "0.2.0",
"license": "MIT",
"author": {
"name": "Felix Heck",
Expand Down
42 changes: 19 additions & 23 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,15 @@ const token = require('./token')
const { error, fakeReply, verify } = require('./utils')
const pkg = require('../package.json')

let manager

/**
* @function
* @public
*
* Get user information based on token with help of Keycloak.
* If all validations and requests are successful, save the
* token and its user data in memory cache.
* @type Object
* @private
*
* @param {string} token The token to be validated
* @param {Function} reply The callback handler
* Internally used properties
*/
function handleKeycloakUserInfo (tkn, reply) {
manager.userInfo(tkn.get()).then((userInfo) => {
const { scope, expiresIn } = tkn.getData()
const userData = { credentials: Object.assign({ scope }, userInfo) }

cache.set(tkn.get(), userData, expiresIn)
reply.continue(userData)
}).catch((err) => {
reply(error('unauthorized', err))
})
const internals = {
manager: undefined,
userInfoFields: undefined
}

/**
Expand All @@ -41,8 +27,16 @@ function handleKeycloakUserInfo (tkn, reply) {
function handleKeycloakValidation (tkn, reply) {
const invalidate = (err) => reply(error('unauthorized', err, error.msg.invalid))

manager.validateAccessToken(tkn.get()).then((res) => {
res ? handleKeycloakUserInfo(tkn, reply) : invalidate()
internals.manager.validateAccessToken(tkn.get()).then((res) => {
if (!res) {
return invalidate()
}

const { expiresIn, credentials } = tkn.getData(internals.userInfoFields)
const userData = { credentials }

cache.set(tkn.get(), userData, expiresIn)
return reply.continue(userData)
}).catch(invalidate)
}

Expand Down Expand Up @@ -108,9 +102,11 @@ function strategy (server) {
*/
function plugin (server, opts, next) {
opts = verify(opts)
manager = new GrantManager(opts.client)
cache.init(server, opts.cache)

internals.manager = new GrantManager(opts.client)
internals.userInfoFields = opts.userInfo

server.auth.scheme('keycloak-jwt', strategy)
server.decorate('server', 'kjwt', { validate })

Expand Down
22 changes: 19 additions & 3 deletions src/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ function token (field) {
return (exp - iat) * 1000
}

/**
* @function
* @private
*
* Get necessary user information out of token content.
*
* @param {Object} content The token its content
* @param {Array.<?string>} [fields] The necessary fields
* @returns {Object} The collection of requested user info
*/
function getUserInfo (content, fields = []) {
return _.pick(content, ['sub', ...fields])
}

/**
* @function
* @public
Expand All @@ -88,12 +102,14 @@ function token (field) {
*
* @returns {Object} The extracted data
*/
function getData () {
function getData (userInfoFields) {
const content = getContent()

return {
scope: getScope(content),
expiresIn: getExpiration(content)
expiresIn: getExpiration(content),
credentials: Object.assign({
scope: getScope(content)
}, getUserInfo(content, userInfoFields))
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ const scheme = joi.object({
}).unknown(true).required(),
cache: joi.alternatives().try(joi.object({
segment: joi.string().default('keycloakJwt')
}), joi.boolean().invalid(true)).default(false)
}), joi.boolean().invalid(true)).default(false),
userInfo: joi.array().items(joi.string().min(1))
}).unknown(true).required()

/**
Expand Down
42 changes: 39 additions & 3 deletions test/_fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,51 @@ const config = {
secret: 'barfoo'
}

const content = {
userData: {
'exp': 5,
'iat': 1,
'sub': '1234567890',
'name': 'John Doe',
'email': 'john.doe@mail.com',
'admin': true,
'realm_access': {
'roles': [
'admin'
]
},
'resource_access': {
'account': {
'roles': [
'manage-account',
'manage-account-links',
'view-profile'
]
},
'same': {
'roles': [
'editor'
]
},
'other-app': {
'roles': [
'other-app:creator'
]
}
}
}
}

/**
* @type Object
* @public
*
* Various JSON Web Tokens
*/
const jwt = {
content: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ',
userData: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjUsImlhdCI6MSwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiYWRtaW4iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX0sInNhbWUiOnsicm9sZXMiOlsiZWRpdG9yIl19LCJvdGhlci1hcHAiOnsicm9sZXMiOlsib3RoZXItYXBwOmNyZWF0b3IiXX19fQ._yxUAslOcgCp2Fd2xyO0q3iB24brG8PqqXQ-TCblQ1w',
userDataExp: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJhZG1pbiJdfSwicmVzb3VyY2VfYWNjZXNzIjp7ImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfSwic2FtZSI6eyJyb2xlcyI6WyJlZGl0b3IiXX0sIm90aGVyLWFwcCI6eyJyb2xlcyI6WyJvdGhlci1hcHA6Y3JlYXRvciJdfX19.Q49BbBtcemvPaDfXyroyuoR56_rbq_pADXeC0ABXyZc'
userData: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjUsImlhdCI6MSwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImVtYWlsIjoiam9obi5kb2VAbWFpbC5jb20iLCJhZG1pbiI6dHJ1ZSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbImFkbWluIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19LCJzYW1lIjp7InJvbGVzIjpbImVkaXRvciJdfSwib3RoZXItYXBwIjp7InJvbGVzIjpbIm90aGVyLWFwcDpjcmVhdG9yIl19fX0.uuhtpYNVtFZvPuRAEktWEDn_2u-dvimWnspXVt-gObU',
userDataExp: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huLmRvZUBtYWlsLmNvbSIsImFkbWluIjp0cnVlLCJyZWFsbV9hY2Nlc3MiOnsicm9sZXMiOlsiYWRtaW4iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX0sInNhbWUiOnsicm9sZXMiOlsiZWRpdG9yIl19LCJvdGhlci1hcHAiOnsicm9sZXMiOlsib3RoZXItYXBwOmNyZWF0b3IiXX19fQ.BcTtSEpyiUVBVkUOwVDM0_T9UIy-vk2aaUAR8XM6Hd0',
userDataScope: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjUsImlhdCI6MSwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImVtYWlsIjoiam9obi5kb2VAbWFpbC5jb20iLCJhZG1pbiI6dHJ1ZX0.2tfThhgwSbIEq2cZcoHSRwL2-UCanF23BXlyphm5ehs'
}

/**
Expand Down Expand Up @@ -92,6 +127,7 @@ module.exports = {
realmUrl,
clientId,
config,
content,
jwt,
validation,
userInfo
Expand Down
35 changes: 7 additions & 28 deletions test/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ test.cb.serial('throw error if plugin gets registered twice', (t) => {

test.cb.serial('authentication does succeed', (t) => {
prototypes.stub('validateAccessToken', fixtures.validation)
prototypes.stub('userInfo', fixtures.userInfo)

getServer(undefined, (server) => {
server.inject({
method: 'GET',
url: '/',
headers: {
authorization: `bearer ${fixtures.jwt.content}`
authorization: `bearer ${fixtures.jwt.userData}`
}
}, (res) => {
t.truthy(res)
Expand All @@ -40,13 +39,12 @@ test.cb.serial('authentication does succeed', (t) => {

test.cb.serial('authentication does succeed – cached', (t) => {
prototypes.stub('validateAccessToken', fixtures.validation)
prototypes.stub('userInfo', fixtures.userInfo)

const mockReq = {
method: 'GET',
url: '/',
headers: {
authorization: `bearer ${fixtures.jwt.content}`
authorization: `bearer ${fixtures.jwt.userData}`
}
}

Expand All @@ -66,7 +64,6 @@ test.cb.serial('authentication does succeed – cached', (t) => {

test.cb.serial('authentication does success – valid roles', (t) => {
prototypes.stub('validateAccessToken', fixtures.validation)
prototypes.stub('userInfo', fixtures.userInfo)

getServer(undefined, (server) => {
server.inject({
Expand All @@ -85,7 +82,6 @@ test.cb.serial('authentication does success – valid roles', (t) => {

test.cb.serial('authentication does fail – invalid roles', (t) => {
prototypes.stub('validateAccessToken', fixtures.validation)
prototypes.stub('userInfo', fixtures.userInfo)

getServer(undefined, (server) => {
server.inject({
Expand All @@ -110,7 +106,7 @@ test.cb.serial('authentication does fail – invalid token', (t) => {
method: 'GET',
url: '/',
headers: {
authorization: `bearer ${fixtures.jwt.content}`
authorization: `bearer ${fixtures.jwt.userData}`
}
}, (res) => {
t.truthy(res)
Expand Down Expand Up @@ -140,10 +136,9 @@ test.cb.serial('authentication does fail – invalid header', (t) => {

test.cb.serial('server method validates token', (t) => {
prototypes.stub('validateAccessToken', fixtures.validation)
prototypes.stub('userInfo', fixtures.userInfo)

getServer(undefined, (server) => {
server.kjwt.validate(`bearer ${fixtures.jwt.content}`, (err, res) => {
server.kjwt.validate(`bearer ${fixtures.jwt.userData}`, (err, res) => {
t.falsy(err)
t.truthy(res)
t.truthy(res.credentials)
Expand All @@ -152,27 +147,11 @@ test.cb.serial('server method validates token', (t) => {
})
})

test.cb.serial('server method invalidates token – userinfo error', (t) => {
prototypes.stub('validateAccessToken', fixtures.validation)
prototypes.stub('userInfo', new Error('an error'), 'reject')

getServer(undefined, (server) => {
server.kjwt.validate(`bearer ${fixtures.jwt.content}`, (err, res) => {
t.falsy(res)
t.truthy(err)
t.truthy(err.isBoom)
t.is(err.output.statusCode, 401)
t.is(err.output.headers['WWW-Authenticate'], 'Bearer error="Error: an error"')
t.end()
})
})
})

test.cb.serial('server method invalidates token – validation error', (t) => {
prototypes.stub('validateAccessToken', new Error('an error'), 'reject')

getServer(undefined, (server) => {
server.kjwt.validate(`bearer ${fixtures.jwt.content}`, (err, res) => {
server.kjwt.validate(`bearer ${fixtures.jwt.userData}`, (err, res) => {
t.falsy(res)
t.truthy(err)
t.truthy(err.isBoom)
Expand All @@ -187,7 +166,7 @@ test.cb.serial('server method invalidates token – invalid', (t) => {
prototypes.stub('validateAccessToken', false)

getServer(undefined, (server) => {
server.kjwt.validate(`bearer ${fixtures.jwt.content}`, (err, res) => {
server.kjwt.validate(`bearer ${fixtures.jwt.userData}`, (err, res) => {
t.falsy(res)
t.truthy(err)
t.truthy(err.isBoom)
Expand All @@ -200,7 +179,7 @@ test.cb.serial('server method invalidates token – invalid', (t) => {

test.cb.serial('server method invalidates token – wrong format', (t) => {
getServer(undefined, (server) => {
server.kjwt.validate(fixtures.jwt.content, (err, res) => {
server.kjwt.validate(fixtures.jwt.userData, (err, res) => {
t.falsy(res)
t.truthy(err)
t.truthy(err.isBoom)
Expand Down
40 changes: 32 additions & 8 deletions test/token.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,10 @@ test('get no bearer token – spaces between', (t) => {
})

test('get decoded content part of token', (t) => {
const jwt = `bearer ${fixtures.jwt.content}`
const jwt = `bearer ${fixtures.jwt.userData}`
const tkn = token(jwt)

t.deepEqual(tkn.getContent(), {
'sub': '1234567890',
'name': 'John Doe',
'admin': true
})
t.deepEqual(tkn.getContent(), fixtures.content.userData)
})

test('get user data of token', (t) => {
Expand All @@ -67,7 +63,21 @@ test('get user data of token', (t) => {

t.truthy(data)
t.is(data.expiresIn, 4000)
t.deepEqual(data.scope.sort(), ['editor', 'other-app:creator', 'realm:admin'])
t.is(data.credentials.sub, fixtures.content.userData.sub)
t.falsy(data.credentials.name)
t.deepEqual(data.credentials.scope.sort(), ['editor', 'other-app:creator', 'realm:admin'])
})

test('get user data of token – additional fields', (t) => {
const jwt = `bearer ${fixtures.jwt.userData}`
const tkn = token(jwt)
const data = tkn.getData(['name'])

t.truthy(data)
t.is(data.expiresIn, 4000)
t.is(data.credentials.sub, fixtures.content.userData.sub)
t.is(data.credentials.name, fixtures.content.userData.name)
t.deepEqual(data.credentials.scope.sort(), ['editor', 'other-app:creator', 'realm:admin'])
})

test('get user data of token – default expiration', (t) => {
Expand All @@ -77,5 +87,19 @@ test('get user data of token – default expiration', (t) => {

t.truthy(data)
t.is(data.expiresIn, 60000)
t.deepEqual(data.scope.sort(), ['editor', 'other-app:creator', 'realm:admin'])
t.is(data.credentials.sub, fixtures.content.userData.sub)
t.falsy(data.credentials.name)
t.deepEqual(data.credentials.scope.sort(), ['editor', 'other-app:creator', 'realm:admin'])
})

test('get user data of token – default scopes', (t) => {
const jwt = `bearer ${fixtures.jwt.userDataScope}`
const tkn = token(jwt)
const data = tkn.getData()

t.truthy(data)
t.is(data.expiresIn, 4000)
t.is(data.credentials.sub, fixtures.content.userData.sub)
t.falsy(data.credentials.name)
t.deepEqual(data.credentials.scope, [])
})
Loading