diff --git a/DESCRIPTION b/DESCRIPTION index ec78d4d..8a712bb 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -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 diff --git a/NAMESPACE b/NAMESPACE index 46a8250..dfc51b4 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -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) diff --git a/R/config.R b/R/config.R index e473ecd..a8bee56 100644 --- a/R/config.R +++ b/R/config.R @@ -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, ...) ) } @@ -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() - }) } diff --git a/R/extendr-wrappers.R b/R/extendr-wrappers.R index 78950b8..84d697e 100644 --- a/R/extendr-wrappers.R +++ b/R/extendr-wrappers.R @@ -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 #' diff --git a/R/keycloak.R b/R/keycloak.R new file mode 100644 index 0000000..2544f52 --- /dev/null +++ b/R/keycloak.R @@ -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) +} diff --git a/R/shiny.R b/R/shiny.R index 417f9e0..be14b42 100644 --- a/R/shiny.R +++ b/R/shiny.R @@ -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 + ) ) ) }, @@ -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) @@ -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 diff --git a/README.md b/README.md index e2b7c14..738a245 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/example/keycloak/app.R b/example/keycloak/app.R new file mode 100644 index 0000000..5c7b2d1 --- /dev/null +++ b/example/keycloak/app.R @@ -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() diff --git a/man/new_keycloak_config.Rd b/man/new_keycloak_config.Rd new file mode 100644 index 0000000..599d777 --- /dev/null +++ b/man/new_keycloak_config.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/keycloak.R +\name{new_keycloak_config} +\alias{new_keycloak_config} +\title{Create a new keycloak_config object} +\usage{ +new_keycloak_config( + base_url, + realm, + client_id, + client_secret, + app_url, + use_refresh_token = TRUE +) +} +\arguments{ +\item{base_url}{The base URL for the Keycloak instance} + +\item{realm}{The realm for the app} + +\item{client_id}{The client ID for the app} + +\item{client_secret}{The client secret for the app} + +\item{app_url}{The URL for the app} + +\item{use_refresh_token}{Enable the use of refresh tokens} +} +\value{ +A keycloak_config object +} +\description{ +Creates a new keycloak_config object +} diff --git a/src/rust/src/entra_id.rs b/src/rust/src/entra_id.rs index 211344c..7ef51d3 100644 --- a/src/rust/src/entra_id.rs +++ b/src/rust/src/entra_id.rs @@ -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::{ @@ -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"; @@ -41,40 +41,17 @@ pub struct AzureADOAuth2Client { reqwest_client: reqwest::Client, client: AzureADClientFull, client_id: String, - jwks: Arc>, + jwks_client: JwksClient, use_refresh_token: bool, _tenant_id: String, } impl AzureADOAuth2Client { fn get_jwk(&self, kid: &str) -> Option { - 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 { - let jwks = reqwest_client - .get(JWKS_URL) - .send() - .await? - .json::() - .await?; - Ok(jwks) -} - -async fn refresh_jwks( - reqwest_client: &reqwest::Client, - jwks_container: &Mutex, -) -> 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, @@ -105,12 +82,27 @@ async fn decode_token_and_maybe_refresh_jwks( client: &AzureADOAuth2Client, access_token: String, ) -> Result { - 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::( + 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( @@ -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 diff --git a/src/rust/src/google.rs b/src/rust/src/google.rs index 3921790..a101ee5 100644 --- a/src/rust/src/google.rs +++ b/src/rust/src/google.rs @@ -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::{ @@ -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 AUTH_BASE_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; @@ -38,49 +38,21 @@ type GoogleClientFull = Client< oauth2::EndpointSet, >; -#[derive(Debug, Deserialize, Serialize, Clone)] -struct GoogleTokenFields { - email: String, -} - #[derive(Clone)] pub struct GoogleOAuth2Client { reqwest_client: reqwest::Client, client: GoogleClientFull, client_id: String, - jwks: Arc>, + jwks_client: JwksClient, use_refresh_token: bool, } impl GoogleOAuth2Client { fn get_jwk(&self, kid: &str) -> Option { - 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 { - let jwks = reqwest_client - .get(JWKS_URL) - .send() - .await? - .json::() - .await?; - Ok(jwks) -} - -async fn refresh_jwks( - reqwest_client: &reqwest::Client, - jwks_container: &Mutex, -) -> 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: &GoogleOAuth2Client, access_token: String, @@ -109,12 +81,25 @@ async fn decode_token_and_maybe_refresh_jwks( client: &GoogleOAuth2Client, access_token: String, ) -> Result { - 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::( + token_trim, + &DecodingKey::from_jwk(&decoding_key)?, + &validation, + )?; + + Ok(OAuth2Response { + access_token, + refresh_token: None, + fields: val.claims, + }) } pub async fn build_oauth2_state_google( @@ -134,13 +119,12 @@ pub async fn build_oauth2_state_google( 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(GoogleOAuth2Client { reqwest_client, client, - jwks, + jwks_client, client_id: client_id.to_string(), use_refresh_token, }) diff --git a/src/rust/src/jwks.rs b/src/rust/src/jwks.rs new file mode 100644 index 0000000..2459d55 --- /dev/null +++ b/src/rust/src/jwks.rs @@ -0,0 +1,82 @@ +use jsonwebtoken::jwk::{Jwk, JwkSet}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::error::TapLockError; + +#[derive(Clone)] +pub struct JwksClient { + url: String, + // Use std::sync::RwLock for fast, synchronous reads during validation + jwks: Arc>, + client: reqwest::Client, + // Async mutex to ensure only one thread performs the network refresh + refresh_lock: Arc>, + // Track last refresh to prevent spam + last_updated: Arc>, +} + +impl JwksClient { + pub async fn new(url: String, client: reqwest::Client) -> Result { + // Initial fetch + let jwks = client.get(&url).send().await?.json::().await?; + + Ok(Self { + url, + jwks: Arc::new(RwLock::new(jwks)), + client, + refresh_lock: Arc::new(AsyncMutex::new(())), + last_updated: Arc::new(Mutex::new(Instant::now())), + }) + } + + pub fn get_key(&self, kid: &str) -> Option { + let jwks = self.jwks.read().expect("jwks lock poisoned"); + jwks.find(kid).cloned() + } + + pub async fn get_key_with_refresh(&self, kid: &str) -> Result { + // 1. Fast path: Check if we already have it + if let Some(key) = self.get_key(kid) { + return Ok(key); + } + + // 2. Slow path: Acquire lock to refresh + let _guard = self.refresh_lock.lock().await; + + // 3. Double-check: Someone else might have refreshed while we waited for the lock + if let Some(key) = self.get_key(kid) { + return Ok(key); + } + + // 4. Rate limit check + { + let mut last = self.last_updated.lock().expect("time lock poisoned"); + if last.elapsed() < Duration::from_secs(10) { + // We just refreshed recently and still didn't find it. + // The key genuinely doesn't exist or we are being spammed. + return Err(TapLockError::KidNotFound); + } + *last = Instant::now(); + } + + // 5. Perform the network request + let new_jwks = self + .client + .get(&self.url) + .send() + .await? + .json::() + .await?; + + // 6. Update the cache + let found_key = new_jwks.find(kid).cloned(); + { + let mut w = self.jwks.write().expect("jwks lock poisoned"); + *w = new_jwks; + } + + found_key.ok_or(TapLockError::KidNotFound) + } +} diff --git a/src/rust/src/keycloak.rs b/src/rust/src/keycloak.rs new file mode 100644 index 0000000..3999742 --- /dev/null +++ b/src/rust/src/keycloak.rs @@ -0,0 +1,203 @@ +use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; +use oauth2::TokenResponse; +use oauth2::{ + basic::{ + BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse, + BasicTokenType, + }, + AuthUrl, AuthorizationCode, Client, ClientId, ClientSecret, CsrfToken, RedirectUrl, Scope, + StandardRevocableToken, StandardTokenResponse, TokenUrl, +}; +use serde::{Deserialize, Serialize}; + +use crate::error::TapLockError; +use crate::jwks::JwksClient; +use crate::{OAuth2Client, OAuth2Response}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +struct KeycloakTokenResponseExtra { + id_token: String, +} + +impl oauth2::ExtraTokenFields for KeycloakTokenResponseExtra {} + +type KeycloakClientFull = Client< + BasicErrorResponse, + StandardTokenResponse, + BasicTokenIntrospectionResponse, + StandardRevocableToken, + BasicRevocationErrorResponse, + oauth2::EndpointSet, + oauth2::EndpointNotSet, + oauth2::EndpointNotSet, + oauth2::EndpointNotSet, + oauth2::EndpointSet, +>; + +#[derive(Clone)] +pub struct KeycloakOAuth2Client { + reqwest_client: reqwest::Client, + client: KeycloakClientFull, + client_id: String, + jwks_client: JwksClient, + use_refresh_token: bool, +} + +impl KeycloakOAuth2Client { + fn get_jwk(&self, kid: &str) -> Option { + self.jwks_client.get_key(kid) + } +} + +fn decode_access_token( + client: &KeycloakOAuth2Client, + access_token: String, +) -> Result { + 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 algo = jwt_header.alg; + let decoding_key = client.get_jwk(&kid).ok_or(TapLockError::KidNotFound)?; + let mut validation = Validation::new(algo); + validation.set_audience(&[&client.client_id]); + let val = decode::( + token_trim, + &DecodingKey::from_jwk(&decoding_key)?, + &validation, + )?; + + Ok(OAuth2Response { + access_token, + refresh_token: None, + fields: val.claims, + }) +} + +async fn decode_token_and_maybe_refresh_jwks( + client: &KeycloakOAuth2Client, + access_token: String, +) -> Result { + 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::( + token_trim, + &DecodingKey::from_jwk(&decoding_key)?, + &validation, + )?; + + Ok(OAuth2Response { + access_token, + refresh_token: None, + fields: val.claims, + }) +} + +pub async fn build_oauth2_state_keycloak( + client_id: &str, + client_secret: &str, + app_url: &str, + base_url: &str, + realm: &str, + use_refresh_token: bool, +) -> std::result::Result { + let base_url = base_url.trim_end_matches('/'); + let auth_url = format!("{base_url}/realms/{realm}/protocol/openid-connect/auth"); + let token_url = format!("{base_url}/realms/{realm}/protocol/openid-connect/token"); + let jwks_url = format!("{base_url}/realms/{realm}/protocol/openid-connect/certs"); + let app_url = app_url.trim_end_matches('/'); + let redirect_url = format!("{app_url}/login"); + + let client = Client::new(ClientId::new(client_id.to_string())) + .set_client_secret(ClientSecret::new(client_secret.to_string())) + .set_auth_uri(AuthUrl::new(auth_url)?) + .set_token_uri(TokenUrl::new(token_url)?) + .set_redirect_uri(RedirectUrl::new(redirect_url)?); + + let reqwest_client = reqwest::Client::new(); + + let jwks_client = JwksClient::new(jwks_url, reqwest_client.clone()).await?; + + Ok(KeycloakOAuth2Client { + reqwest_client, + client, + jwks_client, + client_id: client_id.to_string(), + use_refresh_token, + }) +} + +#[async_trait::async_trait] +impl OAuth2Client for KeycloakOAuth2Client { + async fn exchange_refresh_token( + &self, + refresh_token: String, + ) -> std::result::Result { + if !self.use_refresh_token { + return Err(TapLockError::new("Refresh token is disabled")); + } + let token_result = self + .client + .exchange_refresh_token(&oauth2::RefreshToken::new(refresh_token.to_string())) + .add_scopes( + ["openid", "email", "profile", "offline_access"].map(|s| Scope::new(s.into())), + ) + .request_async(&self.reqwest_client) + .await?; + + let access_token = token_result.extra_fields().id_token.clone(); + let mut response = decode_token_and_maybe_refresh_jwks(self, access_token).await?; + if self.use_refresh_token { + response.refresh_token = Some( + token_result + .refresh_token() + .map(|rt| rt.secret().clone()) + .unwrap_or(refresh_token), + ); + } + Ok(response) + } + async fn exchange_code( + &self, + code: String, + ) -> std::result::Result { + let token_result = self + .client + .exchange_code(AuthorizationCode::new(code)) + .request_async(&self.reqwest_client) + .await?; + + let access_token = token_result.extra_fields().id_token.clone(); + let mut response = decode_token_and_maybe_refresh_jwks(self, access_token).await?; + + if self.use_refresh_token { + response.refresh_token = token_result.refresh_token().map(|rt| rt.secret().clone()); + } + + Ok(response) + } + fn decode_access_token( + &self, + access_token: String, + ) -> std::result::Result { + let response = decode_access_token(self, access_token)?; + Ok(response) + } + fn get_authorization_url(&self) -> String { + let (auth_url, _csrf_token) = self + .client + .authorize_url(CsrfToken::new_random) + .add_extra_param("access_type", "offline") + .add_extra_param("prompt", "consent") + .add_scopes( + ["openid", "email", "profile", "offline_access"].map(|s| Scope::new(s.into())), + ) + .url(); + auth_url.to_string() + } +} diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 5028b54..b20c2f5 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -2,6 +2,8 @@ mod cookies; mod entra_id; mod error; mod google; +mod jwks; +mod keycloak; use extendr_api::prelude::*; use std::sync::Arc; use tokio::sync::oneshot::{self, error::TryRecvError}; @@ -243,6 +245,41 @@ fn initialize_entra_id_runtime( }) } +#[extendr] +fn initialize_keycloak_runtime( + client_id: &str, + client_secret: &str, + app_url: &str, + base_url: &str, + realm: &str, + use_refresh_token: bool, +) -> Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .map_err(TapLockError::Io)?; + + let client = runtime.block_on(keycloak::build_oauth2_state_keycloak( + client_id, + client_secret, + app_url, + base_url, + realm, + use_refresh_token, + ))?; + + let client = Arc::from(client); + + let app_url = Strings::from(app_url).into_robj(); + + Ok(OAuth2Runtime { + client, + runtime, + app_url, + }) +} + /// Return string `"Hello world!"` to R. /// @export #[extendr] @@ -259,6 +296,7 @@ extendr_module! { fn hello_world; fn initialize_google_runtime; fn initialize_entra_id_runtime; + fn initialize_keycloak_runtime; impl AsyncFuture; impl FutureResult; impl OAuth2Runtime;