diff --git a/DESCRIPTION b/DESCRIPTION index 4cfacce..5c77f11 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -20,19 +20,20 @@ URL: https://github.com/r-lib/gh#readme BugReports: https://github.com/r-lib/gh/issues Imports: cli (>= 2.0.1), + gitcreds, httr (>= 1.2), ini, jsonlite Suggests: covr, - credentials (>= 1.3.0), - keyring, knitr, rmarkdown, rprojroot, spelling, testthat (>= 2.3.2), withr +Remotes: + r-lib/gitcreds VignetteBuilder: knitr Encoding: UTF-8 diff --git a/NAMESPACE b/NAMESPACE index b9ccb20..54cbb2d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -14,8 +14,6 @@ export(gh_rate_limit) export(gh_token) export(gh_tree_remote) export(gh_whoami) -export(slugify_url) -importFrom(cli,cli_alert_info) importFrom(cli,cli_status) importFrom(cli,cli_status_update) importFrom(httr,DELETE) diff --git a/NEWS.md b/NEWS.md index 9ccecda..38241a1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,20 +1,14 @@ # gh (development version) * gh can retrieve a PAT from the Git credential store, where the lookup is based - on the targeted API URL. *Currently limited to "github.com", but that will - change.* - -* The environment variables consulted for URL-specific GitHub PATs have changed. + on the targeted API URL. This now uses the gitcreds package. The environment + variables consulted for URL-specific GitHub PATs have changed. - For "https://api.github.com": `GITHUB_PAT_GITHUB_COM` now, instead of `GITHUB_PAT_API_GITHUB_COM` - For "https://github.acme.com/api/v3": `GITHUB_PAT_GITHUB_ACME_COM` now, instead of `GITHUB_PAT_GITHUB_ACME_COM_API_V3` -This also affects the keys searched when keyring support is turned on. -* gh only consults the `GITHUB_PAT` or `GITHUB_TOKEN` environment variables - when the targeted host is "github.com". For other GitHub deployments, e.g. - "github.acme.com", only the URL-specific environment variable is consulted, - e.g. `GITHUB_PAT_GITHUB_ACME_COM`. +* The keyring package is no longer used, in favor of the Git credential store. * The documentation for the GitHub REST API has moved to and endpoints are now documented using diff --git a/R/gh_request.R b/R/gh_request.R index 7d03451..e68c6d3 100644 --- a/R/gh_request.R +++ b/R/gh_request.R @@ -115,6 +115,46 @@ gh_set_url <- function(x) { x } +get_baseurl <- function(url) { # https://github.uni.edu/api/v3/ + if (!any(grepl("^https?://", url))) { + stop("Only works with HTTP(S) protocols") + } + prot <- sub("^(https?://).*$", "\\1", url) # https:// + rest <- sub("^https?://(.*)$", "\\1", url) # github.uni.edu/api/v3/ + host <- sub("/.*$", "", rest) # github.uni.edu + paste0(prot, host) # https://github.uni.edu +} + +# https://api.github.com --> https://github.com +# api.github.com --> github.com +normalize_host <- function(x) { + sub("api[.]github[.]com", "github.com", x) +} + +get_hosturl <- function(url) { + url <- get_baseurl(url) + normalize_host(url) +} + +# (almost) the inverse of get_hosturl() +# https://github.com --> https://api.github.com +# https://github.uni.edu --> https://github.uni.edu/api/v3 +get_apiurl <- function(url) { + host_url <- get_hosturl(url) + prot_host <- strsplit(host_url, "://", fixed = TRUE)[[1]] + if (is_github_dot_com(host_url)) { + paste0(prot_host[[1]], "://api.github.com") + } else { + paste0(host_url, "/api/v3") + } +} + +is_github_dot_com <- function(url) { + url <- get_baseurl(url) + url <- normalize_host(url) + grepl("^https?://github.com", url) +} + gh_set_headers <- function(x) { # x$api_url must be set properly at this point auth <- gh_auth(x$token %||% gh_token(x$api_url)) diff --git a/R/gh_token.R b/R/gh_token.R index 722ad3b..4ea2f93 100644 --- a/R/gh_token.R +++ b/R/gh_token.R @@ -7,33 +7,31 @@ #' PAT also helps with rate limiting. If your gh use is more than casual, you #' want a PAT. #' -#' The PAT corresponding to `api_url` is searched for with a `strategy` that -#' looks in one or more of these places: -#' * `"env"`: environment variable(s) -#' * `"git"`: Git credential store (requires the credentials package) -#' * `"key"`: OS-level keychain (requires the keyring package) -#' -#' Details are in the [Managing Personal Access Tokens](https://gh.r-lib.org/articles/managing-personal-access-tokens.html) vignette. +#' gh calls [gitcreds::gitcreds_get()] with the `api_url`, which checks session +#' environment variables and then the local Git credential store for a PAT +#' appropriate to the `api_url`. Therefore, if you have previously used a PAT +#' with, e.g., command line Git, gh may retrieve and re-use it. You can call +#' [gitcreds::gitcreds_get()] directly, yourself, if you want to see what is +#' found for a specific URL. If no matching PAT is found, +#' [gitcreds::gitcreds_get()] errors, whereas `gh_token()` does not and, +#' instead, returns `""`. +#' +#' See GitHub's documentation on [Creating a personal access +#' token](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token), +#' or use `usethis::create_github_token()` for a guided experience, including +#' pre-selection of recommended scopes. Once you have a PAT, you can use +#' [gitcreds::gitcreds_set()] to add it to the Git credential store. From that +#' point on, gh (via [gitcreds::gitcreds_get()]) should be able to find it +#' without further effort on your part. #' #' @param api_url GitHub API URL. Defaults to the `GITHUB_API_URL` environment #' variable, if set, and otherwise to . -#' @param strategy Where to look for a PAT. If specified, must be a -#' comma-delimited string consisting of "env", "git", and/or "key". Examples: -#' "env", "env,git", "key,git,env". gh searches for a PAT in these places, in -#' this order. -#' -#' By default, `strategy` is "env,git" if the credential package is available -#' and "env" if it is not. #' #' @return A string of 40 hexadecimal digits, if a PAT is found, or the empty #' string, otherwise. For convenience, the return value has an S3 class in #' order to ensure that simple printing strategies don't reveal the entire #' PAT. #' -#' @seealso [slugify_url()] for computing the environment variables or keys that -#' gh uses to search for URL-specific PATs. [gh_whoami()] to see details -#' about a token. -#' #' @export #' #' @examples @@ -44,117 +42,14 @@ #' #' str(gh_token()) #' } -gh_token <- function(api_url = NULL, strategy = NULL) { +gh_token <- function(api_url = NULL) { api_url <- api_url %||% default_api_url() stopifnot(is.character(api_url), length(api_url) == 1) - - strategy <- strategy %||% default_pat_strategy() - stopifnot(is.character(strategy), length(strategy) == 1) - - strategy <- strsplit(strategy, split = ",")[[1]] - match.arg(strategy, c("env", "git", "key"), several.ok = TRUE) - pat <- "" - for(s in strategy) { - f <- switch( - s, - env = pat_envvar, - git = pat_gitcred, - key = pat_keyring - ) - if ((pat <- f(api_url)) != "") break - } - gh_pat(pat) -} - -default_pat_strategy <- function() { - out <- c( - "env", - if (can_load("credentials")) "git", - if (should_use_keyring()) "key" + token <- tryCatch( + gitcreds::gitcreds_get(api_url), + error = function(e) NULL ) - paste0(out, collapse = ",") -} - -pat_envvar <- function(api_url = default_api_url()) { - val <- "" - vars <- make_envvar_names(api_url) - if (length(vars) == 0) { - return(val) - } - for (var in vars) { - if ((val <- Sys.getenv(var, "")) != "") break - } - val -} - -pat_gitcred <- function(api_url = default_api_url()) { - # TODO: drop Gabor's git credentials approach in here - if (is_github_dot_com(api_url) && can_load("credentials")) { - tryCatch( - { - suppressMessages(credentials::set_github_pat()) - Sys.getenv("GITHUB_PAT") - }, - error = function(e) "" - ) - } else { - "" - } -} - -pat_keyring <- function(api_url = default_api_url()) { - vars <- make_envvar_names(api_url) - val <- "" - if (length(vars) == 0 || !should_use_keyring()) { - return(val) - } - key_get <- function(v) { - tryCatch(keyring::key_get(v), error = function(e) NULL) - } - for (var in vars) { - if ((val <- key_get(var) %||% "") != "") break - } - val -} - -#' @importFrom cli cli_alert_info -should_use_keyring <- function() { - # Opt in? - if (tolower(Sys.getenv("GH_KEYRING", "")) != "true") return(FALSE) - - # Can we load the package? - if (!can_load("keyring")) { - cli_alert_info("{.pkg gh}: the {.pkg keyring} package is not available") - return(FALSE) - } - - # If is_locked() errors, the keyring cannot be locked, and we'll use it - err <- FALSE - tryCatch( - locked <- keyring::keyring_is_locked(), - error = function(e) err <<- TRUE - ) - if (err) return(TRUE) - - # Otherwise if locked, and non-interactive session, we won't use it - if (locked && ! is_interactive()) { - cli_alert_info("{.pkg gh}: default keyring is locked") - return(FALSE) - } - - # Otherwise if locked, we try to unlock it here. Otherwise key_get() - # would unlock it, but if that fails, we'll get multiple unlock dialogs - # It is better to fail here, once and for all. - if (locked) { - err <- FALSE - tryCatch(keyring::keyring_unlock(), error = function(e) err <<- TRUE) - if (err) { - cli_alert_info("{.pkg gh}: failed to unlock default keyring") - return(FALSE) - } - } - - TRUE + gh_pat(token$password %||% "") } gh_auth <- function(token) { @@ -165,103 +60,6 @@ gh_auth <- function(token) { } } -#' Compute the suffix that gh uses for GitHub API URL specific PATs -#' -#' @description -#' `slugify_url()` determines a suffix from a URL and this suffix is used to -#' construct the name of an environment variable that holds the PAT for a -#' specific GitHub URL. This is mostly relevant to people using GitHub -#' Enterprise. `slugify_url()` processes the API URL like so: -#' * Extract the host name, i.e. drop both the protocol and any path -#' * Substitute "github.com" for "api.github.com" -#' * Replace special characters with underscores -#' * Convert to ALL CAPS -#' -#' This suffix is then added to `GITHUB_PAT_` to form the name of an environment -#' variable. It's probably easiest to just look at some examples. -#' -#' ```{r} -#' # both give same result -#' slugify_url("https://api.github.com") -#' slugify_url("https://github.com") -#' -#' # an instance of GitHub Enterprise -#' # both give same result -#' slugify_url("https://github.acme.com") -#' slugify_url("https://github.acme.com/api/v3") -#' ``` -#' -#' @param url Character vector of HTTP/HTTPS URLs. They don't have to be in the -#' API-specific form, although they can be. -#' @return Character vector of suffixes. -#' -#' @seealso [gh_token()] -#' @export -#' @examples -#' # main github.com site -#' slugify_url("https://api.github.com") -#' slugify_url("https://github.com") -#' -#' # an instance of GitHub Enterprise -#' slugify_url("https://github.acme.com") -#' slugify_url("https://github.acme.com/api/v3") -slugify_url <- function(url) { # https://jane@github.uni.edu/api/v3 - url <- get_baseurl(url) # https://jane@github.uni.edu - url <- normalize_host(url) - x2 <- sub("^.*://([^/]*@)?", "", url) # github.uni.edu - x3 <- gsub("[.]+", "_", x2) # github_uni_edu - x4 <- gsub("[^-a-zA-Z0-9_]", "", x3) - toupper(x4) # GITHUB_UNI_EDU -} - -get_baseurl <- function(url) { # https://github.uni.edu/api/v3/ - if (!any(grepl("^https?://", url))) { - stop("Only works with HTTP(S) protocols") - } - prot <- sub("^(https?://).*$", "\\1", url) # https:// - rest <- sub("^https?://(.*)$", "\\1", url) # github.uni.edu/api/v3/ - host <- sub("/.*$", "", rest) # github.uni.edu - paste0(prot, host) # https://github.uni.edu -} - -# https://api.github.com --> https://github.com -# api.github.com --> github.com -normalize_host <- function(x) { - sub("api[.]github[.]com", "github.com", x) -} - -get_hosturl <- function(url) { - url <- get_baseurl(url) - normalize_host(url) -} - -# (almost) the inverse of get_hosturl() -# https://github.com --> https://api.github.com -# https://github.uni.edu --> https://github.uni.edu/api/v3 -get_apiurl <- function(url) { - host_url <- get_hosturl(url) - prot_host <- strsplit(host_url, "://", fixed = TRUE)[[1]] - if (is_github_dot_com(host_url)) { - paste0(prot_host[[1]], "://api.github.com") - } else { - paste0(host_url, "/api/v3") - } -} - -is_github_dot_com <- function(url) { - url <- get_baseurl(url) - url <- normalize_host(url) - grepl("^https?://github.com", url) -} - -make_envvar_names <- function(api_url) { - stopifnot(is.character(api_url), length(api_url) == 1) - c( - paste0("GITHUB_PAT_", slugify_url(api_url)), - if (is_github_dot_com(api_url)) c("GITHUB_PAT", "GITHUB_TOKEN") - ) -} - # gh_pat class: exists in order have a print method that hides info ---- new_gh_pat <- function(x) { if (is.character(x) && length(x) == 1) { diff --git a/README.md b/README.md index 1a4f142..10bd8ff 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,12 @@ call. E.g. ``` r my_repos <- gh("GET /users/{username}/repos", username = "gaborcsardi") vapply(my_repos, "[[", "", "name") -#> [1] "alexr" "altlist" "argufy" "disposables" "dotenv" -#> [6] "falsy" "franc" "ISA" "keynote" "keypress" -#> [11] "lpSolve" "macBriain" "maxygen" "MISO" "msgtools" -#> [16] "notifier" "parr" "parsedate" "prompt" "r-font" -#> [21] "r-source" "rcorpora" "roxygenlabs" "sankey" "secret" -#> [26] "spark" "standalones" "svg-term" "tamper" +#> [1] "alexr" "altlist" "argufy" "disposables" "dotenv" +#> [6] "falsy" "franc" "ISA" "keypress" "lpSolve" +#> [11] "macBriain" "maxygen" "MISO" "msgtools" "notifier" +#> [16] "parr" "parsedate" "prompt" "r-font" "r-source" +#> [21] "rcorpora" "roxygenlabs" "sankey" "secret" "spark" +#> [26] "standalones" "svg-term" "tamper" "testthatlabs" ``` The JSON result sent by the API is converted to an R object. @@ -65,12 +65,12 @@ my_repos <- gh( username = "gaborcsardi", sort = "created") vapply(my_repos, "[[", "", "name") -#> [1] "keynote" "lpSolve" "roxygenlabs" "standalones" "altlist" -#> [6] "svg-term" "franc" "sankey" "r-source" "secret" -#> [11] "msgtools" "notifier" "prompt" "parr" "tamper" -#> [16] "alexr" "argufy" "maxygen" "keypress" "macBriain" -#> [21] "MISO" "rcorpora" "disposables" "spark" "dotenv" -#> [26] "parsedate" "r-font" "falsy" "ISA" +#> [1] "testthatlabs" "lpSolve" "roxygenlabs" "standalones" "altlist" +#> [6] "svg-term" "franc" "sankey" "r-source" "secret" +#> [11] "msgtools" "notifier" "prompt" "parr" "tamper" +#> [16] "alexr" "argufy" "maxygen" "keypress" "macBriain" +#> [21] "MISO" "rcorpora" "disposables" "spark" "dotenv" +#> [26] "parsedate" "r-font" "falsy" "ISA" ``` ### POST, PATCH, PUT and DELETE requests diff --git a/inst/WORDLIST b/inst/WORDLIST index 406d966..d2ac370 100644 --- a/inst/WORDLIST +++ b/inst/WORDLIST @@ -1,9 +1,6 @@ Codecov -EDU -Env Github GraphQL -HTTPS JSON LastPass Minimalistic @@ -12,17 +9,12 @@ PATs URI api auth -behaviour -edu -env funder +gitcreds github -hostname https -keychain keyring -macOS +pre repo repos -slugify usethis diff --git a/man/gh_token.Rd b/man/gh_token.Rd index fde250a..88ae782 100644 --- a/man/gh_token.Rd +++ b/man/gh_token.Rd @@ -4,19 +4,11 @@ \alias{gh_token} \title{Return the local user's GitHub Personal Access Token (PAT)} \usage{ -gh_token(api_url = NULL, strategy = NULL) +gh_token(api_url = NULL) } \arguments{ \item{api_url}{GitHub API URL. Defaults to the \code{GITHUB_API_URL} environment variable, if set, and otherwise to \url{https://api.github.com}.} - -\item{strategy}{Where to look for a PAT. If specified, must be a -comma-delimited string consisting of "env", "git", and/or "key". Examples: -"env", "env,git", "key,git,env". gh searches for a PAT in these places, in -this order. - -By default, \code{strategy} is "env,git" if the credential package is available -and "env" if it is not.} } \value{ A string of 40 hexadecimal digits, if a PAT is found, or the empty @@ -31,15 +23,21 @@ require a PAT to prove the request is authorized by a specific GitHub user. A PAT also helps with rate limiting. If your gh use is more than casual, you want a PAT. -The PAT corresponding to \code{api_url} is searched for with a \code{strategy} that -looks in one or more of these places: -\itemize{ -\item \code{"env"}: environment variable(s) -\item \code{"git"}: Git credential store (requires the credentials package) -\item \code{"key"}: OS-level keychain (requires the keyring package) -} +gh calls \code{\link[gitcreds:gitcreds_get]{gitcreds::gitcreds_get()}} with the \code{api_url}, which checks session +environment variables and then the local Git credential store for a PAT +appropriate to the \code{api_url}. Therefore, if you have previously used a PAT +with, e.g., command line Git, gh may retrieve and re-use it. You can call +\code{\link[gitcreds:gitcreds_get]{gitcreds::gitcreds_get()}} directly, yourself, if you want to see what is +found for a specific URL. If no matching PAT is found, +\code{\link[gitcreds:gitcreds_get]{gitcreds::gitcreds_get()}} errors, whereas \code{gh_token()} does not and, +instead, returns \code{""}. -Details are in the \href{https://gh.r-lib.org/articles/managing-personal-access-tokens.html}{Managing Personal Access Tokens} vignette. +See GitHub's documentation on \href{https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token}{Creating a personal access token}, +or use \code{usethis::create_github_token()} for a guided experience, including +pre-selection of recommended scopes. Once you have a PAT, you can use +\code{\link[gitcreds:gitcreds_get]{gitcreds::gitcreds_set()}} to add it to the Git credential store. From that +point on, gh (via \code{\link[gitcreds:gitcreds_get]{gitcreds::gitcreds_get()}}) should be able to find it +without further effort on your part. } \examples{ \dontrun{ @@ -50,8 +48,3 @@ format(gh_token()) str(gh_token()) } } -\seealso{ -\code{\link[=slugify_url]{slugify_url()}} for computing the environment variables or keys that -gh uses to search for URL-specific PATs. \code{\link[=gh_whoami]{gh_whoami()}} to see details -about a token. -} diff --git a/man/slugify_url.Rd b/man/slugify_url.Rd deleted file mode 100644 index fa270fd..0000000 --- a/man/slugify_url.Rd +++ /dev/null @@ -1,53 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/gh_token.R -\name{slugify_url} -\alias{slugify_url} -\title{Compute the suffix that gh uses for GitHub API URL specific PATs} -\usage{ -slugify_url(url) -} -\arguments{ -\item{url}{Character vector of HTTP/HTTPS URLs. They don't have to be in the -API-specific form, although they can be.} -} -\value{ -Character vector of suffixes. -} -\description{ -\code{slugify_url()} determines a suffix from a URL and this suffix is used to -construct the name of an environment variable that holds the PAT for a -specific GitHub URL. This is mostly relevant to people using GitHub -Enterprise. \code{slugify_url()} processes the API URL like so: -\itemize{ -\item Extract the host name, i.e. drop both the protocol and any path -\item Substitute "github.com" for "api.github.com" -\item Replace special characters with underscores -\item Convert to ALL CAPS -} - -This suffix is then added to \code{GITHUB_PAT_} to form the name of an environment -variable. It's probably easiest to just look at some examples.\if{html}{\out{
}}\preformatted{# both give same result -slugify_url("https://api.github.com") -}\if{html}{\out{
}}\preformatted{## [1] "GITHUB_COM" -}\if{html}{\out{
}}\preformatted{slugify_url("https://github.com") -}\if{html}{\out{
}}\preformatted{## [1] "GITHUB_COM" -}\if{html}{\out{
}}\preformatted{# an instance of GitHub Enterprise -# both give same result -slugify_url("https://github.acme.com") -}\if{html}{\out{
}}\preformatted{## [1] "GITHUB_ACME_COM" -}\if{html}{\out{
}}\preformatted{slugify_url("https://github.acme.com/api/v3") -}\if{html}{\out{
}}\preformatted{## [1] "GITHUB_ACME_COM" -} -} -\examples{ -# main github.com site -slugify_url("https://api.github.com") -slugify_url("https://github.com") - -# an instance of GitHub Enterprise -slugify_url("https://github.acme.com") -slugify_url("https://github.acme.com/api/v3") -} -\seealso{ -\code{\link[=gh_token]{gh_token()}} -} diff --git a/tests/testthat.R b/tests/testthat.R index 6a1fafd..d1cf1ae 100644 --- a/tests/testthat.R +++ b/tests/testthat.R @@ -1,8 +1,4 @@ library(testthat) library(gh) -# Don't want to use keyrings on CRAN -withr::with_envvar( - c(GH_NO_KEYRING = "true"), - test_check("gh") -) +test_check("gh") diff --git a/tests/testthat/test-gh_token.R b/tests/testthat/test-gh_token.R index 92d3ded..f79d9df 100644 --- a/tests/testthat/test-gh_token.R +++ b/tests/testthat/test-gh_token.R @@ -5,7 +5,6 @@ test_that("URL specific token is used", { bad2 <- gh_pat(strrep("1", 40)) env <- c( - GH_KEYRING = "false", GITHUB_API_URL = "https://github.acme.com", GITHUB_PAT_GITHUB_ACME_COM = good, GITHUB_PAT_GITHUB_ACME2_COM = good2, @@ -18,7 +17,6 @@ test_that("URL specific token is used", { }) env <- c( - GH_KEYRING = "false", GITHUB_API_URL = NA, GITHUB_PAT_GITHUB_COM = good, GITHUB_PAT = bad, @@ -35,7 +33,6 @@ test_that("fall back to GITHUB_PAT, then GITHUB_TOKEN", { token <- gh_pat(strrep("0", 40)) env <- c( - GH_KEYRING = "false", GITHUB_API_URL = NA, GITHUB_PAT_GITHUB_COM = NA, GITHUB_PAT = pat, @@ -47,7 +44,6 @@ test_that("fall back to GITHUB_PAT, then GITHUB_TOKEN", { }) env <- c( - GH_KEYRING = "false", GITHUB_API_URL = NA, GITHUB_PAT_GITHUB_COM = NA, GITHUB_PAT = NA, @@ -62,8 +58,8 @@ test_that("fall back to GITHUB_PAT, then GITHUB_TOKEN", { # gh_pat class ---- test_that("validate_gh_pat() rejects bad characters, wrong # of characters", { expect_error(gh_pat(strrep("a", 40)), NA) - expect_error(gh_pat(strrep("g", 40)), "40 hexadecimal digits") - expect_error(gh_pat("aa"), "40 hexadecimal digits") + expect_error(gh_pat(strrep("g", 40)), "40 hexadecimal digits", class = "error") + expect_error(gh_pat("aa"), "40 hexadecimal digits", class = "error") }) test_that("format.gh_pat() and str.gh_pat() hide the middle stuff", { @@ -111,19 +107,6 @@ test_that("get_baseurl() works", { ) }) -test_that("slugify_url() works", { - x <- "GITHUB_COM" - expect_equal(slugify_url("https://github.com"), x) - expect_equal(slugify_url("https://github.com/more/stuff"), x) - expect_equal(slugify_url("https://api.github.com"), x) - expect_equal(slugify_url("https://api.github.com/rate_limit"), x) - - x <- "GITHUB_ACME_COM" - expect_equal(slugify_url("https://github.acme.com"), x) - expect_equal(slugify_url("https://github.acme.com/"), x) - expect_equal(slugify_url("https://github.acme.com/api/v3"), x) -}) - test_that("is_github_dot_com() works", { expect_true(is_github_dot_com("https://github.com")) expect_true(is_github_dot_com("https://api.github.com")) @@ -158,14 +141,3 @@ test_that("get_apiurl() works", { expect_equal(get_apiurl("https://github.acme.com/OWNER/REPO"), x) expect_equal(get_apiurl("https://github.acme.com/api/v3"), x) }) - -test_that("make_envvar_names() works", { - expect_equal( - make_envvar_names("https://github.com"), - c("GITHUB_PAT_GITHUB_COM", "GITHUB_PAT", "GITHUB_TOKEN") - ) - expect_equal( - make_envvar_names("https://github.acme.com"), - "GITHUB_PAT_GITHUB_ACME_COM" - ) -}) diff --git a/vignettes/managing-personal-access-tokens.Rmd b/vignettes/managing-personal-access-tokens.Rmd index f1dc78e..37d4093 100644 --- a/vignettes/managing-personal-access-tokens.Rmd +++ b/vignettes/managing-personal-access-tokens.Rmd @@ -15,7 +15,7 @@ knitr::opts_chunk$set( ``` *NOTE: This aspect of gh is under active development, so there may be brief periods where things are out of sync. -Please bear with and feel free to let us know in a GitHub issue.* +Please bear with us and feel free to let us know in a GitHub issue.* ```{r setup} library(gh) @@ -36,7 +36,8 @@ More resources on how and why to get a PAT: - [Setup advice re: PATs](https://usethis.r-lib.org/articles/articles/usethis-setup.html#get-and-store-a-github-personal-access-token-1) - `usethis::create_github_token()` guides you through the process of getting a new PAT - * `credentials::set_github_pat()` + * `gitcreds::gitcreds_set())` helps you explicitly put a PAT into the Git + credential store. ## PAT and host @@ -58,103 +59,59 @@ How are `.api_url` and `.token` determined when the user does not provide them? This is always done before worrying about the PAT. 1. The PAT is obtained via a call to `gh_token(.api_url)`. That is, the token is looked up based on the host. - -`gh_token()` actually has a second argument, `strategy`: - -```{r, eval = FALSE} -gh_token(api_url = NULL, strategy = NULL) -``` - -The PAT corresponding to `api_url` is searched for with a `strategy` that -looks in one or more of these places, usually in this order: - -* "env": environment variable(s) -* "git": Git credential store -* "key": OS-level keychain (requires the keyring package) - -## PAT in an environment variable - -The "env" search strategy looks for a PAT in specific environment variables. -If `api_url` targets "github.com", these variables are consulted, in order: - -1. `GITHUB_PAT_GITHUB_COM` -1. `GTIHUB_PAT` -1. `GITHUB_TOKEN` - -If `api_url` targets another GitHub deployment, such as "github.acme.com", this variable is consulted: - -* `GITHUB_PAT_GITHUB_ACME_COM` - -In both cases, the suffix in `GITHUB_PAT_` is derived from `api_url` using the helper `slugify_url()`. - -Looking up the PAT in an environment variable is definitely more secure than including it explicitly in your code, i.e. providing via `gh(token = "xyz")`. -The simplest way to set this up is to define, e.g., `GITHUB_PAT` in your `.Renviron` startup file. -This is the entry-level solution. - -However, ideally you would not store your PAT in plain text like this. -It is also undesirable to make your PAT available to all your R sessions, regardless of actual need. -Both make it more likely you will expose your PAT publicly, by accident. -Therefore, it is strongly recommended to store your PAT in the Git credential store or system keychain and allow gh to retrieve it on-demand. -See the next two sections for more. - -## PAT in the Git credential store - -The "git" search `strategy` looks up the PAT corresponding to `api_url` in the Git credential store. -This `strategy` has the advantage of using official Git tooling, specific to your operating system, for managing secrets. -The first time the "git" `strategy` is invoked, you may be prompted for your PAT and, if it validates, it is stored for future re-use with this `api_url`. -For the remainder of the current R session, the PAT is also available via one of the usual environment variables: - -* `GITHUB_PAT` for "github.com" -* `GITHUB_PAT_GITHUB_ACME_COM` for "github.acme.com" -This pattern of retrieving the PAT from the store upon first need and caching it in an environment variable is why the default `strategy` tries "env" and then "git": +## The gitcreds package -1. The initial "env" search fails. -2. The "git" search succeeds and sets an environment variable in the session. -3. Subsequent "env" searches succeed. +gh now uses the gitcreds package in the git credential store. +If you use git from the command line, with a GUI or from RStudio, then chances are, you already have a PAT and gitcreds/gh can reuse it. +Call `gitcreds::gitcreds_get()` to try it: -*TO BE FILLED IN: How to get your PAT into the Git credential store.* +gh calls `gitcreds::gitcreds_get()` with a URL to try to find a matching PAT. +`gitcreds::gitcreds_get()` checks session environment variables and then the local Git credential store. +Therefore, if you have previously used a PAT with, e.g., command line Git, gh may retrieve and re-use it. +You can call [gitcreds::gitcreds_get()] directly, yourself, if you want to see what is found for a specific URL. -## PAT in the system keyring: - -The "key" search `strategy` uses the Suggested keyring package to retrieve your PAT from the system keyring, on Windows, macOS and Linux. -To activate keyring, specify a `strategy` that includes "key" or set the `GH_KEYRING` environment variable to `true`, e.g. in your `.Renviron` file. -The keys queried for a PAT are exactly the same as the environment variable names consulted for the "env" `strategy`. For "github.com", the first keyring check looks like this: +``` r +gitcreds::gitcreds_get() +``` -```r -keyring::key_get("GITHUB_PAT_GITHUB_COM") +If you see something like this: +``` r +#> +#> protocol: https +#> host : github.com +#> username: token +#> password: <-- hidden --> ``` +that means that gitcreds could get the PAT from the git credential store. +You can call `gitcreds_get()$password` to see the actual PAT. -gh uses the default keyring backend and the default keyring within that backend. -See `keyring::default_backend()` for details and changing these defaults. +If no matching PAT is found, [gitcreds::gitcreds_get()] errors. -If the selected keyring is locked, and the session is interactive, then gh will try to unlock it. -If the keyring is locked, and the session is not interactive, then gh will not use the keyring. -Note that some keyring backends cannot be locked, e.g. the one that uses environment variables. -On some OSes, e.g. typically on macOS, you need to allow R to access the system keyring. -You can allow this separately for each access, or for all future accesses, until you update or re-install R. -You typically need to give access to each R GUI (e.g. RStudio) and the command line R program separately. -To store your PAT on the keyring, run: +## PAT in an environment variable -```r -keyring::key_set("GITHUB_PAT") +If you don't have a Git installation, or your Git installation does not have a working credential store, then you can specify the PAT in an environment variable. +For `github.com` you can set the `GITHUB_PAT_GITHUB_COM` or `GITHUB_PAT` variable. +For a different GitHub host, call `gitcreds::gitcreds_cache_envvar()` with the API URL to see the environment variable you need to set. +For example: + +```{r} +gitcreds::gitcreds_cache_envvar("https://github.acme.com") ``` ## Recommendations For a normal user, on a machine used for interactive development, we recommend: - * Store your PAT(s) in an official credential store or keychain for your - OS. + * Store your PAT(s) in an official credential store. * Do **not** store your PAT(s) in plain text in, e.g., `.Renviron`. In the past, this has been a common and recommended practice for pragmatic reasons. - However, credentials/keyring/gh have now evolved to the point where it's + However, gitcreds/gh have now evolved to the point where it's possible for all of us to follow better security practices. * If you use a general-purpose password manager, like 1Password or LastPass, you may *also* want to store your PAT(s) there. Why? If your PAT is "forgotten" from the OS-level credential store, intentionally or not, you'll - need to provide it again when prompted. For security reasons, none of the - tools involved will help you re-discover a PAT. + need to provide it again when prompted. If you don't have any other record of your PAT, you'll have to get a new PAT whenever this happens. This is not the end of the world. But if you @@ -162,7 +119,8 @@ For a normal user, on a machine used for interactive development, we recommend: , you will eventually find yourself in a confusing situation where you can't be sure which PAT(s) are in use. -On a headless system, such as on a CI/CD platform, provide the necessary PAT(s) via secure environment variables. Regular environment variables can be used to configure less sensitive settings, such as the API host. +On a headless system, such as on a CI/CD platform, provide the necessary PAT(s) via secure environment variables. +Regular environment variables can be used to configure less sensitive settings, such as the API host. Don't expose your PAT by doing something silly like dumping all environment variables to a log file. Note that on GitHub Actions, specifically, a personal access token is [automatically available to the workflow](https://docs.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token) as the `GITHUB_TOKEN` secret. @@ -203,44 +161,4 @@ Message: Bad credentials This will also be the experience if an invalid PAT is provided directly via `.token`. -If the PAT is retrieved from the Git credential store or is elicited from the user via prompt, the PAT is immediately and explicitly checked for basic validity. - Even a valid PAT can lead to a downstream error, if it has insufficient scopes with respect to a specific request. - -## API URL specifications - -Above we explained that environment variables are consulted during token lookup and that their names are based on the target host. -The table below shows which environment variables are consulted when targeting "github.com" or "github.uni.edu", a fictional instance of GitHub Enterprise hosted by a university. - -Environment variables that depart from the `