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

Introduce explicit helpers to write or read a token #190

Merged
merged 4 commits into from Jun 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions NAMESPACE
Expand Up @@ -102,6 +102,8 @@ export(gm_thread)
export(gm_threads)
export(gm_to)
export(gm_token)
export(gm_token_read)
export(gm_token_write)
export(gm_trash_message)
export(gm_trash_thread)
export(gm_untrash_message)
Expand Down
7 changes: 6 additions & 1 deletion NEWS.md
Expand Up @@ -2,7 +2,7 @@

## Syncing up with gargle

Versions 1.3.0, 1.4.0, and 1.5.0 of gargle introduced some changes around OAuth and gmailr is syncing up that:
Versions 1.3.0, 1.4.0, and 1.5.1 of gargle introduced some changes around OAuth and gmailr is syncing up that:

* `gm_oauth_client()` is a new function to replace the now-deprecated
`gm_oauth_app()`. This is somewhat about a vocabulary change ("client" instead
Expand Down Expand Up @@ -56,6 +56,11 @@ Versions 1.3.0, 1.4.0, and 1.5.0 of gargle introduced some changes around OAuth
Since the lack of an OAuth client undoubtedly remains the most common reason
for `gm_auth()` to fail, its error message includes some specific content if
no OAuth client has been configured.

* `gm_token_write()` + `gm_token_read()` is a new matched pair of functions that
make it much easier to explicitly store a token obtained in an interactive
session then reuse that token elsewhere, such in CI or in a deployed product
(#190).

* `gm_scopes()` can now take a character vector of scopes, each of which can be
an actual scope or a short alias, e.g., `"gmail.readonly"`, which identifies a
Expand Down
96 changes: 95 additions & 1 deletion R/gm_auth.R
Expand Up @@ -397,6 +397,100 @@ fixup_gmail_scopes <- function(scopes) {
ifelse(is.na(m), scopes, haystack[m])
}

# gm_token_write / gm_token_read() ----

#' Write/read a gmailr user token
#'
#' @description `r lifecycle::badge("experimental")`
#'

#' This pair of functions writes an OAuth2 user token to file and reads it back
#' in. This is rarely necessary when working in your primary, interactive
#' computing environment. In that setting, it is recommended to lean into the
#' automatic token caching built-in to gmailr / gargle. However, when preparing
#' a user token for use elsewhere, such as in CI or in a deployed data product,
#' it can be useful to take the full control granted by `gm_token_write()` and
#' `gm_token_read()`.
#'
#' Below is an outline of the intended workflow, but you will need to fill in
#' particulars, such as filepaths and environment variables:
#' * Do auth in your primary, interactive environment as the target user, with
#' the desired OAuth client and scopes.
#' ``` r
#' gm_auth_configure()
#' gm_auth("jane@example.com", cache = FALSE)
#' ````
#' * Confirm you are logged in as the intended user:
#' ``` r
#' gm_profile()
#' ````
#' * Write the current token to file:
#' ``` r
#' gm_token_write(
#' path = "path/to/gmailr-token.rds",
#' key = "GMAILR_KEY"
#' )
#' ```
#' * In the deployed, non-interactive setting, read the token from file and
#' tell gmailr to use it:
#' ``` r
#' gm_auth(token = gm_token_read(
#' path = "path/to/gmailr-token.rds",
#' key = "GMAILR_KEY"
#' )
#' ```
#'
#' @section Security:

#' `gm_token_write()` and `gm_token_read()` have a more security-oriented
#' implementation than the default token caching strategy. OAuth2 user tokens
#' are somewhat opaque by definition, because they aren't written to file in a
#' particularly transparent format. However, `gm_token_write()` always applies
#' some additional obfuscation to make such credentials even more resilient
#' against scraping by an automated tool. However, a knowledgeable R programmer
#' could decode the credential with some effort. The default behaviour of
#' `gm_token_write()` (called without `key`) is suitable for tokens stored in a
#' relatively secure place, such as on Posit Connect within your organization.
#'
#' To prepare a stored credential for exposure in a more public setting, such as
#' on GitHub or CRAN, you must actually encrypt it, using a `key` known only to
#' you. You must make the encryption `key` available via a secure environment
#' variable in any setting where you wish to decrypt and use the token, such as
#' on GitHub Actions.
#'
#' @inheritParams gm_auth
#' @param path The path to write to (`gm_token_write()`) or to read from
#' (`gm_token_read()`).
#' @param key Encryption key, as implemented by httr2's [secret
#' functions](https://httr2.r-lib.org/reference/secrets.html). If absent, a
#' built-in `key` is used. If supplied, the `key` should usually be the name
#' of an environment variable whose value was generated with
#' `gargle::secret_make_key()` (which is a copy of
#' `httr2::secret_make_key()`). The `key` argument of `gm_token_read()` must
#' match the `key` used in `gm_token_write()`.
#'
#' @export
gm_token_write <- function(token = gm_token(),
path = "gmailr-token.rds",
key = NULL) {
if (inherits(token, "request")) {
token <- token$auth_token
}
stopifnot(inherits(token, "Token2.0"))
check_required(path)
key <- key %||% gmailr_obfuscate_key()

gargle::secret_write_rds(token, path, key)
}

#' @rdname gm_token_write
#' @export
gm_token_read <- function(path = "gmailr-token.rds", key = NULL) {
stopifnot(file.exists(path))
key <- key %||% gmailr_obfuscate_key()
gargle::secret_read_rds(path, key)
}

# unexported helpers that are nice for internal use ----
gm_auth_testing <- function() {
can_decrypt <- gargle::secret_has_key("GMAILR_KEY")
Expand All @@ -416,7 +510,7 @@ gm_auth_testing <- function() {
)
}

gm_auth(token = gargle::secret_read_rds(
gm_auth(token = gm_token_read(
system.file("secret", "gmailr-dev-token", package = "gmailr"),
key = "GMAILR_KEY"
))
Expand Down
10 changes: 6 additions & 4 deletions _pkgdown.yml
Expand Up @@ -17,17 +17,19 @@ news:
href: https://www.tidyverse.org/articles/2019/08/gmailr-1-0-0/

reference:
- title: Authentication
- title: Authentication and authorization
desc: >
These functions are used to configure and establish authentication to the gmail API. `gm_auth_configure()` and `gm_auth()` are the most important for most users.
These functions are used to auth with the gmail API. `gm_auth_configure()` and `gm_auth()` are the most important for most users.
contents:
- matches("auth")
- gm_auth
- gm_deauth
- gm_auth_configure
- gm_scopes
- gm_has_token
- gm_profile
- gm_token
- gm_has_token
- gmailr-configuration
- gm_token_write
- title: Messages
desc: >
These functions create, modify, query and delete email messages.
Expand Down
87 changes: 87 additions & 0 deletions man/gm_token_write.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

33 changes: 33 additions & 0 deletions tests/testthat/test-gm_auth.R
Expand Up @@ -138,3 +138,36 @@ test_that("gm_scopes() passes unrecognized scopes through", {
)
)
})

# gm_token_write / gm_token_read() ----

test_that("gm_token_write() / gm_token_read() roundtrip, built-in key", {
fauxen_in <- gargle::gargle2.0_token(
email = "a@example.org",
credentials = list(a = 1)
)
tmp <- withr::local_tempfile(pattern = "fauxen-")

gm_token_write(fauxen_in, tmp)
fauxen_out <- gm_token_read(tmp)

expect_error(readRDS(tmp))
expect_equal(fauxen_in, fauxen_out)
})

test_that("gm_token_write() / gm_token_read() roundtrip, explicit key", {
fauxen_in <- gargle::gargle2.0_token(
email = "b@example.org",
credentials = list(b = 1)
)
tmp <- withr::local_tempfile(pattern = "fauxen-")
withr::local_envvar(GMAILR_ABCXYZ_KEY = gargle::secret_make_key())

gm_token_write(fauxen_in, tmp, key = "GMAILR_ABCXYZ_KEY")

expect_error(readRDS(tmp))
expect_error(gm_token_read(tmp))

fauxen_out <- gm_token_read(tmp, "GMAILR_ABCXYZ_KEY")
expect_equal(fauxen_in, fauxen_out)
})