The webfakes package comes with two fake apps that allow to imitate the OAuth2.0 flow in your test cases. (See Aaron Parecki’s tutorial for a good introduction to OAuth2.0.) One app (oauth2_resource_app()) is the API server that serves both as the resource and provides authorization. oauth2_third_party_app() plays the role of the third-party app. They are useful when testing or demonstrating code handling OAuth2.0 authorization, token caching, etc. in a package. The apps can be used in your tests directly, or you could adapt one or both of them to better mimic a particular OAuth2.0 flow.

In this vignette, we shall look at how to implement a flow using both apps, or the server app together with httr’s OAuth tools. In both cases we shall use the httr package as HTTP client. We shall end with a case study for OAuth2.0 testing. For an example using the curl package instead, look at the test file test-oauth.R of webfakes instead.

The OAuth2.0 resource and authorization server

First we need to create the resource server, which also performs the authorization, and we create variables holding its different URLs.

templog <- tempfile()
rsapp <- new_app_process(
  oauth2_resource_app(
    refresh_duration = .Machine$integer.max,
    access_duration = 10L,
  ),
  opts = server_opts(num_threads = 3)
)

regi_url <- rsapp$url("/register")
auth_url <- rsapp$url("/authorize")
toke_url <- rsapp$url("/token")

rsapp
#> <webfakes_app_process>
#> state:
#>   live
#> auto_start:
#>   TRUE
#> process id:
#>   3610
#> http url:
#>   http://127.0.0.1:49851/
#> fields and methods:
#>   get_app()              # get the app object
#>   get_port()             # query port of the app
#>   get_state()            # query web server process state
#>   local_env(envvars)     # set temporary environment variables
#>   start()                # start the app
#>   url(path, query)       # query url for an api path
#>   stop()                 # stop web server process
#> # see ?webfakes_app_process for details

Fake third party application

OAuth2.0 app creation & registration

Then we create the third-party app, and we create variables holding its different URLs.

tpapp <- new_app_process(
  oauth2_third_party_app("3P app"),
  opts = server_opts(num_threads = 3)
)

redi_url <- tpapp$url("/login/redirect")
conf_url <- tpapp$url("/login/config")

tpapp
#> <webfakes_app_process>
#> state:
#>   live
#> auto_start:
#>   TRUE
#> process id:
#>   3614
#> http url:
#>   http://127.0.0.1:49852/
#> fields and methods:
#>   get_app()              # get the app object
#>   get_port()             # query port of the app
#>   get_state()            # query web server process state
#>   local_env(envvars)     # set temporary environment variables
#>   start()                # start the app
#>   url(path, query)       # query url for an api path
#>   stop()                 # stop web server process
#> # see ?webfakes_app_process for details

We then need to register the third-party app at the resource server. In real life this is done by the admin of the third party app. Our fake resource server provides an endpoint at /register (in regi_url) to do this automatically, without user interaction. We need to send the name of our third party app, and its redirect URL, as query parameters.

url <- paste0(
  regi_url,
  "?name=3P%20app",
  "&redirect_uri=", redi_url
)
reg_resp <- httr::GET(url)
reg_resp
#> Response [http://127.0.0.1:49851/register?name=3P%20app&redirect_uri=http://127.0.0.1:49852/login/redirect]
#>   Date: 2021-04-05 11:13
#>   Status: 200
#>   Content-Type: application/json
#>   Size: 184 B
regdata <- httr::content(reg_resp)
regdata
#> $name
#> $name[[1]]
#> [1] "3P app"
#> 
#> 
#> $client_id
#> $client_id[[1]]
#> [1] "id-56874b1e7ef04ddd10b7abb81aa4da"
#> 
#> 
#> $client_secret
#> $client_secret[[1]]
#> [1] "secret-592132524302aecf56099ef40adcd4"
#> 
#> 
#> $redirect_uri
#> $redirect_uri[[1]]
#> [1] "http://127.0.0.1:49852/login/redirect"

