Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
sunshineo opened this issue Feb 5, 2020 · 31 comments
Labels
type:feature New feature or improvement of existing feature

Comments

@sunshineo
Copy link
Contributor

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
Copy link
Member

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
Copy link
Contributor Author

@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
Copy link
Member

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
Copy link

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

@sunshineo
Copy link
Contributor Author

@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
Copy link
Contributor Author

sunshineo commented Feb 10, 2020

@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.

@sunshineo sunshineo changed the title Use JWT to authenticate Use JWT to authenticate (workaround inside, but looking for a proper solution) Feb 10, 2020
@davimacedo
Copy link
Member

@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
Copy link
Contributor Author

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
Copy link

@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
Copy link
Contributor Author

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

@davimacedo
Copy link
Member

@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
Copy link
Contributor Author

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
Copy link
Contributor Author

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
Copy link
Contributor Author

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. sunshineo@2dcafa3

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
Copy link

stale bot commented Mar 27, 2020

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.

@stale stale bot added the wontfix label Mar 27, 2020
@stale stale bot closed this as completed Apr 3, 2020
@jdposthuma
Copy link
Contributor

@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:

  • /me endpoint that relies directly on the token store
  • How to "log in" the user in the JS SDK. Don't want to have to make an API call, if I already have my JWT.

@sunshineo
Copy link
Contributor Author

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
Copy link
Member

mtrezza commented Feb 10, 2021

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.

@mtrezza mtrezza removed the stale label Feb 10, 2021
@mtrezza mtrezza reopened this Feb 10, 2021
@jdposthuma
Copy link
Contributor

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).

@jdposthuma jdposthuma mentioned this issue Feb 24, 2021
4 tasks
@mtrezza mtrezza added the type:feature New feature or improvement of existing feature label Mar 11, 2021
@unicornist
Copy link

unicornist commented Jun 14, 2021

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: #7093 (comment)

@unicornist
Copy link

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

@sunshineo
Copy link
Contributor Author

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
Copy link

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 #7093 (comment)

@stephannielsen
Copy link

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
Copy link
Contributor Author

@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
Copy link

stephannielsen commented Feb 9, 2022

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
Copy link
Contributor Author

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
Copy link

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
Copy link

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
Copy link
Contributor Author

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
Copy link

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

Thanks for the quick reply.

@dblythy dblythy mentioned this issue Oct 12, 2022
31 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
type:feature New feature or improvement of existing feature
Projects
None yet
Development

No branches or pull requests

7 participants