12

I'm using R Studio Server in combination with R Shiny, running on an Ubuntu 16.04. Everything works fine. I want to secure the R Shiny dashboards (username+pw), and I'm thinking about building a small webpage that communicates with AWS Cognito to verify the users.

I can't find any documentation about this combination (Shiny + Cognito), but do find quite some documentation about both R Shiny Authentication (using NGINX + Auth0) and the use of Cognito (for example in combination with NodeJS).

Is a combination of Shiny and Cognito (with for example PHP or Node JS) logical and secure? What would be the best way to go: a simple web page with some PHP, or a Node JS application, with Shiny incorporated in it?

I realize this question is rather broad, but since I'm sure I'm not the only one walking around with this questions, I still ask so everyone can profit from possible solutions.

asachet
  • 6,620
  • 2
  • 30
  • 74
Dendrobates
  • 3,294
  • 6
  • 37
  • 56
  • 1
    Are you open to use shinyproxy to deploy your shiny apps ? if yes, you have a lot options for authentication https://www.shinyproxy.io/configuration/#authentication – dickoa Feb 12 '19 at 12:47

2 Answers2

6

Here is a description of the set-up I have implemented. This is using AWS Cognito along with AWS-specific features.

Context: I have a bunch of shiny apps, packaged in containers (typically using asachet/shiny-base or one of these Dockerfiles as a base). I want to host them privately and control who can access them.

The set-up below is an alternative to shiny-proxy. In fact, it does not need any kind of shiny server. Each app simply relies on shiny. Each of the containers exposes a port (e.g. EXPOSE 3838) and are simply launched with runApp(".", host="0.0.0.0", port=3838). The scaling policies take care of starting and stopping containers as needed. The authentication logic is completely decoupled from the app code.

My cloud set-up is:

  • An Application Load Balancer (ALB) is used as the user entry point. You must use an HTTPS listener to set up authentication. I simply redirect HTTP traffic to HTTPS.
  • A Elastic Container Service (ECS) task+service for each app. This makes sure my apps are provisioned adequately and run completely independently. Each app can have an independent scaling policy, so each app has the right amount of resource for its traffic. You could even configure the apps to automatically start/stop to save a lot of resources. Obviously, the apps need to be private i.e. only accessible from the ALB.
  • Each ECS has a different ALB target group, so requests to app1.example.com get forwarded to app1, app2.example.com to app2, etc. This is all set up in the ALB rules. This is where we can easily add authentication.

I have a Cognito "user pool" with user accounts allowed to access the apps. This can be used to restrict access to the app at the traffic level rather than the application level.

In order to do that, you first need to create a client app in your Cognito user pool. For app1, I would create a Cognito client app using the 'authorization code grant' flow with openid scope and app1.example.com/oauth2/idpresponse as the callback URL.

Once this is done, you can simply go into the ALB rules and add authentication as a prerequisite for forwarding:

ALB rule example

From now on, the traffic on app1.example.com must be authenticated before being forwarded to app1. Unauthenticated requests will be redirected to the Cognito Hosted UI (something like example.auth.eu-west-2.amazoncognito.com) to enter their credentials. You can customise what the hosted UI looks like in the Cognito settings.

Helpful links

For packaging R code in a container:

For setting up Cognito authentication with an ALB:

asachet
  • 6,620
  • 2
  • 30
  • 74
  • I done one test shiny app following your setup @antoine-sac. It works well, thanks. One question: do you use one cluster for all the apps, and then each app has a service+task, or do you actually have a separate cluster per app? – Tom Greenwood Apr 24 '19 at 13:08
  • I have used a single cluster for all the apps but I don't think it matters. The ELB should be able to point to the ECS services anyway. – asachet Apr 24 '19 at 14:42
  • 1
    As an additional note for readers, I have noticed memory leaks when the containers are running for an extended period of time. Regardless of actual usage, memory consumption was slowly increasing by ~10MB every day. This gets noticeable after a few weeks. I have not managed to get around it so I have switched to a shinyproxy solution for the apps that run 24/7. Shinyproxy works well with cognito (pointers: https://github.com/openanalytics/shinyproxy/issues/131#issuecomment-467829328) – asachet Apr 24 '19 at 14:46
  • lol https://www.youtube.com/watch?v=whLDDNFKhpY – binaryguy Nov 28 '22 at 07:32
0

You can utilize AWS Cognito API to authenticate. I wrote a post about it here.

To make this answer self-contained, here are the details in short. Basically, what you need to do is to use this code in the global.r file of your app:

base_cognito_url <- "https://YOUR_DOMAIN.YOUR_AMAZON_REGION.amazoncognito.com/"
app_client_id <- "YOUR_APP_CLIENT_ID"
app_client_secret <- "YOUR_APP_CLIENT_SECRET"
redirect_uri <- "https://YOUR_APP/redirect_uri"

library(httr)

app <- oauth_app(appname = "my_shiny_app",
                 key = app_client_id,
                 secret = app_client_secret,
                 redirect_uri = redirect_uri)
cognito <- oauth_endpoint(authorize = "authorize",
                          access = "token",
                          base_url = paste0(base_cognito_url, "oauth2"))


retrieve_user_data <- function(user_code){

  failed_token <- FALSE

  # get the token
  tryCatch({token_res <- oauth2.0_access_token(endpoint = cognito,
                                              app = app,
                                              code = user_code,
                                              user_params = list(client_id = app_client_id,
                                                                 grant_type = "authorization_code"),
                                              use_basic_auth = TRUE)},
           error = function(e){failed_token <<- TRUE})

  # check result status, make sure token is valid and that the process did not fail
  if (failed_token) {
    return(NULL)
  }

  # The token did not fail, go ahead and use the token to retrieve user information
  user_information <- GET(url = paste0(base_cognito_url, "oauth2/userInfo"), 
                          add_headers(Authorization = paste("Bearer", token_res$access_token)))

  return(content(user_information))

}

In the server.r you use it like this:

library(shiny)
library(shinyjs)

# define a tibble of allwed users (this can also be read from a local file or from a database)
allowed_users <- tibble(
  user_email = c("user1@example.com",
                 "user2@example.com"))

function(input, output, session){

   # initialize authenticated reactive values ----
   # In addition to these three (auth, name, email)
   # you can add additional reactive values here, if you want them to be based on the user which logged on, e.g. privileges.
   user <- reactiveValues(auth = FALSE, # is the user authenticated or not
                          name = NULL, # user's name as stored and returned by cognito
                          email = NULL)  # user's email as stored and returned by cognito

   # get the url variables ----
   observe({
        query <- parseQueryString(session$clientData$url_search)
        if (!("code" %in% names(query))){
            # no code in the url variables means the user hasn't logged in yet
            showElement("login")
        } else {
            current_user <- retrieve_user_data(query$code)
            # if an error occurred during login
            if (is.null(current_user)){
                hideElement("login")
                showElement("login_error_aws_flow")
                showElement("submit_sign_out_div")
                user$auth <- FALSE
            } else {
                # check if user is in allowed user list
                # for more robustness, use stringr::str_to_lower to avoid case sensitivity
                # i.e., (str_to_lower(current_user$email) %in% str_to_lower(allowed_users$user_email))
                if (current_user$email %in% allowed_users$user_email){
                    hideElement("login")
                    showElement("login_confirmed")
                    showElement("submit_sign_out_div")

                    user$auth <- TRUE
                    user$email <- current_user$email
                    user$name <- current_user$name

                    # ==== User is valid, continue prep ====

                    # show the welcome box with user name
                    output$confirmed_login_name <-
                        renderText({
                            paste0("Hi there!, ",
                                    user$name)
                        })

                    # ==== Put additional login dependent steps here (e.g. db read from source) ====

                    # ADD HERE YOUR REQUIRED LOGIC
                    # I personally like to select the first tab for the user to see, i.e.:
                    showTab("main_navigation", "content_tab_id", select = TRUE) 
                    # (see the next chunk for how this tab is defined in terms of ui elements)

                    # ==== Finish loading and go to tab ====

                } else {
                    # user not allowed. Only show sign-out, perhaps also show a login error message.
                    hideElement("login")
                    showElement("login_error_user")
                    showElement("submit_sign_out_div")
                }
            }
        }
    })

   # This is where you will put your actual elements (the server side that is) ----
   # For example:

    output$some_plot <- renderPlot({
        # *** THIS IS EXTREMELY IMPORTANT!!! ***
        validate(need(user$auth, "No privileges to watch data. Please contact support."))
        # since shinyjs is not safe for hiding content, make sure that any information is covered
        # by the validate(...) expression as was specified. 
        # Rendered elements which were not preceded by a validate expression can be viewed in the html code (even if you use hideElement).

        # only if user is confirmed the information will render (a plot in this case)
        plot(cars)
    })
}

And the ui.r looks like this:

library(shiny)
library(shinyjs)

fluidPage(
    useShinyjs(), # to enable the show/hide of elements such as login and buttons
    hidden( # this is how the logout button will like:
        div(
            id = "submit_sign_out_div",
            a(id = "submit_sign_out",
              "logout",
              href = aws_auth_logout,
              style = "color: black; 
              -webkit-appearance: button; 
              -moz-appearance: button; 
              appearance: button; 
              text-decoration: none; 
              background:#ff9999; 
              position: absolute; 
              top: 0px; left: 20px; 
              z-index: 10000;
              padding: 5px 10px 5px 10px;"
              )
            )
    ),
    navbarPage(
        "Cognito auth example",
        id = "main_navigation",
        tabPanel(
            "identification",
            value = "login_tab_id",
            h1("Login"),
            div(
                id = "login",
                p("To login you must identify with a username and password"),
                # This defines a login button which upon click will redirect to the AWS Cognito login page
                a(id = "login_link",
                  "Click here to login",
                  href = aws_auth_redirect,
                  style = "color: black;
                  -webkit-appearance: button;
                  -moz-appearance: button;
                  appearance: button;
                  text-decoration: none;
                  background:#95c5ff;
                  padding: 5px 10px 5px 10px;")
            ),
            hidden(div(
                id = "login_error_aws_flow",
                p("An error has occurred."),
                p("Please contact support")
            )),
            hidden(
                div(
                    id = "login_confirmed",
                    h3("User confirmed"),
                    fluidRow(
                        textOutput("confirmed_login_name")),
                    fluidRow(
                        p("Use the menu bar to navigate."),
                        p(
                            "Don't forget to logout when you want to close the system."
                        )
                    )
                )
            ),
        ),
        tabPanel("Your actual content", 
                 value = "content_tab_id",
                 fluidRow(plotOutput("some_plot")))
    )
)
Adi Sarid
  • 799
  • 8
  • 13