Part 2: Shiny OAuth authentication in a local dev environment

shiny
rstats
OAuth
destiny2
API
Author

Alan Schussman

Published

September 4, 2023

Handling OAuth authorization in shiny

With a working local server on https, I can interact with the remote API to complete OAuth token requests and receive the redirect appropriately.

First: Everything I know about this I learned from Hadley Wickham’s {httr} example gist that lays out these mechanics. Studying the workflow that Hadley describes in that gist unlocked my understanding of key OAuth and Shiny design elements. My modest improvements are in laying out specifically how to handle the local vs. remote deployment contexts, and I implemented my app with a whole lot of his code verbatim.

The app needs several specific parameters to successfully get authorization from (or, later, to make any requests of) the API. Those parameters need to match the information generated in the API’s developer portal. I’ve set up two applications in the developer portal, because I need two different redirect URLs, one for dev and one for the deployed Shiny app. The screenshot below shows the development app in the portal.

I’m using a separated ui.R and server.R structure because the server logic is pretty lengthy and it made sense to separate; but it does mean I have to duplicate a couple of code elements. Now that I’ve spent a lot of time with it, I’d consider using a single app.R file to avoid the complexity that comes along with that.

First, I set up parameters for my deployed and local/dev app in global.R so that they are available everywhere I’ll need them.

app_url_test <- "https://localhost"
app_url_deployed <- "https://deardestiny.shinyapps.io/armorer"
scope <- ""

# Information for deployed/hosted app
API_key <- "<info from dev portal>"   
appname <- "destiny"
appkey <- "<info from dev portal>" 
appsecret <- "<info from dev portal>" 
APP_URL <- app_url_deployed

# Information for local/test app
dev_API_key <- "<info from dev portal>"   
dev_appname <- "destiny"
dev_appkey <- "<info from dev portal>"
dev_appsecret <- "<info from dev portal>" 

# Destiny 2 API info
authorize_url <- "https://www.bungie.net/en/OAuth/Authorize"
access_url <- "https://www.bungie.net/Platform/App/OAuth/token"
API_root <- "https://www.bungie.net/platform/"
GetMembershipURL <- "User/GetMembershipsById/"

At the end of ui.R I use a slightly-modified function from Hadley’s writeup, in which I set up an object for my API endpoint and my application. The only change here is to conditionally build app based on whether I’m running Shiny in my local environment (interactive()==TRUE) or deployed to a server:

uiFunc <- function(req) {
  destiny <- oauth_endpoint(request = NULL, authorize = authorize_url, access = access_url)
  if(interactive()) {
    app <- oauth_app(appname = dev_appname, key = dev_appkey, secret = dev_appsecret, 
                     redirect_uri = app_url_test)
  } else {
    app <- oauth_app(appname, appkey, appsecret, redirect_uri = APP_URL)
  }
  if (has_auth_code(parseQueryString(req$QUERY_STRING))) {
     ui
  } else {
    # do normal oauth flow here if we don't have a query parameter representing oauth
    if (!has_auth_code(parseQueryString(req$QUERY_STRING))) {
       url <- oauth2.0_authorize_url(destiny, app, scope = scope)
       redirect <- sprintf("location.replace(\"%s\");", url)
       tags$script(HTML(redirect))
    }
  }
}

After building my app object for the right context, this function will either run my UI, or, if the session doesn’t have a query string1, run oauth2.0_authorize_url to ask for a token.

Then, in server.R, I do much the same: I need app and endpoint objects (app is again conditional on context). I look for query parameters, and if I don’t have them, I return back to uiFunc which will then send off an authorization request. Then, when I do have an authorization code in the URL parameters, I use it to make the final part of the “let me use the API” handshake, which is to ask for an actual access token, which I’ll use in all the subsequent requests to the API. Finally, proof that I have successfully authorized comes in being able to pull the users’s membership_id out of the returned token object.

server <- function(input, output, session) {
  
  if (interactive()) {
  # set test/local dev context vars
  options(shiny.port = 8100)
  APP_URL <- app_url_test
  API_key <- dev_API_key
  app <- oauth_app(appname = dev_appname, key = dev_appkey, secret = dev_appsecret, 
                   redirect_uri = app_url_test)
  } else {
    # deployed URL
    app <- oauth_app(appname, appkey, appsecret, APP_URL)
  }
  
  # set up core application info for oauth requests
  destiny <- oauth_endpoint(request = NULL, authorize = authorize_url, access = access_url)
  params <- parseQueryString(isolate(session$clientData$url_search))
  
  # get oauth token
  # produces membership_id, access_token, and encompassing token object
  if (!has_auth_code(params)) {
      cat(file=stderr(), "No auth code in parameters, returning.\n\n")
      return()
  }
  if (!exists("access_token")) {
    cat(file=stderr(), "\nno token\n")
    with_verbose(shinytoken <- oauth2.0_token(
      app = app, endpoint = destiny,
      credentials = oauth2.0_access_token(destiny, app, code=params$code,
                                          use_basic_auth = TRUE)), 
      info=TRUE, data_in=TRUE)
  }
  updateQueryString(APP_URL)
  access_token <- shinytoken[["credentials"]][["access_token"]] 
  membership_id <- shinytoken$credentials$membership_id

Here’s something I had to learn: I wanted to centralize all the app construction in global.R so that I could have that code exist in just one place. But that file doesn’t return true for interactive() so it can’t properly set the local authentication parameters.

As I’ve noted before, the work to write up an explanation like this has substantially improved the work itself. I love the reflexivity between writing code and writing about code.

Now that I have a proper access token, I can make requests of the API that do the real work of the rest of my apps, like get player character inventory and profile information that require authorization. That’s where the real fun starts – decoding miles and miles of deeply nested JSON returned from the API! And that’s also for another day of writeup and sharing.

Footnotes

  1. Having a query string is the indication that the current URL has been returned from an OAuth token request. Without it, at this point in the flow, we know we have to go and make the request for authorization from the API.↩︎