The resource app replies with the client id and the client secret. We’ll use them to authenticate the third party app. In real life they are included in the config of the third party app by its admin. Our third party app has an API endpoint, /login/config (already in conf_url) to configure them.

auth_data <- list(
  auth_url = auth_url,
  token_url = toke_url,
  client_id = regdata$client_id[[1]],
  client_secret = regdata$client_secret[[1]]
)

httr::POST(
  conf_url,
  body = auth_data,
  encode = "json"
)
#> Response [http://127.0.0.1:49852/login/config]
#>   Date: 2021-04-05 11:13
#>   Status: 200
#>   Content-Type: application/json
#>   Size: 41 B

The OAuth2.0 dance

Now a user can go to the login URL of the third party app, /login in our fake app, to authenticate. To start the web page from R, you can run

browseURL(tpapp$url("/login"))

The third party app now has a token, that it can use to authenticate to the resource app. See the test-oauth.R file within webfakes to see how to do this part programmatically, without a browser.

By default our fake third party app saves the token(s) into a local variable, and also returns in JSON, so you can see that in the browser:

#> {
#>   "access_token": "token-399bf5b41324c4579535dddfa87e7b",
#>   "expiry": 10,
#>   "refresh_token": "refresh-token-67f53e8bd7fe114a11310a698ee5ab"
#> }

If you want to change this behavior, you can define the redirect_hook function in the third party app. For example:

thirdp <- oauth2_third_party_app("3P app")
thirdp$redirect_hook <- function(res, tokens) {
  res$
    set_status(200L)$
    send("Authentication complete, return to R!")
}

tpapp2 <- new_app_process(
  thirdp,
  opts = server_opts(num_threads = 3)
)

redi_url2 <- tpapp2$url("/login/redirect")
conf_url2 <- tpapp2$url("/login/config")

tpapp2
#> <webfakes_app_process>
#> state:
#>   live
#> auto_start:
#>   TRUE
#> process id:
#>   3618
#> http url:
#>   http://127.0.0.1:49860/
#> fields and methods:
#>   get_app()              # get the app object
#>   get_port()             # query port of the app
#>   get_state()            # query web server process state
#>   local_env(envvars)     # set temporary environment variables
#>   start()                # start the app
#>   url(path, query)       # query url for an api path
#>   stop()                 # stop web server process
#> # see ?webfakes_app_process for details

url2 <- paste0(
  regi_url,
  "?name=3P%20app2",
  "&redirect_uri=", redi_url2
)
reg_resp2 <- httr::GET(url2)
reg_resp2
#> Response [http://127.0.0.1:49851/register?name=3P%20app2&redirect_uri=http://127.0.0.1:49860/login/redirect]
#>   Date: 2021-04-05 11:13
#>   Status: 200
#>   Content-Type: application/json
#>   Size: 185 B
regdata2 <- httr::content(reg_resp2)
regdata2
#> $name
#> $name[[1]]
#> [1] "3P app2"
#> 
#> 
#> $client_id
#> $client_id[[1]]
#> [1] "id-b56335e1a36841758f372528ca4cc3"
#> 
#> 
#> $client_secret
#> $client_secret[[1]]
#> [1] "secret-910f6705796972ca04e43e9bd0f3c4"
#> 
#> 
#> $redirect_uri
#> $redirect_uri[[1]]
#> [1] "http://127.0.0.1:49860/login/redirect"
auth_data2 <- list(
  auth_url = auth_url,
  token_url = toke_url,
  client_id = regdata2$client_id[[1]],
  client_secret = regdata2$client_secret[[1]]
)

httr::POST(
  conf_url2,
  body = auth_data2,
  encode = "json"
)
#> Response [http://127.0.0.1:49860/login/config]
#>   Date: 2021-04-05 11:13
#>   Status: 200
#>   Content-Type: application/json
#>   Size: 41 B

Then again you can authenticate at the new app with

browseURL(tpapp2$url("/login"))
#> Authentication complete, return to R!

