parse-community / parse-server

Parse Server for Node.js / Express
https://parseplatform.org
Apache License 2.0
20.88k stars 4.78k forks source link

Use JWT to authenticate (workaround inside, but looking for a proper solution) #6390

Open sunshineo opened 4 years ago

sunshineo commented 4 years ago

We need to support requests without Parse session token but a JWT from Auth0. We have a hack but wonder if there is a better way to do this. We don't like the part that we have to call db twice to find the user and then the session. We would have called the db even more times if the user or session does not exist. We had to give the session an insane long expiration time, but I hope that is not a problem. Lastly, we are not sure if setting the x-parse-session-token header is the right way to become that user on the server-side.

Here we share our hack

const express = require('express');
const app = express();
const ParseServer = require('parse-server').ParseServer;
const jwt = require('express-jwt');
const jwksRsa = require('jwks-rsa');

const jwtMiddleware = jwt({
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: 'http://your-app.auth0.com/.well-known/jwks.json'
  }),

  // Validate the audience and the issuer.
  audience: 'https://api.your-domain.com/v1',
  issuer: 'https://your-app.auth0.com/',
  algorithms: ['RS256'],
  credentialsRequired: false,
})
app.use('/parse', jwtMiddleware)

const addParseSessionHeader = async (req) => {
  if (!req.user) {
    return
  }
  const username = req.user.sub
  const userQuery = new Parse.Query('_User')
  userQuery.equalTo('username', username)
  let users
  try {
    users = await userQuery.find()
  }
  catch(e) {
    console.log('Exception when search for user: ', e)
    return
  }
  if (!users || users.length === 0) {
    // TODO: need to creat user
    return
  }
  const user = users[0]
  const sessionQuery = new Parse.Query('_Session')
  sessionQuery.equalTo('user', user)
  let sessions
  try {
    sessions = await sessionQuery.find({ useMasterKey: true })
  }
  catch(e) {
    console.log('Exception when search session for user: ', e)
    return
  }
  if (!sessions || sessions.length === 0) {
    // TODO: need to login to create session
    return
  }
  const session = sessions[0]
  const sessionToken = session.get('sessionToken')
  req.headers['x-parse-session-token'] = sessionToken
}

app.use('/parse', async (req, res, next) => {
  await addParseSessionHeader(req, res)
  next()
})

const parseApi = new ParseServer({
 ...your configs
});
app.use('/parse', parseApi);

app.listen(port, () => console.log(`Listening on port ${port}`));
davimacedo commented 4 years ago

The best way would be writing a new auth adapter like these. It would be nice to have it here. Would you be willed to tackle this and send us a PR?

sunshineo commented 4 years ago

@davimacedo I could be wrong but I think the auth adapter is exactly the opposite. Using one of those adapter will get Parse server to log user in and issue a sessionToken to the client. Later requests will put that sessionToken in the header, and then it's business as usual.

But here we don't want to use the sessionToken at all, we want Parse to use the JWT issued by Auth0. And frankly, it is probably faster and more stable since that does not require a lookup from database or cache

davimacedo commented 4 years ago

Sorry. I've misunderstood your usage case. For your case case I think the middleware before Parse replacing jwt to session token is the way to go. You are not planning to use the SDKs, right?

goshander commented 4 years ago

With JWT use, you still need check token in fast database like redis to check forced token ban

sunshineo commented 4 years ago

@Dynan7 We do not have any kind of that check right now. We rely on the token to be very short-lived and constantly refreshed. We probably will eventually do that so users do not have to login too frequently. Do you think my way of having Parse handle JWT is OK? Was your point that even now I have to do a check from database or cache, it is not worse than JWT since if JWT was setup properly this check is needed anyway?

@davimacedo Is there a plan for Parse to replace session token with JWT? Will this be a small/medium/large project?

sunshineo commented 4 years ago

