mapic/shiny

How to get user context

bneigher opened this issue · 8 comments

@knutole
Love this setup - was most of what I was looking for was done already - nice work.
Only thing I had to do was add NODE_ENV to one of the .env files - as it express defaults app.get("env") to development which will send users to your auth0 instance https://github.com/mapic/shiny-auth0/blob/master/app/app.js#L160.

Here is my question:
I was curious how you go about getting the user auth id for example from inside the shiny dash? I believe it's being stored in a cookie looking at the code.
Seems like the only use case for bare auth proxy is to essentially get general password protection over the shiny dashboard... I would imaging it could be more useful to have the auth serve as a way of letting r shiny know the email / id for context? You seem to indicate it here: https://github.com/mapic/shiny-auth0/blob/master/app/app.js#L40 🤞

Do we need to grab the cookie from the shiny-auth0 container? Is it available in the other containers? How do we get it in r?
Would appreciate the advice - R noob here

@bneigher Hi Ben, glad you can have use of the setup!

As for passing on the user context, this is indeed a useful feature which is not implemented. Your analysis is basically correct: currently there's only a simple Auth0 wrapper around the whole Shiny instance, all or nothing.

In order to implement user-based access rules, this might be possible using Shiny's user directories, although I believe this is a pro-feature only.

What might be easier to setup and maintain is to have your own Node Express server sitting between the Auth0 and Shiny, and serving specific Shiny apps based on your own user access controls. (If you name your Shiny apps long, random integers, this can function as access_tokens, ie. without the name it's not possible for others to find the app.) I think I would attempt something like this, rather than get into the access control details of Shiny.

You can also just expand on the shiny-auth0 Node Express server, of course, where the user has already been serialized.

Good luck!

@bneigher Possibly, you could add some user data to the url query (eg. ?user=ben), and pick it up in the R script like so: https://stackoverflow.com/questions/32872222/how-do-you-pass-parameters-to-a-shiny-app-via-url

@knutole ahh I see. Yea I thought about the query string option, but we're going to start getting more complex with the front-end, and it will need to communicate with other APIs - which means we need a jwt or something to be passed through. I don't think the jwt is a good idea in the query string nor the user id (our primary id for the user) as it is potentially an attack vector.

I'm wondering if the shiny-auth0 express app can forward the jwt to the shiny request (in the req headers during the duration that the session is still active) and then consume and extrat the data from the jwt in R. Similar to this https://github.com/dhenderson/shiny-token-auth with session$clientData$url_search

Still though - I scratch my head because this means we need to have the jwt secret available for R to use in the decode

I'll post my findings here as well as the solution I chose whether it be in the form of a PR or a comment. I'm sure it will be of value to someone one day

@bneigher

I think a way to do this is

  1. to add a Redis instance (https://hub.docker.com/_/redis) to the docker-compose.yml, and
  2. store any sensitive user data in Redis using a jwt as key. You can store from shiny-auth0 using https://github.com/NodeRedis/node_redis and
  3. pass the jwt as query param or req header to the R script.
  4. Then in the R script, you can get the user data from Redis on the jwt key, using https://github.com/richfitz/redux.

This way the jwt is only a temporary token (you can set EXPIRE on the key in Redis), and no sensitive data is exposed.

--

You can create a jwt, store user data to Redis, and pass on the jwt token here: https://github.com/mapic/shiny-auth0/blob/master/app/app.js#L55

Then you can proxy the jwt token to Shiny here (if you wanna use req headers): https://github.com/mapic/shiny-auth0/blob/master/app/routes/secure.js#L29

@knutole I think I found a bug which leads me to believe that this was the intended functionality this whole time!
Here are my findings:
Seems that the proxied request is supposed to contain the custom auth0 context headers as can be seen here: https://github.com/mapic/shiny-auth0/blob/master/app/routes/secure.js#L27 (e.g. x-auth0-user_id)
They are actually not being attached to the r shiny route request for two reasons:

  1. There are no scopes being set in the authorize method to auth0 through passport, which is why https://github.com/mapic/shiny-auth0/blob/master/app/app.js#L39 is basically empty. By adding
// This adds support for the current way to sso
var authenticateWithDefaultPrompt = passport.authenticate('auth0', {
   scope: "profile openid"
});
var authenticateWithPromptNone = passport.authenticate('auth0', {
  prompt: 'none',
  scope: "profile openid"
});

To here: https://github.com/mapic/shiny-auth0/blob/master/app/routes/index.js#L12-
L15 the user jwt, email, username, ect can come through to the express server.

  1. Seems that the setHeader calls here are setting the headers to the proxyReq object, when in reality it needs to be setting the headers on the res so like:
proxy.on('proxyReq', function(proxyReq, req, res, options) {
  debug && console.log('# proxy.on("proxyReq") | proxyReq, options, user => ', proxyReq, options, req.user);
  setIfExists(res, 'x-auth0-nickname', req.user._json.nickname);
  setIfExists(res, 'x-auth0-user_id', req.user._json.user_id);
  setIfExists(res, 'x-auth0-email', req.user._json.email);
  setIfExists(res, 'x-auth0-name', req.user._json.name);
  setIfExists(res, 'x-auth0-picture', req.user._json.picture);
  setIfExists(res, 'x-auth0-locale', req.user._json.locale);
});

Observe the response headers on the /secure request now has these headers

After making this change - essentially it makes needing redis obsolete which is great! One less service (unless we need to scale the web app - in which yea we'd need to whip out the redis). The state of the token / session can be handled directly by passport without needing to manage token lifecycle in another place.

The only remaining question is What should we send in the headers to be hack proof... cause there are ways a maliscious agent could change the headers. I'm playing with sending only the signed jwt from auth0 in the headers and then using the JWT secret (ENV VAR on the server) to decode the jwt and get the user_id that way. In this regard, someone would have to know the JWT secret to be able to sign and encode a custom jwt.

Thoughts?
If you'd like I can make a PR to your shiny-auth0 with the changes

Unfortunately it looks like R Shiny (non pro) strips out these nonstandard headers when making the session, and listing the HTTP Headers with session$request doesn't show them :(
Seems like it's an up-sell barrier that they do since you can whitelist them in the pro config:
https://docs.rstudio.com/shiny-server/#proxied-headers

Seems they've thought of this already :(

So maybe your solution is necessary. Essentially making a SSO code - pass it through query strings, and then use the code to lookup the user data with the redux lib you're suggesting, with a TTL equal to the expiration in the original jwt.

I may be wrong but this is tricky since each page refresh will essentially create a new jwt from the proxy? Maybe passport is smart enough to use the old one from the session - in which case yea the jwt can be the SSO token that's sent to r shiny

@bneigher Yeah, I think that's the way to go here. A page refresh would just create a new jwtwith same user data, so as long as the R script looks up the token each time, there shouldn't be a problem?

If this works cleanly, I'd be happy to see it in a PR! 👍

@bneigher Let me know what you came up with! :)