The fake third party app also has an endpoint to return the saved tokens:

httr::content(httr::GET(tpapp2$url("/locals")))
#> $access_token
#> [1] "token-3ea7922016f1bd94eb3c84bb523d74"
#> 
#> $expiry
#> [1] 10
#> 
#> $refresh_token
#> [1] "refresh-token-3e3a67a8d4844af0f694310f500def"

Now the third-party app can get data on your behalf (the whole goal of OAuth!) — it could also post data on your behalf if the resource app had endpoints for that.

For example the /data endpoint of the third party app queries the resource app and needs authentication. If you run the following without the OAuth dance, your access is denied. But now it works fine:

resp_data <- httr::GET(tpapp2$url("/data"))
resp_data
#> Response [http://127.0.0.1:49860/data]
#>   Date: 2021-04-05 11:13
#>   Status: 200
#>   Content-Type: application/json
#>   Size: 24 B
httr::content(resp_data, as = "text")
#> No encoding supplied: defaulting to UTF-8.
#> [1] "{\"data\":[\"top secret!\"]}"

In real life, access by the third-party app might be limited to some scopes, but the fake apps shipped with webfakes do not handle that.

The fake resource server and httr

When you use httr’s OAuth tool, there’s some gymnastics happening as R is playing the role of a third-party app via httr and httpuv (to listen to the redirect URI).

OAuth2.0 app creation & registration

What’s crucial here is setting httr::oauth_callback() as redirect URI, which is what you do when creating an app for an R package that uses OAuth2.0 to authenticate to a resource server.

url3 <- paste0(
  regi_url,
  "?name=3P%20app2",
  "&redirect_uri=", httr::oauth_callback()
)
reg_resp3 <- httr::GET(url3)
reg_resp3
#> Response [http://127.0.0.1:49851/register?name=3P%20app2&redirect_uri=http://localhost:1410/]
#>   Date: 2021-04-05 11:13
#>   Status: 200
#>   Content-Type: application/json
#>   Size: 170 B
regdata3 <- httr::content(reg_resp3)
regdata3
#> $name
#> $name[[1]]
#> [1] "3P app2"
#> 
#> 
#> $client_id
#> $client_id[[1]]
#> [1] "id-a71e913803e4f2e9a762861d72f808"
#> 
#> 
#> $client_secret
#> $client_secret[[1]]
#> [1] "secret-0840c1e71734c158e004f88eef7699"
#> 
#> 
#> $redirect_uri
#> $redirect_uri[[1]]
#> [1] "http://localhost:1410/"

Now we set the registration data on the third-party app.

app <- httr::oauth_app(
  "3P app2",
  key = regdata3$client_id[[1]],
  secret = regdata3$client_secret[[1]],
  redirect_uri = httr::oauth_callback()
)

endpoint <- httr::oauth_endpoint(
  authorize = auth_url,
  access = toke_url
)

Now we can launch the token creation.

token <- oauth2_httr_login(
  httr::oauth2.0_token(endpoint, app, cache = FALSE)
)
#> Waiting for authentication in browser...
#> Press Esc/Ctrl + C to abort
#> Authentication complete.
token
#> <Token>
#> <oauth_endpoint>
#>  authorize: http://127.0.0.1:49851/authorize
#>  access:    http://127.0.0.1:49851/token
#> <oauth_app> 3P app2
#>   key:    id-a71e913803e4f2e9a762861d72f808
#>   secret: <hidden>
#> <credentials> access_token, expiry, refresh_token
#> ---

Without the token, the query to the resource server fails:

httr::GET(rsapp$url("/data"))
#> Response [http://127.0.0.1:49851/data]
#>   Date: 2021-04-05 11:13
#>   Status: 401
#>   Content-Type: text/plain
#>   Size: 20 B

With the token, it is successful. httr also automatically refreshes the token if needed.

httr::content(
  httr::GET(rsapp$url("/data"), config = token),
  as = "text"
)
#> No encoding supplied: defaulting to UTF-8.
#> [1] "{\"data\":[\"top secret!\"]}"