@Dynan7 On second thought, we are using 3rd party OAuth 2 service (Auth0). Users get a refresh token in their browser and the actual access token expires after a very short time (could be as low as 60s or so). The browser will receive 401 if the access token expired and then browser will call Auth0 with the refresh token to get a new access token. When we need to ban someone, we just invalidate the refresh token, and soon their access token expires and they lose access. In this flow, no database or cache check is needed at all especially on our server side. Auth0 need to valid refresh token, but that's their job when generating access token.

I think a proper solution for Parse to use JWT should NOT be what I did, but trust the JWT and have the permission attached to the request to access data with ACL working. Could be complicated.

davimacedo commented 4 years ago

@sunshineo I am not aware of any plans to replace current session token to jwt but I believe if it is something that the developers can opt-in or not it would be welcome. I'd need to learn more about jwt but I guess it would be medium project (I'm considering we can user some hack in order to not change all SDKs but definitively I'd need to learn more about jwt). @acinader @dplewis thoughts?

sunshineo commented 4 years ago

Thank you @davimacedo! Does anything come to your mind besides attach x-parse-session-token to request.headers ? Is it possible to create something else and attach it to the request that can achieve the same effect?

goshander commented 4 years ago

@Dynan7 On second thought, we are using 3rd party OAuth 2 service (Auth0). Users get a refresh token in their browser and the actual access token expires after a very short time (could be as low as 60s or so). The browser will receive 401 if the access token expired and then browser will call Auth0 with the refresh token to get a new access token. When we need to ban someone, we just invalidate the refresh token, and soon their access token expires and they lose access. In this flow, no database or cache check is needed at all especially on our server side. Auth0 need to valid refresh token, but that's their job when generating access token.

I think a proper solution for Parse to use JWT should NOT be what I did, but trust the JWT and have the permission attached to the request to access data with ACL working. Could be complicated.

That sounds good, if you update token so often, you really no need check it in the any storage. I just thought you have access token with long time expiring In our project we too save permissions in JWT, it's fast and usefull.

sunshineo commented 4 years ago

@Dynan7 Thank you for sharing! How do you use the permissions from JWT in Parse? How does that work with Parse ACL?

davimacedo commented 4 years ago

@sunshineo you can look at this file to see how the session token is handled. Observe that you can also send it in the body on a post request instead of in the header.

sunshineo commented 4 years ago

Thank you @davimacedo . It seems that I can create an Parse.Auth object myself from that user. I do not need to log the user in then get the session. I do not need to have never expired sessions in my database. This also saves a couple database calls. I'll try it and report back.

sunshineo commented 4 years ago

After reading the code my conclusion is that the middleware will overwrite req.auth based on the fact that there is no sessionToken. So I believe my workaround is the only workaround without modify the Parse code. The good news is I'm feeling this is not going to be that hard. I'll try to do a fork and try something.

sunshineo commented 4 years ago

I was able to add this simple change that allows me to pass down the Parse user I mapped from JWT on the request to the Parse middleware. https://github.com/sunshineo/parse-server/commit/2dcafa35e9967a75c94507169f44e4deb442bfdc

if (req.userFromJWT) {
    req.auth = new auth.Auth({
      config: req.config,
      installationId: info.installationId,
      isMaster: false,
      user: req.userFromJWT,
    });
    next();
    return;
  }

This saves one or two database call, and we are no longer messing with the Parse sessions. No more insane long expiration session.

This workaround still requires user to wrap Parse server in express and map the JWT to a Parse server themselves before pass it down to Parse middleware. But is this the final stop? Should Parse take over the JWT decoding and user mapping? I can see that introduce a ton of configurations to support all the different decoding usecases? And I'm not even sure if the user mapping is possible without code. I'd like to hear your thoughts.

Here is what our code looks like now with the hack above

const express = require('express');
const app = express();
const ParseServer = require('parse-server').ParseServer;
const jwt = require('express-jwt');
const jwksRsa = require('jwks-rsa');

