diff --git a/NAMESPACE b/NAMESPACE index 6d44595..3f80daa 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,6 +3,7 @@ export(check_container_class) export(check_envvar) export(check_that) +export(generate_resource) export(get_auth_token) export(get_container) export(list_container_names) @@ -14,4 +15,5 @@ export(read_azure_jsongz) export(read_azure_parquet) export(read_azure_rds) export(read_azure_table) +export(refresh_token) importFrom(rlang,.data) diff --git a/R/get_auth_token.R b/R/get_auth_token.R index a304c64..c849770 100644 --- a/R/get_auth_token.R +++ b/R/get_auth_token.R @@ -12,22 +12,32 @@ #' a user token using the provided parameters, requiring the user to have #' authenticated using their device. If `force_refresh` is set to `TRUE`, a #' fresh web authentication process should be launched. Otherwise it will -#' attempt to use a cached token matching the given `resource` and `tenant`. +#' attempt to use a cached token matching the given `resource`, `tenant` and +#' `aad_version`. #' -#' @param resource A string specifying the URL of the Azure resource for which -#' the token is requested. Defaults to `"https://storage.azure.com"`. +#' @param resource For v2, a vector specifying the URL of the Azure resource +#' for which the token is requested as well as any desired scopes. See +#' [AzureAuth::get_azure_token] for details. For v1, a simple URL such as +#' `"https://storage.azure.com/"` should be supplied. Use [generate_resource] +#' to help provide an appropriate string or vector. The values default to +#' `c("https://storage.azure.com/.default", "openid", "offline_access")`. +#' If setting version to 1, ensure that the `aad_version` argument is also set +#' to 1. Both are set to use AAD version 2 by default. #' @param tenant A string specifying the Azure tenant. Defaults to #' `"organizations"`. See [AzureAuth::get_azure_token] for other values. #' @param client_id A string specifying the application ID (client ID). If #' `NULL`, (the default) the function attempts to obtain the client ID from the #' Azure Resource Manager token, or prompts the user to log in to obtain it. #' @param auth_method A string specifying the authentication method. Defaults to -#' `"authorization_code"`. See ?[AzureAuth::get_azure_token] for other values. +#' `"authorization_code"`. See [AzureAuth::get_azure_token] for other values. +#' @param aad_version Numeric. The AAD version, either 1 or 2 (2 by default) #' @param force_refresh Boolean: whether to use a stored token if available #' (`FALSE`, the default), or try to obtain a new one from Azure (`TRUE`). #' This may be useful if you wish to generate a new token with the same #' `resource` value as an existing token, but a different `tenant` or -#' `auth_method`. +#' `auth_method`. Note that you can also try using [refresh_token] which will +#' cause an existing token to refresh itself, without obtaining a new token +#' from Azure via online reauthentication #' @param ... Optional arguments (`token_args` or `use_cache`) to be passed on #' to [AzureAuth::get_managed_token] or [AzureAuth::get_azure_token]. #' @@ -37,10 +47,14 @@ #' # Get a token for the default resource #' token <- get_auth_token() #' +#' # Force generation of a new token via online reauthentication +#' token <- get_auth_token(force_refresh = TRUE) +#' #' # Get a token for a specific resource and tenant #' token <- get_auth_token( #' resource = "https://graph.microsoft.com", -#' tenant = "my-tenant-id" +#' tenant = "my-tenant-id", +#' aad_version = 1 #' ) #' #' # Get a token using a specific app ID @@ -48,80 +62,67 @@ #' } #' @export get_auth_token <- function( - resource = "https://storage.azure.com", + resource = generate_resource(), tenant = "organizations", client_id = NULL, auth_method = "authorization_code", + aad_version = 2, force_refresh = FALSE, ... ) { - possibly_get_token <- \(...) purrr::possibly(AzureAuth::get_azure_token)(...) + aad_msg <- "Invalid {.arg aad_version} variable supplied (must be 1 or 2)" + aad_version <- check_that(aad_version, \(x) x %in% seq(2), aad_msg) + + safely_get_token <- \(...) purrr::safely(AzureAuth::get_azure_token)(...) + get_azure_token <- purrr::partial( + safely_get_token, + resource = resource, + version = aad_version + ) possibly_get_mtk <- \(...) purrr::possibly(AzureAuth::get_managed_token)(...) dots <- rlang::list2(...) - # if the user specifies force_refresh = TRUE we turn off `use_cache`, + # If the user specifies force_refresh = TRUE we turn off `use_cache`, # otherwise we leave `use_cache` as it is (or as `NULL`, its default value) use_cached <- !force_refresh && (dots[["use_cache"]] %||% TRUE) dots <- rlang::dots_list(!!!dots, use_cache = use_cached, .homonyms = "last") + # We have 4 approaches to get a token, depending on the context # 1. Use environment variables if all three are set - tenant_id_env <- Sys.getenv("AZ_TENANT_ID") - client_id_env <- Sys.getenv("AZ_CLIENT_ID") - client_secret <- Sys.getenv("AZ_APP_SECRET") + token_resp <- rlang::inject(try_token_from_vars(get_azure_token, !!!dots)) + token <- token_resp[["result"]] + token_error <- token_resp[["error"]] - if (all(nzchar(c(tenant_id_env, client_id_env, client_secret)))) { - token <- rlang::inject( - possibly_get_token( - resource = resource, - tenant = tenant_id_env, - app = client_id_env, - password = client_secret, - !!!dots - ) - ) - } else { - # 2. Try to get a managed token (for example on Azure VM, App Service) + # 2. Try to get a managed token (for example on Azure VM, App Service) + if (is.null(token)) { token <- rlang::inject(possibly_get_mtk(resource, !!!dots)) } # 3. If neither of those has worked, try to get an already stored user token - # (unless `force_refresh` is on, in which case skip to option 4 anyway) - if (is.null(token) && !force_refresh) { - # list tokens already locally cached - local_tokens <- AzureAuth::list_azure_tokens() - if (length(local_tokens) > 0) { - resources <- purrr::map(local_tokens, "resource") - scopes <- purrr::map(local_tokens, list("scope", 1)) - resources <- purrr::map2(resources, scopes, `%||%`) - tenants <- purrr::map(local_tokens, "tenant") - resource_index <- gregg(resources, "^{resource}") - tenant_index <- tenant == tenants - # if there are token(s) matching `resource` and `tenant` then return one - token_index <- which(resource_index & tenant_index)[1] - token <- if (!is.na(token_index)) local_tokens[[token_index]] else NULL - } else { - token <- NULL - } + # (unless `force_refresh` is on, in which case skip to option 4) + if (is.null(token) && use_cached) { + token <- match_cached_token(resource, tenant, aad_version) } - # 4. If we still don't have a valid token, try to get a new one via user - # reauthentication + + # 4. If we still don't have a token, try to get a new one via reauthentication if (is.null(token)) { if (!force_refresh) { cli::cli_alert_info("No matching cached token found: fetching new token") } client_id <- client_id %||% get_client_id() - token <- rlang::inject( - possibly_get_token( - resource = resource, + token_resp <- rlang::inject( + get_azure_token( tenant = tenant, app = client_id, auth_type = auth_method, !!!dots ) ) + token <- token_resp[["result"]] + token_error <- token_error %||% token_resp[["error"]] } - # Give some helpful feedback if process above has not worked + # Give some helpful feedback if the steps above have not succeeded if (is.null(token) || length(token) == 0) { cli::cli_alert_info("No authentication token was obtained.") cli::cli_alert_info("Please check any variables you have supplied.") @@ -129,9 +130,66 @@ get_auth_token <- function( "Alternatively, running {.fn AzureRMR::get_azure_login} or {.fn AzureRMR::list_azure_tokens} may shed some light on the problem." ) - invisible(NULL) + error_msg <- "{.fn get_auth_token}: No authentication token was obtained." + cli::cli_abort(as.character(token_error %||% error_msg)) } else { - check_that(token, AzureAuth::is_azure_token, "Invalid token returned") + if (aad_version == 2) { + check_that(token, AzureAuth::is_azure_v2_token, "Invalid token returned") + } else { + check_that(token, AzureAuth::is_azure_v1_token, "Invalid token returned") + } + } +} + + +#' Get token via app and secret environment variables +#' Sub-routine for `get_auth_token()` +#' @keywords internal +#' @returns A list with elements `result` and `error`. If this method is +#' successful, the `result` element will contain a token. +try_token_from_vars <- function(get_token_fun, ...) { + tenant_id_env <- Sys.getenv("AZ_TENANT_ID") + client_id_env <- Sys.getenv("AZ_CLIENT_ID") + client_secret <- Sys.getenv("AZ_APP_SECRET") + + if (all(nzchar(c(tenant_id_env, client_id_env, client_secret)))) { + rlang::inject( + get_token_fun( + tenant = tenant_id_env, + app = client_id_env, + password = client_secret, + ... + ) + ) + } else { + list(result = NULL, error = NULL) + } +} + + +#' Find an already cached token that matches desired parameters +#' Sub-routine for `get_auth_token()` +#' @keywords internal +#' @returns A token from local cache, or NULL if none matches +match_cached_token <- function(resource, tenant, aad_version) { + # list tokens already locally cached + local_tokens <- AzureAuth::list_azure_tokens() + if (length(local_tokens) > 0) { + resources <- purrr::map(local_tokens, "resource") + scopes <- purrr::map(local_tokens, list("scope", 1)) + resources <- purrr::map2_chr(resources, scopes, `%||%`) + tenants <- purrr::map_chr(local_tokens, "tenant") + versions <- purrr::map_int(local_tokens, "version") + + resource_index <- gregg(resources, "^{resource[[1]]}") + tenant_index <- tenants == tenant + version_index <- versions == aad_version + + # return a token matching `resource`, `tenant` and `version`, if any + token_index <- which(resource_index & tenant_index & version_index)[1] + if (!is.na(token_index)) local_tokens[[token_index]] else NULL + } else { + NULL } } @@ -159,7 +217,60 @@ get_client_id <- function() { client_id } -#' Use the token's internal refresh() method to refresh it + +#' Generate appropriate values for the `resource` parameter in [get_auth_token] +#' +#' A helper function to generate appropriate values. Ensure that the `version` +#' argument matches the `aad_version` argument to [get_auth_token]. +#' It's unlikely that you will ever want to set `authorise` to `FALSE` but it's +#' here as an option since [AzureAuth::get_azure_token] supports it. Similarly, +#' you are likely to want to keep `refresh` turned on (this argument has no +#' effect on v1 tokens, it only applies to v2). +#' +#' @param version numeric. The AAD version, either 1 or 2 (2 by default) +#' @param url The URL of the Azure resource host +#' @param path For v2, the path designating the access scope +#' @param authorise Boolean, whether to return a token with authorisation scope, +#' (TRUE, the default) or one that just provides authentication. You are +#' unlikely to want to turn this off +#' @param refresh Boolean, applies to v2 tokens only, whether to return a token +#' that has a refresh token also supplied. +#' @returns A scalar character, or (in most v2 situations) a character vector +#' @export +generate_resource <- function( + version = 2, + url = "https://storage.azure.com", + path = "/.default", + authorise = TRUE, + refresh = TRUE +) { + stopifnot("version must be 1 or 2" = version %in% seq(2)) + scopes <- if (refresh) c("openid", "offline_access") else "openid" + if (authorise) { + if (version == 2) { + c(paste0(url, path), scopes) + } else { + url + } + } else { + if (version == 2) { + scopes + } else { + "" + } + } +} + + +#' Use a token's internal refresh method to refresh it +#' +#' This method avoids the need to refresh by reauthenticating online. It seems +#' like this only works with v1 tokens? v2 tokens always seem to refresh by +#' reauthenticating with Azure online. But v2 tokens ought to refresh +#' automatically and not need manual refreshing. To instead generate a +#' completely fresh token, pass `use_cache = FALSE` or `force_refresh = TRUE` +#' to [get_auth_token]. #' @param token An Azure authentication token #' @returns An Azure authentication token +#' @export refresh_token <- \(token) token$refresh() diff --git a/man/generate_resource.Rd b/man/generate_resource.Rd new file mode 100644 index 0000000..5a039ec --- /dev/null +++ b/man/generate_resource.Rd @@ -0,0 +1,39 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/get_auth_token.R +\name{generate_resource} +\alias{generate_resource} +\title{Generate appropriate values for the \code{resource} parameter in \link{get_auth_token}} +\usage{ +generate_resource( + version = 2, + url = "https://storage.azure.com", + path = "/.default", + authorise = TRUE, + refresh = TRUE +) +} +\arguments{ +\item{version}{numeric. The AAD version, either 1 or 2 (2 by default)} + +\item{url}{The URL of the Azure resource host} + +\item{path}{For v2, the path designating the access scope} + +\item{authorise}{Boolean, whether to return a token with authorisation scope, +(TRUE, the default) or one that just provides authentication. You are +unlikely to want to turn this off} + +\item{refresh}{Boolean, applies to v2 tokens only, whether to return a token +that has a refresh token also supplied.} +} +\value{ +A scalar character, or (in most v2 situations) a character vector +} +\description{ +A helper function to generate appropriate values. Ensure that the \code{version} +argument matches the \code{aad_version} argument to \link{get_auth_token}. +It's unlikely that you will ever want to set \code{authorise} to \code{FALSE} but it's +here as an option since \link[AzureAuth:get_azure_token]{AzureAuth::get_azure_token} supports it. Similarly, +you are likely to want to keep \code{refresh} turned on (this argument has no +effect on v1 tokens, it only applies to v2). +} diff --git a/man/get_auth_token.Rd b/man/get_auth_token.Rd index 8bad90a..a4e4da0 100644 --- a/man/get_auth_token.Rd +++ b/man/get_auth_token.Rd @@ -5,17 +5,24 @@ \title{Get Azure authentication token} \usage{ get_auth_token( - resource = "https://storage.azure.com", + resource = generate_resource(), tenant = "organizations", client_id = NULL, auth_method = "authorization_code", + aad_version = 2, force_refresh = FALSE, ... ) } \arguments{ -\item{resource}{A string specifying the URL of the Azure resource for which -the token is requested. Defaults to \code{"https://storage.azure.com"}.} +\item{resource}{For v2, a vector specifying the URL of the Azure resource +for which the token is requested as well as any desired scopes. See +\link[AzureAuth:get_azure_token]{AzureAuth::get_azure_token} for details. For v1, a simple URL such as +\code{"https://storage.azure.com/"} should be supplied. Use \link{generate_resource} +to help provide an appropriate string or vector. The values default to +\code{c("https://storage.azure.com/.default", "openid", "offline_access")}. +If setting version to 1, ensure that the \code{aad_version} argument is also set +to 1. Both are set to use AAD version 2 by default.} \item{tenant}{A string specifying the Azure tenant. Defaults to \code{"organizations"}. See \link[AzureAuth:get_azure_token]{AzureAuth::get_azure_token} for other values.} @@ -25,13 +32,17 @@ the token is requested. Defaults to \code{"https://storage.azure.com"}.} Azure Resource Manager token, or prompts the user to log in to obtain it.} \item{auth_method}{A string specifying the authentication method. Defaults to -\code{"authorization_code"}. See ?\link[AzureAuth:get_azure_token]{AzureAuth::get_azure_token} for other values.} +\code{"authorization_code"}. See \link[AzureAuth:get_azure_token]{AzureAuth::get_azure_token} for other values.} + +\item{aad_version}{Numeric. The AAD version, either 1 or 2 (2 by default)} \item{force_refresh}{Boolean: whether to use a stored token if available (\code{FALSE}, the default), or try to obtain a new one from Azure (\code{TRUE}). This may be useful if you wish to generate a new token with the same \code{resource} value as an existing token, but a different \code{tenant} or -\code{auth_method}.} +\code{auth_method}. Note that you can also try using \link{refresh_token} which will +cause an existing token to refresh itself, without obtaining a new token +from Azure via online reauthentication} \item{...}{Optional arguments (\code{token_args} or \code{use_cache}) to be passed on to \link[AzureAuth:get_azure_token]{AzureAuth::get_managed_token} or \link[AzureAuth:get_azure_token]{AzureAuth::get_azure_token}.} @@ -53,17 +64,22 @@ If neither of these approaches has returned a token, it will try to retrieve a user token using the provided parameters, requiring the user to have authenticated using their device. If \code{force_refresh} is set to \code{TRUE}, a fresh web authentication process should be launched. Otherwise it will -attempt to use a cached token matching the given \code{resource} and \code{tenant}. +attempt to use a cached token matching the given \code{resource}, \code{tenant} and +\code{aad_version}. } \examples{ \dontrun{ # Get a token for the default resource token <- get_auth_token() +# Force generation of a new token via online reauthentication +token <- get_auth_token(force_refresh = TRUE) + # Get a token for a specific resource and tenant token <- get_auth_token( resource = "https://graph.microsoft.com", - tenant = "my-tenant-id" + tenant = "my-tenant-id", + aad_version = 1 ) # Get a token using a specific app ID diff --git a/man/match_cached_token.Rd b/man/match_cached_token.Rd new file mode 100644 index 0000000..4b0b53d --- /dev/null +++ b/man/match_cached_token.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/get_auth_token.R +\name{match_cached_token} +\alias{match_cached_token} +\title{Find an already cached token that matches desired parameters +Sub-routine for \code{get_auth_token()}} +\usage{ +match_cached_token(resource, tenant, aad_version) +} +\value{ +A token from local cache, or NULL if none matches +} +\description{ +Find an already cached token that matches desired parameters +Sub-routine for \code{get_auth_token()} +} +\keyword{internal} diff --git a/man/refresh_token.Rd b/man/refresh_token.Rd index bcb9b46..bfba5b7 100644 --- a/man/refresh_token.Rd +++ b/man/refresh_token.Rd @@ -13,5 +13,10 @@ refresh_token(token) An Azure authentication token } \description{ -Use the token's internal refresh() method to refresh it +This method avoids the need to refresh by reauthenticating online. It seems +like this only works with v1 tokens? v2 tokens always seem to refresh by +reauthenticating with Azure online. But v2 tokens ought to refresh +automatically and not need manual refreshing. To instead generate a +completely fresh token, pass \code{use_cache = FALSE} or \code{force_refresh = TRUE} +to \link{get_auth_token}. } diff --git a/man/try_token_from_vars.Rd b/man/try_token_from_vars.Rd new file mode 100644 index 0000000..f08379c --- /dev/null +++ b/man/try_token_from_vars.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/get_auth_token.R +\name{try_token_from_vars} +\alias{try_token_from_vars} +\title{Get token via app and secret environment variables +Sub-routine for \code{get_auth_token()}} +\usage{ +try_token_from_vars(get_token_fun, ...) +} +\value{ +A list with elements \code{result} and \code{error}. If this method is +successful, the \code{result} element will contain a token. +} +\description{ +Get token via app and secret environment variables +Sub-routine for \code{get_auth_token()} +} +\keyword{internal} diff --git a/tests/testthat/test-get_auth_token.R b/tests/testthat/test-get_auth_token.R index 694669c..03dcf44 100644 --- a/tests/testthat/test-get_auth_token.R +++ b/tests/testthat/test-get_auth_token.R @@ -3,3 +3,29 @@ test_that("possibly manages failure by returning NULL", { managed_resource <- "https://management.azure.com" expect_null(possibly_get_mtk(managed_resource)) }) + + +test_that("generate_resource() behaves itself", { + generate_resource(version = 3) |> + expect_error() + base_url <- "https://storage.azure.com" + def_url <- paste0(base_url, "/.default") + def1 <- c(def_url, "openid", "offline_access") + generate_resource() |> + expect_equal(def1) + def2 <- c(def_url, "openid") + generate_resource(refresh = FALSE) |> + expect_equal(def2) + generate_resource(authorise = FALSE) |> + expect_equal(c("openid", "offline_access")) + generate_resource(authorise = FALSE, refresh = FALSE) |> + expect_equal("openid") + generate_resource(version = 1) |> + expect_equal(base_url) + generate_resource(version = 1, refresh = FALSE) |> + expect_equal(base_url) + generate_resource(version = 1, authorise = FALSE) |> + expect_equal("") + generate_resource(version = 1, authorise = FALSE, refresh = FALSE) |> + expect_equal("") +})