feathers
feathers copied to clipboard
Stateless JWT Authentication Docs
Hello :wave:
I've been using feathers for two years or so already, and so far it's pretty amazing!
I would like to share my input on authentication service, because I find it a bit misleading/confusing, so better documentation (and more built-in strategies) can help.
I only recently (accidentally) figured out that JWT token issued by auth service does not store user
object as token payload, instead it queries database for EVERY authenticated API call to obtain user
object, which in many scenarios do not make a lot of sense, and it's very heavy on database.
Most of the time you can store user
object completely or partially in JWT payload and just decode it without pinging the database. This is what you see in the most frameworks. It, of course, has some pitfalls like you cannot blacklist the user, or jwt payload gets outdated (after updating user), but it's more a project preference/tradeoff.
I think documentation for auth should clearly state that GET /user will be called on every api call if you set entity
in authentication.
And the second thought: Integrating stateless JWT yourself requires extending the Authentication class, and if you use the framework for the first time it's not that straightforward. If there is StatelessStrategy or something, would be of a great help (I have built my own auth service, can share it as a package).
Hope all this make sense, thanks for commenting!
i was also searching for this and realised i couldn't specify which properties to add to the token,
personally i would like to add roles and permissions information to the token without rewriting an entire strategy
This is documented (even with permissions as an example) in the Stateless JWT guide.
I'm not sure why everybody is so opposed to extending the existing classes. They have been specifically designed in a way that each step is customizable instead of having to add, document and test a separate option for anything anybody would ever want to do (and everybody else gets confused about).
This is documented (even with permissions as an example) in the Stateless JWT guide.
👍
thanks this is great for my use case, im not sure why i didnt find it at first
I'm not sure why everybody is so opposed to extending the existing classes
Hey @daffl, thanks for the update! I saw that documentation part. It's clear how to implement, though stateless JWT feels like second grade citizen (which I feel it should not be). By default, auth is stateful (config-only, no code), and for stateless you need to, extend the class. Second thing: fetching entity
on every authenticate()
call should be clearly stated.
Also, when you use authenticate('jwt')
in hooks, if there's an entity it sets params.user
, but if there's no entity, you have to use params.authentication.payload
.
Sure, the docs can be clarified. Also not saying there can't be a stateless JWT option but there are a lot of intricacies that come with it
- You have to define what you want in the payload
- You can't just disable a user, so tokens need to be made revokable (now everybody needs to set up Redis in their app)
- Tokens should be shorter lived so you also need a refresh token to issue new ones (which also needs to be revokable)
- Often you need the user info anyway so worst case people would get it every time they need it instead of only once
What I'm getting at is that we've been having discussions around this for many years now and it turns out that both approaches are effectively causing the same amount of problems and confusions for people, just different ones. These decisions (also with a clarification that the user is fetched every time) are explained more in the FAQ.
I'd welcome PRs to the Stateless JWT docs that clarifies any problems you ran into.
Also open to suggestions for an API to define the payload via configuration instead of via code but the current authentication system has specifically been designed to be customizable through extension (for oAuth you e.g. almost always have to extend the strategy) so it's really not a bad thing - and from my perspective better than adding new options (which all have to be clearly documented, tested, maintained and supported when someone inevitably is confused by something).
Hey, thanks for clarification, I totally understand that problem is not black and white, it's more like what kind of api are you building (sometimes you don't need to blacklist a token or make short-lived tokens, eg. it's app for trusted users etc), I will think about it during the weekend and may submit a PR, or at least make a separate package that handles stateless auth. Anyways, thanks for swift input!
@daffl I think it would be good to make easy transition stateful <-> stateless auth. That would mean:
- both strategies need to set same property, eg.
params.user
- allow to fetch entity in the same manner a) in case of stateful auth, fetch entity every time b) in case of stateless: store entity inside the token, and just decode it
This way you can just configure strategy as stateful or stateless, without changing hooks, etc.
At the moment, if you have large codebase and you rely to params.user
, you need to change all hooks that use authentication or add a global hook (in app.hooks) that sets params.user = params.payload, which feels a bit hacky.
I think that makes sense. I've been meaning to add more features around that for the next version anyway (built-in token revocation strategy, refresh token) so this would fit in well. Only risk is that the payload could become huge if your entity has a lot of data.
Only risk is that the payload could become huge if your entity has a lot of data
all good points.
i think you could do a property whitelist approach to make it explicit what properties will be exposed where by default only email
is picked to be a part of the payload. (with the assumption that the id is still put in the sub
prop)
This way the payload is intentionally managed
I think email
is too case specific, also think that user payload may become large, but then you can update getEntity(), maybe by default to include whole entity object? At least feels like more common-sense scenario.
Here's my implementation, which is very project-specific, but can clean it up and make it work with feathers configuration.
Also, another mini-incosistency in feathers-cli is that it put authentication.js in /src
folder but it's actually a service, and when you generate services it puts them under src/services
folder, maybe that can me improved too, I personaly move authentication under /authentication/authentication.service.js
and often add authentication.hooks.js
too
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const { NotAuthenticated, NotFound } = require('@feathersjs/errors');
const { pick } = require('lodash');
module.exports = class Authentication {
constructor(options, app) {
this.app = app;
this.log = this.app.log;
// Config
this.secret = app.get('authentication').secret;
this.jwtOptions = app.get('authentication').jwtOptions;
this.whitelist = app.get('authentication').whitelistFields || ['id'];
// Bind methods
this.getUser = this.getUser.bind(this);
this.issueToken = this.issueToken.bind(this);
}
async getUser(query) {
try {
const [user] = await this.app.service('users').find({ query });
if (!user) throw new NotAuthenticated();
if (user.deleted_at) throw new NotAuthenticated('user deleted');
return user;
} catch (e) {
throw new NotFound();
}
}
issueToken(payload) {
payload = pick(payload, this.whitelist);
const access_token = jwt.sign(payload, this.secret, this.jwtOptions);
return access_token;
}
async localStrategy({ email, username, password }) {
this.log.debug('Authenticating using local strategy %s', username || email);
const query = username ? { username } : { email };
try {
const user = await this.getUser(query);
// Compare pass
const passTest = await bcrypt.compare(password, user.password);
if (!passTest) throw new NotAuthenticated('invalid credentials');
delete user.password;
const access_token = this.issueToken(user);
return { user, access_token };
} catch (e) {
this.log.error(e.message);
throw new NotAuthenticated(e.message);
}
}
async jwtStrategy(data) {
this.log.debug('Authenticating using jwt strategy');
try {
let { access_token: token } = data;
const decoded = jwt.verify(token, this.secret, this.jwtOptions);
const user = await this.getUser({ email: decoded.email });
delete user.password;
const refreshToken = await this.issueToken(user);
return Promise.resolve({ user, access_token: refreshToken });
} catch (e) {
throw new NotAuthenticated();
}
}
async find(params) {
try {
this.log.debug('get authenticated user');
if (!params.headers.authorization) throw new NotAuthenticated();
const token = params.headers.authorization.split('Bearer ')[1];
if (!token) throw new NotAuthenticated();
const decoded = jwt.verify(token, this.secret, this.jwtOptions);
// this.log.debug('Decoded %o', decoded);
const user = await this.getUser({ email: decoded.email });
delete user.password;
return Promise.resolve(user);
} catch (e) {
this.log.error(e.message);
throw new NotAuthenticated();
}
}
create(data, params) {
if (data.access_token) return this.jwtStrategy(data, params);
return this.localStrategy(data, params);
}
};
My main point was that the payload and authentication process should be lean and transparent.
payload.sub
already contains the user id so in that regard the whitelist can be empty until explicitly configured by the developer.
So if we want a smooth transition between strategies then it's best to be consistent while at the same time not duplicating data within the token.
How i thought about it and implemented it was like so
When generating the token, the payload json is as follows:
{
...otherJWTPayloadProps,
"sub": user[idProp],
"state": { ...whitelistProps }
}
When running the authentication hook in stateless mode, the hook workflow does the following:
// not the exact code but the concept below i believe is clear
const decodedPayload = jwt.verify(...);
context.params.user = {
// no need to call the user service as I assume i have what i need in state
// also in some cases the user service may not be in the same feathers app
...decodedPayload.state,
[idProp]: decodedPayload.sub
}
This way when i share hooks between the default strategy and the stateless strategy, the hooks only ever need to look at context.params.user
without caring if i'm authenticating with the default strategy or a stateless strategy.
eg. of a shared hook is checking permission.
Notes:
- Data structure above is irrelevant, just my personal spin on it
- Authentication hook doesn't need to know what whitelist properties are, all additional props are scoped to
state
prop - if a specific project needs more information not within user state then that can be a hook that runs after
authenticate('jwt')
hook that will know specifically for that project how to do that