use std::sync::Arc;
use std::time::{Duration, Instant};
use openidconnect::core::{
CoreIdToken, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm,
CoreProviderMetadata,
};
use openidconnect::{ClientId, IssuerUrl, JsonWebKeySetUrl, Nonce};
use tokio::sync::RwLock;
use crate::{Error, Result};
pub const ISSUER_ENV: &str = "OIDC_ISSUER_URL";
pub const AUDIENCES_ENV: &str = "OIDC_AUDIENCES";
pub const SETUP_HINT: &str = "Register your apps with an OpenID Connect provider, then set \
OIDC_ISSUER_URL (e.g. https://accounts.google.com) and OIDC_AUDIENCES (their client IDs, \
comma-separated) in .env.";
const JWKS_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer: String,
pub audiences: Vec<String>,
}
impl OidcConfig {
pub fn from_env() -> Result<Self> {
let issuer = std::env::var(ISSUER_ENV)
.ok()
.filter(|issuer| !issuer.trim().is_empty())
.ok_or(Error::MissingOidcSetting(ISSUER_ENV))?;
let audiences: Vec<String> = std::env::var(AUDIENCES_ENV)
.unwrap_or_default()
.split(',')
.map(str::trim)
.filter(|audience| !audience.is_empty())
.map(str::to_owned)
.collect();
if audiences.is_empty() {
return Err(Error::MissingOidcSetting(AUDIENCES_ENV));
}
Ok(Self {
issuer: issuer.trim().to_owned(),
audiences,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub issuer: String,
pub subject: String,
pub email: Option<String>,
pub email_verified: Option<bool>,
pub name: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum VerifyError {
#[error("invalid ID token: {0}")]
Invalid(String),
#[error("cannot reach the OpenID Connect provider: {0}")]
Unavailable(String),
}
#[derive(Clone)]
pub struct OidcVerifier {
inner: Arc<Inner>,
}
struct Inner {
issuer: IssuerUrl,
audiences: Vec<ClientId>,
jwks_url: JsonWebKeySetUrl,
http: openidconnect::reqwest::Client,
jwks: RwLock<(CoreJsonWebKeySet, Instant)>,
}
impl std::fmt::Debug for OidcVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OidcVerifier")
.field("issuer", &self.inner.issuer.as_str())
.field("audiences", &self.inner.audiences)
.finish()
}
}
fn allowed_algs() -> [CoreJwsSigningAlgorithm; 8] {
use CoreJwsSigningAlgorithm::*;
[
RsaSsaPkcs1V15Sha256,
RsaSsaPkcs1V15Sha384,
RsaSsaPkcs1V15Sha512,
RsaSsaPssSha256,
RsaSsaPssSha384,
EcdsaP256Sha256,
EcdsaP384Sha384,
EdDsa,
]
}
impl OidcVerifier {
pub async fn discover(config: &OidcConfig) -> Result<Self> {
let issuer = IssuerUrl::new(config.issuer.clone())
.map_err(|err| Error::Oidc(format!("invalid {ISSUER_ENV}: {err}")))?;
let http = openidconnect::reqwest::ClientBuilder::new()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|err| Error::Oidc(format!("HTTP client: {err}")))?;
let metadata = CoreProviderMetadata::discover_async(issuer.clone(), &http)
.await
.map_err(|err| {
Error::Oidc(format!("discovery at {} failed: {err}", issuer.as_str()))
})?;
let jwks_url = metadata.jwks_uri().clone();
let jwks = CoreJsonWebKeySet::fetch_async(&jwks_url, &http)
.await
.map_err(|err| Error::Oidc(format!("fetching signing keys failed: {err}")))?;
Ok(Self {
inner: Arc::new(Inner {
issuer,
audiences: config
.audiences
.iter()
.cloned()
.map(ClientId::new)
.collect(),
jwks_url,
http,
jwks: RwLock::new((jwks, Instant::now())),
}),
})
}
pub fn issuer(&self) -> &str {
self.inner.issuer.as_str()
}
pub async fn verify(&self, id_token: &str) -> std::result::Result<Identity, VerifyError> {
let token: CoreIdToken = id_token
.trim()
.parse()
.map_err(|err| VerifyError::Invalid(format!("malformed: {err}")))?;
match self.check(&token).await {
Ok(identity) => Ok(identity),
Err(err) if self.refresh_keys_if_stale().await? => {
self.check(&token).await.map_err(|_| err)
}
Err(err) => Err(err),
}
}
async fn check(&self, token: &CoreIdToken) -> std::result::Result<Identity, VerifyError> {
let jwks = self.inner.jwks.read().await.0.clone();
let mut last_error = None;
for audience in &self.inner.audiences {
let verifier = CoreIdTokenVerifier::new_public_client(
audience.clone(),
self.inner.issuer.clone(),
jwks.clone(),
)
.set_allowed_algs(allowed_algs());
match token.claims(&verifier, |_: Option<&Nonce>| Ok(())) {
Ok(claims) => {
return Ok(Identity {
issuer: claims.issuer().to_string(),
subject: claims.subject().to_string(),
email: claims.email().map(|email| email.to_string()),
email_verified: claims.email_verified(),
name: claims
.name()
.and_then(|name| name.get(None))
.map(|name| name.to_string()),
});
}
Err(err) => last_error = Some(err.to_string()),
}
}
Err(VerifyError::Invalid(
last_error.unwrap_or_else(|| "no audience configured".into()),
))
}
async fn refresh_keys_if_stale(&self) -> std::result::Result<bool, VerifyError> {
let mut jwks = self.inner.jwks.write().await;
if jwks.1.elapsed() < JWKS_REFRESH_INTERVAL {
return Ok(false);
}
let fresh = CoreJsonWebKeySet::fetch_async(&self.inner.jwks_url, &self.inner.http)
.await
.map_err(|err| VerifyError::Unavailable(err.to_string()))?;
*jwks = (fresh, Instant::now());
Ok(true)
}
}
#[derive(Clone)]
pub struct OidcClient {
metadata: CoreProviderMetadata,
client_id: ClientId,
client_secret: Option<openidconnect::ClientSecret>,
http: openidconnect::reqwest::Client,
}
impl std::fmt::Debug for OidcClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OidcClient")
.field("issuer", &self.metadata.issuer().as_str())
.field("client_id", &self.client_id.as_str())
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingSignIn {
pub state: String,
pub pkce_verifier: String,
pub nonce: String,
}
impl PendingSignIn {
pub fn encode(&self) -> String {
format!("{}.{}.{}", self.state, self.pkce_verifier, self.nonce)
}
pub fn decode(value: &str) -> Option<Self> {
let mut parts = value.split('.');
let pending = Self {
state: parts.next()?.to_owned(),
pkce_verifier: parts.next()?.to_owned(),
nonce: parts.next()?.to_owned(),
};
parts.next().is_none().then_some(pending)
}
}
impl OidcClient {
pub async fn discover(
issuer: &str,
client_id: &str,
client_secret: Option<&str>,
) -> Result<Self> {
let issuer = IssuerUrl::new(issuer.to_owned())
.map_err(|err| Error::Oidc(format!("invalid issuer URL: {err}")))?;
let http = openidconnect::reqwest::ClientBuilder::new()
.redirect(openidconnect::reqwest::redirect::Policy::none())
.build()
.map_err(|err| Error::Oidc(format!("HTTP client: {err}")))?;
let metadata = CoreProviderMetadata::discover_async(issuer.clone(), &http)
.await
.map_err(|err| {
Error::Oidc(format!("discovery at {} failed: {err}", issuer.as_str()))
})?;
Ok(Self {
metadata,
client_id: ClientId::new(client_id.to_owned()),
client_secret: client_secret
.filter(|secret| !secret.is_empty())
.map(|secret| openidconnect::ClientSecret::new(secret.to_owned())),
http,
})
}
pub fn authorize_url(&self, redirect_url: &str) -> Result<(String, PendingSignIn)> {
let client = openidconnect::core::CoreClient::from_provider_metadata(
self.metadata.clone(),
self.client_id.clone(),
self.client_secret.clone(),
)
.set_redirect_uri(
openidconnect::RedirectUrl::new(redirect_url.to_owned())
.map_err(|err| Error::Oidc(format!("invalid redirect URL: {err}")))?,
);
let (challenge, verifier) = openidconnect::PkceCodeChallenge::new_random_sha256();
let (url, state, nonce) = client
.authorize_url(
openidconnect::core::CoreAuthenticationFlow::AuthorizationCode,
openidconnect::CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(openidconnect::Scope::new("email".into()))
.add_scope(openidconnect::Scope::new("profile".into()))
.set_pkce_challenge(challenge)
.url();
Ok((
url.to_string(),
PendingSignIn {
state: state.secret().clone(),
pkce_verifier: verifier.secret().clone(),
nonce: nonce.secret().clone(),
},
))
}
pub async fn exchange(
&self,
redirect_url: &str,
code: &str,
state: &str,
pending: &PendingSignIn,
) -> Result<String> {
if state != pending.state {
return Err(Error::Oidc(
"sign-in state does not match; start again".into(),
));
}
let client = openidconnect::core::CoreClient::from_provider_metadata(
self.metadata.clone(),
self.client_id.clone(),
self.client_secret.clone(),
)
.set_redirect_uri(
openidconnect::RedirectUrl::new(redirect_url.to_owned())
.map_err(|err| Error::Oidc(format!("invalid redirect URL: {err}")))?,
);
let response = client
.exchange_code(openidconnect::AuthorizationCode::new(code.to_owned()))
.map_err(|err| Error::Oidc(format!("token endpoint: {err}")))?
.set_pkce_verifier(openidconnect::PkceCodeVerifier::new(
pending.pkce_verifier.clone(),
))
.request_async(&self.http)
.await
.map_err(|err| Error::Oidc(format!("exchanging the code failed: {err}")))?;
let id_token = openidconnect::TokenResponse::id_token(&response)
.ok_or_else(|| Error::Oidc("the provider returned no ID token".into()))?;
let verifier = client.id_token_verifier().set_allowed_algs(allowed_algs());
id_token
.claims(&verifier, &Nonce::new(pending.nonce.clone()))
.map_err(|err| Error::Oidc(format!("invalid ID token: {err}")))?;
Ok(id_token.to_string())
}
}