const jwtMiddleware = jwt({
  secret: jwksRsa.expressJwtSecret({
    cache: true,
    rateLimit: true,
    jwksRequestsPerMinute: 5,
    jwksUri: 'http://your-app.auth0.com/.well-known/jwks.json'
  }),

  // Validate the audience and the issuer.
  audience: 'https://api.your-domain.com/v1',
  issuer: 'https://your-app.auth0.com/',
  algorithms: ['RS256'],
  credentialsRequired: false,
})
app.use('/parse', jwtMiddleware)

const rejectInvalidToken = (err, req, res, next) => {
  // this means no token was provided, using other auth methods
  if (err.code === 'credentials_required') {
    return next()
  }
  res.statusCode = 401;
  res.send(err);  
}
app.use('/parse', rejectInvalidToken)

const getUser = async (jwtUser) => {
  const username = jwtUser.sub
  const userQuery = new Parse.Query('_User')
  userQuery.equalTo('username', username)
  let users
  try {
    users = await userQuery.find({ useMasterKey: true })
  }
  catch(e) {
    console.log('Exception when search for user: ', e)
    return
  }
  if (!users || users.length === 0) {
    return
  }
  return users[0]
}
const createUser = async (jwtUser) => {
  const username = jwtUser.sub
  const user = new Parse.User()
  user.set('username', username)
  user.set('password', username)
  try {
    await user.save(null, { useMasterKey: true })
  }
  catch(e) {
    console.log('Exception when create user: ', e)
    return
  }
  return user
}
const mapJWT2ParseUserHelper = async (req) => {
  const jwtUser = req.user
  if (!jwtUser) {
    return
  }
  req.userFromJWT = await getUser(jwtUser) || await createUser(jwtUser)
}
const mapJWT2ParseUser = async (req, res, next) => {
  await mapJWT2ParseUserHelper(req)
  next()
}
app.use('/parse', mapJWT2ParseUser)

const parseApi = new ParseServer({
 ...your configs
});
app.use('/parse', parseApi);

app.listen(port, () => console.log(`Listening on port ${port}`));
stale[bot] commented 4 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

jdposthuma commented 3 years ago

@sunshineo - thanks for this. Hugely helpful to be able to leverage JWTs in our deployment of parse (eg: offline authentication, abstracting authentication from authorisation).

I'm running into a couple of problems. How did you solve the following issues:

sunshineo commented 3 years ago

Not sure about the /me endpoint but my change is merged in so if you provide JWT on the request to /me endpoint it should work

If you have a JWT, you can try make a dummy request to your backend and verify if the JWT works. If it works, your frontend can treat the user as "logged in", if not, say the JWT expired, you should discard the JWT and frontend shows as the user is not logged in.

mtrezza commented 3 years ago

This seems to have been stale-closed. We have since disabled the stale bot from this repo to handle issues manually. I'm therefore reopening.l since there seems to be still discussion going on.

The issue can be closed onces it's clear that is can be closed manually.

jdposthuma commented 3 years ago

Yes, I'm assigning the parse user that I instantiate from my JWT to req.userFromJWT. Thanks for the feature!

I also discovered properties in the Parse JS SDK that allow my JWT to be passed in the header as a bearer token:

Parse.serverAuthType = 'Bearer'
Parse.serverAuthToken = `${session?.getIdToken()?.getJwtToken()}`

This works well for me as I'm trying to run parse behind behind AWS API Gateway (with a lambda authorizer) using tokens generated from Cognito.

I do need to fork this project in order to add support to call the login endpoint with a JWT instead of a password and store the JWT as the session token. This will make the change easy for my JS, Android and iOS clients while being sure to handle any potential lingering uses of the sessionToken (eg: /me, WSS, etc).

unicornist commented 3 years ago

Anybody tried to store user roles on this JWT too? I wrote my issues about querying for user roles in another issue related to validating user roles in here: https://github.com/parse-community/parse-server/issues/7093#issuecomment-860734757

unicornist commented 3 years ago

