diff --git a/DESCRIPTION b/DESCRIPTION index ccf1c71..3f450c0 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -16,7 +16,6 @@ URL: https://rstudio.github.io/rstudioapi/, https://github.com/rstudio/rstudioapi BugReports: https://github.com/rstudio/rstudioapi/issues Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 Suggests: testthat, knitr, @@ -25,6 +24,8 @@ Suggests: covr, curl, jsonlite, + R6, withr VignetteBuilder: knitr Encoding: UTF-8 +Config/roxygen2/version: 8.0.0 diff --git a/NEWS.md b/NEWS.md index 3d39b0c..8a6abaf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,14 @@ # rstudioapi (development version) +* `getDelegatedAzureToken()` gains an `as` argument. With `as = "AzureToken"`, + the token is returned as an R6 object compatible with the `AzureToken` class + from the `AzureAuth` package, so it can be passed directly to packages like + `AzureGraph` and `Microsoft365R`. (rstudio/rstudio#17619) + +* `getDelegatedAzureToken()` now returns the same token shape in all IDEs: + the relative `expires_in` field is converted to an absolute `expires_at` + timestamp, matching what RStudio sessions already returned. + * `getMode()` no longer fails on very old versions of RStudio that lack the internal `.rs.isDesktop()` helper. In that case, it now falls back to `versionInfo()$mode`, which has been available since RStudio 0.97.124. (#326) diff --git a/R/auth.R b/R/auth.R index e09a28e..75e8bda 100644 --- a/R/auth.R +++ b/R/auth.R @@ -14,41 +14,174 @@ #' #' @param resource The name of an Azure resource or service, normally a URL. #' -#' @return A list containing the OAuth2 token details. Throws an error if unavailable. +#' @param as The form of the returned token. `"list"` (the default) returns a +#' plain list of OAuth2 token details. `"AzureToken"` returns an R6 object +#' compatible with the `AzureToken` class from the \pkg{AzureAuth} package, +#' so it can be passed directly to packages like \pkg{AzureGraph} and +#' \pkg{Microsoft365R}. The \pkg{R6} package must be installed for this +#' option. +#' +#' @return When `as = "list"`, a list containing the OAuth2 token details, +#' including the fields `access_token`, `token_type`, `scope`, and +#' `expires_at` (the expiry time, in seconds since the Unix epoch). When +#' `as = "AzureToken"`, an R6 object of class `AzureToken` wrapping the same +#' details, with a `refresh()` method that requests a new delegated token +#' from Workbench. Throws an error if a token is unavailable. #' #' @examples #' \dontrun{ #' getDelegatedAzureToken("https://storage.azure.com") +#' +#' # Authenticate with Microsoft Graph using AzureGraph / Microsoft365R +#' token <- getDelegatedAzureToken("https://graph.microsoft.com/", as = "AzureToken") +#' gr <- AzureGraph::ms_graph$new(token = token) +#' site <- Microsoft365R::get_sharepoint_site( +#' site_url = "https://example.sharepoint.com/sites/my-site", +#' token = token +#' ) #' } #' @export -getDelegatedAzureToken <- function(resource) { +getDelegatedAzureToken <- function(resource, as = c("list", "AzureToken")) { if (missing(resource) || !is.character(resource) || length(resource) != 1 || !nzchar(resource)) { stop("resource must be a non-empty character string") } + as <- match.arg(as) # Try the internal RStudio API first (works in RStudio IDE) if (hasFun("getDelegatedAzureToken")) { - return(callFun("getDelegatedAzureToken", resource)) + token <- callFun("getDelegatedAzureToken", resource) + } else { + assertWorkbenchSession() + assertWorkbenchVersion(.WORKBENCH_FEATURE_DELEGATED_AZURE) + + body <- list( + params = list(jsonlite::unbox(resource)) + ) + + response <- callWorkbenchRPC( + method = "delegated_azure_token", + body = body, + error_context = "retrieving delegated Azure token" + ) + + if (is.null(response$token)) { + stop("Malformed response: missing 'token' field") + } + + token <- response$token } - assertWorkbenchSession() - assertWorkbenchVersion(.WORKBENCH_FEATURE_DELEGATED_AZURE) + token <- normalizeDelegatedAzureToken(token) - body <- list( - params = list(jsonlite::unbox(resource)) - ) + if (as == "AzureToken") { + return(asDelegatedAzureToken(token, resource)) + } - response <- callWorkbenchRPC( - method = "delegated_azure_token", - body = body, - error_context = "retrieving delegated Azure token" - ) + token +} + +# Internal helper to normalize delegated Azure token lists. The internal +# RStudio API converts the relative 'expires_in' to an absolute 'expires_at'; +# the direct Workbench RPC path returns the raw token endpoint response. Make +# both paths return the same shape. +normalizeDelegatedAzureToken <- function(token) { + if (is.null(token$expires_at) && !is.null(token$expires_in)) { + token$expires_at <- as.numeric(Sys.time()) + as.numeric(token$expires_in) + token$expires_in <- NULL + token$ext_expires_in <- NULL + } + + token +} + +# Internal helper to add the credential fields expected by consumers of the +# 'AzureToken' interface (e.g. AzureGraph::process_headers reads 'token_type' +# and 'access_token'; AzureAuth's validate() reads 'expires_on'). +delegatedAzureCredentials <- function(token) { + if (is.null(token$token_type)) { + token$token_type <- "Bearer" + } + + if (!is.null(token$expires_at)) { + token$expires_on <- as.character(round(as.numeric(token$expires_at))) + } + + token +} - if (is.null(response$token)) { - stop("Malformed response: missing 'token' field") +# Internal helper to wrap a delegated Azure token in an R6 object compatible +# with the 'AzureToken' class from the AzureAuth package. The object is not a +# subclass of AzureAuth's implementation (AzureAuth need not be installed); +# it provides the fields and methods that AzureGraph, Microsoft365R, and +# related packages rely on. Refreshing requests a new delegated token from +# Workbench, which owns the underlying refresh token. +asDelegatedAzureToken <- function(token, resource) { + if (!requireNamespace("R6", quietly = TRUE)) { + stop("Package 'R6' is required when as = \"AzureToken\". Please install it with: install.packages('R6')") } - response$token + generator <- R6::R6Class("AzureToken", + + public = list( + + version = 1, + resource = NULL, + scope = NULL, + tenant = NULL, + aad_host = NULL, + auth_type = "delegated", + client = NULL, + token_args = list(), + authorize_args = list(), + credentials = NULL, + + initialize = function(token, resource) { + self$credentials <- delegatedAzureCredentials(token) + self$resource <- resource + }, + + cache = function() { + invisible(self) + }, + + hash = function() { + paste0("rstudioapi-delegated-", gsub("[^A-Za-z0-9._-]+", "-", self$resource)) + }, + + validate = function() { + expires_on <- self$credentials$expires_on + if (is.null(expires_on) || is.na(expires_on)) { + return(TRUE) + } + + as.numeric(Sys.time()) < as.numeric(expires_on) + }, + + can_refresh = function() { + TRUE + }, + + refresh = function() { + token <- getDelegatedAzureToken(self$resource, as = "list") + self$credentials <- delegatedAzureCredentials(token) + invisible(self) + }, + + print = function(...) { + cat("\n") + cat(" resource:", self$resource, "\n") + expires_on <- self$credentials$expires_on + if (!is.null(expires_on)) { + expiry <- as.POSIXct(as.numeric(expires_on), origin = "1970-01-01") + cat(" expires:", format(expiry), "\n") + } + invisible(self) + } + + ) + ) + + generator$new(token, resource) } #' Get the User's Identity Token diff --git a/man/getDelegatedAzureToken.Rd b/man/getDelegatedAzureToken.Rd index 42ea325..72d1bcc 100644 --- a/man/getDelegatedAzureToken.Rd +++ b/man/getDelegatedAzureToken.Rd @@ -4,13 +4,25 @@ \alias{getDelegatedAzureToken} \title{OAuth2 Tokens for Delegated Azure Resources} \usage{ -getDelegatedAzureToken(resource) +getDelegatedAzureToken(resource, as = c("list", "AzureToken")) } \arguments{ \item{resource}{The name of an Azure resource or service, normally a URL.} + +\item{as}{The form of the returned token. \code{"list"} (the default) returns a +plain list of OAuth2 token details. \code{"AzureToken"} returns an R6 object +compatible with the \code{AzureToken} class from the \pkg{AzureAuth} package, +so it can be passed directly to packages like \pkg{AzureGraph} and +\pkg{Microsoft365R}. The \pkg{R6} package must be installed for this +option.} } \value{ -A list containing the OAuth2 token details. Throws an error if unavailable. +When \code{as = "list"}, a list containing the OAuth2 token details, +including the fields \code{access_token}, \code{token_type}, \code{scope}, and +\code{expires_at} (the expiry time, in seconds since the Unix epoch). When +\code{as = "AzureToken"}, an R6 object of class \code{AzureToken} wrapping the same +details, with a \code{refresh()} method that requests a new delegated token +from Workbench. Throws an error if a token is unavailable. } \description{ When Workbench is using Azure Active Directory for sign-in, this function can @@ -20,5 +32,13 @@ to. This requires configuring delegated permissions in Azure itself. \examples{ \dontrun{ getDelegatedAzureToken("https://storage.azure.com") + +# Authenticate with Microsoft Graph using AzureGraph / Microsoft365R +token <- getDelegatedAzureToken("https://graph.microsoft.com/", as = "AzureToken") +gr <- AzureGraph::ms_graph$new(token = token) +site <- Microsoft365R::get_sharepoint_site( + site_url = "https://example.sharepoint.com/sites/my-site", + token = token +) } } diff --git a/man/jobAdd.Rd b/man/jobAdd.Rd index e2d47a4..c6c51c2 100644 --- a/man/jobAdd.Rd +++ b/man/jobAdd.Rd @@ -60,15 +60,15 @@ the button will invoke the \code{replay} action.}} } \seealso{ -Other jobs: -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobAddOutput.Rd b/man/jobAddOutput.Rd index e0dc5e6..d3f6f7e 100644 --- a/man/jobAddOutput.Rd +++ b/man/jobAddOutput.Rd @@ -17,15 +17,15 @@ jobAddOutput(job, output, error = FALSE) Adds text output to a background job. } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobAddProgress.Rd b/man/jobAddProgress.Rd index 348aa96..27e7072 100644 --- a/man/jobAddProgress.Rd +++ b/man/jobAddProgress.Rd @@ -15,15 +15,15 @@ jobAddProgress(job, units) Adds incremental progress units to a background job. } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobGetState.Rd b/man/jobGetState.Rd index b2fbb1e..8dd5886 100644 --- a/man/jobGetState.Rd +++ b/man/jobGetState.Rd @@ -13,15 +13,15 @@ jobGetState(job) Get Background Job State } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobList.Rd b/man/jobList.Rd index 67129c8..76d877b 100644 --- a/man/jobList.Rd +++ b/man/jobList.Rd @@ -10,15 +10,15 @@ jobList() List any registered background jobs. } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobRemove.Rd b/man/jobRemove.Rd index 9e55bb9..9d28939 100644 --- a/man/jobRemove.Rd +++ b/man/jobRemove.Rd @@ -13,15 +13,15 @@ jobRemove(job) Remove a background job from RStudio's Background Jobs pane. } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobRunScript.Rd b/man/jobRunScript.Rd index aa28654..4992773 100644 --- a/man/jobRunScript.Rd +++ b/man/jobRunScript.Rd @@ -35,15 +35,15 @@ environment object to create an object with that name.} Starts an R script as a background job. } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobSetProgress.Rd b/man/jobSetProgress.Rd index 7f435fc..fb51fac 100644 --- a/man/jobSetProgress.Rd +++ b/man/jobSetProgress.Rd @@ -15,15 +15,15 @@ jobSetProgress(job, units) Updates the progress for a background job. } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetState}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetState]{jobSetState()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobSetState.Rd b/man/jobSetState.Rd index e38a86b..bfa4005 100644 --- a/man/jobSetState.Rd +++ b/man/jobSetState.Rd @@ -27,15 +27,15 @@ job was cancelled.} \item{failed}{The job finished but did not succeed.} } } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetStatus}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetStatus]{jobSetStatus()}} } \concept{jobs} diff --git a/man/jobSetStatus.Rd b/man/jobSetStatus.Rd index 53a57e8..e85f193 100644 --- a/man/jobSetStatus.Rd +++ b/man/jobSetStatus.Rd @@ -15,15 +15,15 @@ jobSetStatus(job, status) Update a background job's informational status text. } \seealso{ -Other jobs: -\code{\link{jobAdd}()}, -\code{\link{jobAddOutput}()}, -\code{\link{jobAddProgress}()}, -\code{\link{jobGetState}()}, -\code{\link{jobList}()}, -\code{\link{jobRemove}()}, -\code{\link{jobRunScript}()}, -\code{\link{jobSetProgress}()}, -\code{\link{jobSetState}()} +Other jobs: +\code{\link[=jobAdd]{jobAdd()}}, +\code{\link[=jobAddOutput]{jobAddOutput()}}, +\code{\link[=jobAddProgress]{jobAddProgress()}}, +\code{\link[=jobGetState]{jobGetState()}}, +\code{\link[=jobList]{jobList()}}, +\code{\link[=jobRemove]{jobRemove()}}, +\code{\link[=jobRunScript]{jobRunScript()}}, +\code{\link[=jobSetProgress]{jobSetProgress()}}, +\code{\link[=jobSetState]{jobSetState()}} } \concept{jobs} diff --git a/man/launcherAvailable.Rd b/man/launcherAvailable.Rd index 83bfc9b..ac33d97 100644 --- a/man/launcherAvailable.Rd +++ b/man/launcherAvailable.Rd @@ -12,18 +12,18 @@ Workbench jobs; that is, jobs normally launched by the user through the RStudio IDE's user interface. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherConfig.Rd b/man/launcherConfig.Rd index 562361d..8eebfe2 100644 --- a/man/launcherConfig.Rd +++ b/man/launcherConfig.Rd @@ -17,18 +17,18 @@ Define a Workbench launcher configuration, suitable for use with the \code{confi argument to \code{\link[=launcherSubmitJob]{launcherSubmitJob()}}. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherContainer.Rd b/man/launcherContainer.Rd index 2d9f1c5..7bb1f99 100644 --- a/man/launcherContainer.Rd +++ b/man/launcherContainer.Rd @@ -20,18 +20,18 @@ Define a launcher container, suitable for use with the \code{container} argument to \code{\link[=launcherSubmitJob]{launcherSubmitJob()}}. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherControlJob.Rd b/man/launcherControlJob.Rd index 2298775..dbf06f1 100644 --- a/man/launcherControlJob.Rd +++ b/man/launcherControlJob.Rd @@ -21,18 +21,18 @@ your launcher plugin documentation to see which operations are supported.} Interact with a Workbench job. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherGetInfo.Rd b/man/launcherGetInfo.Rd index 21289d5..5570301 100644 --- a/man/launcherGetInfo.Rd +++ b/man/launcherGetInfo.Rd @@ -11,18 +11,18 @@ Retrieve information about the Workbench launcher, as well as the different clus that the launcher has been configured to use. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherGetJob.Rd b/man/launcherGetJob.Rd index ac3d8fa..dfa3e54 100644 --- a/man/launcherGetJob.Rd +++ b/man/launcherGetJob.Rd @@ -13,18 +13,18 @@ launcherGetJob(jobId) Retrieve information on a Workbench job with id \code{jobId}. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherGetJobs.Rd b/man/launcherGetJobs.Rd index b3220bb..ebf4833 100644 --- a/man/launcherGetJobs.Rd +++ b/man/launcherGetJobs.Rd @@ -29,18 +29,18 @@ as RStudio R sessions?} Retrieve information on Workbench jobs. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherHostMount.Rd b/man/launcherHostMount.Rd index 91a04bf..7f63a70 100644 --- a/man/launcherHostMount.Rd +++ b/man/launcherHostMount.Rd @@ -19,18 +19,18 @@ argument to \code{\link[=launcherSubmitJob]{launcherSubmitJob()}}. This can be used to mount a path from the host into the generated container. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherNfsMount.Rd b/man/launcherNfsMount.Rd index 60d107a..7d1ff8d 100644 --- a/man/launcherNfsMount.Rd +++ b/man/launcherNfsMount.Rd @@ -22,18 +22,18 @@ be used to mount a path from a networked filesystem into a newly generated container. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherPlacementConstraint.Rd b/man/launcherPlacementConstraint.Rd index 5b5c5a7..f69c74b 100644 --- a/man/launcherPlacementConstraint.Rd +++ b/man/launcherPlacementConstraint.Rd @@ -18,18 +18,18 @@ Define a launcher placement constraint, suitable for use with the \code{\link[=launcherSubmitJob]{launcherSubmitJob()}}. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherResourceLimit.Rd b/man/launcherResourceLimit.Rd index 93a80f8..c689424 100644 --- a/man/launcherResourceLimit.Rd +++ b/man/launcherResourceLimit.Rd @@ -20,18 +20,18 @@ Define a launcher resource limit, suitable for use with the \code{\link[=launcherSubmitJob]{launcherSubmitJob()}}. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherSubmitJob}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherSubmitJob.Rd b/man/launcherSubmitJob.Rd index 7041024..56647df 100644 --- a/man/launcherSubmitJob.Rd +++ b/man/launcherSubmitJob.Rd @@ -99,18 +99,18 @@ Submit a Workbench job. See information. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitR}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitR]{launcherSubmitR()}} } \concept{job-launcher functionality} diff --git a/man/launcherSubmitR.Rd b/man/launcherSubmitR.Rd index d1214c0..1f36a86 100644 --- a/man/launcherSubmitR.Rd +++ b/man/launcherSubmitR.Rd @@ -24,18 +24,18 @@ See \code{\link[=launcherSubmitJob]{launcherSubmitJob()}} for running jobs with full control over command, environment, and so forth. } \seealso{ -Other job-launcher functionality: -\code{\link{launcherAvailable}()}, -\code{\link{launcherConfig}()}, -\code{\link{launcherContainer}()}, -\code{\link{launcherControlJob}()}, -\code{\link{launcherGetInfo}()}, -\code{\link{launcherGetJob}()}, -\code{\link{launcherGetJobs}()}, -\code{\link{launcherHostMount}()}, -\code{\link{launcherNfsMount}()}, -\code{\link{launcherPlacementConstraint}()}, -\code{\link{launcherResourceLimit}()}, -\code{\link{launcherSubmitJob}()} +Other job-launcher functionality: +\code{\link[=launcherAvailable]{launcherAvailable()}}, +\code{\link[=launcherConfig]{launcherConfig()}}, +\code{\link[=launcherContainer]{launcherContainer()}}, +\code{\link[=launcherControlJob]{launcherControlJob()}}, +\code{\link[=launcherGetInfo]{launcherGetInfo()}}, +\code{\link[=launcherGetJob]{launcherGetJob()}}, +\code{\link[=launcherGetJobs]{launcherGetJobs()}}, +\code{\link[=launcherHostMount]{launcherHostMount()}}, +\code{\link[=launcherNfsMount]{launcherNfsMount()}}, +\code{\link[=launcherPlacementConstraint]{launcherPlacementConstraint()}}, +\code{\link[=launcherResourceLimit]{launcherResourceLimit()}}, +\code{\link[=launcherSubmitJob]{launcherSubmitJob()}} } \concept{job-launcher functionality} diff --git a/tests/testthat/test-oauth.R b/tests/testthat/test-oauth.R index ea428be..ff3ab05 100644 --- a/tests/testthat/test-oauth.R +++ b/tests/testthat/test-oauth.R @@ -134,3 +134,98 @@ test_that("getIdentityToken handles missing RPC cookie", { "RPC cookie not found" ) }) + +test_that("getDelegatedAzureToken validates the 'as' parameter", { + expect_error( + getDelegatedAzureToken("https://storage.azure.com", as = "oops"), + "'arg' should be one of" + ) +}) + +test_that("normalizeDelegatedAzureToken converts expires_in to expires_at", { + token <- list( + access_token = "abc123", + expires_in = 3600, + ext_expires_in = 3600 + ) + + before <- as.numeric(Sys.time()) + normalized <- normalizeDelegatedAzureToken(token) + after <- as.numeric(Sys.time()) + + expect_null(normalized$expires_in) + expect_null(normalized$ext_expires_in) + expect_gte(normalized$expires_at, before + 3600) + expect_lte(normalized$expires_at, after + 3600) + + # Tokens that already carry expires_at are left alone + token <- list(access_token = "abc123", expires_at = 42) + expect_identical(normalizeDelegatedAzureToken(token), token) +}) + +test_that("asDelegatedAzureToken returns an AzureToken-compatible object", { + skip_if_not_installed("R6") + + token <- list( + access_token = "abc123", + scope = "https://graph.microsoft.com/.default", + expires_at = as.numeric(Sys.time()) + 3600 + ) + + obj <- asDelegatedAzureToken(token, "https://graph.microsoft.com/") + + # AzureAuth::is_azure_token() checks R6-ness and the class name + expect_true(R6::is.R6(obj)) + expect_s3_class(obj, "AzureToken") + + expect_identical(obj$resource, "https://graph.microsoft.com/") + expect_identical(obj$version, 1) + expect_identical(obj$credentials$access_token, "abc123") + expect_identical(obj$credentials$token_type, "Bearer") + expect_identical( + obj$credentials$expires_on, + as.character(round(token$expires_at)) + ) + + expect_true(obj$validate()) + expect_true(obj$can_refresh()) + expect_output(print(obj), "Delegated Azure token") +}) + +test_that("delegated AzureToken objects fail validation once expired", { + skip_if_not_installed("R6") + + token <- list( + access_token = "abc123", + expires_at = as.numeric(Sys.time()) - 10 + ) + + obj <- asDelegatedAzureToken(token, "https://graph.microsoft.com/") + expect_false(obj$validate()) +}) + +test_that("delegated AzureToken refresh requests a new token", { + skip_if_not_installed("R6") + + token <- list( + access_token = "old-token", + expires_at = as.numeric(Sys.time()) - 10 + ) + obj <- asDelegatedAzureToken(token, "https://graph.microsoft.com/") + + local_mocked_bindings( + getDelegatedAzureToken = function(resource, as = "list") { + list( + access_token = paste0("new-token-for-", resource), + expires_at = as.numeric(Sys.time()) + 3600 + ) + } + ) + + obj$refresh() + expect_identical( + obj$credentials$access_token, + "new-token-for-https://graph.microsoft.com/" + ) + expect_true(obj$validate()) +})