use std::{sync::Arc, time::Duration};
use data_encoding::{BASE64, BASE64URL_NOPAD};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use super::ProviderIdentity;
use crate::Text;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_RESPONSE_BYTES: u64 = 256 * 1024;
const CLOCK_SKEW_SECS: i64 = 300;
const SCOPES: &str = "openid email profile";
pub(super) const RESERVED_PARAMS: [&str; 8] = [
"response_type",
"client_id",
"redirect_uri",
"scope",
"state",
"nonce",
"code_challenge",
"code_challenge_method",
];
pub struct OidcProvider {
pub(super) key: String,
pub(super) label: Text,
issuer: String,
issuers: Vec<String>,
client_id: String,
client_secret: String,
scopes: Vec<String>,
pub(super) params: Vec<(String, String)>,
endpoints: Arc<tokio::sync::OnceCell<Endpoints>>,
}
impl OidcProvider {
#[must_use]
pub fn google(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
let provider = Self::new(
"google",
"Google",
"https://accounts.google.com",
client_id,
client_secret,
);
let endpoints = Endpoints {
authorization: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
token: "https://oauth2.googleapis.com/token".to_string(),
};
Self {
issuers: vec![
"https://accounts.google.com".to_string(),
"accounts.google.com".to_string(),
],
endpoints: Arc::new(tokio::sync::OnceCell::new_with(Some(endpoints))),
..provider
}
}
#[must_use]
pub fn new(
key: impl Into<String>,
label: impl Into<Text>,
issuer: impl Into<String>,
client_id: impl Into<String>,
client_secret: impl Into<String>,
) -> Self {
let issuer = issuer.into();
Self {
key: key.into(),
label: label.into(),
issuers: vec![issuer.clone()],
issuer,
client_id: client_id.into(),
client_secret: client_secret.into(),
scopes: Vec::new(),
params: Vec::new(),
endpoints: Arc::new(tokio::sync::OnceCell::new()),
}
}
#[must_use]
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scopes.push(scope.into());
self
}
#[must_use]
pub fn param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.params.push((name.into(), value.into()));
self
}
pub(super) fn key_is_valid(&self) -> bool {
!self.key.is_empty()
&& self
.key
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
}
pub(super) fn issuer_is_valid(&self) -> bool {
secure_url(&self.issuer)
}
pub(super) async fn authorization_url(
&self,
redirect_uri: &str,
pending: &Pending,
) -> Result<String, Failure> {
let endpoints = self.endpoints().await?;
let scope = std::iter::once(SCOPES)
.chain(self.scopes.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ");
let mut query = form_urlencoded::Serializer::new(String::new());
query
.append_pair("response_type", "code")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", redirect_uri)
.append_pair("scope", &scope)
.append_pair("state", &pending.state)
.append_pair("nonce", &pending.nonce)
.append_pair("code_challenge", &code_challenge(&pending.verifier))
.append_pair("code_challenge_method", "S256");
for (name, value) in &self.params {
query.append_pair(name, value);
}
let query = query.finish();
let separator = if endpoints.authorization.contains('?') {
'&'
} else {
'?'
};
Ok(format!("{}{separator}{query}", endpoints.authorization))
}
pub(super) async fn redeem(
&self,
code: &str,
redirect_uri: &str,
pending: &Pending,
) -> Result<ProviderIdentity, Failure> {
let endpoints = self.endpoints().await?;
let form = [
("grant_type".to_string(), "authorization_code".to_string()),
("code".to_string(), code.to_string()),
("redirect_uri".to_string(), redirect_uri.to_string()),
("code_verifier".to_string(), pending.verifier.clone()),
];
let encode =
|value: &str| form_urlencoded::byte_serialize(value.as_bytes()).collect::<String>();
let credentials = format!(
"{}:{}",
encode(&self.client_id),
encode(&self.client_secret)
);
let authorization = format!("Basic {}", BASE64.encode(credentials.as_bytes()));
let token = endpoints.token.clone();
let answer: TokenResponse = request(move |agent| {
agent
.post(&token)
.header("authorization", &authorization)
.header("accept", "application/json")
.send_form(
form.iter()
.map(|(key, value)| (key.as_str(), value.as_str())),
)
})
.await?;
let now = jiff::Timestamp::now().as_second();
identity(self, &answer.id_token, &pending.nonce, now).ok_or(Failure::Refused)
}
async fn endpoints(&self) -> Result<&Endpoints, Failure> {
self.endpoints
.get_or_try_init(|| discover(self.issuer.clone()))
.await
}
}
impl std::fmt::Debug for OidcProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OidcProvider")
.field("key", &self.key)
.field("issuer", &self.issuer)
.field("client_id", &self.client_id)
.field("client_secret", &"<redacted>")
.finish_non_exhaustive()
}
}
#[derive(Debug)]
struct Endpoints {
authorization: String,
token: String,
}
#[derive(Debug, Clone, serde::Serialize, Deserialize)]
pub(super) struct Pending {
pub(super) panel: String,
pub(super) provider: String,
pub(super) state: String,
pub(super) nonce: String,
pub(super) verifier: String,
pub(super) next: String,
pub(super) started: i64,
}
impl Pending {
pub(super) fn new(panel: String, provider: String, next: String) -> Self {
Self {
panel,
provider,
state: secret(),
nonce: secret(),
verifier: secret(),
next,
started: jiff::Timestamp::now().as_second(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Failure {
Unavailable,
Refused,
}
fn secret() -> String {
let mut bytes = [0; 32];
getrandom::fill(&mut bytes).expect("the sign-in randomness source failed");
BASE64URL_NOPAD.encode(&bytes)
}
pub(super) fn code_challenge(verifier: &str) -> String {
BASE64URL_NOPAD.encode(&Sha256::digest(verifier.as_bytes()))
}
pub(super) fn secure_url(url: &str) -> bool {
let Ok(parsed) = url::Url::parse(url) else {
return false;
};
match parsed.scheme() {
"https" => parsed.host().is_some(),
"http" => match parsed.host() {
Some(url::Host::Domain(domain)) => domain == "localhost",
Some(url::Host::Ipv4(address)) => address.is_loopback(),
Some(url::Host::Ipv6(address)) => address.is_loopback(),
None => false,
},
_ => false,
}
}
async fn discover(issuer: String) -> Result<Endpoints, Failure> {
#[derive(Deserialize)]
struct Discovery {
issuer: String,
authorization_endpoint: String,
token_endpoint: String,
}
let url = format!(
"{}/.well-known/openid-configuration",
issuer.trim_end_matches('/')
);
let document: Discovery = request(move |agent| agent.get(&url).call()).await?;
let endpoints_secure =
secure_url(&document.authorization_endpoint) && secure_url(&document.token_endpoint);
if document.issuer != issuer || !endpoints_secure {
tracing::error!(
%issuer,
discovered = %document.issuer,
endpoints_secure,
"an OpenID Connect discovery document failed its checks"
);
return Err(Failure::Refused);
}
Ok(Endpoints {
authorization: document.authorization_endpoint,
token: document.token_endpoint,
})
}
#[derive(Deserialize)]
struct TokenResponse {
id_token: String,
}
async fn request<T, F>(send: F) -> Result<T, Failure>
where
T: serde::de::DeserializeOwned + Send + 'static,
F: FnOnce(&ureq::Agent) -> Result<http::Response<ureq::Body>, ureq::Error> + Send + 'static,
{
let answer = tokio::task::spawn_blocking(move || {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(REQUEST_TIMEOUT))
.http_status_as_error(false)
.max_redirects(0)
.build()
.into();
let mut response = send(&agent).map_err(|error| {
tracing::warn!(%error, "an OpenID Connect provider could not be reached");
Failure::Unavailable
})?;
let status = response.status();
if status.is_server_error() {
tracing::warn!(%status, "an OpenID Connect provider answered with a server error");
return Err(Failure::Unavailable);
}
if !status.is_success() {
tracing::warn!(%status, "an OpenID Connect provider refused a request");
return Err(Failure::Refused);
}
response
.body_mut()
.with_config()
.limit(MAX_RESPONSE_BYTES)
.read_to_string()
.map_err(|_| Failure::Unavailable)
})
.await
.map_err(|_| Failure::Unavailable)??;
serde_json::from_str(&answer).map_err(|error| {
tracing::warn!(%error, "an OpenID Connect provider's answer does not parse");
Failure::Refused
})
}
pub(super) fn identity(
provider: &OidcProvider,
id_token: &str,
nonce: &str,
now: i64,
) -> Option<ProviderIdentity> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Audience {
One(String),
Many(Vec<String>),
}
#[derive(Deserialize)]
#[serde(untagged)]
enum Flag {
Bool(bool),
Text(String),
}
#[derive(Deserialize)]
struct Claims {
iss: String,
sub: String,
aud: Audience,
azp: Option<String>,
exp: i64,
iat: i64,
nonce: Option<String>,
email: Option<String>,
email_verified: Option<Flag>,
name: Option<String>,
}
let mut parts = id_token.split('.');
let (Some(_header), Some(payload), Some(_signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return None;
};
let payload = BASE64URL_NOPAD
.decode(payload.trim_end_matches('=').as_bytes())
.ok()?;
let all: serde_json::Map<String, serde_json::Value> = serde_json::from_slice(&payload).ok()?;
let claims: Claims = serde_json::from_value(serde_json::Value::Object(all.clone())).ok()?;
let audience = match claims.aud {
Audience::One(one) => vec![one],
Audience::Many(many) => many,
};
let issued_here = provider.issuers.contains(&claims.iss);
let addressed_here = audience.contains(&provider.client_id)
&& (audience.len() == 1 || claims.azp.is_some())
&& claims
.azp
.as_ref()
.is_none_or(|azp| *azp == provider.client_id);
let live = claims.exp > now - CLOCK_SKEW_SECS && claims.iat < now + CLOCK_SKEW_SECS;
let ours = claims.nonce.as_deref().is_some_and(|claimed| {
subtle::ConstantTimeEq::ct_eq(claimed.as_bytes(), nonce.as_bytes()).into()
});
if !(issued_here && addressed_here && live && ours) || claims.sub.is_empty() {
return None;
}
let email_verified = match claims.email_verified {
Some(Flag::Bool(verified)) => verified,
Some(Flag::Text(text)) => text == "true",
None => false,
};
Some(ProviderIdentity {
provider: provider.key.clone(),
issuer: provider.issuer.clone(),
subject: claims.sub,
email: claims.email.filter(|email| !email.is_empty()),
email_verified,
name: claims.name.filter(|name| !name.is_empty()),
claims: all,
})
}