Did you guys test this method of authentication with ACL / CLP limited classes and object operations? Are they working with this?

sunshineo commented 3 years ago

Have not tested, but the solution is setting the user on the request before doing anything. So if the user can operation on ACL/CLP limited classes and object, then the JWT should be able to as well. Roles can be saved on JWT but Parse got no way to use that info. Parse cares which user is sending the request and then figure out the roles of the user from the DB

unicornist commented 3 years ago

Thanks, I'll give it a try and add roles to it, and will inform in here. Someone recently added a role-based validation for cloud functions, but other than that, you can always manually check for roles in any trigger or function and if it's not in JWT it's a no-brainer to query for it on each call. I explained in that new feature's thread https://github.com/parse-community/parse-server/issues/7093#issuecomment-860734757

stephannielsen commented 2 years ago

Are you using this approach together with Parse Dashboard by any chance? While I can successfully protect the /parse/* endpoints like this but this also locks out Parse Dashboard as it does not add a valid bearer token to it's requests. Any idea to enable both use cases?

sunshineo commented 2 years ago

@stephannielsen Your use case sounds very nature at first. But according to my memory, Dashboard is doing bunch of fancy stuff using the master key and you can see the master key in the browser's developer panel. So able to log into the dashboard means user is almighty. Using JWT on Dashboard is not possible

stephannielsen commented 2 years ago

Yep sure, I didn't plan adding jwt authentication to the dashboard. The thing is, with the solution above, Dashboard can't load any data as its requests are blocked due to a missing jwt token. However, my idea is now to add another Middleware which accepts requests with the master key as the Dashboard includes it in its request headers.

sunshineo commented 2 years ago

I misunderstood your question before. Dashboard requests to the server is very strange, it does not have JWT of course, it also does not have master key in the header. The master key is placed in the POST body. Dashboard always to HTTP POST even when it need to do GET/PUT/DELETE, etc. So your code should allow the request to continue if it does not have JWT. And Dashboard will work. That is what we do now. My code above is quite old.

const jwtMiddleware = jwt(jwtAuth0Options)
app.use('/parse', jwtMiddleware)

const rejectInvalidToken = (err, req, res, next) => {
  // this means no token was provided, using other auth methods
  if (err.code === 'credentials_required') {
    return next()
  }
  logger.warn('Invalid non empty jwt token: ', err);
  res.set({ 'Access-Control-Allow-Origin': '*' })
    .status(401)
    .send(err)
}
app.use('/parse', rejectInvalidToken)
stephannielsen commented 2 years ago

Whoops, looked at my dashboard request the wrong way. You're right, it is in the body of the POST.

Now I also understand this better. If a token is present, you validate it. If there is no token, you rely on Parse built-in mechanisms to protect the resources. Makes sense, as this allows normal Parse behavior if you have public resources. In my use case all data/queries requires authentication so I simply returned 401 also if there is no token present. But I guess the more appropriate way is to use Parse CLP/ACL mechanisms in combination with access token validation just to replace Parse's session token mechanism.

stephannielsen commented 2 years ago

So this works fine with CLP and ACLs on normal Parse endpoints. But we also use cloud functions. Using the JWT for authentication to access the cloud function works fine, however it seems not possible to run queries in the same user context afterwards and leverage the JWT for CLP/ACL on queries performed in the cloud function. Typically, you can parse a session token to queries but now we don't have a session token for this user.

const query = new Parse.Query("MyQuery");
query.find({
    sessionToken: request.user.get("sessionToken") // request.user is the correct user, but session token is always undefined here
}).then(results => {

});

Or am I missing something?

sunshineo commented 2 years ago

In my original post I said "...We had to give the session an insane long expiration time..." What we do is we try to find a session for that user and if none was found, create it and set it to expiration in 10 years (max allowed.

stephannielsen commented 2 years ago

Yes, sure, but that requires to use sessions again which were no longer needed without cloud functions...

Thanks for the quick reply.