Skip to content

Commit

Permalink
feat: JWT should be able to identify subjects using another field tha…
Browse files Browse the repository at this point in the history
…n the user ID (closes #1066)
  • Loading branch information
claustres committed Jan 28, 2025
1 parent 7aed999 commit c5706ef
Show file tree
Hide file tree
Showing 4 changed files with 213 additions and 9 deletions.
35 changes: 27 additions & 8 deletions core/api/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export class AuthenticationProviderStrategy extends OAuthStrategy {
export class JWTAuthenticationStrategy extends JWTStrategy {
async authenticate (authentication, params) {
const { accessToken } = authentication
const authConfig = this.authentication.configuration
const { identityFields } = authConfig
const { entity } = this.configuration
const renewJwt = _.get(this.configuration, 'renewJwt', true)

Expand All @@ -106,14 +108,31 @@ export class JWTAuthenticationStrategy extends JWTStrategy {
// Second key trick
// Return user attached to the token if any
// Return basic information for a stateless token otherwise
// As we only target MongoDB now, check for a valid ID otherwise assume a stateless token as well
if (payload.sub && ObjectID.isValid(payload.sub)) {
const entityId = await this.getEntityId(result, params)
const value = await this.getEntity(entityId, params)

return {
...result,
[entity]: value
if (payload.sub) {
// Check for a valid MongoDB ID
if (ObjectID.isValid(payload.sub)) {
const entityId = await this.getEntityId(result, params)
const value = await this.getEntity(entityId, params)

return {
...result,
[entity]: value
}
} else if (identityFields) {
// Otherwise use others fields to identify the user if defined
const query = {
$or: _.reduce(identityFields, (or, field) => or.concat([{ [field]: payload.sub }]), []),
$limit: 1
}
const response = await this.entityService.find({ ...params, query })
const [value = null] = response.data ? response.data : response
// Otherwise assume a stateless token
if (value) {
return {
...result,
[entity]: value
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion core/api/hooks/hooks.authorisations.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export function createJWT (options = {}) {
const accessTokens = await Promise.all(items.map(item => hook.app.getService('authentication').createAccessToken(
// Provided function can be used to pick or omit properties in JWT payload
(typeof options.payload === 'function' ? options.payload(user) : {}),
// Provided function can be used for custom options cdepending on the user,
// Provided function can be used for custom options depending on the user,
// then we merge with default auth options for global properties like aud, iss, etc.
_.merge({}, defaults, (typeof options.jwt === 'function' ? options.jwt(user) : options)))
))
Expand Down
184 changes: 184 additions & 0 deletions test/api/core/authentication.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import authentication from '@feathersjs/authentication'
import request from 'superagent'
import chai from 'chai'
import chailint from 'chai-lint'
import core, { kdk } from '../../../core/api/index.js'

const { authenticate } = authentication.hooks
const { util, expect } = chai

describe('core:authentication', () => {
let app, server, port, baseUrl, userIdAccessToken, emailAccessToken, phoneAccessToken, statelessAccessToken,
userService, userObject, authenticationService

before(async () => {
chailint(chai, util)

app = kdk()
port = app.get('port')
baseUrl = `http://localhost:${port}${app.get('apiPath')}`
await app.db.connect()
await app.db.instance.dropDatabase()
})

it('registers the services', async () => {
await app.configure(core)
authenticationService = app.getService('authentication')
expect(authenticationService).toExist()
userService = app.getService('users')
expect(userService).toExist()
// Register hooks
userService.hooks({
before: { all: authenticate('jwt') }
})
// Now app is configured launch the server
server = await app.listen(port)
await new Promise(resolve => server.once('listening', resolve))
})
// Let enough time to process
.timeout(10000)

it('unauthenticated user cannot access services', async () => {
try {
await request.get(`${baseUrl}/users`)
} catch (error) {
/* FIXME: Not sure why but in this case the raised error is in text format
expect(error).toExist()
expect(error.name).to.equal('NotAuthenticated')
*/
expect(error.status).to.equal(500)
}
})

it('creates user tokens with different subject identifiers', async () => {
userObject = await userService.create({
email: '[email protected]',
name: 'test user',
profile: { phone: '0623256968' }
})
userIdAccessToken = await authenticationService.createAccessToken({
sub: userObject._id
})
emailAccessToken = await authenticationService.createAccessToken({
sub: userObject.email
})
phoneAccessToken = await authenticationService.createAccessToken({
sub: userObject.profile.phone
})
})

it('checks all user tokens are recognized', async () => {
let response = await request
.post(`${baseUrl}/authentication`)
.send({ accessToken: userIdAccessToken, strategy: 'jwt' })
let accessToken = response.body.accessToken
let user = response.body.user
expect(accessToken).toExist()
expect(accessToken).not.to.equal(userIdAccessToken)
expect(user).toExist()
response = await request
.post(`${baseUrl}/authentication`)
.send({ accessToken: emailAccessToken, strategy: 'jwt' })
accessToken = response.body.accessToken
user = response.body.user
expect(accessToken).toExist()
expect(accessToken).not.to.equal(emailAccessToken)
expect(user).toExist()
response = await request
.post(`${baseUrl}/authentication`)
.send({ accessToken: phoneAccessToken, strategy: 'jwt' })
accessToken = response.body.accessToken
user = response.body.user
expect(accessToken).toExist()
expect(accessToken).not.to.equal(phoneAccessToken)
expect(user).toExist()
})

it('checks all user tokens can be used to access services in header', async () => {
let response = await request
.get(`${baseUrl}/users`)
.set('Authorization', 'Bearer ' + userIdAccessToken)
let users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
response = await request
.get(`${baseUrl}/users`)
.set('Authorization', 'Bearer ' + emailAccessToken)
users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
response = await request
.get(`${baseUrl}/users`)
.set('Authorization', 'Bearer ' + phoneAccessToken)
users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
})

it('checks all user tokens can be used to access services in query', async () => {
let response = await request
.get(`${baseUrl}/users`)
.query({ jwt: userIdAccessToken })
let users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
response = await request
.get(`${baseUrl}/users`)
.query({ jwt: emailAccessToken })
users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
response = await request
.get(`${baseUrl}/users`)
.query({ jwt: phoneAccessToken })
users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
})

it('creates a stateless token with a custom payload', async () => {
statelessAccessToken = await authenticationService.createAccessToken({
property: 'mycustomproperty'
}, {
subject: 'mycustomapp'
})
})

it('checks stateless token is recognized', async () => {
const response = await request
.post(`${baseUrl}/authentication`)
.send({ accessToken: statelessAccessToken, strategy: 'jwt' })
const accessToken = response.body.accessToken
const user = response.body.user
expect(accessToken).toExist()
expect(accessToken).not.to.equal(statelessAccessToken)
expect(user).beUndefined()
const payload = await authenticationService.verifyAccessToken(accessToken, app.get('authentication').jwtOptions)
expect(payload.property).to.equal('mycustomproperty')
})

it('checks stateless token can be used to access services in header', async () => {
const response = await request
.get(`${baseUrl}/users`)
.set('Authorization', 'Bearer ' + statelessAccessToken)
const users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
})

it('checks stateless token can be used to access services in query', async () => {
const response = await request
.get(`${baseUrl}/users`)
.query({ jwt: statelessAccessToken })
const users = response.body.data
expect(users).toExist()
expect(users[0]._id).to.equal(userObject._id.toString())
})

// Cleanup
after(async () => {
if (server) await server.close()
await app.db.instance.dropDatabase()
await app.db.disconnect()
})
})
1 change: 1 addition & 0 deletions test/api/core/config/default.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ module.exports = {
path: API_PREFIX + '/authentication',
service: API_PREFIX + '/users',
entity: 'user',
identityFields: ['email', 'profile.phone'],
authStrategies: [
'jwt',
'local'
Expand Down

0 comments on commit c5706ef

Please sign in to comment.