1use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use openidconnect::core::{
17 CoreIdToken, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm,
18 CoreProviderMetadata,
19};
20use openidconnect::{ClientId, IssuerUrl, JsonWebKeySetUrl, Nonce};
21use tokio::sync::RwLock;
22
23use crate::{Error, Result};
24
25pub const ISSUER_ENV: &str = "OIDC_ISSUER_URL";
27pub const AUDIENCES_ENV: &str = "OIDC_AUDIENCES";
29
30pub const SETUP_HINT: &str = "Register your apps with an OpenID Connect provider, then set \
32 OIDC_ISSUER_URL (e.g. https://accounts.google.com) and OIDC_AUDIENCES (their client IDs, \
33 comma-separated) in .env.";
34
35const JWKS_REFRESH_INTERVAL: Duration = Duration::from_secs(30);
37
38#[derive(Debug, Clone)]
40pub struct OidcConfig {
41 pub issuer: String,
42 pub audiences: Vec<String>,
44}
45
46impl OidcConfig {
47 pub fn from_env() -> Result<Self> {
49 let issuer = std::env::var(ISSUER_ENV)
50 .ok()
51 .filter(|issuer| !issuer.trim().is_empty())
52 .ok_or(Error::MissingOidcSetting(ISSUER_ENV))?;
53 let audiences: Vec<String> = std::env::var(AUDIENCES_ENV)
54 .unwrap_or_default()
55 .split(',')
56 .map(str::trim)
57 .filter(|audience| !audience.is_empty())
58 .map(str::to_owned)
59 .collect();
60 if audiences.is_empty() {
61 return Err(Error::MissingOidcSetting(AUDIENCES_ENV));
62 }
63 Ok(Self {
64 issuer: issuer.trim().to_owned(),
65 audiences,
66 })
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Identity {
73 pub issuer: String,
75 pub subject: String,
77 pub email: Option<String>,
78 pub email_verified: Option<bool>,
79 pub name: Option<String>,
80}
81
82#[derive(Debug, thiserror::Error)]
84pub enum VerifyError {
85 #[error("invalid ID token: {0}")]
87 Invalid(String),
88 #[error("cannot reach the OpenID Connect provider: {0}")]
90 Unavailable(String),
91}
92
93#[derive(Clone)]
95pub struct OidcVerifier {
96 inner: Arc<Inner>,
97}
98
99struct Inner {
100 issuer: IssuerUrl,
101 audiences: Vec<ClientId>,
102 jwks_url: JsonWebKeySetUrl,
103 http: openidconnect::reqwest::Client,
104 jwks: RwLock<(CoreJsonWebKeySet, Instant)>,
105}
106
107impl std::fmt::Debug for OidcVerifier {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 f.debug_struct("OidcVerifier")
110 .field("issuer", &self.inner.issuer.as_str())
111 .field("audiences", &self.inner.audiences)
112 .finish()
113 }
114}
115
116fn allowed_algs() -> [CoreJwsSigningAlgorithm; 8] {
118 use CoreJwsSigningAlgorithm::*;
119 [
120 RsaSsaPkcs1V15Sha256,
121 RsaSsaPkcs1V15Sha384,
122 RsaSsaPkcs1V15Sha512,
123 RsaSsaPssSha256,
124 RsaSsaPssSha384,
125 EcdsaP256Sha256,
126 EcdsaP384Sha384,
127 EdDsa,
128 ]
129}
130
131impl OidcVerifier {
132 pub async fn discover(config: &OidcConfig) -> Result<Self> {
135 let issuer = IssuerUrl::new(config.issuer.clone())
136 .map_err(|err| Error::Oidc(format!("invalid {ISSUER_ENV}: {err}")))?;
137 let http = openidconnect::reqwest::ClientBuilder::new()
138 .redirect(openidconnect::reqwest::redirect::Policy::none())
139 .build()
140 .map_err(|err| Error::Oidc(format!("HTTP client: {err}")))?;
141 let metadata = CoreProviderMetadata::discover_async(issuer.clone(), &http)
142 .await
143 .map_err(|err| {
144 Error::Oidc(format!("discovery at {} failed: {err}", issuer.as_str()))
145 })?;
146 let jwks_url = metadata.jwks_uri().clone();
147 let jwks = CoreJsonWebKeySet::fetch_async(&jwks_url, &http)
148 .await
149 .map_err(|err| Error::Oidc(format!("fetching signing keys failed: {err}")))?;
150 Ok(Self {
151 inner: Arc::new(Inner {
152 issuer,
153 audiences: config
154 .audiences
155 .iter()
156 .cloned()
157 .map(ClientId::new)
158 .collect(),
159 jwks_url,
160 http,
161 jwks: RwLock::new((jwks, Instant::now())),
162 }),
163 })
164 }
165
166 pub fn issuer(&self) -> &str {
168 self.inner.issuer.as_str()
169 }
170
171 pub async fn verify(&self, id_token: &str) -> std::result::Result<Identity, VerifyError> {
173 let token: CoreIdToken = id_token
174 .trim()
175 .parse()
176 .map_err(|err| VerifyError::Invalid(format!("malformed: {err}")))?;
177
178 match self.check(&token).await {
179 Ok(identity) => Ok(identity),
180 Err(err) if self.refresh_keys_if_stale().await? => {
182 self.check(&token).await.map_err(|_| err)
183 }
184 Err(err) => Err(err),
185 }
186 }
187
188 async fn check(&self, token: &CoreIdToken) -> std::result::Result<Identity, VerifyError> {
189 let jwks = self.inner.jwks.read().await.0.clone();
190 let mut last_error = None;
191 for audience in &self.inner.audiences {
192 let verifier = CoreIdTokenVerifier::new_public_client(
193 audience.clone(),
194 self.inner.issuer.clone(),
195 jwks.clone(),
196 )
197 .set_allowed_algs(allowed_algs());
198 match token.claims(&verifier, |_: Option<&Nonce>| Ok(())) {
200 Ok(claims) => {
201 return Ok(Identity {
202 issuer: claims.issuer().to_string(),
203 subject: claims.subject().to_string(),
204 email: claims.email().map(|email| email.to_string()),
205 email_verified: claims.email_verified(),
206 name: claims
207 .name()
208 .and_then(|name| name.get(None))
209 .map(|name| name.to_string()),
210 });
211 }
212 Err(err) => last_error = Some(err.to_string()),
213 }
214 }
215 Err(VerifyError::Invalid(
216 last_error.unwrap_or_else(|| "no audience configured".into()),
217 ))
218 }
219
220 async fn refresh_keys_if_stale(&self) -> std::result::Result<bool, VerifyError> {
222 let mut jwks = self.inner.jwks.write().await;
223 if jwks.1.elapsed() < JWKS_REFRESH_INTERVAL {
224 return Ok(false);
225 }
226 let fresh = CoreJsonWebKeySet::fetch_async(&self.inner.jwks_url, &self.inner.http)
227 .await
228 .map_err(|err| VerifyError::Unavailable(err.to_string()))?;
229 *jwks = (fresh, Instant::now());
230 Ok(true)
231 }
232}
233
234#[derive(Clone)]
244pub struct OidcClient {
245 metadata: CoreProviderMetadata,
246 client_id: ClientId,
247 client_secret: Option<openidconnect::ClientSecret>,
248 http: openidconnect::reqwest::Client,
249}
250
251impl std::fmt::Debug for OidcClient {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 f.debug_struct("OidcClient")
254 .field("issuer", &self.metadata.issuer().as_str())
255 .field("client_id", &self.client_id.as_str())
256 .finish()
257 }
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct PendingSignIn {
265 pub state: String,
266 pub pkce_verifier: String,
267 pub nonce: String,
268}
269
270impl PendingSignIn {
271 pub fn encode(&self) -> String {
273 format!("{}.{}.{}", self.state, self.pkce_verifier, self.nonce)
274 }
275
276 pub fn decode(value: &str) -> Option<Self> {
277 let mut parts = value.split('.');
278 let pending = Self {
279 state: parts.next()?.to_owned(),
280 pkce_verifier: parts.next()?.to_owned(),
281 nonce: parts.next()?.to_owned(),
282 };
283 parts.next().is_none().then_some(pending)
284 }
285}
286
287impl OidcClient {
288 pub async fn discover(
291 issuer: &str,
292 client_id: &str,
293 client_secret: Option<&str>,
294 ) -> Result<Self> {
295 let issuer = IssuerUrl::new(issuer.to_owned())
296 .map_err(|err| Error::Oidc(format!("invalid issuer URL: {err}")))?;
297 let http = openidconnect::reqwest::ClientBuilder::new()
298 .redirect(openidconnect::reqwest::redirect::Policy::none())
299 .build()
300 .map_err(|err| Error::Oidc(format!("HTTP client: {err}")))?;
301 let metadata = CoreProviderMetadata::discover_async(issuer.clone(), &http)
302 .await
303 .map_err(|err| {
304 Error::Oidc(format!("discovery at {} failed: {err}", issuer.as_str()))
305 })?;
306 Ok(Self {
307 metadata,
308 client_id: ClientId::new(client_id.to_owned()),
309 client_secret: client_secret
310 .filter(|secret| !secret.is_empty())
311 .map(|secret| openidconnect::ClientSecret::new(secret.to_owned())),
312 http,
313 })
314 }
315
316 pub fn authorize_url(&self, redirect_url: &str) -> Result<(String, PendingSignIn)> {
319 let client = openidconnect::core::CoreClient::from_provider_metadata(
320 self.metadata.clone(),
321 self.client_id.clone(),
322 self.client_secret.clone(),
323 )
324 .set_redirect_uri(
325 openidconnect::RedirectUrl::new(redirect_url.to_owned())
326 .map_err(|err| Error::Oidc(format!("invalid redirect URL: {err}")))?,
327 );
328 let (challenge, verifier) = openidconnect::PkceCodeChallenge::new_random_sha256();
329 let (url, state, nonce) = client
330 .authorize_url(
331 openidconnect::core::CoreAuthenticationFlow::AuthorizationCode,
332 openidconnect::CsrfToken::new_random,
333 Nonce::new_random,
334 )
335 .add_scope(openidconnect::Scope::new("email".into()))
336 .add_scope(openidconnect::Scope::new("profile".into()))
337 .set_pkce_challenge(challenge)
338 .url();
339 Ok((
340 url.to_string(),
341 PendingSignIn {
342 state: state.secret().clone(),
343 pkce_verifier: verifier.secret().clone(),
344 nonce: nonce.secret().clone(),
345 },
346 ))
347 }
348
349 pub async fn exchange(
352 &self,
353 redirect_url: &str,
354 code: &str,
355 state: &str,
356 pending: &PendingSignIn,
357 ) -> Result<String> {
358 if state != pending.state {
359 return Err(Error::Oidc(
360 "sign-in state does not match; start again".into(),
361 ));
362 }
363 let client = openidconnect::core::CoreClient::from_provider_metadata(
364 self.metadata.clone(),
365 self.client_id.clone(),
366 self.client_secret.clone(),
367 )
368 .set_redirect_uri(
369 openidconnect::RedirectUrl::new(redirect_url.to_owned())
370 .map_err(|err| Error::Oidc(format!("invalid redirect URL: {err}")))?,
371 );
372 let response = client
373 .exchange_code(openidconnect::AuthorizationCode::new(code.to_owned()))
374 .map_err(|err| Error::Oidc(format!("token endpoint: {err}")))?
375 .set_pkce_verifier(openidconnect::PkceCodeVerifier::new(
376 pending.pkce_verifier.clone(),
377 ))
378 .request_async(&self.http)
379 .await
380 .map_err(|err| Error::Oidc(format!("exchanging the code failed: {err}")))?;
381 let id_token = openidconnect::TokenResponse::id_token(&response)
382 .ok_or_else(|| Error::Oidc("the provider returned no ID token".into()))?;
383 let verifier = client.id_token_verifier().set_allowed_algs(allowed_algs());
384 id_token
385 .claims(&verifier, &Nonce::new(pending.nonce.clone()))
386 .map_err(|err| Error::Oidc(format!("invalid ID token: {err}")))?;
387 Ok(id_token.to_string())
388 }
389}