use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use std::time::Duration;
use openidconnect::core::{
CoreAuthenticationFlow, CoreClient, CoreProviderMetadata, CoreUserInfoClaims,
};
use openidconnect::reqwest as oidc_reqwest;
use openidconnect::{
AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, Scope, SubjectIdentifier, TokenResponse,
};
use parking_lot::RwLock;
use url::Url;
use crate::error::{Error, Result};
pub const DEFAULT_UPSTREAM_SCOPES: &[&str] = &["openid", "email", "profile"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UpstreamProvider {
pub slug: String,
pub issuer: String,
pub client_id: String,
pub client_secret: String,
pub scopes: Vec<String>,
pub auth_params: BTreeMap<String, String>,
}
#[derive(Clone, Debug)]
pub struct UpstreamUserInfo {
pub provider: String,
pub subject: String,
pub email: Option<String>,
pub email_verified: bool,
pub name: Option<String>,
pub picture: Option<String>,
pub raw_claims: serde_json::Value,
}
pub struct OidcClient {
inner: CoreClient<
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>,
provider: UpstreamProvider,
redirect_uri: RedirectUrl,
}
impl OidcClient {
pub fn provider(&self) -> &UpstreamProvider {
&self.provider
}
pub fn redirect_uri(&self) -> &RedirectUrl {
&self.redirect_uri
}
pub fn start_login(&self, state: CsrfToken) -> StartedLogin {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let mut request = self.inner.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
move || state,
Nonce::new_random,
);
for scope in &self.provider.scopes {
if scope == "openid" {
continue;
}
request = request.add_scope(Scope::new(scope.clone()));
}
for (k, v) in &self.provider.auth_params {
request = request.add_extra_param(k.clone(), v.clone());
}
let (url, csrf_token, nonce) = request.set_pkce_challenge(pkce_challenge).url();
StartedLogin {
url,
csrf_token,
nonce,
pkce_verifier,
}
}
pub async fn complete_login(
&self,
code: String,
pkce_verifier: PkceCodeVerifier,
nonce: Nonce,
) -> Result<UpstreamUserInfo> {
let http = build_oidc_http_client(HttpClientOptions::default())?;
let token_response = self
.inner
.exchange_code(AuthorizationCode::new(code))
.map_err(|e| Error::Oidc(format!("exchange_code config: {e}")))?
.set_pkce_verifier(pkce_verifier)
.request_async(&http)
.await
.map_err(|e| Error::Oidc(format!("token exchange: {e}")))?;
let id_token = token_response
.id_token()
.ok_or_else(|| Error::Oidc("upstream returned no id_token".to_string()))?;
let id_token_verifier = self.inner.id_token_verifier();
let claims = id_token
.claims(&id_token_verifier, &nonce)
.map_err(|e| Error::Oidc(format!("id_token verify: {e}")))?;
let subject = claims.subject().to_string();
let mut email = claims.email().map(|e| e.to_string());
let mut email_verified = claims.email_verified().unwrap_or(false);
let mut name = claims
.name()
.and_then(|map| map.get(None))
.map(|n| n.to_string());
let mut picture = claims
.picture()
.and_then(|map| map.get(None))
.map(|u| u.to_string());
let mut raw_claims =
serde_json::to_value(claims).unwrap_or_else(|_| serde_json::json!({"sub": subject}));
if let Ok(req) = self.inner.user_info(
token_response.access_token().clone(),
Some(SubjectIdentifier::new(subject.clone())),
) && let Ok(userinfo) = req.request_async(&http).await
{
let user_claims: CoreUserInfoClaims = userinfo;
if email.is_none() {
email = user_claims.email().map(|e| e.to_string());
}
if !email_verified {
email_verified = user_claims.email_verified().unwrap_or(email_verified);
}
if name.is_none() {
name = user_claims
.name()
.and_then(|map| map.get(None))
.map(|n| n.to_string());
}
if picture.is_none() {
picture = user_claims
.picture()
.and_then(|map| map.get(None))
.map(|u| u.to_string());
}
if let Ok(userinfo_value) = serde_json::to_value(&user_claims) {
merge_json(&mut raw_claims, userinfo_value);
}
}
Ok(UpstreamUserInfo {
provider: self.provider.slug.clone(),
subject,
email,
email_verified,
name,
picture,
raw_claims,
})
}
}
pub struct StartedLogin {
pub url: Url,
pub csrf_token: CsrfToken,
pub nonce: Nonce,
pub pkce_verifier: PkceCodeVerifier,
}
#[derive(Clone, Default)]
pub struct OidcRegistry {
inner: Arc<RwLock<HashMap<String, Arc<OidcClient>>>>,
}
impl OidcRegistry {
pub fn new() -> Self {
Self::default()
}
pub async fn add(&self, provider: UpstreamProvider, redirect_uri: Url) -> Result<()> {
let issuer = IssuerUrl::new(provider.issuer.clone())
.map_err(|e| Error::Oidc(format!("issuer url {}: {e}", provider.issuer)))?;
let http = build_oidc_http_client(HttpClientOptions::default())?;
let metadata = CoreProviderMetadata::discover_async(issuer, &http)
.await
.map_err(|e| Error::Oidc(format!("discover {}: {e}", provider.slug)))?;
let redirect = RedirectUrl::new(redirect_uri.to_string())
.map_err(|e| Error::Oidc(format!("redirect_uri {redirect_uri}: {e}")))?;
let client_secret = if provider.client_secret.is_empty() {
None
} else {
Some(ClientSecret::new(provider.client_secret.clone()))
};
let inner = CoreClient::from_provider_metadata(
metadata,
ClientId::new(provider.client_id.clone()),
client_secret,
)
.set_redirect_uri(redirect.clone());
let client = OidcClient {
inner,
provider: provider.clone(),
redirect_uri: redirect,
};
self.inner
.write()
.insert(provider.slug.clone(), Arc::new(client));
Ok(())
}
pub fn client(&self, slug: &str) -> Option<Arc<OidcClient>> {
self.inner.read().get(slug).cloned()
}
pub fn slugs(&self) -> Vec<String> {
self.inner.read().keys().cloned().collect()
}
pub fn remove(&self, slug: &str) -> bool {
self.inner.write().remove(slug).is_some()
}
pub fn len(&self) -> usize {
self.inner.read().len()
}
pub fn is_empty(&self) -> bool {
self.inner.read().is_empty()
}
}
#[derive(Clone, Debug)]
pub struct HttpClientOptions {
pub connect_timeout_secs: u64,
pub request_timeout_secs: u64,
}
impl Default for HttpClientOptions {
fn default() -> Self {
Self {
connect_timeout_secs: 5,
request_timeout_secs: 10,
}
}
}
pub fn build_oidc_http_client(opts: HttpClientOptions) -> Result<oidc_reqwest::Client> {
oidc_reqwest::ClientBuilder::new()
.redirect(oidc_reqwest::redirect::Policy::none())
.connect_timeout(Duration::from_secs(opts.connect_timeout_secs))
.timeout(Duration::from_secs(opts.request_timeout_secs))
.build()
.map_err(|e| Error::Oidc(format!("build oidc http client: {e}")))
}
fn merge_json(target: &mut serde_json::Value, src: serde_json::Value) {
match (target, src) {
(serde_json::Value::Object(a), serde_json::Value::Object(b)) => {
for (k, v) in b {
merge_json(a.entry(k).or_insert(serde_json::Value::Null), v);
}
}
(slot, src) => {
if !src.is_null() {
*slot = src;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_starts_empty() {
let reg = OidcRegistry::new();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
assert!(reg.client("google").is_none());
assert!(reg.slugs().is_empty());
}
#[test]
fn merge_json_merges_objects_and_keeps_existing_on_null() {
let mut a = serde_json::json!({"email": "a@x", "groups": ["a"]});
let b = serde_json::json!({"email": serde_json::Value::Null, "name": "Alice"});
merge_json(&mut a, b);
assert_eq!(a["email"], "a@x");
assert_eq!(a["name"], "Alice");
assert_eq!(a["groups"], serde_json::json!(["a"]));
}
#[test]
fn upstream_provider_record_is_clonable() {
let p = UpstreamProvider {
slug: "google".to_string(),
issuer: "https://accounts.google.com".to_string(),
client_id: "client".to_string(),
client_secret: "secret".to_string(),
scopes: vec!["openid".to_string(), "email".to_string()],
auth_params: BTreeMap::new(),
};
let dup = p.clone();
assert_eq!(p, dup);
}
#[tokio::test]
async fn discover_unreachable_issuer_returns_oidc_error() {
let reg = OidcRegistry::new();
let provider = UpstreamProvider {
slug: "ghost".to_string(),
issuer: "http://127.0.0.1:1/oidc".to_string(),
client_id: "client".to_string(),
client_secret: "secret".to_string(),
scopes: vec!["openid".to_string()],
auth_params: BTreeMap::new(),
};
let redirect = Url::parse("https://example.com/login/ghost/callback").unwrap();
let result = reg.add(provider, redirect).await;
assert!(matches!(result, Err(Error::Oidc(_))));
}
}