Advanced topics

Applications

With these apps, or only the resource server app, you can now test your code that helps users create and store an OAuth2.0 token.

Like all webfakes apps, OAuth2.0 apps are extensible: you can add your own endpoints and middleware to it. E.g. here we add logging via mw_log() and a new endpoint.

rsapp2 <- oauth2_resource_app(
  refresh_duration = .Machine$integer.max,
  access_duration = 10L
)
logfile <- tempfile("oauth-rs-", fileext = ".log")
rsapp2$use(mw_log(stream = logfile), .first = TRUE)
rsapp2$get("/docs", function(req, res) {
  res$
    set_status(200L)$
    send("See vignette('oauth', package = 'webfakes')")
})

rsapp2_process <- new_app_process(
  rsapp2,
  opts = server_opts(num_threads = 3)
)

If you want to customize one of the apps or both apps a lot, it might make sense to use their source code as starting point or inspiration.

Debugging

See the usual debugging advice for webfakes apps. In particular, you can add the mw_log() middleware to write the log of the app to a file, like we did above.

The resource app has a /locals endpoint, that returns all data stored in the app, this includes the tokens and the refresh tokens:

httr::content(
  httr::GET(rsapp$url("/locals"))
)
#> $apps
#> $apps[[1]]
#> $apps[[1]]$name
#> [1] "3P app"
#> 
#> $apps[[1]]$client_id
#> [1] "id-56874b1e7ef04ddd10b7abb81aa4da"
#> 
#> $apps[[1]]$client_secret
#> [1] "secret-592132524302aecf56099ef40adcd4"
#> 
#> $apps[[1]]$redirect_uri
#> [1] "http://127.0.0.1:49852/login/redirect"
#> 
#> 
#> $apps[[2]]
#> $apps[[2]]$name
#> [1] "3P app2"
#> 
#> $apps[[2]]$client_id
#> [1] "id-b56335e1a36841758f372528ca4cc3"
#> 
#> $apps[[2]]$client_secret
#> [1] "secret-910f6705796972ca04e43e9bd0f3c4"
#> 
#> $apps[[2]]$redirect_uri
#> [1] "http://127.0.0.1:49860/login/redirect"
#> 
#> 
#> $apps[[3]]
#> $apps[[3]]$name
#> [1] "3P app2"
#> 
#> $apps[[3]]$client_id
#> [1] "id-a71e913803e4f2e9a762861d72f808"
#> 
#> $apps[[3]]$client_secret
#> [1] "secret-0840c1e71734c158e004f88eef7699"
#> 
#> $apps[[3]]$redirect_uri
#> [1] "http://localhost:1410/"
#> 
#> 
#> 
#> $access
#> $access[[1]]
#> $access[[1]]$client_id
#> [1] "id-56874b1e7ef04ddd10b7abb81aa4da"
#> 
#> $access[[1]]$token
#> [1] "token-399bf5b41324c4579535dddfa87e7b"
#> 
#> $access[[1]]$expiry
#> [1] "2021-04-05 11:13:36"
#> 
#> 
#> $access[[2]]
#> $access[[2]]$client_id
#> [1] "id-b56335e1a36841758f372528ca4cc3"
#> 
#> $access[[2]]$token
#> [1] "token-3ea7922016f1bd94eb3c84bb523d74"
#> 
#> $access[[2]]$expiry
#> [1] "2021-04-05 11:13:37"
#> 
#> 
#> $access[[3]]
#> $access[[3]]$client_id
#> [1] "id-a71e913803e4f2e9a762861d72f808"
#> 
#> $access[[3]]$token
#> [1] "token-31e3c3af78216a59431ea301f2aa71"
#> 
#> $access[[3]]$expiry
#> [1] "2021-04-05 11:13:38"
#> 
#> 
#> 
#> $refresh
#> $refresh[[1]]
#> $refresh[[1]]$client_id
#> [1] "id-56874b1e7ef04ddd10b7abb81aa4da"
#> 
#> $refresh[[1]]$token
#> [1] "refresh-token-67f53e8bd7fe114a11310a698ee5ab"
#> 
#> $refresh[[1]]$expiry
#> [1] "2089-04-23 14:27:33"
#> 
#> 
#> $refresh[[2]]
#> $refresh[[2]]$client_id
#> [1] "id-b56335e1a36841758f372528ca4cc3"
#> 
#> $refresh[[2]]$token
#> [1] "refresh-token-3e3a67a8d4844af0f694310f500def"
#> 
#> $refresh[[2]]$expiry
#> [1] "2089-04-23 14:27:34"
#> 
#> 
#> $refresh[[3]]
#> $refresh[[3]]$client_id
#> [1] "id-a71e913803e4f2e9a762861d72f808"
#> 
#> $refresh[[3]]$token
#> [1] "refresh-token-c696cc521003b2111618d2363dc826"
#> 
#> $refresh[[3]]$expiry
#> [1] "2089-04-23 14:27:35"

