Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ Config/testthat/edition: 3
Encoding: UTF-8
Roxygen: list(markdown = TRUE)
RoxygenNote: 7.3.2
Config/rextendr/version: 0.3.1.9001
Config/rextendr/version: 0.4.2
SystemRequirements: Cargo (Rust's package manager), rustc, OpenSSL >= 1.0.2
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export(is_expired)
export(is_valid)
export(new_entra_id_config)
export(new_google_config)
export(new_keycloak_config)
export(new_openid_config)
export(token)
useDynLib(tapLock, .registration = TRUE)
22 changes: 16 additions & 6 deletions R/config.R
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,20 @@
#' - `client_secret`
#' - `tenant_id`
#'
#' The `"keycloak"` provider accepts the following arguments:
#' - `base_url`
#' - `realm`
#' - `client_id`
#' - `client_secret`
#'
#' @return An openid_config object
#' @export
new_openid_config <- function(provider, app_url, ...) {
switch(provider,
switch(
provider,
entra_id = new_entra_id_config(app_url = app_url, ...),
google = new_google_config(app_url = app_url, ...)
google = new_google_config(app_url = app_url, ...),
keycloak = new_keycloak_config(app_url = app_url, ...)
)
}

Expand All @@ -43,20 +51,22 @@ POLL_INTERVAL <- 0.005 # nolint: object_name_linter.

async_future_to_promise <- function(x) {
promises::promise(function(resolve, reject) {

if (is_error(x)) {
return(reject(x$value))
}

poll_recursive <- function() {
result <- x$poll()
if (result$is_pending()) return(later::later(poll_recursive, POLL_INTERVAL))
if (result$is_ready()) return(resolve(result$value()))
if (result$is_pending()) {
return(later::later(poll_recursive, POLL_INTERVAL))
}
if (result$is_ready()) {
return(resolve(result$value()))
}
if (result$is_error()) return(reject(result$error()))
}

poll_recursive()

})
}

Expand Down
2 changes: 2 additions & 0 deletions R/extendr-wrappers.R
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ initialize_google_runtime <- function(client_id, client_secret, app_url, use_ref

initialize_entra_id_runtime <- function(client_id, client_secret, app_url, tenant_id, use_refresh_token) .Call(wrap__initialize_entra_id_runtime, client_id, client_secret, app_url, tenant_id, use_refresh_token)

initialize_keycloak_runtime <- function(client_id, client_secret, app_url, base_url, realm, use_refresh_token) .Call(wrap__initialize_keycloak_runtime, client_id, client_secret, app_url, base_url, realm, use_refresh_token)

#' @title Parse cookies
#' @description Parses cookies from a string
#'
Expand Down
33 changes: 33 additions & 0 deletions R/keycloak.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#' @title Create a new keycloak_config object
#' @description Creates a new keycloak_config object
#'
#' @param base_url The base URL for the Keycloak instance
#' @param realm The realm for the app
#' @param client_id The client ID for the app
#' @param client_secret The client secret for the app
#' @param app_url The URL for the app
#' @param use_refresh_token Enable the use of refresh tokens
#'
#' @return A keycloak_config object
#' @export
new_keycloak_config <- function(
base_url,
realm,
client_id,
client_secret,
app_url,
use_refresh_token = TRUE
) {
runtime_result <- initialize_keycloak_runtime(
client_id = client_id,
client_secret = client_secret,
app_url = app_url,
base_url = base_url,
realm = realm,
use_refresh_token = use_refresh_token
)
if (is_error(runtime_result)) {
rlang::abort(runtime_result$value)
}
return(runtime_result)
}
25 changes: 19 additions & 6 deletions R/shiny.R
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@ internal_add_auth_layers <- function(config, tower) {
status = 302,
headers = list(
Location = config$get_app_url(),
"Set-Cookie" = build_cookie("access_token", add_bearer(token$access_token)),
"Set-Cookie" = build_cookie("refresh_token", token$refresh_token)
"Set-Cookie" = build_cookie(
"access_token",
add_bearer(token$access_token)
),
"Set-Cookie" = build_cookie(
"refresh_token",
token$refresh_token
)
)
)
},
Expand Down Expand Up @@ -63,8 +69,14 @@ internal_add_auth_layers <- function(config, tower) {
response$headers <- append(
response$headers,
list(
"Set-Cookie" = build_cookie("access_token", add_bearer(token$access_token)),
"Set-Cookie" = build_cookie("refresh_token", token$refresh_token)
"Set-Cookie" = build_cookie(
"access_token",
add_bearer(token$access_token)
),
"Set-Cookie" = build_cookie(
"refresh_token",
token$refresh_token
)
)
)
return(response)
Expand Down Expand Up @@ -113,11 +125,12 @@ internal_add_auth_layers <- function(config, tower) {

token_decode_result <- access_token(config, cookies$access_token)

if (methods::is(token_decode_result, "error")) rlang::abort(token_decode_result$value)
if (methods::is(token_decode_result, "error")) {
rlang::abort(token_decode_result$value)
}

session$userData$token <- token_decode_result
})

}

#' @title Add authentication middle ware to a 'tower' object
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ tapLock supports the following authentication providers:

- [Google](https://developers.google.com/identity/protocols/oauth2/openid-connect)
- [Microsoft Entra ID](https://www.microsoft.com/en-us/security/business/identity-access/microsoft-entra-id)
- [Keycloak](https://www.keycloak.org/)

> If you need support for other providers, please contact us at
> [hola@ixpantia.com](mailto:hola@ixpantia.com). Or, if you are a
Expand Down
42 changes: 42 additions & 0 deletions example/keycloak/app.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
library(shiny)
#library(tapLock)
devtools::load_all("../..")

auth_config <- new_openid_config(
provider = "keycloak",
client_id = Sys.getenv("CLIENT_ID"),
client_secret = Sys.getenv("CLIENT_SECRET"),
app_url = Sys.getenv("APP_URL"),
base_url = Sys.getenv("BASE_URL"),
realm = Sys.getenv("REALM"),
use_refresh_token = TRUE
)


ui <- fluidPage(
tags$h1("r.sso example"),
uiOutput("profile"),
textOutput("user")
)

server <- function(input, output, session) {
output$profile <- renderUI({
tags$img(src = token()$picture)
})

output$user <- renderText({
given_name <- token()$given_name
family_name <- token()$family_name
expires_at <- expires_at(token())
glue::glue(
"Hello {given_name} {family_name}!",
"Your authenticated session will expire at {expires_at}.",
.sep = " "
)
}) |>
bindEvent(TRUE)
}
shinyApp(ui, server) |>
tower::create_tower() |>
tapLock::add_auth_layers(auth_config) |>
tower::build_tower()
34 changes: 34 additions & 0 deletions man/new_keycloak_config.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

63 changes: 27 additions & 36 deletions src/rust/src/entra_id.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
use jsonwebtoken::{decode, decode_header, DecodingKey, Validation};
use oauth2::TokenResponse;
use oauth2::{
basic::{
Expand All @@ -9,9 +9,9 @@ use oauth2::{
StandardRevocableToken, StandardTokenResponse, TokenUrl,
};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};

use crate::error::TapLockError;
use crate::jwks::JwksClient;
use crate::{OAuth2Client, OAuth2Response};

const JWKS_URL: &str = "https://login.microsoftonline.com/common/discovery/keys";
Expand Down Expand Up @@ -41,40 +41,17 @@ pub struct AzureADOAuth2Client {
reqwest_client: reqwest::Client,
client: AzureADClientFull,
client_id: String,
jwks: Arc<Mutex<JwkSet>>,
jwks_client: JwksClient,
use_refresh_token: bool,
_tenant_id: String,
}

impl AzureADOAuth2Client {
fn get_jwk(&self, kid: &str) -> Option<jsonwebtoken::jwk::Jwk> {
let jwks = self.jwks.lock().expect("mutex should not be poissoned");
jwks.find(kid).cloned()
self.jwks_client.get_key(kid)
}
}

async fn fetch_jwks(reqwest_client: &reqwest::Client) -> Result<JwkSet, TapLockError> {
let jwks = reqwest_client
.get(JWKS_URL)
.send()
.await?
.json::<JwkSet>()
.await?;
Ok(jwks)
}

async fn refresh_jwks(
reqwest_client: &reqwest::Client,
jwks_container: &Mutex<JwkSet>,
) -> Result<(), TapLockError> {
let jwks = fetch_jwks(reqwest_client).await?;
let mut jwks_container = jwks_container
.lock()
.expect("mutex should not be poissoned");
*jwks_container = jwks;
Ok(())
}

fn decode_access_token(
client: &AzureADOAuth2Client,
access_token: String,
Expand Down Expand Up @@ -105,12 +82,27 @@ async fn decode_token_and_maybe_refresh_jwks(
client: &AzureADOAuth2Client,
access_token: String,
) -> Result<OAuth2Response, TapLockError> {
let mut response = decode_access_token(client, access_token.clone());
if let Err(TapLockError::KidNotFound) = response {
refresh_jwks(&client.reqwest_client, &client.jwks).await?;
response = decode_access_token(client, access_token.clone());
}
response
let token_trim = access_token.trim_start_matches("Bearer").trim();
let jwt_header = decode_header(token_trim)?;
let kid = jwt_header.kid.ok_or(TapLockError::KidNotFound)?;

let decoding_key = client.jwks_client.get_key_with_refresh(&kid).await?;
let algo = jwt_header.alg;
let mut validation = Validation::new(algo);

validation.set_audience(&[&client.client_id]);

let val = decode::<serde_json::Value>(
token_trim,
&DecodingKey::from_jwk(&decoding_key)?,
&validation,
)?;

Ok(OAuth2Response {
access_token,
refresh_token: None,
fields: val.claims,
})
}

pub async fn build_oauth2_state_azure_ad(
Expand All @@ -133,13 +125,12 @@ pub async fn build_oauth2_state_azure_ad(

let reqwest_client = reqwest::Client::new();

let jwks = fetch_jwks(&reqwest_client).await?;
let jwks = Arc::new(Mutex::new(jwks));
let jwks_client = JwksClient::new(JWKS_URL.to_string(), reqwest_client.clone()).await?;

Ok(AzureADOAuth2Client {
reqwest_client,
client,
jwks,
jwks_client,
client_id: client_id.to_string(),
use_refresh_token,
_tenant_id: tenant_id.to_string(), // Store tenant ID
Expand Down
Loading
Loading