context: Do not cache OIDC client

It turns out, we do NOT want to keep one single, global OIDC client data
structure.  There are two major problems with this:

1. If the OIDC IdP happens to be unavailable when the process starts,
   Rocket will fail to ignite and the process will exit.  This is
   unnecessary, since the only functionality that will be unavailable
   without the IdP is new logins; existing sessions/tokens will still be
   valid.
2. Identity providers can change keys, URLs, etc. at any time.  If we
   cache everything and never look it up again, all future login
   attempts will fail until the server is restarted.

The official recommendation for caching OIDC IdP configuration and keys
is to use native HTTP cache control.  Unfortunately, most IdPs
explicitly disable caching of their HTTP responses.
This commit is contained in:
2025-04-08 21:40:20 -05:00
parent a50dca7fae
commit dbd9165626
5 changed files with 60 additions and 37 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
/Rocket.toml /Rocket.toml
/jwt.secret /jwt.secret
/meilisearch.token /meilisearch.token
/oidc.secret

View File

@@ -1,3 +1,4 @@
use std::path::Path;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Read; use std::io::Read;
use std::time::SystemTime; use std::time::SystemTime;
@@ -177,18 +178,15 @@ impl From<LoginError> for LoginFailed {
/// Create an openidconnect Client instance from the given Config /// Create an openidconnect Client instance from the given Config
pub async fn get_oidc_client( pub async fn get_oidc_client(
config: &Config, discovery_url: String,
client_id: String,
client_secret: Option<String>,
callback_url: String,
http_client: &reqwest::Client,
) -> Result<OidcClient, OidcError> { ) -> Result<OidcClient, OidcError> {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let oidc_url = config.oidc.discovery_url.clone();
let client_id = config.oidc.client_id.clone();
let client_secret = config.oidc.client_secret.clone();
let provider_metadata = CoreProviderMetadata::discover_async( let provider_metadata = CoreProviderMetadata::discover_async(
IssuerUrl::new(oidc_url)?, IssuerUrl::new(discovery_url)?,
&http_client, http_client,
) )
.await?; .await?;
Ok(CoreClient::from_provider_metadata( Ok(CoreClient::from_provider_metadata(
@@ -196,13 +194,13 @@ pub async fn get_oidc_client(
ClientId::new(client_id), ClientId::new(client_id),
client_secret.map(ClientSecret::new), client_secret.map(ClientSecret::new),
) )
.set_redirect_uri(RedirectUrl::new(config.oidc.callback_url.clone())?)) .set_redirect_uri(RedirectUrl::new(callback_url)?))
} }
/// Load the JWT secret from the path specified in the configuration /// Load a secret from the specified path
pub fn load_secret(config: &Config) -> Result<Vec<u8>, std::io::Error> { pub fn load_secret<P: AsRef<Path>>(path: P) -> Result<Vec<u8>, std::io::Error> {
let mut secret = vec![]; let mut secret = vec![];
let mut f = std::fs::File::open(&config.auth.jwt_secret)?; let mut f = std::fs::File::open(path)?;
f.read_to_end(&mut secret)?; f.read_to_end(&mut secret)?;
Ok(secret) Ok(secret)
} }
@@ -263,15 +261,12 @@ async fn exchange_code(
) -> Result<CoreIdTokenClaims, LoginError> { ) -> Result<CoreIdTokenClaims, LoginError> {
let pkce_verifier = PkceCodeVerifier::new(state.pkce); let pkce_verifier = PkceCodeVerifier::new(state.pkce);
debug!("Exchanging authorization code for access token"); debug!("Exchanging authorization code for access token");
let http_client = reqwest::ClientBuilder::new() let http_client = &ctx.oidc_http_client;
.redirect(reqwest::redirect::Policy::none()) let oidc = ctx.oidc().await?;
.build() let token_response = oidc
.unwrap();
let token_response = ctx
.oidc
.exchange_code(AuthorizationCode::new(code))? .exchange_code(AuthorizationCode::new(code))?
.set_pkce_verifier(pkce_verifier) .set_pkce_verifier(pkce_verifier)
.request_async(&http_client) .request_async(http_client)
.await .await
.map_err(LoginError::TokenRequestError)?; .map_err(LoginError::TokenRequestError)?;
debug!( debug!(
@@ -284,9 +279,9 @@ async fn exchange_code(
let id_token = token_response let id_token = token_response
.id_token() .id_token()
.ok_or(LoginError::MissingIdToken)?; .ok_or(LoginError::MissingIdToken)?;
let id_token_verifier = ctx.oidc.id_token_verifier(); let id_token_verifier = oidc.id_token_verifier();
let claims = id_token let claims = id_token
.claims(&ctx.oidc.id_token_verifier(), &Nonce::new(state.nonce))?; .claims(&oidc.id_token_verifier(), &Nonce::new(state.nonce))?;
if let Some(expected_access_token_hash) = claims.access_token_hash() { if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash = AccessTokenHash::from_token( let actual_access_token_hash = AccessTokenHash::from_token(
token_response.access_token(), token_response.access_token(),
@@ -306,13 +301,13 @@ pub async fn oidc_login(
ctx: &State<Context>, ctx: &State<Context>,
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
config: &RocketConfig, config: &RocketConfig,
) -> Redirect { ) -> Result<Redirect, LoginFailed> {
let (pkce_challenge, pkce_verifier) = let (pkce_challenge, pkce_verifier) =
PkceCodeChallenge::new_random_sha256(); PkceCodeChallenge::new_random_sha256();
trace!("PKCE: {:?} {:?}", pkce_challenge, pkce_verifier); trace!("PKCE: {:?} {:?}", pkce_challenge, pkce_verifier);
let (auth_url, csrf_token, nonce) = ctx let oidc = ctx.oidc().await.map_err(LoginError::from)?;
.oidc let (auth_url, csrf_token, nonce) = oidc
.authorize_url( .authorize_url(
CoreAuthenticationFlow::AuthorizationCode, CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random, CsrfToken::new_random,
@@ -355,7 +350,7 @@ pub async fn oidc_login(
error!("Failed to serialize OIDC client state: {}", e); error!("Failed to serialize OIDC client state: {}", e);
}, },
}; };
Redirect::to(auth_url.to_string()) Ok(Redirect::to(auth_url.to_string()))
} }
/// Handle OpenID Connect authorization callback /// Handle OpenID Connect authorization callback

View File

@@ -18,7 +18,7 @@ pub struct AuthConfig {
pub login_ttl: u64, pub login_ttl: u64,
} }
#[derive(Debug, Deserialize)] #[derive(Clone, Debug, Deserialize)]
pub struct OidcConfig { pub struct OidcConfig {
pub discovery_url: String, pub discovery_url: String,
pub client_id: String, pub client_id: String,

View File

@@ -51,16 +51,18 @@ pub enum LoginError {
InvalidAccessToken, InvalidAccessToken,
#[error("JWT serialization error: {0}")] #[error("JWT serialization error: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error), JwtError(#[from] jsonwebtoken::errors::Error),
#[error("Invalid OIDC configuration: {0}")]
Oidc(#[from] OidcError),
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum InitError { pub enum InitError {
#[error("Meilisearch error: {0}")] #[error("Meilisearch error: {0}")]
Meilisearch(#[from] crate::meilisearch::Error), Meilisearch(#[from] crate::meilisearch::Error),
#[error("Failed to load JWT secret: {0}")] #[error("Failed to load JWT secret: {0}")]
LoadSecret(std::io::Error), LoadJwtSecret(std::io::Error),
#[error("Failed to load OIDC client secret: {0}")]
#[error("Invalid OIDC configuration: {0}")] LoadOidcSecret(std::io::Error),
Oidc(#[from] OidcError), #[error("Failed to initialize HTTP client: {0}")]
ReqwestError(#[from] reqwest::Error)
} }

View File

@@ -15,21 +15,46 @@ pub use error::InitError;
pub struct Context { pub struct Context {
client: MeilisearchClient, client: MeilisearchClient,
jwt_secret: Vec<u8>, jwt_secret: Vec<u8>,
oidc: auth::OidcClient, oidc: config::OidcConfig,
oidc_client_secret: Option<String>,
oidc_http_client: reqwest::Client,
} }
impl Context { impl Context {
pub async fn init(config: &Config) -> Result<Self, InitError> { pub async fn init(config: &Config) -> Result<Self, InitError> {
let client = MeilisearchClient::try_from(config)?; let client = MeilisearchClient::try_from(config)?;
let jwt_secret = let jwt_secret = auth::load_secret(&config.auth.jwt_secret)
auth::load_secret(config).map_err(InitError::LoadSecret)?; .map_err(InitError::LoadJwtSecret)?;
let oidc = auth::get_oidc_client(config).await?; let oidc = config.oidc.clone();
let oidc_client_secret = match &config.oidc.client_secret {
Some(p) => match auth::load_secret(p) {
Ok(s) => Some(String::from_utf8_lossy(&s).trim().to_string()),
Err(e) => return Err(InitError::LoadOidcSecret(e)),
},
None => None,
};
let oidc_http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self { Ok(Self {
client, client,
jwt_secret, jwt_secret,
oidc, oidc,
oidc_client_secret,
oidc_http_client,
}) })
} }
pub async fn oidc(&self) -> Result<auth::OidcClient, error::OidcError> {
auth::get_oidc_client(
self.oidc.discovery_url.clone(),
self.oidc.client_id.clone(),
self.oidc_client_secret.clone(),
self.oidc.callback_url.clone(),
&self.oidc_http_client,
)
.await
}
} }
/// Initialize the application context /// Initialize the application context