Case study for OAuth2.0 testing

Consider a package that has a function that uses OAuth2.0 to access GitHub. In this section we’ll show how you can use webfakes to test this function.

gh_base <- function() {
  Sys.getenv("FAKE_GH_API_BASE", "https://api.github.com")
}
gh_oauth_base <- function() {
  Sys.getenv(
    "FAKE_GH_AUTH_BASE",
    "https://github.com/login/oauth"
  )
}
gh_repos <- function() {
  ghapp <- httr::oauth_app(
    "gh_repos",
    key = Sys.getenv("GH_CLIENT_ID"),
    secret = Sys.getenv("GH_CLIENT_SECRET")
  )
  endpoints <- httr::oauth_endpoint(
    base_url = gh_oauth_base(),
    request = NULL,
    authorize = "authorize",
    access = "access_token"
  )
  gh_token <- httr::oauth2.0_token(
    endpoints,
    ghapp,
    cache = FALSE
  )
  httr_token <- httr::config(token = gh_token)
  response <- httr::GET(
    paste0(gh_base(), "/user/repos?visibility=public"),
    httr::add_headers(Accept = "application/vnd.github.v3+json"),
    config = httr_token
  )
  httr::stop_for_status(response)
  json <- httr::content(response, as = "text")
  repos <- jsonlite::fromJSON(json, simplifyVector = FALSE)
  vapply(repos, function(r) r$full_name, character(1))
}

gh_repos() uses an OAuth2.0 app to get a token from GitHub, and then uses that token to authenticate and list the public repositories of the current user. In a real package you would probably get the token in a separate function, cache it, and re-use it for multiple queries. (Also, you don’t actually need authorization for listing public repositories, so this is a somewhat artificial example.)

To run this function, you would need to register an app at https://github.com/settings/developers. Make sure that you set http://localhost:1410 as the authorization callback URL. You can set the other options as you please. Then in R set the GH_CLIENT_ID and GH_CLIENT_SECRET environment variables to the client id and client secret of the app:

Sys.setenv(GH_CLIENT_ID = "<your client id here>")
Sys.setenv(GH_CLIENT_SECRET = "<your client secret here>")

When you run this function it opens a Window in your browser, where you can authorize the app to access your public information on GitHub. For the subsequent runs that browser window still opens, but the authorization is automatic. In a real package you could cache the OAuth2.0 tokens on the machine, e.g. using the keyring package.

