From 0e2bbe1aeea526a882ec23730de22d12f26635e6 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 25 Nov 2025 22:02:22 +0000 Subject: [PATCH 01/23] Add OAuth integration support with getOAuthCredentials function --- DESCRIPTION | 3 + NAMESPACE | 1 + R/auth.R | 202 ++++++++++++++++++++++++++++++++++++ man/getOAuthCredentials.Rd | 40 +++++++ tests/testthat/test-oauth.R | 89 ++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 man/getOAuthCredentials.Rd create mode 100644 tests/testthat/test-oauth.R diff --git a/DESCRIPTION b/DESCRIPTION index 7d9b0a7..2f703e2 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -17,6 +17,9 @@ URL: https://rstudio.github.io/rstudioapi/, BugReports: https://github.com/rstudio/rstudioapi/issues Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.3 +Imports: + httr, + jsonlite Suggests: testthat, knitr, diff --git a/NAMESPACE b/NAMESPACE index 5934340..e33f045 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -43,6 +43,7 @@ export(getActiveProject) export(getConsoleEditorContext) export(getDelegatedAzureToken) export(getMode) +export(getOAuthCredentials) export(getPersistentValue) export(getRStudioPackageDependencies) export(getSourceEditorContext) diff --git a/R/auth.R b/R/auth.R index 41a55fd..83350c8 100644 --- a/R/auth.R +++ b/R/auth.R @@ -18,3 +18,205 @@ getDelegatedAzureToken <- function(resource) { } callFun("getDelegatedAzureToken", resource) } + +#' Retrieve OAuth Credentials for Integrations +#' +#' Retrieve OAuth credentials for a configured OAuth integration in Posit Workbench. +#' This function exchanges the current session for OAuth credentials that can be used +#' to authenticate with external services. This works in any IDE running within a +#' Posit Workbench session. +#' +#' @param integration_id The ID of the OAuth integration configured in Posit Workbench. +#' +#' @return A list containing: +#' \describe{ +#' \item{access_token}{The OAuth access token.} +#' \item{expiry}{The token expiry time as a POSIXct datetime object.} +#' \item{integration_id}{The integration ID that was used to retrieve the credentials.} +#' } +#' Returns \code{NULL} if the credentials cannot be retrieved or the integration is not found. +#' +#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' in any IDE running within a Posit Workbench session (not just RStudio). +#' +#' @examples +#' \dontrun{ +#' # Retrieve OAuth credentials for an integration +#' creds <- getOAuthCredentials("my-oauth-integration-id") +#' if (!is.null(creds)) { +#' cat("Access token:", creds$access_token, "\n") +#' cat("Expires at:", format(creds$expiry), "\n") +#' } +#' } +#' @export +getOAuthCredentials <- function(integration_id) { + # Check if we're in a Workbench session + if (Sys.getenv("POSIT_PRODUCT") != "WORKBENCH") { + stop("OAuth credentials are only available within Posit Workbench sessions.") + } + + # Check version requirement (2025.11.0+) + wb_version <- Sys.getenv("RSTUDIO_VERSION") + if (nzchar(wb_version)) { + required_version <- numeric_version("2025.11.0") + current_version <- tryCatch( + numeric_version(gsub("[-+].*$", "", wb_version)), + error = function(e) NULL + ) + + if (!is.null(current_version) && current_version < required_version) { + stop(sprintf( + "OAuth credentials require Posit Workbench version 2025.11.0 or later. Current version: %s", + wb_version + )) + } + } + + # Get the RPC cookie for authentication + rpc_cookie <- .getRPCCookie() + + # Get the server address + server_url <- Sys.getenv("RS_SERVER_ADDRESS") + if (!nzchar(server_url)) { + stop("RS_SERVER_ADDRESS environment variable not set. Cannot determine Posit Workbench server address.") + } + + # Make the API request + endpoint <- paste0(server_url, "/oauth_token") + + # Prepare request body (matching Python implementation exactly) + body <- list( + method = "/oauth_token", + kwparams = list( + uuid = integration_id + ) + ) + + # Make HTTP request (POST with JSON body) + response <- .workbenchRequest( + url = endpoint, + method = "POST", + body = body, + rpc_cookie = rpc_cookie + ) + + # Check for errors in response + if (!is.null(response$error)) { + error_msg <- if (is.list(response$error)) { + paste(response$error$message, response$error$description, sep = ": ") + } else { + as.character(response$error) + } + stop(sprintf("Error retrieving OAuth credentials: %s", error_msg)) + } + + # Check if result is false (unsuccessful) + if (!is.null(response$result) && !isTRUE(response$result)) { + return(NULL) + } + + # Return credentials if found + if (!is.null(response$access_token)) { + return(list( + access_token = response$access_token, + expiry = as.POSIXct(response$expiry, format = "%Y-%m-%dT%H:%M:%OS", tz = "UTC"), + integration_id = integration_id + )) + } + + return(NULL) +} + +# Internal helper to get RPC cookie +.getRPCCookie <- function() { + # Try to read from environment variable first + cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") + if (nzchar(cookie)) { + return(cookie) + } + + # Try to read from file + runtime_dir <- Sys.getenv("PWB_SESSION_RUNTIME_DIR") + if (nzchar(runtime_dir)) { + cookie_file <- file.path(runtime_dir, "rpc_cookie") + if (file.exists(cookie_file)) { + cookie <- tryCatch( + readLines(cookie_file, n = 1, warn = FALSE), + error = function(e) NULL + ) + if (!is.null(cookie) && nzchar(cookie)) { + return(cookie) + } + } + } + + stop("RPC cookie not found. Ensure either PWB_SESSION_RUNTIME_DIR is set with a valid cookie file, or RS_SESSION_RPC_COOKIE environment variable is defined.") +} + +# Internal helper to make authenticated requests to Workbench +.workbenchRequest <- function(url, method = "GET", body = NULL, rpc_cookie = NULL) { + # Check if httr is available + if (!requireNamespace("httr", quietly = TRUE)) { + stop("Package 'httr' is required for OAuth functionality. Please install it with: install.packages('httr')") + } + + # Prepare headers + headers <- httr::add_headers( + "Content-Type" = "application/json" + ) + + if (!is.null(rpc_cookie)) { + headers <- httr::add_headers( + "Content-Type" = "application/json", + "X-RS-Session-Server-RPC-Cookie" = rpc_cookie + ) + } + + # Determine SSL verification settings + verify_ssl <- TRUE + ca_bundle <- Sys.getenv("REQUESTS_CA_BUNDLE") + if (!nzchar(ca_bundle)) { + ca_bundle <- Sys.getenv("CURL_CA_BUNDLE") + } + + ssl_config <- if (nzchar(ca_bundle)) { + httr::config(cainfo = ca_bundle) + } else { + httr::config(ssl_verifypeer = verify_ssl) + } + + # Make request + response <- if (method == "POST") { + httr::POST( + url, + body = body, + encode = "json", + headers, + ssl_config + ) + } else if (method == "GET" && !is.null(body)) { + # For GET with body, use POST-style request + httr::GET( + url, + body = body, + encode = "json", + headers, + ssl_config + ) + } else { + httr::GET(url, headers, ssl_config) + } + + # Check HTTP status + if (httr::http_error(response)) { + stop(sprintf( + "HTTP request failed with status %s: %s", + httr::status_code(response), + httr::content(response, "text", encoding = "UTF-8") + )) + } + + # Parse JSON response + content <- httr::content(response, "text", encoding = "UTF-8") + jsonlite::fromJSON(content, simplifyVector = FALSE) +} diff --git a/man/getOAuthCredentials.Rd b/man/getOAuthCredentials.Rd new file mode 100644 index 0000000..32804b2 --- /dev/null +++ b/man/getOAuthCredentials.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{getOAuthCredentials} +\alias{getOAuthCredentials} +\title{Retrieve OAuth Credentials for Integrations} +\usage{ +getOAuthCredentials(integration_id) +} +\arguments{ +\item{integration_id}{The ID of the OAuth integration configured in Posit Workbench.} +} +\value{ +A list containing: +\describe{ +\item{access_token}{The OAuth access token.} +\item{expiry}{The token expiry time as a POSIXct datetime object.} +\item{integration_id}{The integration ID that was used to retrieve the credentials.} +} +Returns \code{NULL} if the credentials cannot be retrieved or the integration is not found. +} +\description{ +Retrieve OAuth credentials for a configured OAuth integration in Posit Workbench. +This function exchanges the current session for OAuth credentials that can be used +to authenticate with external services. This works in any IDE running within a +Posit Workbench session. +} +\note{ +This function requires Posit Workbench version 2025.11.0 or later. It works +in any IDE running within a Posit Workbench session (not just RStudio). +} +\examples{ +\dontrun{ +# Retrieve OAuth credentials for an integration +creds <- getOAuthCredentials("my-oauth-integration-id") +if (!is.null(creds)) { + cat("Access token:", creds$access_token, "\n") + cat("Expires at:", format(creds$expiry), "\n") +} +} +} diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R new file mode 100644 index 0000000..e1410ce --- /dev/null +++ b/tests/testthat/test-oauth.R @@ -0,0 +1,89 @@ +context("OAuth API") + +test_that("getOAuthCredentials fails gracefully outside Workbench", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + + # Temporarily unset POSIT_PRODUCT + Sys.unsetenv("POSIT_PRODUCT") + + expect_error( + getOAuthCredentials("test-integration"), + "OAuth credentials are only available within Posit Workbench sessions" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } +}) + +test_that("getOAuthCredentials requires minimum version", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") + + # Set environment to simulate Workbench with old version + Sys.setenv(POSIT_PRODUCT = "WORKBENCH") + Sys.setenv(RSTUDIO_VERSION = "2024.01.0") + + # Expect error about version requirement + expect_error( + getOAuthCredentials("test-integration"), + "OAuth credentials require Posit Workbench version 2025.11.0 or later" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } else { + Sys.unsetenv("POSIT_PRODUCT") + } + + if (nzchar(old_rstudio_version)) { + Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) + } else { + Sys.unsetenv("RSTUDIO_VERSION") + } +}) + +test_that("getOAuthCredentials handles missing RPC cookie", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") + old_rpc_cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") + old_runtime_dir <- Sys.getenv("PWB_SESSION_RUNTIME_DIR") + + # Set environment to simulate Workbench with correct version but no cookie + Sys.setenv(POSIT_PRODUCT = "WORKBENCH") + Sys.setenv(RSTUDIO_VERSION = "2025.11.0") + Sys.unsetenv("RS_SESSION_RPC_COOKIE") + Sys.unsetenv("PWB_SESSION_RUNTIME_DIR") + + # Expect error about missing RPC cookie + expect_error( + getOAuthCredentials("test-integration"), + "RPC cookie not found" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } else { + Sys.unsetenv("POSIT_PRODUCT") + } + + if (nzchar(old_rstudio_version)) { + Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) + } else { + Sys.unsetenv("RSTUDIO_VERSION") + } + + if (nzchar(old_rpc_cookie)) { + Sys.setenv(RS_SESSION_RPC_COOKIE = old_rpc_cookie) + } + + if (nzchar(old_runtime_dir)) { + Sys.setenv(PWB_SESSION_RUNTIME_DIR = old_runtime_dir) + } +}) From 8abd00fd2e9e95589618b970a46ee04f61b2a65e Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Fri, 2 Jan 2026 14:59:04 +0000 Subject: [PATCH 02/23] Implement OAuth integration functions and update documentation - Added `getOAuthIntegrations` and `getOAuthIntegration` functions for managing OAuth integrations in Posit Workbench. - Updated `getOAuthCredentials` function to use audience GUID instead of integration ID. - Enhanced error handling for OAuth functionality checks. - Updated DESCRIPTION and NAMESPACE files to include new imports and exports. - Added corresponding documentation files for new functions. - Expanded test coverage for OAuth integration functionalities. --- DESCRIPTION | 7 +- NAMESPACE | 2 + R/auth.R | 294 ++++++++++++++++++++++++++++++------ man/getOAuthCredentials.Rd | 8 +- man/getOAuthIntegration.Rd | 51 +++++++ man/getOAuthIntegrations.Rd | 55 +++++++ tests/testthat/test-oauth.R | 130 +++++++++++++++- 7 files changed, 494 insertions(+), 53 deletions(-) create mode 100644 man/getOAuthIntegration.Rd create mode 100644 man/getOAuthIntegrations.Rd diff --git a/DESCRIPTION b/DESCRIPTION index 2f703e2..2264d6e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -17,14 +17,13 @@ URL: https://rstudio.github.io/rstudioapi/, BugReports: https://github.com/rstudio/rstudioapi/issues Roxygen: list(markdown = TRUE) RoxygenNote: 7.3.3 -Imports: - httr, - jsonlite Suggests: testthat, knitr, rmarkdown, clipr, - covr + covr, + httr, + jsonlite VignetteBuilder: knitr Encoding: UTF-8 diff --git a/NAMESPACE b/NAMESPACE index e33f045..a71a996 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -44,6 +44,8 @@ export(getConsoleEditorContext) export(getDelegatedAzureToken) export(getMode) export(getOAuthCredentials) +export(getOAuthIntegration) +export(getOAuthIntegrations) export(getPersistentValue) export(getRStudioPackageDependencies) export(getSourceEditorContext) diff --git a/R/auth.R b/R/auth.R index 83350c8..6ee9681 100644 --- a/R/auth.R +++ b/R/auth.R @@ -19,6 +19,54 @@ getDelegatedAzureToken <- function(resource) { callFun("getDelegatedAzureToken", resource) } +# Internal helper to check if running in Posit Workbench +.checkWorkbenchSession <- function() { + if (Sys.getenv("POSIT_PRODUCT") != "WORKBENCH") { + stop("OAuth functionality is only available within Posit Workbench sessions.") + } +} + +# Internal helper to check version requirement +.checkWorkbenchVersion <- function(feature_name = "OAuth functionality") { + # Try to get version from versionInfo() first, fall back to environment variable + version_info <- tryCatch( + versionInfo(), + error = function(e) { + # If versionInfo() fails (e.g., RStudio not running), fall back to environment variable + wb_version <- Sys.getenv("RSTUDIO_VERSION") + if (nzchar(wb_version)) { + list(version = wb_version) + } else { + NULL + } + } + ) + + # Check if version meets minimum requirement + if (!is.null(version_info) && !is.null(version_info$version)) { + required_version <- numeric_version("2025.11.0") + current_version <- tryCatch( + { + # Handle both numeric_version objects and strings + if (inherits(version_info$version, "numeric_version")) { + version_info$version + } else { + numeric_version(gsub("[-+].*$", "", as.character(version_info$version))) + } + }, + error = function(e) NULL + ) + + if (!is.null(current_version) && current_version < required_version) { + stop(sprintf( + "%s require Posit Workbench version 2025.11.0 or later. Current version: %s", + feature_name, + version_info$version + )) + } + } +} + #' Retrieve OAuth Credentials for Integrations #' #' Retrieve OAuth credentials for a configured OAuth integration in Posit Workbench. @@ -26,13 +74,13 @@ getDelegatedAzureToken <- function(resource) { #' to authenticate with external services. This works in any IDE running within a #' Posit Workbench session. #' -#' @param integration_id The ID of the OAuth integration configured in Posit Workbench. +#' @param audience The GUID of the OAuth integration configured in Posit Workbench. #' #' @return A list containing: #' \describe{ #' \item{access_token}{The OAuth access token.} #' \item{expiry}{The token expiry time as a POSIXct datetime object.} -#' \item{integration_id}{The integration ID that was used to retrieve the credentials.} +#' \item{audience}{The integration GUID (audience) that was used to retrieve the credentials.} #' } #' Returns \code{NULL} if the credentials cannot be retrieved or the integration is not found. #' @@ -42,36 +90,210 @@ getDelegatedAzureToken <- function(resource) { #' @examples #' \dontrun{ #' # Retrieve OAuth credentials for an integration -#' creds <- getOAuthCredentials("my-oauth-integration-id") +#' creds <- getOAuthCredentials("4c1cfecb-1927-4f19-bc2f-d8ac261364e0") #' if (!is.null(creds)) { #' cat("Access token:", creds$access_token, "\n") #' cat("Expires at:", format(creds$expiry), "\n") #' } #' } #' @export -getOAuthCredentials <- function(integration_id) { - # Check if we're in a Workbench session - if (Sys.getenv("POSIT_PRODUCT") != "WORKBENCH") { - stop("OAuth credentials are only available within Posit Workbench sessions.") +getOAuthCredentials <- function(audience) { + .checkWorkbenchSession() + .checkWorkbenchVersion("OAuth credentials") + + # Prepare request body (matching Python implementation exactly) + # Note: The RPC endpoint expects "uuid" parameter + body <- list( + method = "/oauth_token", + kwparams = list( + uuid = audience + ) + ) + + # Make RPC call + response <- .callWorkbenchRPC( + endpoint_path = "/oauth_token", + body = body, + error_context = "retrieving OAuth credentials" + ) + + # Check if result is false (unsuccessful) + if (!is.null(response$result) && !isTRUE(response$result)) { + return(NULL) } - # Check version requirement (2025.11.0+) - wb_version <- Sys.getenv("RSTUDIO_VERSION") - if (nzchar(wb_version)) { - required_version <- numeric_version("2025.11.0") - current_version <- tryCatch( - numeric_version(gsub("[-+].*$", "", wb_version)), - error = function(e) NULL + # Return credentials if found + if (!is.null(response$access_token)) { + return(list( + access_token = response$access_token, + expiry = as.POSIXct(response$expiry, format = "%Y-%m-%dT%H:%M:%OS", tz = "UTC"), + audience = audience + )) + } + + return(NULL) +} + +#' Get OAuth Integrations +#' +#' Retrieve a list of all OAuth integrations configured in Posit Workbench. +#' This returns metadata about each integration including its authentication status, +#' scopes, and configuration details. +#' +#' @return A list of OAuth integrations, where each element contains: +#' \describe{ +#' \item{type}{The integration type (e.g., "custom").} +#' \item{name}{The integration name.} +#' \item{display_name}{The display name (may be NULL).} +#' \item{client_id}{The OAuth client ID.} +#' \item{auth_url}{The authorization URL.} +#' \item{token_url}{The token URL.} +#' \item{scopes}{A character vector of OAuth scopes.} +#' \item{issuer}{The OAuth issuer URL.} +#' \item{authenticated}{Boolean indicating if currently authenticated.} +#' \item{guid}{The globally unique identifier for this integration (useful for \code{getOAuthCredentials()}).} +#' } +#' Returns an empty list if no integrations are configured. +#' +#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' in any IDE running within a Posit Workbench session (not just RStudio). +#' +#' @examples +#' \dontrun{ +#' # Get all OAuth integrations +#' integrations <- getOAuthIntegrations() +#' +#' # Show all integrations +#' for (int in integrations) { +#' cat(sprintf("%s (%s): %s\n", +#' int$name, +#' int$guid, +#' if (int$authenticated) "authenticated" else "not authenticated")) +#' } +#' +#' # Filter to authenticated integrations only +#' authenticated <- Filter(function(x) x$authenticated, integrations) +#' +#' # Get credentials for the first authenticated integration +#' if (length(authenticated) > 0) { +#' creds <- getOAuthCredentials(audience = authenticated[[1]]$guid) +#' } +#' } +#' @export +getOAuthIntegrations <- function() { + .checkWorkbenchSession() + .checkWorkbenchVersion("OAuth integrations") + + # Prepare request body + body <- list( + method = "/oauth_integrations", + kwparams = list() + ) + + # Make RPC call + response <- .callWorkbenchRPC( + endpoint_path = "/oauth_integrations", + body = body, + error_context = "retrieving OAuth integrations" + ) + + # Check if result is false (unsuccessful) + if (!is.null(response$result) && !isTRUE(response$result)) { + return(list()) + } + + # Flatten the providers structure and return just the integrations + if (!is.null(response$providers) && length(response$providers) > 0) { + all_integrations <- unlist( + lapply(response$providers, function(provider) { + if (!is.null(provider$integrations)) { + provider$integrations + } else { + list() + } + }), + recursive = FALSE ) - if (!is.null(current_version) && current_version < required_version) { - stop(sprintf( - "OAuth credentials require Posit Workbench version 2025.11.0 or later. Current version: %s", - wb_version - )) + # Rename uid to guid for consistency + all_integrations <- lapply(all_integrations, function(integration) { + if (!is.null(integration$uid)) { + integration$guid <- integration$uid + integration$uid <- NULL + } + integration + }) + + return(all_integrations) + } + + # Return empty list if no providers/integrations found + return(list()) +} + +#' Get a Specific OAuth Integration +#' +#' Retrieve metadata for a specific OAuth integration by its globally unique identifier. +#' This is a convenience function that filters the results from \code{getOAuthIntegrations()}. +#' +#' @param guid The globally unique identifier (GUID) of the OAuth integration to retrieve. +#' +#' @return A list containing the integration metadata: +#' \describe{ +#' \item{type}{The integration type (e.g., "custom").} +#' \item{name}{The integration name.} +#' \item{display_name}{The display name (may be NULL).} +#' \item{client_id}{The OAuth client ID.} +#' \item{auth_url}{The authorization URL.} +#' \item{token_url}{The token URL.} +#' \item{scopes}{A character vector of OAuth scopes.} +#' \item{issuer}{The OAuth issuer URL.} +#' \item{authenticated}{Boolean indicating if currently authenticated.} +#' \item{guid}{The globally unique identifier for this integration.} +#' } +#' Returns \code{NULL} if no integration with the specified GUID is found. +#' +#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' in any IDE running within a Posit Workbench session (not just RStudio). +#' +#' @examples +#' \dontrun{ +#' # Get a specific integration by GUID +#' integration <- getOAuthIntegration("4c1cfecb-1927-4f19-bc2f-d8ac261364e0") +#' +#' if (!is.null(integration)) { +#' cat("Found integration:", integration$name, "\n") +#' cat("Authenticated:", integration$authenticated, "\n") +#' +#' # Get credentials if authenticated +#' if (integration$authenticated) { +#' creds <- getOAuthCredentials(audience = integration$guid) +#' } +#' } +#' } +#' @export +getOAuthIntegration <- function(guid) { + if (missing(guid) || !is.character(guid) || length(guid) != 1 || !nzchar(guid)) { + stop("guid must be a non-empty character string") + } + + # Get all integrations + integrations <- getOAuthIntegrations() + + # Find the matching integration + for (integration in integrations) { + if (!is.null(integration$guid) && integration$guid == guid) { + return(integration) } } + # Not found + return(NULL) +} + +# Internal helper to call Workbench RPC endpoints +# Handles: server URL, RPC cookie, error checking, result validation +.callWorkbenchRPC <- function(endpoint_path, body, error_context = "RPC call") { # Get the RPC cookie for authentication rpc_cookie <- .getRPCCookie() @@ -82,15 +304,7 @@ getOAuthCredentials <- function(integration_id) { } # Make the API request - endpoint <- paste0(server_url, "/oauth_token") - - # Prepare request body (matching Python implementation exactly) - body <- list( - method = "/oauth_token", - kwparams = list( - uuid = integration_id - ) - ) + endpoint <- paste0(server_url, endpoint_path) # Make HTTP request (POST with JSON body) response <- .workbenchRequest( @@ -107,24 +321,11 @@ getOAuthCredentials <- function(integration_id) { } else { as.character(response$error) } - stop(sprintf("Error retrieving OAuth credentials: %s", error_msg)) + stop(sprintf("Error %s: %s", error_context, error_msg)) } - # Check if result is false (unsuccessful) - if (!is.null(response$result) && !isTRUE(response$result)) { - return(NULL) - } - - # Return credentials if found - if (!is.null(response$access_token)) { - return(list( - access_token = response$access_token, - expiry = as.POSIXct(response$expiry, format = "%Y-%m-%dT%H:%M:%OS", tz = "UTC"), - integration_id = integration_id - )) - } - - return(NULL) + # Return the full response (caller can check result field if needed) + response } # Internal helper to get RPC cookie @@ -160,6 +361,11 @@ getOAuthCredentials <- function(integration_id) { stop("Package 'httr' is required for OAuth functionality. Please install it with: install.packages('httr')") } + # Check if jsonlite is available + if (!requireNamespace("jsonlite", quietly = TRUE)) { + stop("Package 'jsonlite' is required for OAuth functionality. Please install it with: install.packages('jsonlite')") + } + # Prepare headers headers <- httr::add_headers( "Content-Type" = "application/json" diff --git a/man/getOAuthCredentials.Rd b/man/getOAuthCredentials.Rd index 32804b2..dfdb8c8 100644 --- a/man/getOAuthCredentials.Rd +++ b/man/getOAuthCredentials.Rd @@ -4,17 +4,17 @@ \alias{getOAuthCredentials} \title{Retrieve OAuth Credentials for Integrations} \usage{ -getOAuthCredentials(integration_id) +getOAuthCredentials(audience) } \arguments{ -\item{integration_id}{The ID of the OAuth integration configured in Posit Workbench.} +\item{audience}{The GUID of the OAuth integration configured in Posit Workbench.} } \value{ A list containing: \describe{ \item{access_token}{The OAuth access token.} \item{expiry}{The token expiry time as a POSIXct datetime object.} -\item{integration_id}{The integration ID that was used to retrieve the credentials.} +\item{audience}{The integration GUID (audience) that was used to retrieve the credentials.} } Returns \code{NULL} if the credentials cannot be retrieved or the integration is not found. } @@ -31,7 +31,7 @@ in any IDE running within a Posit Workbench session (not just RStudio). \examples{ \dontrun{ # Retrieve OAuth credentials for an integration -creds <- getOAuthCredentials("my-oauth-integration-id") +creds <- getOAuthCredentials("4c1cfecb-1927-4f19-bc2f-d8ac261364e0") if (!is.null(creds)) { cat("Access token:", creds$access_token, "\n") cat("Expires at:", format(creds$expiry), "\n") diff --git a/man/getOAuthIntegration.Rd b/man/getOAuthIntegration.Rd new file mode 100644 index 0000000..802a7a1 --- /dev/null +++ b/man/getOAuthIntegration.Rd @@ -0,0 +1,51 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{getOAuthIntegration} +\alias{getOAuthIntegration} +\title{Get a Specific OAuth Integration} +\usage{ +getOAuthIntegration(guid) +} +\arguments{ +\item{guid}{The globally unique identifier (GUID) of the OAuth integration to retrieve.} +} +\value{ +A list containing the integration metadata: +\describe{ +\item{type}{The integration type (e.g., "custom").} +\item{name}{The integration name.} +\item{display_name}{The display name (may be NULL).} +\item{client_id}{The OAuth client ID.} +\item{auth_url}{The authorization URL.} +\item{token_url}{The token URL.} +\item{scopes}{A character vector of OAuth scopes.} +\item{issuer}{The OAuth issuer URL.} +\item{authenticated}{Boolean indicating if currently authenticated.} +\item{guid}{The globally unique identifier for this integration.} +} +Returns \code{NULL} if no integration with the specified GUID is found. +} +\description{ +Retrieve metadata for a specific OAuth integration by its globally unique identifier. +This is a convenience function that filters the results from \code{getOAuthIntegrations()}. +} +\note{ +This function requires Posit Workbench version 2025.11.0 or later. It works +in any IDE running within a Posit Workbench session (not just RStudio). +} +\examples{ +\dontrun{ +# Get a specific integration by GUID +integration <- getOAuthIntegration("4c1cfecb-1927-4f19-bc2f-d8ac261364e0") + +if (!is.null(integration)) { + cat("Found integration:", integration$name, "\n") + cat("Authenticated:", integration$authenticated, "\n") + + # Get credentials if authenticated + if (integration$authenticated) { + creds <- getOAuthCredentials(audience = integration$guid) + } +} +} +} diff --git a/man/getOAuthIntegrations.Rd b/man/getOAuthIntegrations.Rd new file mode 100644 index 0000000..db69551 --- /dev/null +++ b/man/getOAuthIntegrations.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{getOAuthIntegrations} +\alias{getOAuthIntegrations} +\title{Get OAuth Integrations} +\usage{ +getOAuthIntegrations() +} +\value{ +A list of OAuth integrations, where each element contains: +\describe{ +\item{type}{The integration type (e.g., "custom").} +\item{name}{The integration name.} +\item{display_name}{The display name (may be NULL).} +\item{client_id}{The OAuth client ID.} +\item{auth_url}{The authorization URL.} +\item{token_url}{The token URL.} +\item{scopes}{A character vector of OAuth scopes.} +\item{issuer}{The OAuth issuer URL.} +\item{authenticated}{Boolean indicating if currently authenticated.} +\item{guid}{The globally unique identifier for this integration (useful for \code{getOAuthCredentials()}).} +} +Returns an empty list if no integrations are configured. +} +\description{ +Retrieve a list of all OAuth integrations configured in Posit Workbench. +This returns metadata about each integration including its authentication status, +scopes, and configuration details. +} +\note{ +This function requires Posit Workbench version 2025.11.0 or later. It works +in any IDE running within a Posit Workbench session (not just RStudio). +} +\examples{ +\dontrun{ +# Get all OAuth integrations +integrations <- getOAuthIntegrations() + +# Show all integrations +for (int in integrations) { + cat(sprintf("\%s (\%s): \%s\n", + int$name, + int$guid, + if (int$authenticated) "authenticated" else "not authenticated")) +} + +# Filter to authenticated integrations only +authenticated <- Filter(function(x) x$authenticated, integrations) + +# Get credentials for the first authenticated integration +if (length(authenticated) > 0) { + creds <- getOAuthCredentials(audience = authenticated[[1]]$guid) +} +} +} diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index e1410ce..8146ceb 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -9,7 +9,7 @@ test_that("getOAuthCredentials fails gracefully outside Workbench", { expect_error( getOAuthCredentials("test-integration"), - "OAuth credentials are only available within Posit Workbench sessions" + "OAuth functionality is only available within Posit Workbench sessions" ) # Restore environment @@ -87,3 +87,131 @@ test_that("getOAuthCredentials handles missing RPC cookie", { Sys.setenv(PWB_SESSION_RUNTIME_DIR = old_runtime_dir) } }) + +test_that("getOAuthIntegrations fails gracefully outside Workbench", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + + # Temporarily unset POSIT_PRODUCT + Sys.unsetenv("POSIT_PRODUCT") + + expect_error( + getOAuthIntegrations(), + "OAuth functionality is only available within Posit Workbench sessions" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } +}) + +test_that("getOAuthIntegrations requires minimum version", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") + + # Set environment to simulate Workbench with old version + Sys.setenv(POSIT_PRODUCT = "WORKBENCH") + Sys.setenv(RSTUDIO_VERSION = "2024.01.0") + + # Expect error about version requirement + expect_error( + getOAuthIntegrations(), + "OAuth integrations require Posit Workbench version 2025.11.0 or later" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } else { + Sys.unsetenv("POSIT_PRODUCT") + } + + if (nzchar(old_rstudio_version)) { + Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) + } else { + Sys.unsetenv("RSTUDIO_VERSION") + } +}) + +test_that("getOAuthIntegrations handles missing RPC cookie", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") + old_rpc_cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") + old_runtime_dir <- Sys.getenv("PWB_SESSION_RUNTIME_DIR") + + # Set environment to simulate Workbench with correct version but no cookie + Sys.setenv(POSIT_PRODUCT = "WORKBENCH") + Sys.setenv(RSTUDIO_VERSION = "2025.11.0") + Sys.unsetenv("RS_SESSION_RPC_COOKIE") + Sys.unsetenv("PWB_SESSION_RUNTIME_DIR") + + # Expect error about missing RPC cookie + expect_error( + getOAuthIntegrations(), + "RPC cookie not found" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } else { + Sys.unsetenv("POSIT_PRODUCT") + } + + if (nzchar(old_rstudio_version)) { + Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) + } else { + Sys.unsetenv("RSTUDIO_VERSION") + } + + if (nzchar(old_rpc_cookie)) { + Sys.setenv(RS_SESSION_RPC_COOKIE = old_rpc_cookie) + } + + if (nzchar(old_runtime_dir)) { + Sys.setenv(PWB_SESSION_RUNTIME_DIR = old_runtime_dir) + } +}) + +test_that("getOAuthIntegration validates guid parameter", { + expect_error( + getOAuthIntegration(), + "guid must be a non-empty character string" + ) + + expect_error( + getOAuthIntegration(""), + "guid must be a non-empty character string" + ) + + expect_error( + getOAuthIntegration(123), + "guid must be a non-empty character string" + ) + + expect_error( + getOAuthIntegration(c("guid1", "guid2")), + "guid must be a non-empty character string" + ) +}) + +test_that("getOAuthIntegration fails gracefully outside Workbench", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + + # Temporarily unset POSIT_PRODUCT + Sys.unsetenv("POSIT_PRODUCT") + + expect_error( + getOAuthIntegration("test-guid"), + "OAuth functionality is only available within Posit Workbench sessions" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } +}) From 18222b372df9ea5a1f364490d894ad9f9e69cecf Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Fri, 2 Jan 2026 17:51:27 +0000 Subject: [PATCH 03/23] Allow getDelegatedAzureToken to work outside RStudio --- R/auth.R | 205 +++++++++++++++++++++++----------- man/getDelegatedAzureToken.Rd | 3 + tests/testthat/test-oauth.R | 50 ++++++++- 3 files changed, 189 insertions(+), 69 deletions(-) diff --git a/R/auth.R b/R/auth.R index 6ee9681..5ad35ff 100644 --- a/R/auth.R +++ b/R/auth.R @@ -1,3 +1,11 @@ +# Feature names and their minimum version requirements +.WORKBENCH_FEATURE_DELEGATED_AZURE <- "Delegated Azure tokens" +.WORKBENCH_FEATURE_OAUTH <- "OAuth functionality" + +.WORKBENCH_MIN_VERSIONS <- list() +.WORKBENCH_MIN_VERSIONS[[.WORKBENCH_FEATURE_DELEGATED_AZURE]] <- "2024.12.0" +.WORKBENCH_MIN_VERSIONS[[.WORKBENCH_FEATURE_OAUTH]] <- "2026.01.0" + #' OAuth2 Tokens for Delegated Azure Resources #' #' When Workbench is using Azure Active Directory for sign-in, this function can @@ -6,65 +14,56 @@ #' #' @param resource The name of an Azure resource or service, normally a URL. #' +#' @return A list containing the OAuth2 token details, or NULL if unavailable. +#' #' @examples #' \dontrun{ #' getDelegatedAzureToken("https://storage.azure.com") #' } #' @export getDelegatedAzureToken <- function(resource) { - version <- versionInfo() - if (is.null(version$edition)) { - stop("Delegated Azure Credentials are not available in the open-source edition of RStudio.") - } - callFun("getDelegatedAzureToken", resource) -} + # Try the internal RStudio API first (works in RStudio IDE) + result <- tryCatch( + { + version <- versionInfo() + if (is.null(version$edition)) { + stop("Delegated Azure Credentials are not available in the open-source edition of RStudio.") + } + callFun("getDelegatedAzureToken", resource) + }, + error = function(e) { + # If callFun fails, fall back to RPC endpoint (works in any Workbench session) + NULL + } + ) -# Internal helper to check if running in Posit Workbench -.checkWorkbenchSession <- function() { - if (Sys.getenv("POSIT_PRODUCT") != "WORKBENCH") { - stop("OAuth functionality is only available within Posit Workbench sessions.") + # If callFun succeeded, return its result + if (!is.null(result)) { + return(result) } -} -# Internal helper to check version requirement -.checkWorkbenchVersion <- function(feature_name = "OAuth functionality") { - # Try to get version from versionInfo() first, fall back to environment variable - version_info <- tryCatch( - versionInfo(), - error = function(e) { - # If versionInfo() fails (e.g., RStudio not running), fall back to environment variable - wb_version <- Sys.getenv("RSTUDIO_VERSION") - if (nzchar(wb_version)) { - list(version = wb_version) - } else { - NULL - } - } + # Fallback: use the RPC endpoint + .checkWorkbenchSession() + .checkWorkbenchVersion(.WORKBENCH_FEATURE_DELEGATED_AZURE) + + # Prepare request body + body <- list( + params = list(resource) ) - # Check if version meets minimum requirement - if (!is.null(version_info) && !is.null(version_info$version)) { - required_version <- numeric_version("2025.11.0") - current_version <- tryCatch( - { - # Handle both numeric_version objects and strings - if (inherits(version_info$version, "numeric_version")) { - version_info$version - } else { - numeric_version(gsub("[-+].*$", "", as.character(version_info$version))) - } - }, - error = function(e) NULL - ) + # Make RPC call (will throw error if result=false or other issues) + response <- .callWorkbenchRPC( + method = "delegated_azure_token", + body = body, + error_context = "retrieving delegated Azure token" + ) - if (!is.null(current_version) && current_version < required_version) { - stop(sprintf( - "%s require Posit Workbench version 2025.11.0 or later. Current version: %s", - feature_name, - version_info$version - )) - } + # Return the token object + if (!is.null(response$token)) { + return(response$token) } + + return(NULL) } #' Retrieve OAuth Credentials for Integrations @@ -99,12 +98,11 @@ getDelegatedAzureToken <- function(resource) { #' @export getOAuthCredentials <- function(audience) { .checkWorkbenchSession() - .checkWorkbenchVersion("OAuth credentials") + .checkWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) # Prepare request body (matching Python implementation exactly) # Note: The RPC endpoint expects "uuid" parameter body <- list( - method = "/oauth_token", kwparams = list( uuid = audience ) @@ -112,16 +110,11 @@ getOAuthCredentials <- function(audience) { # Make RPC call response <- .callWorkbenchRPC( - endpoint_path = "/oauth_token", + method = "oauth_token", body = body, error_context = "retrieving OAuth credentials" ) - # Check if result is false (unsuccessful) - if (!is.null(response$result) && !isTRUE(response$result)) { - return(NULL) - } - # Return credentials if found if (!is.null(response$access_token)) { return(list( @@ -182,26 +175,20 @@ getOAuthCredentials <- function(audience) { #' @export getOAuthIntegrations <- function() { .checkWorkbenchSession() - .checkWorkbenchVersion("OAuth integrations") + .checkWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) # Prepare request body body <- list( - method = "/oauth_integrations", kwparams = list() ) # Make RPC call response <- .callWorkbenchRPC( - endpoint_path = "/oauth_integrations", + method = "oauth_integrations", body = body, error_context = "retrieving OAuth integrations" ) - # Check if result is false (unsuccessful) - if (!is.null(response$result) && !isTRUE(response$result)) { - return(list()) - } - # Flatten the providers structure and return just the integrations if (!is.null(response$providers) && length(response$providers) > 0) { all_integrations <- unlist( @@ -291,9 +278,71 @@ getOAuthIntegration <- function(guid) { return(NULL) } +# Internal helper to check if running in Posit Workbench +.checkWorkbenchSession <- function() { + if (Sys.getenv("POSIT_PRODUCT") != "WORKBENCH") { + stop("OAuth functionality is only available within Posit Workbench sessions.") + } +} + +# Internal helper to check version requirement +.checkWorkbenchVersion <- function(feature_name) { + # Look up minimum version for this feature + min_version <- .WORKBENCH_MIN_VERSIONS[[feature_name]] + if (is.null(min_version)) { + stop(sprintf("Unknown feature name: %s", feature_name)) + } + + # Try to get version from versionInfo() first, fall back to environment variable + version_info <- tryCatch( + versionInfo(), + error = function(e) { + # If versionInfo() fails (e.g., RStudio not running), fall back to environment variable + wb_version <- Sys.getenv("RSTUDIO_VERSION") + if (nzchar(wb_version)) { + list(version = wb_version) + } else { + NULL + } + } + ) + + # Check if version meets minimum requirement + if (!is.null(version_info) && !is.null(version_info$version)) { + version_string <- as.character(version_info$version) + + # Skip version check for dev builds + if (grepl("dev", version_string, ignore.case = TRUE)) { + return(invisible(NULL)) + } + + required_version <- numeric_version(min_version) + current_version <- tryCatch( + { + # Handle both numeric_version objects and strings + if (inherits(version_info$version, "numeric_version")) { + version_info$version + } else { + numeric_version(gsub("[-+].*$", "", version_string)) + } + }, + error = function(e) NULL + ) + + if (!is.null(current_version) && current_version < required_version) { + stop(sprintf( + "%s require Posit Workbench version %s or later. Current version: %s", + feature_name, + min_version, + version_info$version + )) + } + } +} + # Internal helper to call Workbench RPC endpoints # Handles: server URL, RPC cookie, error checking, result validation -.callWorkbenchRPC <- function(endpoint_path, body, error_context = "RPC call") { +.callWorkbenchRPC <- function(method, body, error_context = "RPC call") { # Get the RPC cookie for authentication rpc_cookie <- .getRPCCookie() @@ -303,8 +352,11 @@ getOAuthIntegration <- function(guid) { stop("RS_SERVER_ADDRESS environment variable not set. Cannot determine Posit Workbench server address.") } - # Make the API request - endpoint <- paste0(server_url, endpoint_path) + # Make the API request (add leading slash for URL path) + endpoint <- paste0(server_url, "/", method) + + # Add the method field to the body (RPC convention) + body$method <- method # Make HTTP request (POST with JSON body) response <- .workbenchRequest( @@ -314,7 +366,7 @@ getOAuthIntegration <- function(guid) { rpc_cookie = rpc_cookie ) - # Check for errors in response + # Check for JSON-RPC error field if (!is.null(response$error)) { error_msg <- if (is.list(response$error)) { paste(response$error$message, response$error$description, sep = ": ") @@ -324,7 +376,26 @@ getOAuthIntegration <- function(guid) { stop(sprintf("Error %s: %s", error_context, error_msg)) } - # Return the full response (caller can check result field if needed) + # Check if result is false (unsuccessful) + if (!is.null(response$result) && !isTRUE(response$result)) { + # Check for detailed error messages + if (!is.null(response$detail)) { + stop(sprintf("Error %s: %s", error_context, response$detail)) + } + + # Check for OAuth2-specific errors + if (!is.null(response$oauth2_error)) { + oauth_err <- response$oauth2_error + error_code <- if (!is.null(oauth_err$error)) oauth_err$error else "unknown" + error_desc <- if (!is.null(oauth_err$error_description)) oauth_err$error_description else "no description" + stop(sprintf("OAuth2 error %s: %s - %s", error_context, error_code, error_desc)) + } + + # Generic failure with no detail + stop(sprintf("Error %s: request failed with result=false", error_context)) + } + + # Return the full response for the caller to extract needed fields response } diff --git a/man/getDelegatedAzureToken.Rd b/man/getDelegatedAzureToken.Rd index 962c58a..a040d40 100644 --- a/man/getDelegatedAzureToken.Rd +++ b/man/getDelegatedAzureToken.Rd @@ -9,6 +9,9 @@ getDelegatedAzureToken(resource) \arguments{ \item{resource}{The name of an Azure resource or service, normally a URL.} } +\value{ +A list containing the OAuth2 token details, or NULL if unavailable. +} \description{ When Workbench is using Azure Active Directory for sign-in, this function can return an OAuth2 token for a service Workbench users have delegated access diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index 8146ceb..965ec6d 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -30,7 +30,7 @@ test_that("getOAuthCredentials requires minimum version", { # Expect error about version requirement expect_error( getOAuthCredentials("test-integration"), - "OAuth credentials require Posit Workbench version 2025.11.0 or later" + "OAuth functionality require Posit Workbench version 2025.11.0 or later" ) # Restore environment @@ -118,7 +118,7 @@ test_that("getOAuthIntegrations requires minimum version", { # Expect error about version requirement expect_error( getOAuthIntegrations(), - "OAuth integrations require Posit Workbench version 2025.11.0 or later" + "OAuth functionality require Posit Workbench version 2025.11.0 or later" ) # Restore environment @@ -176,6 +176,52 @@ test_that("getOAuthIntegrations handles missing RPC cookie", { } }) +test_that("getOAuthCredentials allows dev versions", { + # Save current environment + old_posit_product <- Sys.getenv("POSIT_PRODUCT") + old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") + old_rpc_cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") + old_server_address <- Sys.getenv("RS_SERVER_ADDRESS") + + # Set environment to simulate Workbench with dev version (even if old) + Sys.setenv(POSIT_PRODUCT = "WORKBENCH") + Sys.setenv(RSTUDIO_VERSION = "2024.01.0-dev") + Sys.setenv(RS_SESSION_RPC_COOKIE = "test-cookie") + Sys.setenv(RS_SERVER_ADDRESS = "http://localhost:8787") + + # Should not error about version requirement (will error about connection, but that's OK) + expect_error( + getOAuthCredentials("test-integration"), + "HTTP request failed", + class = "error" + ) + + # Restore environment + if (nzchar(old_posit_product)) { + Sys.setenv(POSIT_PRODUCT = old_posit_product) + } else { + Sys.unsetenv("POSIT_PRODUCT") + } + + if (nzchar(old_rstudio_version)) { + Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) + } else { + Sys.unsetenv("RSTUDIO_VERSION") + } + + if (nzchar(old_rpc_cookie)) { + Sys.setenv(RS_SESSION_RPC_COOKIE = old_rpc_cookie) + } else { + Sys.unsetenv("RS_SESSION_RPC_COOKIE") + } + + if (nzchar(old_server_address)) { + Sys.setenv(RS_SERVER_ADDRESS = old_server_address) + } else { + Sys.unsetenv("RS_SERVER_ADDRESS") + } +}) + test_that("getOAuthIntegration validates guid parameter", { expect_error( getOAuthIntegration(), From e2b0b059085d097a697ff9fc151d25f68ab83805 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Mon, 5 Jan 2026 19:31:17 +0000 Subject: [PATCH 04/23] Add findOAuthIntegration function and update related documentation --- NAMESPACE | 1 + R/auth.R | 142 ++++++++++++++++++++++-------------- man/findOAuthIntegration.Rd | 53 ++++++++++++++ man/getOAuthIntegration.Rd | 2 +- tests/testthat/test-oauth.R | 8 +- 5 files changed, 148 insertions(+), 58 deletions(-) create mode 100644 man/findOAuthIntegration.Rd diff --git a/NAMESPACE b/NAMESPACE index a71a996..0da8137 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -38,6 +38,7 @@ export(document_range) export(executeCommand) export(filesPaneNavigate) export(findFun) +export(findOAuthIntegration) export(getActiveDocumentContext) export(getActiveProject) export(getConsoleEditorContext) diff --git a/R/auth.R b/R/auth.R index 5ad35ff..626bcd5 100644 --- a/R/auth.R +++ b/R/auth.R @@ -218,10 +218,74 @@ getOAuthIntegrations <- function() { return(list()) } +#' Find OAuth Integration by Criteria +#' +#' Search for an OAuth integration that matches the specified criteria. Returns the first +#' integration that matches all provided filter parameters. If no parameters are provided, +#' returns the first available integration. +#' +#' @param type Optional integration type to match (e.g., "custom"). +#' @param name Optional integration name to match. +#' @param display_name Optional display name to match. +#' @param guid Optional globally unique identifier (GUID) to match. +#' @param authenticated Optional logical indicating whether to match only authenticated integrations (TRUE), +#' only unauthenticated integrations (FALSE), or either (NULL, the default). +#' +#' @return A list containing the integration metadata, or \code{NULL} if no matching integration is found. +#' +#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' in any IDE running within a Posit Workbench session (not just RStudio). +#' +#' @examples +#' \dontrun{ +#' # Find by GUID +#' integration <- findOAuthIntegration(guid = "4c1cfecb-1927-4f19-bc2f-d8ac261364e0") +#' +#' # Find by name +#' integration <- findOAuthIntegration(name = "my-github-integration") +#' +#' # Find authenticated integration of specific type +#' integration <- findOAuthIntegration(type = "custom", authenticated = TRUE) +#' +#' # Find by display name +#' integration <- findOAuthIntegration(display_name = "GitHub Production") +#' } +#' @export +findOAuthIntegration <- function(type = NULL, name = NULL, display_name = NULL, guid = NULL, authenticated = NULL) { + # Get all integrations + integrations <- getOAuthIntegrations() + + # Find the first matching integration + for (integration in integrations) { + # Check each filter criterion (only if provided) + if (!is.null(type) && (is.null(integration$type) || integration$type != type)) { + next + } + if (!is.null(name) && (is.null(integration$name) || integration$name != name)) { + next + } + if (!is.null(display_name) && (is.null(integration$display_name) || integration$display_name != display_name)) { + next + } + if (!is.null(guid) && (is.null(integration$guid) || integration$guid != guid)) { + next + } + if (!is.null(authenticated) && (is.null(integration$authenticated) || integration$authenticated != authenticated)) { + next + } + + # All criteria matched + return(integration) + } + + # No match found + return(NULL) +} + #' Get a Specific OAuth Integration #' #' Retrieve metadata for a specific OAuth integration by its globally unique identifier. -#' This is a convenience function that filters the results from \code{getOAuthIntegrations()}. +#' This is a convenience function that calls \code{findOAuthIntegration(guid = guid)}. #' #' @param guid The globally unique identifier (GUID) of the OAuth integration to retrieve. #' @@ -264,18 +328,7 @@ getOAuthIntegration <- function(guid) { stop("guid must be a non-empty character string") } - # Get all integrations - integrations <- getOAuthIntegrations() - - # Find the matching integration - for (integration in integrations) { - if (!is.null(integration$guid) && integration$guid == guid) { - return(integration) - } - } - - # Not found - return(NULL) + findOAuthIntegration(guid = guid) } # Internal helper to check if running in Posit Workbench @@ -293,50 +346,33 @@ getOAuthIntegration <- function(guid) { stop(sprintf("Unknown feature name: %s", feature_name)) } - # Try to get version from versionInfo() first, fall back to environment variable - version_info <- tryCatch( - versionInfo(), - error = function(e) { - # If versionInfo() fails (e.g., RStudio not running), fall back to environment variable - wb_version <- Sys.getenv("RSTUDIO_VERSION") - if (nzchar(wb_version)) { - list(version = wb_version) - } else { - NULL - } - } - ) + # Get Workbench version from environment variable + wb_version <- Sys.getenv("RSTUDIO_VERSION") - # Check if version meets minimum requirement - if (!is.null(version_info) && !is.null(version_info$version)) { - version_string <- as.character(version_info$version) + # If environment variable not set, skip version check + if (!nzchar(wb_version)) { + return(invisible(NULL)) + } - # Skip version check for dev builds - if (grepl("dev", version_string, ignore.case = TRUE)) { - return(invisible(NULL)) - } + # Skip version check for dev builds + if (grepl("dev", wb_version, ignore.case = TRUE)) { + return(invisible(NULL)) + } - required_version <- numeric_version(min_version) - current_version <- tryCatch( - { - # Handle both numeric_version objects and strings - if (inherits(version_info$version, "numeric_version")) { - version_info$version - } else { - numeric_version(gsub("[-+].*$", "", version_string)) - } - }, - error = function(e) NULL - ) + # Compare versions + required_version <- numeric_version(min_version) + current_version <- tryCatch( + numeric_version(gsub("[-+].*$", "", wb_version)), + error = function(e) NULL + ) - if (!is.null(current_version) && current_version < required_version) { - stop(sprintf( - "%s require Posit Workbench version %s or later. Current version: %s", - feature_name, - min_version, - version_info$version - )) - } + if (!is.null(current_version) && current_version < required_version) { + stop(sprintf( + "%s require Posit Workbench version %s or later. Current version: %s", + feature_name, + min_version, + wb_version + )) } } diff --git a/man/findOAuthIntegration.Rd b/man/findOAuthIntegration.Rd new file mode 100644 index 0000000..15173d9 --- /dev/null +++ b/man/findOAuthIntegration.Rd @@ -0,0 +1,53 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/auth.R +\name{findOAuthIntegration} +\alias{findOAuthIntegration} +\title{Find OAuth Integration by Criteria} +\usage{ +findOAuthIntegration( + type = NULL, + name = NULL, + display_name = NULL, + guid = NULL, + authenticated = NULL +) +} +\arguments{ +\item{type}{Optional integration type to match (e.g., "custom").} + +\item{name}{Optional integration name to match.} + +\item{display_name}{Optional display name to match.} + +\item{guid}{Optional globally unique identifier (GUID) to match.} + +\item{authenticated}{Optional logical indicating whether to match only authenticated integrations (TRUE), +only unauthenticated integrations (FALSE), or either (NULL, the default).} +} +\value{ +A list containing the integration metadata, or \code{NULL} if no matching integration is found. +} +\description{ +Search for an OAuth integration that matches the specified criteria. Returns the first +integration that matches all provided filter parameters. If no parameters are provided, +returns the first available integration. +} +\note{ +This function requires Posit Workbench version 2025.11.0 or later. It works +in any IDE running within a Posit Workbench session (not just RStudio). +} +\examples{ +\dontrun{ +# Find by GUID +integration <- findOAuthIntegration(guid = "4c1cfecb-1927-4f19-bc2f-d8ac261364e0") + +# Find by name +integration <- findOAuthIntegration(name = "my-github-integration") + +# Find authenticated integration of specific type +integration <- findOAuthIntegration(type = "custom", authenticated = TRUE) + +# Find by display name +integration <- findOAuthIntegration(display_name = "GitHub Production") +} +} diff --git a/man/getOAuthIntegration.Rd b/man/getOAuthIntegration.Rd index 802a7a1..d7e7fe1 100644 --- a/man/getOAuthIntegration.Rd +++ b/man/getOAuthIntegration.Rd @@ -27,7 +27,7 @@ Returns \code{NULL} if no integration with the specified GUID is found. } \description{ Retrieve metadata for a specific OAuth integration by its globally unique identifier. -This is a convenience function that filters the results from \code{getOAuthIntegrations()}. +This is a convenience function that calls \code{findOAuthIntegration(guid = guid)}. } \note{ This function requires Posit Workbench version 2025.11.0 or later. It works diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index 965ec6d..1ba5937 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -30,7 +30,7 @@ test_that("getOAuthCredentials requires minimum version", { # Expect error about version requirement expect_error( getOAuthCredentials("test-integration"), - "OAuth functionality require Posit Workbench version 2025.11.0 or later" + "OAuth functionality require Posit Workbench version 2026.01.0 or later" ) # Restore environment @@ -56,7 +56,7 @@ test_that("getOAuthCredentials handles missing RPC cookie", { # Set environment to simulate Workbench with correct version but no cookie Sys.setenv(POSIT_PRODUCT = "WORKBENCH") - Sys.setenv(RSTUDIO_VERSION = "2025.11.0") + Sys.setenv(RSTUDIO_VERSION = "2026.01.0") Sys.unsetenv("RS_SESSION_RPC_COOKIE") Sys.unsetenv("PWB_SESSION_RUNTIME_DIR") @@ -118,7 +118,7 @@ test_that("getOAuthIntegrations requires minimum version", { # Expect error about version requirement expect_error( getOAuthIntegrations(), - "OAuth functionality require Posit Workbench version 2025.11.0 or later" + "OAuth functionality require Posit Workbench version 2026.01.0 or later" ) # Restore environment @@ -144,7 +144,7 @@ test_that("getOAuthIntegrations handles missing RPC cookie", { # Set environment to simulate Workbench with correct version but no cookie Sys.setenv(POSIT_PRODUCT = "WORKBENCH") - Sys.setenv(RSTUDIO_VERSION = "2025.11.0") + Sys.setenv(RSTUDIO_VERSION = "2026.01.0") Sys.unsetenv("RS_SESSION_RPC_COOKIE") Sys.unsetenv("PWB_SESSION_RUNTIME_DIR") From 30ab910bf2b6a0cbcc841364e55754f20699a228 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 16:34:35 +0000 Subject: [PATCH 05/23] Fallback on RS_SERVER_ADDRESS for Workbench detection --- R/auth.R | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/R/auth.R b/R/auth.R index 626bcd5..38f841a 100644 --- a/R/auth.R +++ b/R/auth.R @@ -333,9 +333,17 @@ getOAuthIntegration <- function(guid) { # Internal helper to check if running in Posit Workbench .checkWorkbenchSession <- function() { - if (Sys.getenv("POSIT_PRODUCT") != "WORKBENCH") { - stop("OAuth functionality is only available within Posit Workbench sessions.") + if (Sys.getenv("POSIT_PRODUCT") == "WORKBENCH") { + return(invisible(NULL)) } + + # Fall back to RS_SERVER_ADDRESS for older versions + # TODO: Remove RS_SERVER_ADDRESS check when 2025.09 falls out of support + if (nzchar(Sys.getenv("RS_SERVER_ADDRESS"))) { + return(invisible(NULL)) + } + + stop("OAuth functionality is only available within Posit Workbench sessions.") } # Internal helper to check version requirement From 81d958ccec80f02b9dcda09d69fa62bc93f18c04 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 16:41:26 +0000 Subject: [PATCH 06/23] Fallback on versionInfo for version check --- R/auth.R | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/R/auth.R b/R/auth.R index 38f841a..a112770 100644 --- a/R/auth.R +++ b/R/auth.R @@ -354,10 +354,22 @@ getOAuthIntegration <- function(guid) { stop(sprintf("Unknown feature name: %s", feature_name)) } - # Get Workbench version from environment variable + # Get Workbench version from environment variable first wb_version <- Sys.getenv("RSTUDIO_VERSION") - # If environment variable not set, skip version check + # If environment variable not set, fallback to versionInfo() + if (!nzchar(wb_version)) { + version_info <- tryCatch( + versionInfo(), + error = function(e) NULL + ) + + if (!is.null(version_info) && !is.null(version_info$version)) { + wb_version <- as.character(version_info$version) + } + } + + # If still no version, skip version check if (!nzchar(wb_version)) { return(invisible(NULL)) } From cbef13a81998332399d7b7161a54d9e1bd4bb20a Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 16:43:36 +0000 Subject: [PATCH 07/23] Comment cleanup --- R/auth.R | 38 +------------------------------------- 1 file changed, 1 insertion(+), 37 deletions(-) diff --git a/R/auth.R b/R/auth.R index a112770..bca53fc 100644 --- a/R/auth.R +++ b/R/auth.R @@ -37,28 +37,23 @@ getDelegatedAzureToken <- function(resource) { } ) - # If callFun succeeded, return its result if (!is.null(result)) { return(result) } - # Fallback: use the RPC endpoint .checkWorkbenchSession() .checkWorkbenchVersion(.WORKBENCH_FEATURE_DELEGATED_AZURE) - # Prepare request body body <- list( params = list(resource) ) - # Make RPC call (will throw error if result=false or other issues) response <- .callWorkbenchRPC( method = "delegated_azure_token", body = body, error_context = "retrieving delegated Azure token" ) - # Return the token object if (!is.null(response$token)) { return(response$token) } @@ -100,22 +95,18 @@ getOAuthCredentials <- function(audience) { .checkWorkbenchSession() .checkWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) - # Prepare request body (matching Python implementation exactly) - # Note: The RPC endpoint expects "uuid" parameter body <- list( kwparams = list( uuid = audience ) ) - # Make RPC call response <- .callWorkbenchRPC( method = "oauth_token", body = body, error_context = "retrieving OAuth credentials" ) - # Return credentials if found if (!is.null(response$access_token)) { return(list( access_token = response$access_token, @@ -177,12 +168,10 @@ getOAuthIntegrations <- function() { .checkWorkbenchSession() .checkWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) - # Prepare request body body <- list( kwparams = list() ) - # Make RPC call response <- .callWorkbenchRPC( method = "oauth_integrations", body = body, @@ -214,7 +203,6 @@ getOAuthIntegrations <- function() { return(all_integrations) } - # Return empty list if no providers/integrations found return(list()) } @@ -252,12 +240,9 @@ getOAuthIntegrations <- function() { #' } #' @export findOAuthIntegration <- function(type = NULL, name = NULL, display_name = NULL, guid = NULL, authenticated = NULL) { - # Get all integrations integrations <- getOAuthIntegrations() - # Find the first matching integration for (integration in integrations) { - # Check each filter criterion (only if provided) if (!is.null(type) && (is.null(integration$type) || integration$type != type)) { next } @@ -274,7 +259,6 @@ findOAuthIntegration <- function(type = NULL, name = NULL, display_name = NULL, next } - # All criteria matched return(integration) } @@ -348,16 +332,14 @@ getOAuthIntegration <- function(guid) { # Internal helper to check version requirement .checkWorkbenchVersion <- function(feature_name) { - # Look up minimum version for this feature min_version <- .WORKBENCH_MIN_VERSIONS[[feature_name]] if (is.null(min_version)) { stop(sprintf("Unknown feature name: %s", feature_name)) } - # Get Workbench version from environment variable first wb_version <- Sys.getenv("RSTUDIO_VERSION") - # If environment variable not set, fallback to versionInfo() + # RSTUDIO_VERSION is not set in RStudio sessions, but versionInfo() should match the Workbench version if (!nzchar(wb_version)) { version_info <- tryCatch( versionInfo(), @@ -369,7 +351,6 @@ getOAuthIntegration <- function(guid) { } } - # If still no version, skip version check if (!nzchar(wb_version)) { return(invisible(NULL)) } @@ -379,7 +360,6 @@ getOAuthIntegration <- function(guid) { return(invisible(NULL)) } - # Compare versions required_version <- numeric_version(min_version) current_version <- tryCatch( numeric_version(gsub("[-+].*$", "", wb_version)), @@ -402,19 +382,15 @@ getOAuthIntegration <- function(guid) { # Get the RPC cookie for authentication rpc_cookie <- .getRPCCookie() - # Get the server address server_url <- Sys.getenv("RS_SERVER_ADDRESS") if (!nzchar(server_url)) { stop("RS_SERVER_ADDRESS environment variable not set. Cannot determine Posit Workbench server address.") } - # Make the API request (add leading slash for URL path) endpoint <- paste0(server_url, "/", method) - # Add the method field to the body (RPC convention) body$method <- method - # Make HTTP request (POST with JSON body) response <- .workbenchRequest( url = endpoint, method = "POST", @@ -432,9 +408,7 @@ getOAuthIntegration <- function(guid) { stop(sprintf("Error %s: %s", error_context, error_msg)) } - # Check if result is false (unsuccessful) if (!is.null(response$result) && !isTRUE(response$result)) { - # Check for detailed error messages if (!is.null(response$detail)) { stop(sprintf("Error %s: %s", error_context, response$detail)) } @@ -447,11 +421,9 @@ getOAuthIntegration <- function(guid) { stop(sprintf("OAuth2 error %s: %s - %s", error_context, error_code, error_desc)) } - # Generic failure with no detail stop(sprintf("Error %s: request failed with result=false", error_context)) } - # Return the full response for the caller to extract needed fields response } @@ -463,7 +435,6 @@ getOAuthIntegration <- function(guid) { return(cookie) } - # Try to read from file runtime_dir <- Sys.getenv("PWB_SESSION_RUNTIME_DIR") if (nzchar(runtime_dir)) { cookie_file <- file.path(runtime_dir, "rpc_cookie") @@ -483,17 +454,14 @@ getOAuthIntegration <- function(guid) { # Internal helper to make authenticated requests to Workbench .workbenchRequest <- function(url, method = "GET", body = NULL, rpc_cookie = NULL) { - # Check if httr is available if (!requireNamespace("httr", quietly = TRUE)) { stop("Package 'httr' is required for OAuth functionality. Please install it with: install.packages('httr')") } - # Check if jsonlite is available if (!requireNamespace("jsonlite", quietly = TRUE)) { stop("Package 'jsonlite' is required for OAuth functionality. Please install it with: install.packages('jsonlite')") } - # Prepare headers headers <- httr::add_headers( "Content-Type" = "application/json" ) @@ -505,7 +473,6 @@ getOAuthIntegration <- function(guid) { ) } - # Determine SSL verification settings verify_ssl <- TRUE ca_bundle <- Sys.getenv("REQUESTS_CA_BUNDLE") if (!nzchar(ca_bundle)) { @@ -528,7 +495,6 @@ getOAuthIntegration <- function(guid) { ssl_config ) } else if (method == "GET" && !is.null(body)) { - # For GET with body, use POST-style request httr::GET( url, body = body, @@ -540,7 +506,6 @@ getOAuthIntegration <- function(guid) { httr::GET(url, headers, ssl_config) } - # Check HTTP status if (httr::http_error(response)) { stop(sprintf( "HTTP request failed with status %s: %s", @@ -549,7 +514,6 @@ getOAuthIntegration <- function(guid) { )) } - # Parse JSON response content <- httr::content(response, "text", encoding = "UTF-8") jsonlite::fromJSON(content, simplifyVector = FALSE) } From 7d62456f892bc18d400f73892bbfd59e06b918a7 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 16:47:11 +0000 Subject: [PATCH 08/23] Use long_version in version compare error --- R/auth.R | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/R/auth.R b/R/auth.R index bca53fc..e7401d5 100644 --- a/R/auth.R +++ b/R/auth.R @@ -348,6 +348,7 @@ getOAuthIntegration <- function(guid) { if (!is.null(version_info) && !is.null(version_info$version)) { wb_version <- as.character(version_info$version) + long_version <- version_info$long_version } } @@ -371,7 +372,7 @@ getOAuthIntegration <- function(guid) { "%s require Posit Workbench version %s or later. Current version: %s", feature_name, min_version, - wb_version + long_version )) } } From fa567f937b73cbe6565dab694c71aedfce49e3cb Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 16:53:24 +0000 Subject: [PATCH 09/23] Align version compatibility error message --- R/auth.R | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/R/auth.R b/R/auth.R index e7401d5..24faf5d 100644 --- a/R/auth.R +++ b/R/auth.R @@ -369,10 +369,9 @@ getOAuthIntegration <- function(guid) { if (!is.null(current_version) && current_version < required_version) { stop(sprintf( - "%s require Posit Workbench version %s or later. Current version: %s", - feature_name, - min_version, - long_version + "This API is not available in Posit Workbench version %s. Please upgrade to version %s or later.", + long_version, + min_version )) } } From a60f41c843aefee5845ca0884e926680644ada56 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 20:56:48 +0000 Subject: [PATCH 10/23] Fix documentation mismatch for documentNew type parameter --- R/auth.R | 9 +++++---- R/document-api.R | 3 ++- tests/testthat/test-oauth.R | 4 ++-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/R/auth.R b/R/auth.R index 24faf5d..99001ea 100644 --- a/R/auth.R +++ b/R/auth.R @@ -78,7 +78,7 @@ getDelegatedAzureToken <- function(resource) { #' } #' Returns \code{NULL} if the credentials cannot be retrieved or the integration is not found. #' -#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' @note This function requires Posit Workbench version 2026.01.0 or later. It works #' in any IDE running within a Posit Workbench session (not just RStudio). #' #' @examples @@ -139,7 +139,7 @@ getOAuthCredentials <- function(audience) { #' } #' Returns an empty list if no integrations are configured. #' -#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' @note This function requires Posit Workbench version 2026.01.0 or later. It works #' in any IDE running within a Posit Workbench session (not just RStudio). #' #' @examples @@ -221,7 +221,7 @@ getOAuthIntegrations <- function() { #' #' @return A list containing the integration metadata, or \code{NULL} if no matching integration is found. #' -#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' @note This function requires Posit Workbench version 2026.01.0 or later. It works #' in any IDE running within a Posit Workbench session (not just RStudio). #' #' @examples @@ -288,7 +288,7 @@ findOAuthIntegration <- function(type = NULL, name = NULL, display_name = NULL, #' } #' Returns \code{NULL} if no integration with the specified GUID is found. #' -#' @note This function requires Posit Workbench version 2025.11.0 or later. It works +#' @note This function requires Posit Workbench version 2026.01.0 or later. It works #' in any IDE running within a Posit Workbench session (not just RStudio). #' #' @examples @@ -338,6 +338,7 @@ getOAuthIntegration <- function(guid) { } wb_version <- Sys.getenv("RSTUDIO_VERSION") + long_version <- wb_version # Initialize with env var value # RSTUDIO_VERSION is not set in RStudio sessions, but versionInfo() should match the Workbench version if (!nzchar(wb_version)) { diff --git a/R/document-api.R b/R/document-api.R index f2e20c3..143b578 100644 --- a/R/document-api.R +++ b/R/document-api.R @@ -19,7 +19,8 @@ #' @param ranges A list of one or more ranges, typically created #' through \code{\link{document_range}()}. #' -#' @param type The type of document to be created. +#' @param type The type of document to be created. One of \code{"r"} (default), +#' \code{"rmarkdown"}, or \code{"sql"}. #' #' @param execute Should the code be executed after the document #' is created? diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index 1ba5937..8bca507 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -30,7 +30,7 @@ test_that("getOAuthCredentials requires minimum version", { # Expect error about version requirement expect_error( getOAuthCredentials("test-integration"), - "OAuth functionality require Posit Workbench version 2026.01.0 or later" + "This API is not available in Posit Workbench version 2024.01.0\\. Please upgrade to version 2026\\.01\\.0 or later\\." ) # Restore environment @@ -118,7 +118,7 @@ test_that("getOAuthIntegrations requires minimum version", { # Expect error about version requirement expect_error( getOAuthIntegrations(), - "OAuth functionality require Posit Workbench version 2026.01.0 or later" + "This API is not available in Posit Workbench version 2024.01.0\\. Please upgrade to version 2026\\.01\\.0 or later\\." ) # Restore environment From d1167fed62cf7a08d1f168150d8d43fb279c22a2 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 21:04:57 +0000 Subject: [PATCH 11/23] Fix documentNew signature to match documentation Change type parameter default from c('r', 'rmarkdown', 'sql') to 'r' and explicitly specify choices in match.arg() call. This resolves R CMD check warning about codoc mismatch between code and docs. --- R/document-api.R | 4 ++-- man/findOAuthIntegration.Rd | 2 +- man/getOAuthCredentials.Rd | 2 +- man/getOAuthIntegration.Rd | 2 +- man/getOAuthIntegrations.Rd | 2 +- man/rstudio-documents.Rd | 5 +++-- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/R/document-api.R b/R/document-api.R index 143b578..adc12a1 100644 --- a/R/document-api.R +++ b/R/document-api.R @@ -230,11 +230,11 @@ getConsoleEditorContext <- function() { #' @export documentNew <- function( text, - type = c("r", "rmarkdown", "sql"), + type = "r", position = document_position(0, 0), execute = FALSE) { - type <- match.arg(type) + type <- match.arg(type, choices = c("r", "rmarkdown", "sql")) callFun("documentNew", type, text, position[1], position[2], execute) } diff --git a/man/findOAuthIntegration.Rd b/man/findOAuthIntegration.Rd index 15173d9..c613ec6 100644 --- a/man/findOAuthIntegration.Rd +++ b/man/findOAuthIntegration.Rd @@ -33,7 +33,7 @@ integration that matches all provided filter parameters. If no parameters are pr returns the first available integration. } \note{ -This function requires Posit Workbench version 2025.11.0 or later. It works +This function requires Posit Workbench version 2026.01.0 or later. It works in any IDE running within a Posit Workbench session (not just RStudio). } \examples{ diff --git a/man/getOAuthCredentials.Rd b/man/getOAuthCredentials.Rd index dfdb8c8..76ea190 100644 --- a/man/getOAuthCredentials.Rd +++ b/man/getOAuthCredentials.Rd @@ -25,7 +25,7 @@ to authenticate with external services. This works in any IDE running within a Posit Workbench session. } \note{ -This function requires Posit Workbench version 2025.11.0 or later. It works +This function requires Posit Workbench version 2026.01.0 or later. It works in any IDE running within a Posit Workbench session (not just RStudio). } \examples{ diff --git a/man/getOAuthIntegration.Rd b/man/getOAuthIntegration.Rd index d7e7fe1..d7f14c7 100644 --- a/man/getOAuthIntegration.Rd +++ b/man/getOAuthIntegration.Rd @@ -30,7 +30,7 @@ Retrieve metadata for a specific OAuth integration by its globally unique identi This is a convenience function that calls \code{findOAuthIntegration(guid = guid)}. } \note{ -This function requires Posit Workbench version 2025.11.0 or later. It works +This function requires Posit Workbench version 2026.01.0 or later. It works in any IDE running within a Posit Workbench session (not just RStudio). } \examples{ diff --git a/man/getOAuthIntegrations.Rd b/man/getOAuthIntegrations.Rd index db69551..04c87a2 100644 --- a/man/getOAuthIntegrations.Rd +++ b/man/getOAuthIntegrations.Rd @@ -28,7 +28,7 @@ This returns metadata about each integration including its authentication status scopes, and configuration details. } \note{ -This function requires Posit Workbench version 2025.11.0 or later. It works +This function requires Posit Workbench version 2026.01.0 or later. It works in any IDE running within a Posit Workbench session (not just RStudio). } \examples{ diff --git a/man/rstudio-documents.Rd b/man/rstudio-documents.Rd index 90c5f3f..74e087a 100644 --- a/man/rstudio-documents.Rd +++ b/man/rstudio-documents.Rd @@ -36,7 +36,7 @@ documentSaveAll() documentNew( text, - type = c("r", "rmarkdown", "sql"), + type = "r", position = document_position(0, 0), execute = FALSE ) @@ -67,7 +67,8 @@ through \code{\link{document_range}()}.} console is currently focused? Set this to \code{FALSE} if you'd always like to target the currently-active or last-active editor in the Source pane.} -\item{type}{The type of document to be created.} +\item{type}{The type of document to be created. One of \code{"r"} (default), +\code{"rmarkdown"}, or \code{"sql"}.} \item{execute}{Should the code be executed after the document is created?} From e3436a36def30d15c90a9cbc906132b1ab52d7fc Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Tue, 6 Jan 2026 21:07:24 +0000 Subject: [PATCH 12/23] Fix test to not require specific HTTP error message The 'getOAuthCredentials allows dev versions' test was expecting a specific error message 'HTTP request failed', but the error message varies across different environments (could be 'Could not resolve host', 'Failed to connect', etc.). Changed to just expect any error, since the test's purpose is to verify that dev versions skip the version check, not to test the exact HTTP error. --- tests/testthat/test-oauth.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index 8bca507..0e9204c 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -190,9 +190,9 @@ test_that("getOAuthCredentials allows dev versions", { Sys.setenv(RS_SERVER_ADDRESS = "http://localhost:8787") # Should not error about version requirement (will error about connection, but that's OK) + # Error message may vary: "HTTP request failed", "Could not resolve host", "Failed to connect", etc. expect_error( getOAuthCredentials("test-integration"), - "HTTP request failed", class = "error" ) From 64e1413def39f90347846f091818762512e1af60 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 05:36:58 +0000 Subject: [PATCH 13/23] Remove optional type parameter from findOAuthIntegration function --- R/auth.R | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/R/auth.R b/R/auth.R index 99001ea..c69db4d 100644 --- a/R/auth.R +++ b/R/auth.R @@ -212,7 +212,6 @@ getOAuthIntegrations <- function() { #' integration that matches all provided filter parameters. If no parameters are provided, #' returns the first available integration. #' -#' @param type Optional integration type to match (e.g., "custom"). #' @param name Optional integration name to match. #' @param display_name Optional display name to match. #' @param guid Optional globally unique identifier (GUID) to match. @@ -226,9 +225,6 @@ getOAuthIntegrations <- function() { #' #' @examples #' \dontrun{ -#' # Find by GUID -#' integration <- findOAuthIntegration(guid = "4c1cfecb-1927-4f19-bc2f-d8ac261364e0") -#' #' # Find by name #' integration <- findOAuthIntegration(name = "my-github-integration") #' @@ -239,13 +235,10 @@ getOAuthIntegrations <- function() { #' integration <- findOAuthIntegration(display_name = "GitHub Production") #' } #' @export -findOAuthIntegration <- function(type = NULL, name = NULL, display_name = NULL, guid = NULL, authenticated = NULL) { +findOAuthIntegration <- function(name = NULL, display_name = NULL, guid = NULL, authenticated = NULL) { integrations <- getOAuthIntegrations() for (integration in integrations) { - if (!is.null(type) && (is.null(integration$type) || integration$type != type)) { - next - } if (!is.null(name) && (is.null(integration$name) || integration$name != name)) { next } From f1107a503942c3cc727fb0edf7131967ac807824 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 20:26:32 +0000 Subject: [PATCH 14/23] Use curl instead of httr for http requests --- DESCRIPTION | 2 +- R/auth.R | 66 +++++++++++++++++++++++------------------------------ 2 files changed, 30 insertions(+), 38 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 2264d6e..8daf537 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -23,7 +23,7 @@ Suggests: rmarkdown, clipr, covr, - httr, + curl, jsonlite VignetteBuilder: knitr Encoding: UTF-8 diff --git a/R/auth.R b/R/auth.R index c69db4d..ad705d5 100644 --- a/R/auth.R +++ b/R/auth.R @@ -448,66 +448,58 @@ getOAuthIntegration <- function(guid) { # Internal helper to make authenticated requests to Workbench .workbenchRequest <- function(url, method = "GET", body = NULL, rpc_cookie = NULL) { - if (!requireNamespace("httr", quietly = TRUE)) { - stop("Package 'httr' is required for OAuth functionality. Please install it with: install.packages('httr')") + if (!requireNamespace("curl", quietly = TRUE)) { + stop("Package 'curl' is required for OAuth functionality. Please install it with: install.packages('curl')") } if (!requireNamespace("jsonlite", quietly = TRUE)) { stop("Package 'jsonlite' is required for OAuth functionality. Please install it with: install.packages('jsonlite')") } - headers <- httr::add_headers( - "Content-Type" = "application/json" - ) + handle <- curl::new_handle() + headers <- list("Content-Type" = "application/json") if (!is.null(rpc_cookie)) { - headers <- httr::add_headers( - "Content-Type" = "application/json", - "X-RS-Session-Server-RPC-Cookie" = rpc_cookie - ) + headers[["X-RS-Session-Server-RPC-Cookie"]] <- rpc_cookie } + curl::handle_setheaders(handle, .list = headers) - verify_ssl <- TRUE + # Configure SSL certificate bundle if available ca_bundle <- Sys.getenv("REQUESTS_CA_BUNDLE") if (!nzchar(ca_bundle)) { ca_bundle <- Sys.getenv("CURL_CA_BUNDLE") } - - ssl_config <- if (nzchar(ca_bundle)) { - httr::config(cainfo = ca_bundle) - } else { - httr::config(ssl_verifypeer = verify_ssl) + if (nzchar(ca_bundle)) { + curl::handle_setopt(handle, cainfo = ca_bundle) } - # Make request - response <- if (method == "POST") { - httr::POST( - url, - body = body, - encode = "json", - headers, - ssl_config - ) - } else if (method == "GET" && !is.null(body)) { - httr::GET( - url, - body = body, - encode = "json", - headers, - ssl_config + # Set POST options if needed + if (method == "POST") { + json <- jsonlite::toJSON(body, auto_unbox = TRUE) + curl::handle_setopt( + handle = handle, + post = TRUE, + postfields = json ) - } else { - httr::GET(url, headers, ssl_config) } - if (httr::http_error(response)) { + # Make request + response <- tryCatch( + curl::curl_fetch_memory(url, handle = handle), + error = function(e) { + stop(sprintf("HTTP request failed: %s", e$message)) + } + ) + + if (response$status_code >= 400) { + content <- enc2utf8(rawToChar(response$content)) stop(sprintf( "HTTP request failed with status %s: %s", - httr::status_code(response), - httr::content(response, "text", encoding = "UTF-8") + response$status_code, + content )) } - content <- httr::content(response, "text", encoding = "UTF-8") + content <- enc2utf8(rawToChar(response$content)) jsonlite::fromJSON(content, simplifyVector = FALSE) } From e6e0d4a0671bace526cdbf0b4dd565bf61f3f200 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 20:52:05 +0000 Subject: [PATCH 15/23] Style updates - Remove unecessary return() - Rename check -> assert - Remove unecessary `.` for internal funcs - Augment credential response --- R/auth.R | 62 +++++++++++++++++++++++++++++--------------------------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/R/auth.R b/R/auth.R index ad705d5..9d1e8b7 100644 --- a/R/auth.R +++ b/R/auth.R @@ -41,14 +41,14 @@ getDelegatedAzureToken <- function(resource) { return(result) } - .checkWorkbenchSession() - .checkWorkbenchVersion(.WORKBENCH_FEATURE_DELEGATED_AZURE) + assertWorkbenchSession() + assertWorkbenchVersion(.WORKBENCH_FEATURE_DELEGATED_AZURE) body <- list( params = list(resource) ) - response <- .callWorkbenchRPC( + response <- callWorkbenchRPC( method = "delegated_azure_token", body = body, error_context = "retrieving delegated Azure token" @@ -58,7 +58,7 @@ getDelegatedAzureToken <- function(resource) { return(response$token) } - return(NULL) + NULL } #' Retrieve OAuth Credentials for Integrations @@ -92,8 +92,8 @@ getDelegatedAzureToken <- function(resource) { #' } #' @export getOAuthCredentials <- function(audience) { - .checkWorkbenchSession() - .checkWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) + assertWorkbenchSession() + assertWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) body <- list( kwparams = list( @@ -101,21 +101,21 @@ getOAuthCredentials <- function(audience) { ) ) - response <- .callWorkbenchRPC( + response <- callWorkbenchRPC( method = "oauth_token", body = body, error_context = "retrieving OAuth credentials" ) if (!is.null(response$access_token)) { - return(list( - access_token = response$access_token, - expiry = as.POSIXct(response$expiry, format = "%Y-%m-%dT%H:%M:%OS", tz = "UTC"), - audience = audience - )) + if (!is.null(response$expiry)) { + response$expiry <- as.POSIXct(response$expiry, format = "%Y-%m-%dT%H:%M:%OS", tz = "UTC") + } + response$audience <- audience + return(response) } - return(NULL) + NULL } #' Get OAuth Integrations @@ -165,14 +165,14 @@ getOAuthCredentials <- function(audience) { #' } #' @export getOAuthIntegrations <- function() { - .checkWorkbenchSession() - .checkWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) + assertWorkbenchSession() + assertWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) body <- list( kwparams = list() ) - response <- .callWorkbenchRPC( + response <- callWorkbenchRPC( method = "oauth_integrations", body = body, error_context = "retrieving OAuth integrations" @@ -203,7 +203,7 @@ getOAuthIntegrations <- function() { return(all_integrations) } - return(list()) + list() } #' Find OAuth Integration by Criteria @@ -256,7 +256,7 @@ findOAuthIntegration <- function(name = NULL, display_name = NULL, guid = NULL, } # No match found - return(NULL) + NULL } #' Get a Specific OAuth Integration @@ -308,8 +308,8 @@ getOAuthIntegration <- function(guid) { findOAuthIntegration(guid = guid) } -# Internal helper to check if running in Posit Workbench -.checkWorkbenchSession <- function() { +# Internal helper to assert running in Posit Workbench +assertWorkbenchSession <- function() { if (Sys.getenv("POSIT_PRODUCT") == "WORKBENCH") { return(invisible(NULL)) } @@ -323,15 +323,15 @@ getOAuthIntegration <- function(guid) { stop("OAuth functionality is only available within Posit Workbench sessions.") } -# Internal helper to check version requirement -.checkWorkbenchVersion <- function(feature_name) { +# Internal helper to assert version requirement +assertWorkbenchVersion <- function(feature_name) { min_version <- .WORKBENCH_MIN_VERSIONS[[feature_name]] if (is.null(min_version)) { stop(sprintf("Unknown feature name: %s", feature_name)) } wb_version <- Sys.getenv("RSTUDIO_VERSION") - long_version <- wb_version # Initialize with env var value + version_string <- wb_version # For error messages # RSTUDIO_VERSION is not set in RStudio sessions, but versionInfo() should match the Workbench version if (!nzchar(wb_version)) { @@ -342,7 +342,7 @@ getOAuthIntegration <- function(guid) { if (!is.null(version_info) && !is.null(version_info$version)) { wb_version <- as.character(version_info$version) - long_version <- version_info$long_version + version_string <- version_info$long_version } } @@ -364,17 +364,19 @@ getOAuthIntegration <- function(guid) { if (!is.null(current_version) && current_version < required_version) { stop(sprintf( "This API is not available in Posit Workbench version %s. Please upgrade to version %s or later.", - long_version, + version_string, min_version )) } + + invisible(NULL) } # Internal helper to call Workbench RPC endpoints # Handles: server URL, RPC cookie, error checking, result validation -.callWorkbenchRPC <- function(method, body, error_context = "RPC call") { +callWorkbenchRPC <- function(method, body, error_context = "RPC call") { # Get the RPC cookie for authentication - rpc_cookie <- .getRPCCookie() + rpc_cookie <- getRPCCookie() server_url <- Sys.getenv("RS_SERVER_ADDRESS") if (!nzchar(server_url)) { @@ -385,7 +387,7 @@ getOAuthIntegration <- function(guid) { body$method <- method - response <- .workbenchRequest( + response <- workbenchRequest( url = endpoint, method = "POST", body = body, @@ -422,7 +424,7 @@ getOAuthIntegration <- function(guid) { } # Internal helper to get RPC cookie -.getRPCCookie <- function() { +getRPCCookie <- function() { # Try to read from environment variable first cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") if (nzchar(cookie)) { @@ -447,7 +449,7 @@ getOAuthIntegration <- function(guid) { } # Internal helper to make authenticated requests to Workbench -.workbenchRequest <- function(url, method = "GET", body = NULL, rpc_cookie = NULL) { +workbenchRequest <- function(url, method = "GET", body = NULL, rpc_cookie = NULL) { if (!requireNamespace("curl", quietly = TRUE)) { stop("Package 'curl' is required for OAuth functionality. Please install it with: install.packages('curl')") } From fa6ff532bdee4c61d4667c0107275037678719d5 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 21:22:45 +0000 Subject: [PATCH 16/23] Improve error handling and documentation --- R/auth.R | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/R/auth.R b/R/auth.R index 9d1e8b7..a9f85da 100644 --- a/R/auth.R +++ b/R/auth.R @@ -14,7 +14,7 @@ #' #' @param resource The name of an Azure resource or service, normally a URL. #' -#' @return A list containing the OAuth2 token details, or NULL if unavailable. +#' @return A list containing the OAuth2 token details. Throws an error if unavailable. #' #' @examples #' \dontrun{ @@ -54,11 +54,11 @@ getDelegatedAzureToken <- function(resource) { error_context = "retrieving delegated Azure token" ) - if (!is.null(response$token)) { - return(response$token) + if (is.null(response$token)) { + stop("Malformed response: missing 'token' field") } - NULL + response$token } #' Retrieve OAuth Credentials for Integrations @@ -76,7 +76,7 @@ getDelegatedAzureToken <- function(resource) { #' \item{expiry}{The token expiry time as a POSIXct datetime object.} #' \item{audience}{The integration GUID (audience) that was used to retrieve the credentials.} #' } -#' Returns \code{NULL} if the credentials cannot be retrieved or the integration is not found. +#' Throws an error if the credentials cannot be retrieved or the integration is not found. #' #' @note This function requires Posit Workbench version 2026.01.0 or later. It works #' in any IDE running within a Posit Workbench session (not just RStudio). @@ -107,15 +107,16 @@ getOAuthCredentials <- function(audience) { error_context = "retrieving OAuth credentials" ) - if (!is.null(response$access_token)) { - if (!is.null(response$expiry)) { - response$expiry <- as.POSIXct(response$expiry, format = "%Y-%m-%dT%H:%M:%OS", tz = "UTC") - } - response$audience <- audience - return(response) + if (is.null(response$access_token)) { + stop("Malformed response: missing 'access_token' field") } - NULL + if (!is.null(response$expiry)) { + response$expiry <- as.POSIXct(response$expiry, format = "%Y-%m-%dT%H:%M:%OS", tz = "UTC") + } + response$audience <- audience + + response } #' Get OAuth Integrations @@ -437,11 +438,14 @@ getRPCCookie <- function() { if (file.exists(cookie_file)) { cookie <- tryCatch( readLines(cookie_file, n = 1, warn = FALSE), - error = function(e) NULL + error = function(e) { + stop(sprintf("RPC cookie file exists at '%s' but could not be read: %s", cookie_file, e$message)) + } ) - if (!is.null(cookie) && nzchar(cookie)) { - return(cookie) + if (is.null(cookie) || !nzchar(cookie)) { + stop(sprintf("RPC cookie file exists at '%s' but is empty", cookie_file)) } + return(cookie) } } From f799e1358faa69e5dd2b7713a01dc1926f737ad6 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 21:38:03 +0000 Subject: [PATCH 17/23] Simplify getDelegatedAzureToken fallback --- R/auth.R | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/R/auth.R b/R/auth.R index a9f85da..c176c23 100644 --- a/R/auth.R +++ b/R/auth.R @@ -23,22 +23,8 @@ #' @export getDelegatedAzureToken <- function(resource) { # Try the internal RStudio API first (works in RStudio IDE) - result <- tryCatch( - { - version <- versionInfo() - if (is.null(version$edition)) { - stop("Delegated Azure Credentials are not available in the open-source edition of RStudio.") - } - callFun("getDelegatedAzureToken", resource) - }, - error = function(e) { - # If callFun fails, fall back to RPC endpoint (works in any Workbench session) - NULL - } - ) - - if (!is.null(result)) { - return(result) + if (hasFun("getDelegatedAzureToken")) { + return(callFun("getDelegatedAzureToken", resource)) } assertWorkbenchSession() From b7864fbd969f22e9aedd4cb23d0ec1696bbdecfe Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 21:52:27 +0000 Subject: [PATCH 18/23] Use withr and aggregate duplicate test conditions --- DESCRIPTION | 3 +- tests/testthat/test-oauth.R | 233 +++++------------------------------- 2 files changed, 35 insertions(+), 201 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 8daf537..56320bc 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -24,6 +24,7 @@ Suggests: clipr, covr, curl, - jsonlite + jsonlite, + withr VignetteBuilder: knitr Encoding: UTF-8 diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index 0e9204c..3f270f6 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -1,193 +1,69 @@ context("OAuth API") -test_that("getOAuthCredentials fails gracefully outside Workbench", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - - # Temporarily unset POSIT_PRODUCT - Sys.unsetenv("POSIT_PRODUCT") +test_that("OAuth functions fail gracefully outside Workbench", { + withr::local_envvar( + POSIT_PRODUCT = NA, + RS_SERVER_ADDRESS = NA + ) expect_error( getOAuthCredentials("test-integration"), "OAuth functionality is only available within Posit Workbench sessions" ) - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } -}) - -test_that("getOAuthCredentials requires minimum version", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") - - # Set environment to simulate Workbench with old version - Sys.setenv(POSIT_PRODUCT = "WORKBENCH") - Sys.setenv(RSTUDIO_VERSION = "2024.01.0") - - # Expect error about version requirement expect_error( - getOAuthCredentials("test-integration"), - "This API is not available in Posit Workbench version 2024.01.0\\. Please upgrade to version 2026\\.01\\.0 or later\\." + getOAuthIntegrations(), + "OAuth functionality is only available within Posit Workbench sessions" ) - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } else { - Sys.unsetenv("POSIT_PRODUCT") - } - - if (nzchar(old_rstudio_version)) { - Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) - } else { - Sys.unsetenv("RSTUDIO_VERSION") - } -}) - -test_that("getOAuthCredentials handles missing RPC cookie", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") - old_rpc_cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") - old_runtime_dir <- Sys.getenv("PWB_SESSION_RUNTIME_DIR") - - # Set environment to simulate Workbench with correct version but no cookie - Sys.setenv(POSIT_PRODUCT = "WORKBENCH") - Sys.setenv(RSTUDIO_VERSION = "2026.01.0") - Sys.unsetenv("RS_SESSION_RPC_COOKIE") - Sys.unsetenv("PWB_SESSION_RUNTIME_DIR") - - # Expect error about missing RPC cookie expect_error( - getOAuthCredentials("test-integration"), - "RPC cookie not found" + getOAuthIntegration("test-guid"), + "OAuth functionality is only available within Posit Workbench sessions" ) - - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } else { - Sys.unsetenv("POSIT_PRODUCT") - } - - if (nzchar(old_rstudio_version)) { - Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) - } else { - Sys.unsetenv("RSTUDIO_VERSION") - } - - if (nzchar(old_rpc_cookie)) { - Sys.setenv(RS_SESSION_RPC_COOKIE = old_rpc_cookie) - } - - if (nzchar(old_runtime_dir)) { - Sys.setenv(PWB_SESSION_RUNTIME_DIR = old_runtime_dir) - } }) -test_that("getOAuthIntegrations fails gracefully outside Workbench", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - - # Temporarily unset POSIT_PRODUCT - Sys.unsetenv("POSIT_PRODUCT") +test_that("OAuth functions require minimum version", { + withr::local_envvar( + RS_SERVER_ADDRESS = "http://localhost:8787", + RSTUDIO_VERSION = "2024.01.0" + ) expect_error( - getOAuthIntegrations(), - "OAuth functionality is only available within Posit Workbench sessions" + getOAuthCredentials("test-integration"), + "This API is not available in Posit Workbench version 2024.01.0\\. Please upgrade to version 2026\\.01\\.0 or later\\." ) - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } -}) - -test_that("getOAuthIntegrations requires minimum version", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") - - # Set environment to simulate Workbench with old version - Sys.setenv(POSIT_PRODUCT = "WORKBENCH") - Sys.setenv(RSTUDIO_VERSION = "2024.01.0") - - # Expect error about version requirement expect_error( getOAuthIntegrations(), "This API is not available in Posit Workbench version 2024.01.0\\. Please upgrade to version 2026\\.01\\.0 or later\\." ) - - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } else { - Sys.unsetenv("POSIT_PRODUCT") - } - - if (nzchar(old_rstudio_version)) { - Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) - } else { - Sys.unsetenv("RSTUDIO_VERSION") - } }) -test_that("getOAuthIntegrations handles missing RPC cookie", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") - old_rpc_cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") - old_runtime_dir <- Sys.getenv("PWB_SESSION_RUNTIME_DIR") +test_that("OAuth functions handle missing RPC cookie", { + withr::local_envvar( + RS_SERVER_ADDRESS = "http://localhost:8787", + RSTUDIO_VERSION = "2026.01.0", + RS_SESSION_RPC_COOKIE = NA, + PWB_SESSION_RUNTIME_DIR = NA + ) - # Set environment to simulate Workbench with correct version but no cookie - Sys.setenv(POSIT_PRODUCT = "WORKBENCH") - Sys.setenv(RSTUDIO_VERSION = "2026.01.0") - Sys.unsetenv("RS_SESSION_RPC_COOKIE") - Sys.unsetenv("PWB_SESSION_RUNTIME_DIR") + expect_error( + getOAuthCredentials("test-integration"), + "RPC cookie not found" + ) - # Expect error about missing RPC cookie expect_error( getOAuthIntegrations(), "RPC cookie not found" ) - - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } else { - Sys.unsetenv("POSIT_PRODUCT") - } - - if (nzchar(old_rstudio_version)) { - Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) - } else { - Sys.unsetenv("RSTUDIO_VERSION") - } - - if (nzchar(old_rpc_cookie)) { - Sys.setenv(RS_SESSION_RPC_COOKIE = old_rpc_cookie) - } - - if (nzchar(old_runtime_dir)) { - Sys.setenv(PWB_SESSION_RUNTIME_DIR = old_runtime_dir) - } }) -test_that("getOAuthCredentials allows dev versions", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - old_rstudio_version <- Sys.getenv("RSTUDIO_VERSION") - old_rpc_cookie <- Sys.getenv("RS_SESSION_RPC_COOKIE") - old_server_address <- Sys.getenv("RS_SERVER_ADDRESS") - - # Set environment to simulate Workbench with dev version (even if old) - Sys.setenv(POSIT_PRODUCT = "WORKBENCH") - Sys.setenv(RSTUDIO_VERSION = "2024.01.0-dev") - Sys.setenv(RS_SESSION_RPC_COOKIE = "test-cookie") - Sys.setenv(RS_SERVER_ADDRESS = "http://localhost:8787") +test_that("OAuth functions allow dev versions", { + withr::local_envvar( + RS_SERVER_ADDRESS = "http://localhost:8787", + RSTUDIO_VERSION = "2024.01.0-dev", + RS_SESSION_RPC_COOKIE = "test-cookie" + ) # Should not error about version requirement (will error about connection, but that's OK) # Error message may vary: "HTTP request failed", "Could not resolve host", "Failed to connect", etc. @@ -195,31 +71,6 @@ test_that("getOAuthCredentials allows dev versions", { getOAuthCredentials("test-integration"), class = "error" ) - - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } else { - Sys.unsetenv("POSIT_PRODUCT") - } - - if (nzchar(old_rstudio_version)) { - Sys.setenv(RSTUDIO_VERSION = old_rstudio_version) - } else { - Sys.unsetenv("RSTUDIO_VERSION") - } - - if (nzchar(old_rpc_cookie)) { - Sys.setenv(RS_SESSION_RPC_COOKIE = old_rpc_cookie) - } else { - Sys.unsetenv("RS_SESSION_RPC_COOKIE") - } - - if (nzchar(old_server_address)) { - Sys.setenv(RS_SERVER_ADDRESS = old_server_address) - } else { - Sys.unsetenv("RS_SERVER_ADDRESS") - } }) test_that("getOAuthIntegration validates guid parameter", { @@ -243,21 +94,3 @@ test_that("getOAuthIntegration validates guid parameter", { "guid must be a non-empty character string" ) }) - -test_that("getOAuthIntegration fails gracefully outside Workbench", { - # Save current environment - old_posit_product <- Sys.getenv("POSIT_PRODUCT") - - # Temporarily unset POSIT_PRODUCT - Sys.unsetenv("POSIT_PRODUCT") - - expect_error( - getOAuthIntegration("test-guid"), - "OAuth functionality is only available within Posit Workbench sessions" - ) - - # Restore environment - if (nzchar(old_posit_product)) { - Sys.setenv(POSIT_PRODUCT = old_posit_product) - } -}) From 393422f7b279d69d92b893d937fc5c1a67c71513 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 22:03:07 +0000 Subject: [PATCH 19/23] Add regex matching in find for parity with Python --- R/auth.R | 37 +++++++++++++++++++++++-------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/R/auth.R b/R/auth.R index c176c23..8495116 100644 --- a/R/auth.R +++ b/R/auth.R @@ -199,11 +199,13 @@ getOAuthIntegrations <- function() { #' integration that matches all provided filter parameters. If no parameters are provided, #' returns the first available integration. #' -#' @param name Optional integration name to match. -#' @param display_name Optional display name to match. -#' @param guid Optional globally unique identifier (GUID) to match. +#' @param name Optional integration name to match. Supports regular expressions. +#' For exact matches, use anchors like \code{^github-main$}. +#' @param display_name Optional display name to match. Supports regular expressions. +#' For exact matches, use anchors like \code{^GitHub Production$}. +#' @param guid Optional globally unique identifier (GUID) to match. Exact match only. #' @param authenticated Optional logical indicating whether to match only authenticated integrations (TRUE), -#' only unauthenticated integrations (FALSE), or either (NULL, the default). +#' only unauthenticated integrations (FALSE), or either (NULL, the default). Exact match only. #' #' @return A list containing the integration metadata, or \code{NULL} if no matching integration is found. #' @@ -212,25 +214,32 @@ getOAuthIntegrations <- function() { #' #' @examples #' \dontrun{ -#' # Find by name -#' integration <- findOAuthIntegration(name = "my-github-integration") +#' # Find by exact name +#' integration <- findOAuthIntegration(name = "^my-github-integration$") #' -#' # Find authenticated integration of specific type -#' integration <- findOAuthIntegration(type = "custom", authenticated = TRUE) +#' # Find by name pattern (any integration with "github" in the name) +#' integration <- findOAuthIntegration(name = "github") #' -#' # Find by display name -#' integration <- findOAuthIntegration(display_name = "GitHub Production") +#' # Find authenticated integration by display name pattern +#' integration <- findOAuthIntegration(display_name = "GitHub.*", authenticated = TRUE) +#' +#' # Find by exact GUID +#' integration <- findOAuthIntegration(guid = "4c1cfecb-1927-4f19-bc2f-d8ac261364e0") #' } #' @export findOAuthIntegration <- function(name = NULL, display_name = NULL, guid = NULL, authenticated = NULL) { integrations <- getOAuthIntegrations() for (integration in integrations) { - if (!is.null(name) && (is.null(integration$name) || integration$name != name)) { - next + if (!is.null(name)) { + if (is.null(integration$name) || !grepl(name, integration$name)) { + next + } } - if (!is.null(display_name) && (is.null(integration$display_name) || integration$display_name != display_name)) { - next + if (!is.null(display_name)) { + if (is.null(integration$display_name) || !grepl(display_name, integration$display_name)) { + next + } } if (!is.null(guid) && (is.null(integration$guid) || integration$guid != guid)) { next From f7c6b5a9bdc8eb7f28442c6d20184ea265bedc7c Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Wed, 7 Jan 2026 22:15:55 +0000 Subject: [PATCH 20/23] Docs --- man/findOAuthIntegration.Rd | 29 ++++++++++++++--------------- man/getDelegatedAzureToken.Rd | 2 +- man/getOAuthCredentials.Rd | 2 +- 3 files changed, 16 insertions(+), 17 deletions(-) diff --git a/man/findOAuthIntegration.Rd b/man/findOAuthIntegration.Rd index c613ec6..d1f53a3 100644 --- a/man/findOAuthIntegration.Rd +++ b/man/findOAuthIntegration.Rd @@ -5,7 +5,6 @@ \title{Find OAuth Integration by Criteria} \usage{ findOAuthIntegration( - type = NULL, name = NULL, display_name = NULL, guid = NULL, @@ -13,16 +12,16 @@ findOAuthIntegration( ) } \arguments{ -\item{type}{Optional integration type to match (e.g., "custom").} +\item{name}{Optional integration name to match. Supports regular expressions. +For exact matches, use anchors like \code{^github-main$}.} -\item{name}{Optional integration name to match.} +\item{display_name}{Optional display name to match. Supports regular expressions. +For exact matches, use anchors like \code{^GitHub Production$}.} -\item{display_name}{Optional display name to match.} - -\item{guid}{Optional globally unique identifier (GUID) to match.} +\item{guid}{Optional globally unique identifier (GUID) to match. Exact match only.} \item{authenticated}{Optional logical indicating whether to match only authenticated integrations (TRUE), -only unauthenticated integrations (FALSE), or either (NULL, the default).} +only unauthenticated integrations (FALSE), or either (NULL, the default). Exact match only.} } \value{ A list containing the integration metadata, or \code{NULL} if no matching integration is found. @@ -38,16 +37,16 @@ in any IDE running within a Posit Workbench session (not just RStudio). } \examples{ \dontrun{ -# Find by GUID -integration <- findOAuthIntegration(guid = "4c1cfecb-1927-4f19-bc2f-d8ac261364e0") +# Find by exact name +integration <- findOAuthIntegration(name = "^my-github-integration$") -# Find by name -integration <- findOAuthIntegration(name = "my-github-integration") +# Find by name pattern (any integration with "github" in the name) +integration <- findOAuthIntegration(name = "github") -# Find authenticated integration of specific type -integration <- findOAuthIntegration(type = "custom", authenticated = TRUE) +# Find authenticated integration by display name pattern +integration <- findOAuthIntegration(display_name = "GitHub.*", authenticated = TRUE) -# Find by display name -integration <- findOAuthIntegration(display_name = "GitHub Production") +# Find by exact GUID +integration <- findOAuthIntegration(guid = "4c1cfecb-1927-4f19-bc2f-d8ac261364e0") } } diff --git a/man/getDelegatedAzureToken.Rd b/man/getDelegatedAzureToken.Rd index a040d40..42ea325 100644 --- a/man/getDelegatedAzureToken.Rd +++ b/man/getDelegatedAzureToken.Rd @@ -10,7 +10,7 @@ getDelegatedAzureToken(resource) \item{resource}{The name of an Azure resource or service, normally a URL.} } \value{ -A list containing the OAuth2 token details, or NULL if unavailable. +A list containing the OAuth2 token details. Throws an error if unavailable. } \description{ When Workbench is using Azure Active Directory for sign-in, this function can diff --git a/man/getOAuthCredentials.Rd b/man/getOAuthCredentials.Rd index 76ea190..f18785f 100644 --- a/man/getOAuthCredentials.Rd +++ b/man/getOAuthCredentials.Rd @@ -16,7 +16,7 @@ A list containing: \item{expiry}{The token expiry time as a POSIXct datetime object.} \item{audience}{The integration GUID (audience) that was used to retrieve the credentials.} } -Returns \code{NULL} if the credentials cannot be retrieved or the integration is not found. +Throws an error if the credentials cannot be retrieved or the integration is not found. } \description{ Retrieve OAuth credentials for a configured OAuth integration in Posit Workbench. From d00ddd0487400a5a0196f17a07968b9898c6a01a Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Thu, 8 Jan 2026 11:25:26 -0800 Subject: [PATCH 21/23] Apply suggestion from @atheriel Co-authored-by: Aaron Jacobs --- tests/testthat/test-oauth.R | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index 3f270f6..c7ec825 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -1,5 +1,3 @@ -context("OAuth API") - test_that("OAuth functions fail gracefully outside Workbench", { withr::local_envvar( POSIT_PRODUCT = NA, From 98bd46a574616f1529d4910c3b816403f94161f2 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Thu, 8 Jan 2026 21:13:03 +0000 Subject: [PATCH 22/23] Add input validation --- R/auth.R | 19 +++++++++++++++++++ tests/testthat/test-oauth.R | 25 +++++++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/R/auth.R b/R/auth.R index 8495116..a8bea5e 100644 --- a/R/auth.R +++ b/R/auth.R @@ -22,6 +22,10 @@ #' } #' @export getDelegatedAzureToken <- function(resource) { + if (missing(resource) || !is.character(resource) || length(resource) != 1 || !nzchar(resource)) { + stop("resource must be a non-empty character string") + } + # Try the internal RStudio API first (works in RStudio IDE) if (hasFun("getDelegatedAzureToken")) { return(callFun("getDelegatedAzureToken", resource)) @@ -78,6 +82,21 @@ getDelegatedAzureToken <- function(resource) { #' } #' @export getOAuthCredentials <- function(audience) { + # Validate input + if (missing(audience) || !is.character(audience) || length(audience) != 1 || !nzchar(audience)) { + stop("audience must be a non-empty character string") + } + + # Validate GUID format + guid_pattern <- "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + if (!grepl(guid_pattern, audience)) { + stop( + "audience must be a valid GUID (e.g., '4c1cfecb-1927-4f19-bc2f-d8ac261364e0').\n", + "Use getOAuthIntegrations() to list available integrations and their GUIDs, or\n", + "use findOAuthIntegration() to search for a specific integration by name." + ) + } + assertWorkbenchSession() assertWorkbenchVersion(.WORKBENCH_FEATURE_OAUTH) diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index c7ec825..730b217 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -5,7 +5,7 @@ test_that("OAuth functions fail gracefully outside Workbench", { ) expect_error( - getOAuthCredentials("test-integration"), + getOAuthCredentials("4c1cfecb-1927-4f19-bc2f-d8ac261364e0"), "OAuth functionality is only available within Posit Workbench sessions" ) @@ -27,7 +27,7 @@ test_that("OAuth functions require minimum version", { ) expect_error( - getOAuthCredentials("test-integration"), + getOAuthCredentials("4c1cfecb-1927-4f19-bc2f-d8ac261364e0"), "This API is not available in Posit Workbench version 2024.01.0\\. Please upgrade to version 2026\\.01\\.0 or later\\." ) @@ -46,7 +46,7 @@ test_that("OAuth functions handle missing RPC cookie", { ) expect_error( - getOAuthCredentials("test-integration"), + getOAuthCredentials("4c1cfecb-1927-4f19-bc2f-d8ac261364e0"), "RPC cookie not found" ) @@ -66,7 +66,7 @@ test_that("OAuth functions allow dev versions", { # Should not error about version requirement (will error about connection, but that's OK) # Error message may vary: "HTTP request failed", "Could not resolve host", "Failed to connect", etc. expect_error( - getOAuthCredentials("test-integration"), + getOAuthCredentials("4c1cfecb-1927-4f19-bc2f-d8ac261364e0"), class = "error" ) }) @@ -92,3 +92,20 @@ test_that("getOAuthIntegration validates guid parameter", { "guid must be a non-empty character string" ) }) + +test_that("getOAuthCredentials validates GUID format", { + expect_error( + getOAuthCredentials("not-a-guid"), + "audience must be a valid GUID" + ) + + expect_error( + getOAuthCredentials("4c1cfecb19274f19bc2fd8ac261364e0"), + "audience must be a valid GUID" + ) + + expect_error( + getOAuthCredentials("4c1cfecb-1927-4f19-bc2f-d8ac26136"), + "audience must be a valid GUID" + ) +}) From 64782bf8c2e13a8d70078caa1bf2dd2719bf06e4 Mon Sep 17 00:00:00 2001 From: Zach Hannum Date: Thu, 8 Jan 2026 21:24:31 +0000 Subject: [PATCH 23/23] Use explicit unbox instead of auto unbox --- R/auth.R | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/R/auth.R b/R/auth.R index a8bea5e..0dc245f 100644 --- a/R/auth.R +++ b/R/auth.R @@ -35,7 +35,7 @@ getDelegatedAzureToken <- function(resource) { assertWorkbenchVersion(.WORKBENCH_FEATURE_DELEGATED_AZURE) body <- list( - params = list(resource) + params = list(jsonlite::unbox(resource)) ) response <- callWorkbenchRPC( @@ -102,7 +102,7 @@ getOAuthCredentials <- function(audience) { body <- list( kwparams = list( - uuid = audience + uuid = jsonlite::unbox(audience) ) ) @@ -400,7 +400,7 @@ callWorkbenchRPC <- function(method, body, error_context = "RPC call") { endpoint <- paste0(server_url, "/", method) - body$method <- method + body$method <- jsonlite::unbox(method) response <- workbenchRequest( url = endpoint, @@ -495,7 +495,7 @@ workbenchRequest <- function(url, method = "GET", body = NULL, rpc_cookie = NULL # Set POST options if needed if (method == "POST") { - json <- jsonlite::toJSON(body, auto_unbox = TRUE) + json <- jsonlite::toJSON(body) curl::handle_setopt( handle = handle, post = TRUE,