diff --git a/DESCRIPTION b/DESCRIPTION index a8614611..9e8411ed 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -31,6 +31,7 @@ Imports: Suggests: covr, DBItest, + httr2, knitr, magrittr, rmarkdown, diff --git a/NEWS.md b/NEWS.md index 0fae612a..cf17bfaa 100644 --- a/NEWS.md +++ b/NEWS.md @@ -25,6 +25,10 @@ * SQL Server: Fix issue related to writing when using SIMBA drivers (#816). +* `snowflake()` and `databricks()` now accept a `session` argument for passing + viewer-based OAuth credentials from Shiny sessions on Posit Connect + (@atheriel, #853). + # odbc 1.5.0 ## Major changes diff --git a/R/driver-databricks.R b/R/driver-databricks.R index bc069706..337b64ff 100644 --- a/R/driver-databricks.R +++ b/R/driver-databricks.R @@ -31,6 +31,8 @@ NULL #' default name. #' @param uid,pwd Manually specify a username and password for authentication. #' Specifying these options will disable automated credential discovery. +#' @param session A Shiny session object, when using viewer-based credentials on +#' Posit Connect. #' @param ... Further arguments passed on to [`dbConnect()`]. #' #' @returns An `OdbcConnection` object with an active connection to a Databricks @@ -42,6 +44,16 @@ NULL #' odbc::databricks(), #' httpPath = "sql/protocolv1/o/4425955464597947/1026-023828-vn51jugj" #' ) +#' +#' # Use credentials from the viewer (when possible) in a Shiny app +#' # deployed to Posit Connect. +#' server <- function(input, output, session) { +#' conn <- DBI::dbConnect( +#' odbc::databricks(), +#' httpPath = "sql/protocolv1/o/4425955464597947/1026-023828-vn51jugj", +#' session = session +#' ) +#' } #' } #' @export databricks <- function() { @@ -64,6 +76,7 @@ setMethod("dbConnect", "DatabricksOdbcDriver", HTTPPath, uid = NULL, pwd = NULL, + session = NULL, ...) { call <- caller_env() # For backward compatibility with RStudio connection string @@ -74,6 +87,7 @@ setMethod("dbConnect", "DatabricksOdbcDriver", check_string(driver, allow_null = TRUE, call = call) check_string(uid, allow_null = TRUE, call = call) check_string(pwd, allow_null = TRUE, call = call) + check_shiny_session(session, allow_null = TRUE, call = call) args <- databricks_args( httpPath = if (missing(httpPath)) HTTPPath else httpPath, @@ -82,6 +96,7 @@ setMethod("dbConnect", "DatabricksOdbcDriver", driver = driver, uid = uid, pwd = pwd, + session = session, ... ) inject(dbConnect(odbc(), !!!args)) @@ -94,6 +109,7 @@ databricks_args <- function(httpPath, driver = NULL, uid = NULL, pwd = NULL, + session = NULL, ...) { host <- databricks_host(workspace) @@ -104,7 +120,7 @@ databricks_args <- function(httpPath, useNativeQuery = useNativeQuery ) - auth <- databricks_auth_args(host, uid = uid, pwd = pwd) + auth <- databricks_auth_args(host, uid = uid, pwd = pwd, session = session) all <- utils::modifyList(c(args, auth), list(...)) arg_names <- tolower(names(all)) @@ -202,7 +218,16 @@ databricks_user_agent <- function() { user_agent } -databricks_auth_args <- function(host, uid = NULL, pwd = NULL) { +databricks_auth_args <- function(host, uid = NULL, pwd = NULL, session = NULL) { + # If a session is supplied, any viewer-based auth takes precedence. + if (!is.null(session)) { + check_installed("httr2", "for viewer-based authentication") + access_token <- connect_viewer_token(session, paste0("https://", host)) + if (!is.null(access_token)) { + return(list(authMech = 11, auth_flow = 0, auth_accesstoken = access_token)) + } + } + if (!is.null(uid) && !is.null(pwd)) { return(list(uid = uid, pwd = pwd, authMech = 3)) } else if (xor(is.null(uid), is.null(pwd))) { diff --git a/R/driver-snowflake.R b/R/driver-snowflake.R index b2071cfa..8db3af1b 100644 --- a/R/driver-snowflake.R +++ b/R/driver-snowflake.R @@ -114,6 +114,8 @@ setMethod("odbcDataType", "Snowflake", #' default. #' @param uid,pwd Manually specify a username and password for authentication. #' Specifying these options will disable ambient credential discovery. +#' @param session A Shiny session object, when using viewer-based credentials on +#' Posit Connect. #' @param ... Further arguments passed on to [`dbConnect()`]. #' #' @returns An `OdbcConnection` object with an active connection to a Snowflake @@ -138,6 +140,12 @@ setMethod("odbcDataType", "Snowflake", #' uid = "me", #' pwd = rstudioapi::askForPassword() #' ) +#' +#' # Use credentials from the viewer (when possible) in a Shiny app +#' # deployed to Posit Connect. +#' server <- function(input, output, session) { +#' conn <- DBI::dbConnect(odbc::snowflake(), session = session) +#' } #' } #' @export snowflake <- function() { @@ -156,6 +164,7 @@ setMethod( schema = NULL, uid = NULL, pwd = NULL, + session = NULL, ...) { call <- caller_env() check_string(account, call = call) @@ -164,6 +173,7 @@ setMethod( check_string(database, allow_null = TRUE, call = call) check_string(uid, allow_null = TRUE, call = call) check_string(pwd, allow_null = TRUE, call = call) + check_shiny_session(session, allow_null = TRUE, call = call) args <- snowflake_args( account = account, driver = driver, @@ -172,6 +182,7 @@ setMethod( schema = schema, uid = uid, pwd = pwd, + session = session, ... ) inject(dbConnect(odbc(), !!!args)) @@ -266,7 +277,19 @@ snowflake_auth_args <- function(account, uid = NULL, pwd = NULL, authenticator = NULL, + session = NULL, ...) { + # If a session is supplied, any viewer-based auth takes precedence. + if (!is.null(session)) { + check_installed("httr2", "for viewer-based authentication") + access_token <- connect_viewer_token( + session, paste0("https://", account, ".snowflakecomputing.com") + ) + if (!is.null(access_token)) { + return(list(authenticator = "oauth", token = access_token)) + } + } + if (!is.null(uid) && # allow for uid without pwd for externalbrowser auth (#817) (!is.null(pwd) || identical(authenticator, "externalbrowser"))) { diff --git a/R/utils.R b/R/utils.R index e0d8796d..d817db34 100644 --- a/R/utils.R +++ b/R/utils.R @@ -454,3 +454,75 @@ replace_or_append <- function(lines, pattern, replacement) { } lines } + +check_shiny_session <- function(x, + ..., + allow_null = FALSE, + arg = caller_arg(x), + call = caller_env()) { + if (!missing(x)) { + if (inherits(x, "ShinySession")) { + return(invisible(NULL)) + } + if (allow_null && is_null(x)) { + return(invisible(NULL)) + } + } + + stop_input_type( + x, + "a Shiny session object", + ..., + allow_null = allow_null, + arg = arg, + call = call + ) +} + +# Request an OAuth access token for the given resource from Posit Connect. The +# OAuth token will belong to the user owning the given Shiny session. +connect_viewer_token <- function(session, resource) { + # Ensure we're running on Connect. + server_url <- Sys.getenv("CONNECT_SERVER") + api_key <- Sys.getenv("CONNECT_API_KEY") + if (nchar(server_url) == 0 || nchar(api_key) == 0) { + cli::cli_inform(c( + "!" = "Ignoring {.var sesssion} parameter.", + "i" = "Viewer-based credentials are only available when running on Connect." + )) + return(NULL) + } + + # Older versions or certain configurations of Connect might not supply a user + # session token. + token <- session$request$HTTP_POSIT_CONNECT_USER_SESSION_TOKEN + if (is.null(token)) { + cli::cli_abort( + "Viewer-based credentials are not supported by this version of Connect." + ) + } + + # See: https://docs.posit.co/connect/api/#post-/v1/oauth/integrations/credentials + req <- httr2::request(server_url) + req <- httr2::req_url_path_append( + req, "__api__/v1/oauth/integrations/credentials" + ) + req <- httr2::req_headers(req, + Authorization = paste("Key", api_key), .redact = "Authorization" + ) + req <- httr2::req_body_form( + req, + grant_type = "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token_type = "urn:posit:connect:user-session-token", + subject_token = token, + resource = resource + ) + + # TODO: Do we need more precise error handling? + req <- httr2::req_error( + req, body = function(resp) httr2::resp_body_json(resp)$error + ) + + resp <- httr2::resp_body_json(httr2::req_perform(req)) + resp$access_token +} diff --git a/man/databricks.Rd b/man/databricks.Rd index 5d385af2..1e2204ad 100644 --- a/man/databricks.Rd +++ b/man/databricks.Rd @@ -18,6 +18,7 @@ databricks() HTTPPath, uid = NULL, pwd = NULL, + session = NULL, ... ) } @@ -43,6 +44,9 @@ default name.} \item{uid, pwd}{Manually specify a username and password for authentication. Specifying these options will disable automated credential discovery.} +\item{session}{A Shiny session object, when using viewer-based credentials on +Posit Connect.} + \item{...}{Further arguments passed on to \code{\link[=dbConnect]{dbConnect()}}.} } \value{ @@ -66,5 +70,15 @@ DBI::dbConnect( odbc::databricks(), httpPath = "sql/protocolv1/o/4425955464597947/1026-023828-vn51jugj" ) + +# Use credentials from the viewer (when possible) in a Shiny app +# deployed to Posit Connect. +server <- function(input, output, session) { + conn <- DBI::dbConnect( + odbc::databricks(), + httpPath = "sql/protocolv1/o/4425955464597947/1026-023828-vn51jugj", + session = session + ) +} } } diff --git a/man/snowflake.Rd b/man/snowflake.Rd index 45b2713c..595636ae 100644 --- a/man/snowflake.Rd +++ b/man/snowflake.Rd @@ -16,6 +16,7 @@ snowflake() schema = NULL, uid = NULL, pwd = NULL, + session = NULL, ... ) } @@ -42,6 +43,9 @@ default.} \item{uid, pwd}{Manually specify a username and password for authentication. Specifying these options will disable ambient credential discovery.} +\item{session}{A Shiny session object, when using viewer-based credentials on +Posit Connect.} + \item{...}{Further arguments passed on to \code{\link[=dbConnect]{dbConnect()}}.} } \value{ @@ -74,5 +78,11 @@ DBI::dbConnect( uid = "me", pwd = rstudioapi::askForPassword() ) + +# Use credentials from the viewer (when possible) in a Shiny app +# deployed to Posit Connect. +server <- function(input, output, session) { + conn <- DBI::dbConnect(odbc::snowflake(), session = session) +} } }