diff --git a/DESCRIPTION b/DESCRIPTION index c5f3795..6d7f598 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -28,6 +28,7 @@ Suggests: knitr, rmarkdown, shiny, + tower, testthat (>= 3.0.0) VignetteBuilder: knitr diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2d93efd --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +.PHONY: install + +install: + Rscript -e "devtools::install()" + + diff --git a/NAMESPACE b/NAMESPACE index 98a4bb7..91b4f9a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1,6 +1,9 @@ # Generated by roxygen2: do not edit by hand +S3method(internal_add_auth_layers,entra_id_config) +S3method(internal_add_auth_layers,google_config) S3method(print,access_token) +export(add_auth_layers) export(expires_at) export(expires_in) export(get_token_field) @@ -10,6 +13,5 @@ export(new_auth0_config) export(new_entra_id_config) export(new_google_config) export(new_openid_config) -export(sso_shiny_app) export(token) export(use_futures) diff --git a/R/auth0.R b/R/auth0.R index f5fd547..ffd94a5 100644 --- a/R/auth0.R +++ b/R/auth0.R @@ -105,14 +105,9 @@ get_client_id.auth0_config <- function(config) { #' @keywords internal #' @noRd -shiny_app.auth0_config <- function(config, app) { - app_handler <- app$httpHandler - login_handler <- function(req) { - - # If the user sends a POST request to /login, we'll get a code - # and exchange it for an access token. We'll then redirect the - # user to the root path, setting a cookie with the access token. - if (req$PATH_INFO == "/login") { +internal_add_auth_layers.auth0_config <- function(config, tower) { + tower |> + tower::add_get_route("/login", \(req) { query <- shiny::parseQueryString(req$QUERY_STRING) token <- promises::future_promise({ request_token(config, query[["code"]]) @@ -140,9 +135,8 @@ shiny_app.auth0_config <- function(config, app) { } ) ) - } - - if (req$PATH_INFO == "/logout") { + }) |> + tower::add_get_route("/logout", \(req) { return( shiny::httpResponse( status = 302, @@ -152,72 +146,61 @@ shiny_app.auth0_config <- function(config, app) { ) ) ) - } - - # Get eh HTTP cookies from the request - cookies <- parse_cookies(req$HTTP_COOKIE) - - # If the user requests the root path, we'll check if they have - # an access token. If they don't, we'll redirect them to the - # login page. - if (req$PATH_INFO == "/") { + }) |> + tower::add_http_layer(\(req) { + # Get the HTTP cookies from the request + cookies <- parse_cookies(req$HTTP_COOKIE) + req$PARSED_COOKIES <- cookies + + # If the user requests the root path, we'll check if they have + # an access token. If they don't, we'll redirect them to the + # login page. + if (req$PATH_INFO == "/") { + token <- tryCatch( + expr = access_token(config, remove_bearer(cookies$access_token)), + error = function(e) { + return(NULL) + } + ) + if (is.null(token)) { + return( + shiny::httpResponse( + status = 302, + headers = list( + Location = get_login_url(config) + ) + ) + ) + } + } + }) |> + tower::add_http_layer(function(req) { + # If the user requests any other path, we'll check if they have + # an access token. If they don't, we'll return a 403 Forbidden + # response. token <- tryCatch( - expr = access_token(config, remove_bearer(cookies$access_token)), + expr = access_token( + config, + remove_bearer(req$PARSED_COOKIES$access_token) + ), error = function(e) { return(NULL) } ) + if (is.null(token)) { return( shiny::httpResponse( - status = 302, - headers = list( - Location = get_login_url(config) - ) + status = 403, + content_type = "text/plain", + content = "Forbidden" ) ) } - } - - # If the user requests any other path, we'll check if they have - # an access token. If they don't, we'll return a 403 Forbidden - # response. - token <- tryCatch( - expr = access_token(config, remove_bearer(cookies$access_token)), - error = function(e) { - return(NULL) - } - ) - - if (is.null(token)) { - return( - shiny::httpResponse( - status = 403, - content_type = "text/plain", - content = "Forbidden" - ) - ) - } - - # If we have reached this point, the user has a valid access - # token and therefore we can return NULL, which will cause the - # app handler to be called. - return(NULL) - } - - handlers <- list( - login_handler, - app_handler - ) - - app$httpHandler <- function(req) { - for (handler in handlers) { - response <- handler(req) - if (!is.null(response)) { - return(response) - } - } - } - return(app) + # If we have reached this point, the user has a valid access + # token and therefore we can return NULL, which will cause the + # app handler to be called. + return(NULL) + }) } diff --git a/R/config.R b/R/config.R index a4cf375..40b92c6 100644 --- a/R/config.R +++ b/R/config.R @@ -79,6 +79,11 @@ request_token <- function(config, authorization_code) { UseMethod("request_token") } +#' @keywords internal +request_token_refresh <- function(config, refresh_token) { + UseMethod("request_token_refresh") +} + #' @title Decode a token #' @description Decodes a token #' diff --git a/R/entra_id.R b/R/entra_id.R index 3fe0076..0c7a887 100644 --- a/R/entra_id.R +++ b/R/entra_id.R @@ -67,7 +67,8 @@ request_token.entra_id_config <- function(config, authorization_code) { client_id = config$client_id, client_secret = config$client_secret, grant_type = "authorization_code", - redirect_uri = config$redirect_uri + redirect_uri = config$redirect_uri, + scope = "profile openid email offline_access" ) |> httr2::req_perform() resp_status <- httr2::resp_status(res) @@ -75,7 +76,33 @@ request_token.entra_id_config <- function(config, authorization_code) { stop(httr2::resp_body_string(res)) } resp_body <- httr2::resp_body_json(res) - access_token(config, resp_body$access_token) + list( + at = access_token(config, resp_body$access_token), + rt = resp_body$refresh_token + ) +} + +#' @keywords internal +request_token_refresh.entra_id_config <- function(config, refresh_token) { + res <- httr2::request(config$token_url) |> + httr2::req_method("POST") |> + httr2::req_body_form( + refresh_token = refresh_token, + client_id = config$client_id, + client_secret = config$client_secret, + grant_type = "refresh_token", + scope = "profile openid email offline_access" + ) |> + httr2::req_perform() + resp_status <- httr2::resp_status(res) + if (resp_status != 200) { + stop(httr2::resp_body_string(res)) + } + resp_body <- httr2::resp_body_json(res) + list( + at = access_token(config, resp_body$access_token), + rt = resp_body$refresh_token + ) } #' @keywords internal @@ -86,6 +113,9 @@ decode_token.entra_id_config <- function(config, token) { jose::jwt_decode_sig(token, jwk), error = function(e) { NULL + }, + warning = function(w) { + NULL } ) }) |> @@ -102,15 +132,10 @@ get_client_id.entra_id_config <- function(config) { config$client_id } -#' @keywords internal -shiny_app.entra_id_config <- function(config, app) { - app_handler <- app$httpHandler - login_handler <- function(req) { - - # If the user sends a POST request to /login, we'll get a code - # and exchange it for an access token. We'll then redirect the - # user to the root path, setting a cookie with the access token. - if (req$REQUEST_METHOD == "POST" && req$PATH_INFO == "/login") { +#' @export +internal_add_auth_layers.entra_id_config <- function(config, tower) { + tower |> + tower::add_post_route("/login", function(req) { form <- shiny::parseQueryString(req[["rook.input"]]$read_lines()) token <- promises::future_promise({ request_token(config, form[["code"]]) @@ -123,7 +148,8 @@ shiny_app.entra_id_config <- function(config, app) { status = 302, headers = list( Location = config$app_url, - "Set-Cookie" = build_cookie("access_token", get_bearer(token)) + "Set-Cookie" = build_cookie("access_token", get_bearer(token$at)), + "Set-Cookie" = build_cookie("refresh_token", token$rt) ) ) }, @@ -131,91 +157,112 @@ shiny_app.entra_id_config <- function(config, app) { shiny::httpResponse( status = 302, headers = list( - Location = config$app_url, - "Set-Cookie" = build_cookie("access_token", "") + Location = get_login_url(config), + "Set-Cookie" = build_cookie("access_token", ""), + "Set-Cookie" = build_cookie("refresh_token", "") ) ) } ) ) - } - - if (req$PATH_INFO == "/logout") { + }) |> + tower::add_get_route("/logout", function(req) { return( shiny::httpResponse( status = 302, headers = list( Location = config$app_url, - "Set-Cookie" = build_cookie("access_token", "") + "Set-Cookie" = build_cookie("access_token", ""), + "Set-Cookie" = build_cookie("refresh_token", "") ) ) ) - } - - # Get eh HTTP cookies from the request - cookies <- parse_cookies(req$HTTP_COOKIE) + }) |> + tower::add_http_layer(function(req) { + # Get the HTTP cookies from the request + cookies <- parse_cookies(req$HTTP_COOKIE) + req$PARSED_COOKIES <- cookies - # If the user requests the root path, we'll check if they have - # an access token. If they don't, we'll redirect them to the - # login page. - if (req$PATH_INFO == "/") { + # If the user requests the root path, we'll check if they have + # an access token. If they don't, we'll redirect them to the + # login page. + if (req$PATH_INFO == "/") { + token <- tryCatch( + expr = access_token(config, remove_bearer(cookies$access_token)), + error = function(e) { + return(NULL) + } + ) + if (is.null(token) && shiny::isTruthy(cookies$refresh_token)) { + # Ask for a new token using the refresh_token + token <- promises::future_promise({ + request_token_refresh(config, cookies$refresh_token) + }) + return( + promises::then( + token, + onFulfilled = function(token) { + shiny::httpResponse( + status = 302, + headers = list( + Location = config$app_url, + "Set-Cookie" = build_cookie("access_token", get_bearer(token$at)), + "Set-Cookie" = build_cookie("refresh_token", token$rt) + ) + ) + }, + onRejected = function(e) { + shiny::httpResponse( + status = 302, + headers = list( + Location = get_login_url(config), + "Set-Cookie" = build_cookie("access_token", ""), + "Set-Cookie" = build_cookie("refresh_token", "") + ) + ) + } + ) + ) + } + if (is.null(token)) { + return( + shiny::httpResponse( + status = 302, + headers = list( + Location = get_login_url(config) + ) + ) + ) + } + } + }) |> + tower::add_http_layer(function(req) { + # If the user requests any other path, we'll check if they have + # an access token. If they don't, we'll return a 403 Forbidden + # response. token <- tryCatch( - expr = access_token(config, remove_bearer(cookies$access_token)), + expr = access_token( + config, + remove_bearer(req$PARSED_COOKIES$access_token) + ), error = function(e) { return(NULL) } ) + if (is.null(token)) { return( shiny::httpResponse( - status = 302, - headers = list( - Location = get_login_url(config) - ) + status = 403, + content_type = "text/plain", + content = "Forbidden" ) ) } - } - - # If the user requests any other path, we'll check if they have - # an access token. If they don't, we'll return a 403 Forbidden - # response. - token <- tryCatch( - expr = access_token(config, remove_bearer(cookies$access_token)), - error = function(e) { - return(NULL) - } - ) - - if (is.null(token)) { - return( - shiny::httpResponse( - status = 403, - content_type = "text/plain", - content = "Forbidden" - ) - ) - } - - # If we have reached this point, the user has a valid access - # token and therefore we can return NULL, which will cause the - # app handler to be called. - return(NULL) - } - - handlers <- list( - login_handler, - app_handler - ) - - app$httpHandler <- function(req) { - for (handler in handlers) { - response <- handler(req) - if (!is.null(response)) { - return(response) - } - } - } - return(app) + # If we have reached this point, the user has a valid access + # token and therefore we can return NULL, which will cause the + # app handler to be called. + return(NULL) + }) } diff --git a/R/futures.R b/R/futures.R index 31cbac2..ebabf40 100644 --- a/R/futures.R +++ b/R/futures.R @@ -1,4 +1,3 @@ - #' @title Use futures for asynchronous computations #' @description Enable a future plan for asynchronous computations. #' Since tapLock needs to do calls to external APIs, it can be a good idea diff --git a/R/google.R b/R/google.R index b13a6d2..4c6f835 100644 --- a/R/google.R +++ b/R/google.R @@ -97,15 +97,10 @@ get_client_id.google_config <- function(config) { config$client_id } -#' @keywords internal -shiny_app.google_config <- function(config, app) { - app_handler <- app$httpHandler - login_handler <- function(req) { - - # If the user sends a POST request to /login, we'll get a code - # and exchange it for an access token. We'll then redirect the - # user to the root path, setting a cookie with the access token. - if (req$PATH_INFO == "/login") { +#' @export +internal_add_auth_layers.google_config <- function(config, tower) { + tower |> + tower::add_get_route("/login", function(req) { query <- shiny::parseQueryString(req$QUERY_STRING) token <- promises::future_promise({ request_token(config, query[["code"]]) @@ -133,9 +128,8 @@ shiny_app.google_config <- function(config, app) { } ) ) - } - - if (req$PATH_INFO == "/logout") { + }) |> + tower::add_get_route("/logout", function(req) { return( shiny::httpResponse( status = 302, @@ -145,72 +139,61 @@ shiny_app.google_config <- function(config, app) { ) ) ) - } - - # Get eh HTTP cookies from the request - cookies <- parse_cookies(req$HTTP_COOKIE) - - # If the user requests the root path, we'll check if they have - # an access token. If they don't, we'll redirect them to the - # login page. - if (req$PATH_INFO == "/") { + }) |> + tower::add_http_layer(function(req) { + # Get the HTTP cookies from the request + cookies <- parse_cookies(req$HTTP_COOKIE) + req$PARSED_COOKIES <- cookies + + # If the user requests the root path, we'll check if they have + # an access token. If they don't, we'll redirect them to the + # login page. + if (req$PATH_INFO == "/") { + token <- tryCatch( + expr = access_token(config, remove_bearer(cookies$access_token)), + error = function(e) { + return(NULL) + } + ) + if (is.null(token)) { + return( + shiny::httpResponse( + status = 302, + headers = list( + Location = get_login_url(config) + ) + ) + ) + } + } + }) |> + tower::add_http_layer(function(req) { + # If the user requests any other path, we'll check if they have + # an access token. If they don't, we'll return a 403 Forbidden + # response. token <- tryCatch( - expr = access_token(config, remove_bearer(cookies$access_token)), + expr = access_token( + config, + remove_bearer(req$PARSED_COOKIES$access_token) + ), error = function(e) { return(NULL) } ) + if (is.null(token)) { return( shiny::httpResponse( - status = 302, - headers = list( - Location = get_login_url(config) - ) + status = 403, + content_type = "text/plain", + content = "Forbidden" ) ) } - } - - # If the user requests any other path, we'll check if they have - # an access token. If they don't, we'll return a 403 Forbidden - # response. - token <- tryCatch( - expr = access_token(config, remove_bearer(cookies$access_token)), - error = function(e) { - return(NULL) - } - ) - - if (is.null(token)) { - return( - shiny::httpResponse( - status = 403, - content_type = "text/plain", - content = "Forbidden" - ) - ) - } - - # If we have reached this point, the user has a valid access - # token and therefore we can return NULL, which will cause the - # app handler to be called. - return(NULL) - } - - handlers <- list( - login_handler, - app_handler - ) - - app$httpHandler <- function(req) { - for (handler in handlers) { - response <- handler(req) - if (!is.null(response)) { - return(response) - } - } - } - return(app) + # If we have reached this point, the user has a valid access + # token and therefore we can return NULL, which will cause the + # app handler to be called. + return(NULL) + }) } diff --git a/R/shiny.R b/R/shiny.R index 9fe6c64..e145687 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -19,9 +19,13 @@ rsso_server <- function(config, server_func) { } } -#' @keywords internal -shiny_app <- function(config, app) { - UseMethod("shiny_app") +internal_add_auth_layers <- function(config, tower) { + UseMethod("internal_add_auth_layers") +} + +#' @export +add_auth_layers <- function(tower, config) { + internal_add_auth_layers(config, tower) } #' @title Get the access token @@ -36,21 +40,3 @@ shiny_app <- function(config, app) { token <- function(session = shiny::getDefaultReactiveDomain()) { session$userData$token } - -#' @title Create a Shiny app with SSO -#' -#' @description Creates a Shiny app with SSO (single sign-on) -#' based on the given configuration. -#' -#' @param config An openid_config object -#' @param ui A Shiny UI function -#' @param server A Shiny server function. This function requires -#' all three arguments: `input`, `output`, and `session`. -#' -#' @seealso [tapLock::new_openid_config()] -#' @return A Shiny app (Compatible with [`shinyApp()`][shiny::shinyApp]) -#' @export -sso_shiny_app <- function(config, ui, server) { - app <- shiny::shinyApp(ui = ui, server = rsso_server(config, server)) - shiny_app(config, app) -} diff --git a/example/app.R b/example/app.R index 6707044..6ad8758 100644 --- a/example/app.R +++ b/example/app.R @@ -28,5 +28,7 @@ server <- function(input, output, session) { }) } - -sso_shiny_app(auth_config, ui, server) +shinyApp(ui, server) |> + tower::create_tower() |> + tapLock::add_auth_layers(auth_config) |> + tower::build_tower() diff --git a/man/sso_shiny_app.Rd b/man/sso_shiny_app.Rd deleted file mode 100644 index 29d6ee8..0000000 --- a/man/sso_shiny_app.Rd +++ /dev/null @@ -1,26 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/shiny.R -\name{sso_shiny_app} -\alias{sso_shiny_app} -\title{Create a Shiny app with SSO} -\usage{ -sso_shiny_app(config, ui, server) -} -\arguments{ -\item{config}{An openid_config object} - -\item{ui}{A Shiny UI function} - -\item{server}{A Shiny server function. This function requires -all three arguments: \code{input}, \code{output}, and \code{session}.} -} -\value{ -A Shiny app (Compatible with \code{\link[shiny:shinyApp]{shinyApp()}}) -} -\description{ -Creates a Shiny app with SSO (single sign-on) -based on the given configuration. -} -\seealso{ -\code{\link[=new_openid_config]{new_openid_config()}} -} diff --git a/man/tapLock-package.Rd b/man/tapLock-package.Rd index a806164..15ae7f7 100644 --- a/man/tapLock-package.Rd +++ b/man/tapLock-package.Rd @@ -11,14 +11,13 @@ Swift and seamless Single Sign-On (SSO) integration. Designed for effortless com \seealso{ Useful links: \itemize{ - \item \url{https://github.com/maurolepore/tapLock} - \item \url{https://maurolepore.github.io/tapLock/} - \item Report bugs at \url{https://github.com/maurolepore/tapLock/issues} + \item \url{https://github.com/ixpantia/tapLock} + \item Report bugs at \url{https://github.com/ixpantia/tapLock/issues} } } \author{ -\strong{Maintainer}: +\strong{Maintainer}: Andres Quintero \email{andres@ixpantia.com} Other contributors: \itemize{