diff --git a/.gitignore b/.gitignore index 1bd84a9..cd9b838 100644 --- a/.gitignore +++ b/.gitignore @@ -263,3 +263,6 @@ __pycache__/ .RHistory misc/ .Rproj.user +.vscode/ + +.Renviron \ No newline at end of file diff --git a/DESCRIPTION b/DESCRIPTION index b38f82e..3ca6580 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: AzureAuth Title: Authentication Services for Azure Active Directory -Version: 1.3.3 +Version: 1.4.0 Authors@R: c( person("Hong", "Ooi", , "hongooi73@gmail.com", role = c("aut", "cre")), person("Tyler", "Littlefield", role="ctb"), diff --git a/NEWS.md b/NEWS.md index f853696..65b0786 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,8 @@ +# AzureAuth 1.4.0 + +- Add new CLI auth type, `get_azure_token(auth_type="cli")` to use the Azure CLI + to retrieve a user token. + # AzureAuth 1.3.3 - Documentation update only: diff --git a/R/AzureToken.R b/R/AzureToken.R index 849ab83..966e5e0 100644 --- a/R/AzureToken.R +++ b/R/AzureToken.R @@ -67,7 +67,7 @@ public=list( if(is.null(self$credentials)) { res <- private$initfunc(auth_info) - self$credentials <- process_aad_response(res) + self$credentials <- private$process_response(res) } private$set_expiry_time(request_time) @@ -126,7 +126,7 @@ public=list( } else private$initfunc() # reauthenticate if no refresh token (cannot reuse any supplied creds) - creds <- try(process_aad_response(res)) + creds <- try(private$process_response(res)) if(inherits(creds, "try-error")) { delete_azure_token(hash=self$hash(), confirm=FALSE) @@ -216,6 +216,11 @@ private=list( list(resource=self$resource) else list(scope=paste_v2_scopes(self$scope)) ) + }, + + process_response = function(res) + { + process_aad_response(res) } )) diff --git a/R/classes.R b/R/classes.R index 1d51ba6..055aaa0 100644 --- a/R/classes.R +++ b/R/classes.R @@ -285,6 +285,57 @@ private=list( )) +#' @rdname AzureToken +#' @export +AzureTokenCLI <- R6::R6Class("AzureTokenCLI", + inherit = AzureToken, + public = list( + initialize = function(common_args) + { + self$auth_type <- "cli" + do.call(super$initialize, common_args) + } + ), + private = list( + initfunc = function(init_args) + { + tryCatch( + { + cmd <- build_az_token_cmd( + resource = self$resource, + tenant = self$tenant + ) + result <- execute_cmd(cmd) + # result is a multi-line JSON string, concatenate together + paste0(result) + }, + warning = function(cond) + { + not_found <- grepl("not found", cond, fixed = TRUE) + not_loggedin <- grepl("az login", cond, fixed = TRUE) | + grepl("az account set", cond, fixed = TRUE) + bad_resource <- grepl( + "was not found in the tenant", + cond, + fixed = TRUE + ) + if (not_found) + message("Azure CLI not found on path.") + else if (not_loggedin) + message("Please run 'az login' to set up account.") + else + message("Failed to invoke the Azure CLI.") + } + ) + }, + process_response = function(res) + { + process_cli_response(res, self$resource) + } + ) +) + + norenew_alert <- function(version) { if(version == 1) diff --git a/R/token.R b/R/token.R index 35ca0dc..ceb78f7 100644 --- a/R/token.R +++ b/R/token.R @@ -240,7 +240,7 @@ #' #' } #' @export -get_azure_token <- function(resource, tenant, app, password=NULL, username=NULL, certificate=NULL, auth_type=NULL, +get_azure_token <- function(resource=NULL, tenant=NULL, app=NULL, password=NULL, username=NULL, certificate=NULL, auth_type=NULL, aad_host="https://login.microsoftonline.com/", version=1, authorize_args=list(), token_args=list(), use_cache=NULL, on_behalf_of=NULL, auth_code=NULL, device_creds=NULL) @@ -271,6 +271,8 @@ get_azure_token <- function(resource, tenant, app, password=NULL, username=NULL, AzureTokenOnBehalfOf$new(common_args, on_behalf_of), resource_owner= AzureTokenResOwner$new(common_args), + cli= + AzureTokenCLI$new(common_args), stop("Unknown authentication method ", auth_type, call.=FALSE)) } @@ -279,7 +281,7 @@ get_azure_token <- function(resource, tenant, app, password=NULL, username=NULL, #' @param confirm For `delete_azure_token`, whether to prompt for confirmation before deleting a token. #' @rdname get_azure_token #' @export -delete_azure_token <- function(resource, tenant, app, password=NULL, username=NULL, certificate=NULL, auth_type=NULL, +delete_azure_token <- function(resource=NULL, tenant=NULL, app=NULL, password=NULL, username=NULL, certificate=NULL, auth_type=NULL, aad_host="https://login.microsoftonline.com/", version=1, authorize_args=list(), token_args=list(), on_behalf_of=NULL, hash=NULL, confirm=TRUE) @@ -344,7 +346,7 @@ list_azure_tokens <- function() #' @rdname get_azure_token #' @export -token_hash <- function(resource, tenant, app, password=NULL, username=NULL, certificate=NULL, auth_type=NULL, +token_hash <- function(resource=NULL, tenant=NULL, app=NULL, password=NULL, username=NULL, certificate=NULL, auth_type=NULL, aad_host="https://login.microsoftonline.com/", version=1, authorize_args=list(), token_args=list(), on_behalf_of=NULL) { @@ -411,3 +413,22 @@ is_azure_v2_token <- function(object) { is_azure_token(object) && object$version == 2 } + +#' @rdname az_login +#' @export +az_login <- function(...) +{ + args <- list(...) + cmdargs <- list(command = "az", args = c("login")) + for (arg in names(args)) + { + argval <- args[[arg]] + # CLI expects dashes, not underscores + argkey <- gsub("_", "-", arg) + if (is.logical(argval)) + cmdargs$args <- c(cmdargs$args, paste0("--", argkey)) + else + cmdargs$args <- c(cmdargs$args, paste0("--", argkey, " ", argval)) + } + execute_cmd(cmdargs) +} \ No newline at end of file diff --git a/R/utils.R b/R/utils.R index 6cfca6c..97b2888 100644 --- a/R/utils.R +++ b/R/utils.R @@ -3,8 +3,8 @@ select_auth_type <- function(password, username, certificate, auth_type, on_beha if(!is.null(auth_type)) { if(!auth_type %in% - c("authorization_code", "device_code", "client_credentials", "resource_owner", "on_behalf_of", - "managed")) + c("authorization_code", "device_code", "client_credentials", + "resource_owner", "on_behalf_of", "managed", "cli")) stop("Invalid authentication method") return(auth_type) } @@ -59,6 +59,20 @@ process_aad_response <- function(res) else httr::content(res) } +process_cli_response <- function(res, resource) +{ + # Parse the JSON from the CLI and fix the names to snake_case + ret <- jsonlite::parse_json(res) + tok_data <- list( + token_type = ret$tokenType, + access_token = ret$accessToken, + expires_on = as.numeric(as.POSIXct(ret$expiresOn)) + ) + # CLI doesn't return resource identifier so we need to pass it through + if (!missing(resource)) tok_data$resource <- resource + return(tok_data) +} + # need to capture bad scopes before requesting auth code # v2.0 endpoint will show error page rather than redirecting, causing get_azure_token to wait forever @@ -147,3 +161,62 @@ in_shiny <- function() { ("shiny" %in% loadedNamespaces()) && shiny::isRunning() } + +build_az_token_cmd <- function(command = "az", resource, tenant) +{ + args <- c("account", "get-access-token", "--output json") + if (!missing(resource)) args <- c(args, paste("--resource", resource)) + if (!missing(tenant)) args <- c(args, paste("--tenant", tenant)) + list(command = command, args = args) +} + +handle_az_cmd_errors <- function(cond) +{ + not_loggedin <- grepl("az login", cond, fixed = TRUE) | + grepl("az account set", cond, fixed = TRUE) + not_found <- grepl("not found", cond, fixed = TRUE) + error_in <- grepl("error in running", cond, fixed = TRUE) + + if (not_found | error_in) + { + msg <- paste("az is not installed or not in PATH.\n", + "Please see: ", + "https://learn.microsoft.com/en-us/cli/azure/install-azure-cli\n", + "for installation instructions." + ) + stop(msg) + } + else if (not_loggedin) + { + stop("You are not logged into the Azure CLI. + Please call AzureAuth::az_login() + or run 'az login' from your shell and try again.") + } + else + { + # Other misc errors, pass through the CLI error message + message("Failed to invoke the Azure CLI.") + stop(cond) + } +} + +execute_cmd <- function(cmd) +{ + tryCatch( + { + cat(cmd$command, paste(cmd$args), "\n") + result <- do.call(system2, append(cmd, list(stdout = TRUE))) + # result is a multi-line JSON string, concatenate together + paste0(result) + }, + warning = function() + { + # if an error case, catch it, pass the error string and handle it + handle_az_cmd_errors(result) + }, + error = function(cond) + { + handle_az_cmd_errors(cond$message) + } + ) +} diff --git a/README.md b/README.md index b5082ed..aac0c53 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,15 @@ tok2 <- get_azure_token("resource2", "mytenant," "serviceapp_id", password="serviceapp_secret", auth_type="on_behalf_of", on_behalf_of=tok0) ``` +6. The **cli** method uses the + [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + command `az account get-access-token` to retrieve an auth token. It is mostly + useful for interactive programming. + +```r +get_azure_token(auth_type="cli") +``` + If you don't specify the method, `get_azure_token` makes a best guess based on the presence or absence of the other authentication arguments, and whether httpuv is installed. ### Managed identities diff --git a/tests/testthat/test30_azurecli.R b/tests/testthat/test30_azurecli.R new file mode 100644 index 0000000..c34a009 --- /dev/null +++ b/tests/testthat/test30_azurecli.R @@ -0,0 +1,133 @@ +test_that("cli auth_type can be selected", +{ + auth_type <- select_auth_type(auth_type = "cli") + expect_equal(auth_type, "cli") +}) + +test_that("az account command is assembled properly", +{ + resource <- "my_resource" + tenant <- "microsoft.com" + cmd <- build_az_token_cmd(resource = resource, tenant = tenant) + expect_equal(cmd$command, "az") + expect_equal( + cmd$args, + c( + "account", + "get-access-token", + "--output json", + "--resource my_resource", + "--tenant microsoft.com" + ) + ) +}) + +test_that("az account command is assembled properly even if missing tenant", +{ + resource <- "my_resource" + cmd <- build_az_token_cmd(resource = resource) + expect_equal(cmd$command, "az") + expect_equal( + cmd$args, + c( + "account", + "get-access-token", + "--output json", + "--resource my_resource" + ) + ) +}) + +test_that("az account command is assembled properly even if missing resource", +{ + tenant <- "microsoft.com" + cmd <- build_az_token_cmd(tenant = tenant) + expect_equal(cmd$command, "az") + expect_equal( + cmd$args, + c( + "account", + "get-access-token", + "--output json", + "--tenant microsoft.com" + ) + ) +}) + +test_that("the token data from az login response is converted to an R list", +{ + res <- paste( + '{ "accessToken": "eyJ0",', + '"expiresOn": "2022-09-23 23:35:16.000000",', + '"tenant": "microsoft.com", "tokenType": "Bearer"}' + ) + expected <- list( + token_type = "Bearer", + access_token = "eyJ0", + expires_on = 1664001316, + resource = "foo" + ) + actual <- process_cli_response(res, resource = "foo") + expect_equal(actual, expected) +}) + +test_that("the token data from az login is handled by AzureTokenCLI", +{ + res <- paste( + '{ "accessToken": "eyJ0",', + '"expiresOn": "2022-09-23 23:35:16.000000",', + '"tenant": "microsoft.com", "tokenType": "Bearer"}' + ) + TestClass <- R6::R6Class(inherit = AzureTokenCLI, + public = list( + initialize = function() { self$resource <- "foo" }, + run_test = function() { + private$process_response(res) + } + ) + ) + expected <- list( + token_type = "Bearer", + access_token = "eyJ0", + expires_on = 1664001316, + resource = "foo" + ) + tc <- TestClass$new() + expect_equal(tc$run_test(), expected) +}) + +test_that("the appropriate error is thrown when the az CLI is not installed", +{ + expect_error( + handle_az_cmd_errors("error in running command"), + regexp = "az is not installed or not in PATH." + ) +}) + +test_that("invalid scope error is handled", { + msg <- paste0( + "ERROR: AADSTS70011: The provided request must include a 'scope' input parameter. ", + "The provided value for the input parameter 'scope' is not valid. ", + "The scope my_resource/.default offline_access openid profile is not valid. ", + "The scope format is invalid. ", + "Scope must be in a valid URI form or a valid Guid .\n", + "Trace ID: 09da0917-570a-4f10-93f0-a61340d06300\n", + "Correlation ID: 6d2114db-6f1a-43fa-8484-b0a6783cf47b\n", + "Timestamp: 2022-10-10 22:55:14Z\n", + "To re-authenticate, please run:\n", + "az login --scope my_resource/.default" + ) + expect_error(handle_az_cmd_errors(msg)) +}) + +test_that("the appropriate error is thrown when the tenant is invalid", +{ + errmsg <- "Failed to resolve tenant 'faketenant'" + expect_error(handle_az_cmd_errors(errmsg), regexp = "Failed to resolve tenant") +}) + +test_that("the appropriate error is thrown when the user is not logged in", +{ + errmsg <- "ERROR: Please run 'az login' to setup account." + expect_error(handle_az_cmd_errors(errmsg), regexp = "You are not logged in") +}) diff --git a/vignettes/token.Rmd b/vignettes/token.Rmd index 5d2deb0..8eb9f83 100644 --- a/vignettes/token.Rmd +++ b/vignettes/token.Rmd @@ -108,6 +108,15 @@ tok2 <- get_azure_token("resource2", "mytenant," "serviceapp_id", password="serviceapp_secret", auth_type="on_behalf_of", on_behalf_of=tok0) ``` +6. The **cli** method uses the + [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) + command `az account get-access-token` to retrieve an auth token. It is mostly + useful for interactive programming. + +```r +get_azure_token(auth_type="cli") +``` + If you don't specify the method, `get_azure_token` makes a best guess based on the presence or absence of the other authentication arguments, and whether httpuv is installed. ```r