gh_repos()
#> Waiting for authentication in browser...
#> Press Esc/Ctrl + C to abort
#> Authentication complete.
#>  [1] "gaborcsardi/altlist"     "gaborcsardi/argufy"     
#>  [3] "gaborcsardi/async"       "gaborcsardi/disposables"
#>  [5] "gaborcsardi/dotenv"      "gaborcsardi/falsy"      
#>  [7] "gaborcsardi/franc"       "gaborcsardi/ISA"        
#>  [9] "gaborcsardi/keypress"    "gaborcsardi/lpSolve"    
#> [11] "gaborcsardi/macBriain"   "gaborcsardi/maxygen"    
#> [13] "gaborcsardi/MISO"        "gaborcsardi/msgtools"   
#> [15] "gaborcsardi/notifier"    "gaborcsardi/odbc"       
#> [17] "gaborcsardi/parr"        "gaborcsardi/parsedate"  
#> [19] "gaborcsardi/prompt"      "gaborcsardi/r-font"     
#> [21] "gaborcsardi/r-source"    "gaborcsardi/rcorpora"   
#> [23] "gaborcsardi/roxygenlabs" "gaborcsardi/sankey"     
#> [25] "gaborcsardi/secret"      "gaborcsardi/spark"      
#> [27] "gaborcsardi/standalones"

Let’s write a test case now for gh_repos(). We will use oauth2_repource_app() to fake GitHub.

testthat::test_that("gh_repos", {
  testthat::skip_on_cran()
  fake_app <- oauth2_resource_app(token_endpoint = "/access_token")
  fake_app$get("/user/repos", function(req, res) {
    if (!app$is_authorized(req, res)) return()
    res$send_json(list(
      list(full_name = "user/repo1"),
      list(full_name = "user/repo2")
    ), auto_unbox = TRUE)
  })

  fake_proc <- local_app_process(
    fake_app,
    opts = server_opts(num_threads = 3)
  )

  # register the app to our fake GH server
  reg_url <- paste0(
    fake_proc$url("/register"),
    "?name=gh_repos&redirect_uri=http://localhost:1410/"
  )
  regdata <- httr::content(httr::GET(reg_url))
  withr::local_envvar(
    GH_CLIENT_ID = regdata$client_id[[1]],
    GH_CLIENT_SECRET = regdata$client_secret[[1]],
    FAKE_GH_API_BASE = fake_proc$url(),
    FAKE_GH_AUTH_BASE = fake_proc$url()
  )

  ret <- suppressMessages(webfakes::oauth2_httr_login(
    gh_repos()
  ))
  testthat::expect_equal(ret, c("user/repo1", "user/repo2"))
})
#> Test passed 🥳

Some important points about this test case:

  • Since the test case will fail if port 1410 is taken, it is safer to skip it on CRAN.

  • fake_app is a webfakes app, that is almost the same as oauth2_resource_app(), with two changes.

    • The first is the we change the end point for getting a token to /access_token because that is what GitHub uses.
    • The second is that we also add the /user/repos endpoint. This endpoint needs authorization, this is why it calls app$is_authorized(), which fails without it, and the app returns 401 Access denied.
  • fake_proc is the app process that runs our fake GH app. Always run the fake OAuth2.0 apps with multiple threads.

  • Our fake GitHub app has an endpoint to register the app we are testing. We need to send the app’s name and the correct redirect URI, and fake_app will reply with a fake client id and client secret.

  • We set GH_CLIENT_ID and GH_CLIENT_SECRET to the fake ones we just got from fake_app.

  • We also redirect gh_token() to the fake app, by setting FAKE_GH_API_BASE and FAKE_GH_AUTH_BASE.

  • Now if we call gh_repos(), it will connect to our fake app. We also need to make sure that httr can log in to the fake app, without a browser interaction. The webfakes::oauth2_httr_login() wrapper takes care of that. It runs an HTTP client in a background process, to perform the log in.

  • If the background process in webfakes::oauth2_httr_login() fails to log in for some reason, the test code might freeze. This is another good reason to skip this test on CRAN.

  • suppressMessages() suppresses the (harmless) httr messages about the authorization.

  • The test case does have a lot of boilerplate to set up and manage the fake apps. Note that you can refactor this code into its own helper function, that starts up the fake app sub-process on demand in a helper file. That way you can reuse it for multiple test files and test cases.

This test case ensures that the OAuth2.0 setup in gh_repos() stays correct.