1mod linker;
6mod provider;
7
8use std::{marker::PhantomData, time::Duration};
9
10use topcoat::{
11 context::Cx,
12 cookie::{Cookie, Cookies, SameSite, cookies, try_private_cookies},
13 router::{
14 Body, RouteFuture,
15 error::{not_found, see_other},
16 request::{client_ip, uri},
17 response::{IntoResponse, Response},
18 },
19 view::{BoxView, ViewExt, view},
20};
21
22use self::{
23 linker::DynLinker,
24 provider::{Failure, Pending},
25};
26pub use self::{
27 linker::{IdentityLinker, ProviderIdentity},
28 provider::OidcProvider,
29};
30use super::{
31 Auth, PanelUser, infrastructure_failure,
32 login::{admit, login_url, next_from_query},
33 mail::private,
34 realm::{Realm, current_realm, panel_prefix},
35 registration::with_next,
36};
37use crate::{flash::set_notification, outcome::Notification};
38
39#[cfg(test)]
40mod tests;
41
42const PENDING_LIFETIME: Duration = Duration::from_mins(10);
44
45const COOKIE_NAME: &str = "__Host-tablo-oidc";
48
49const THROTTLE_SCOPE: &str = "provider-sign-in";
51
52pub struct Oidc<U> {
80 base_url: String,
82 linker: Box<dyn DynLinker>,
83 pub(super) providers: Vec<OidcProvider>,
84 user: PhantomData<fn() -> U>,
85}
86
87impl<U: PanelUser> Oidc<U> {
88 #[must_use]
97 pub fn new(base_url: impl Into<String>, linker: impl IdentityLinker<User = U>) -> Self {
98 Self {
99 base_url: base_url.into().trim_end_matches('/').to_string(),
100 linker: Box::new(linker),
101 providers: Vec::new(),
102 user: PhantomData,
103 }
104 }
105
106 #[must_use]
108 pub fn provider(mut self, provider: OidcProvider) -> Self {
109 self.providers.push(provider);
110 self
111 }
112
113 fn erase(self) -> Oidc<()> {
114 Oidc {
115 base_url: self.base_url,
116 linker: self.linker,
117 providers: self.providers,
118 user: PhantomData,
119 }
120 }
121}
122
123impl Oidc<()> {
124 fn find(&self, key: &str) -> Option<&OidcProvider> {
126 self.providers.iter().find(|provider| provider.key == key)
127 }
128
129 fn redirect_uri(&self, prefix: &str, key: &str) -> String {
131 format!("{}{prefix}/sign-in/{key}/callback", self.base_url)
132 }
133}
134
135impl<U> std::fmt::Debug for Oidc<U> {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("Oidc")
138 .field("base_url", &self.base_url)
139 .field("providers", &self.providers)
140 .finish_non_exhaustive()
141 }
142}
143
144impl<U: PanelUser> Auth<U> {
145 #[must_use]
158 pub fn oidc(mut self, oidc: Oidc<U>) -> Self {
159 self.config.oidc = Some(oidc.erase());
160 self
161 }
162}
163
164fn start_url(cx: &Cx, key: &str) -> String {
166 format!("{}/sign-in/{key}", panel_prefix(cx))
167}
168
169fn route_of<'a>(cx: &'a Cx, path: &str) -> Option<(&'a OidcProvider, bool)> {
172 let oidc = current_realm(cx)?.auth.oidc()?;
173 let rest = path
174 .strip_prefix(&panel_prefix(cx))?
175 .strip_prefix("/sign-in/")?;
176 let (key, callback) = match rest.strip_suffix("/callback") {
177 Some(key) => (key, true),
178 None => (rest, false),
179 };
180 Some((oidc.find(key)?, callback))
181}
182
183pub(super) fn serves(cx: &Cx, path: &str) -> bool {
186 route_of(cx, path).is_some()
187}
188
189pub(super) fn buttons<'a>(cx: &'a Cx, next: &str) -> Option<BoxView<'a>> {
192 let oidc = current_realm(cx)?.auth.oidc()?;
193 let links: Vec<(String, String, String)> = oidc
194 .providers
195 .iter()
196 .map(|provider| {
197 let label = crate::t!(
198 cx,
199 "tablo-oidc-continue",
200 provider = provider.label.localize(cx)
201 );
202 let href = with_next(start_url(cx, &provider.key), next);
203 (provider.key.clone(), href, label)
204 })
205 .collect();
206 let or = crate::t!(cx, "tablo-oidc-or");
207 Some(
208 view! {
209 <div class="flex flex-col gap-3">
210 <div
211 class="flex items-center gap-3 text-xs text-muted-foreground uppercase"
212 >
213 <span class="h-px flex-1 bg-border"></span>
214 (or)
215 <span class="h-px flex-1 bg-border"></span>
216 </div>
217 for (key, href, label) in links {
218 <a
221 href=(href)
222 data-oidc-provider=(key)
223 class=(tablo_ui::button_variants(
224 tablo_ui::ButtonVariant::Outline,
225 tablo_ui::ButtonSize::Md,
226 ))
227 >
228 (label)
229 </a>
230 }
231 </div>
232 }
233 .boxed(),
234 )
235}
236
237pub(super) fn start(cx: &Cx, _body: Body) -> RouteFuture<'_> {
240 Box::pin(async move {
241 let panel = current_realm(cx).ok_or_else(not_found)?;
242 let oidc = panel.auth.oidc().ok_or_else(not_found)?;
243 let (provider, false) = route_of(cx, uri(cx).path()).ok_or_else(not_found)? else {
244 return Err(not_found().into());
245 };
246 let next = next_from_query(cx).unwrap_or_default();
247 let pending = Pending::new(panel.prefix.clone(), provider.key.clone(), next.clone());
248 let redirect_uri = oidc.redirect_uri(&panel.prefix, &provider.key);
249 let url = match provider.authorization_url(&redirect_uri, &pending).await {
250 Ok(url) => url,
251 Err(failure) => return refused(cx, provider, failure, &next),
252 };
253 let sealed = serde_json::to_string(&pending).map_err(topcoat::Error::from)?;
254 sealed_cookies(cx)?.add(
255 Cookie::build((COOKIE_NAME, sealed))
256 .path("/")
257 .http_only(true)
258 .secure(true)
259 .same_site(SameSite::Lax)
261 .max_age(topcoat::cookie::time::Duration::seconds(
262 i64::try_from(PENDING_LIFETIME.as_secs()).unwrap_or(i64::MAX),
263 ))
264 .build(),
265 );
266 private(see_other(url).into_response(cx)?)
267 })
268}
269
270pub(super) fn callback(cx: &Cx, _body: Body) -> RouteFuture<'_> {
273 Box::pin(async move {
274 let panel = current_realm(cx).ok_or_else(not_found)?;
275 let oidc = panel.auth.oidc().ok_or_else(not_found)?;
276 let (provider, true) = route_of(cx, uri(cx).path()).ok_or_else(not_found)? else {
277 return Err(not_found().into());
278 };
279 let pending = sealed_cookies(cx)?
281 .get(COOKIE_NAME)
282 .and_then(|cookie| serde_json::from_str::<Pending>(cookie.value()).ok());
283 cookies(cx).remove(Cookie::build((COOKIE_NAME, "")).path("/").secure(true));
284 let query = query(cx);
285 let read = |key: &str| query.iter().find(|(name, _)| name == key).map(|(_, v)| v);
286 let now = jiff::Timestamp::now().as_second();
287 let lifetime = i64::try_from(PENDING_LIFETIME.as_secs()).unwrap_or(i64::MAX);
288 let Some(pending) = pending.filter(|pending| {
289 (0..lifetime).contains(&(now - pending.started))
290 && pending.panel == panel.prefix
291 && pending.provider == provider.key
292 && read("state").is_some_and(|state| {
293 subtle::ConstantTimeEq::ct_eq(state.as_bytes(), pending.state.as_bytes()).into()
294 })
295 }) else {
296 return refused(cx, provider, Failure::Refused, "");
297 };
298 let next = pending.next.clone();
299 let Some(code) = read("code").filter(|code| !code.is_empty()) else {
300 return refused(cx, provider, Failure::Refused, &next);
302 };
303 let throttle = panel.auth.login_throttle();
306 let client = client_ip(cx);
307 if !throttle
308 .attempt(cx, THROTTLE_SCOPE, &provider.key, client)
309 .await?
310 {
311 tracing::warn!(panel = %panel.prefix, client = ?client, "provider sign-in throttled");
312 return refused(cx, provider, Failure::Refused, &next);
313 }
314 let redirect_uri = oidc.redirect_uri(&panel.prefix, &provider.key);
315 let identity = match provider.redeem(code, &redirect_uri, &pending).await {
316 Ok(identity) => identity,
317 Err(failure) => return refused(cx, provider, failure, &next),
318 };
319 throttle
320 .clear(cx, THROTTLE_SCOPE, &provider.key, client)
321 .await?;
322 let user = match oidc.linker.sign_in(cx, &identity).await {
323 Ok(user) => user,
324 Err(error) => {
325 let error = infrastructure_failure(error);
326 if crate::error::TabloError::is_infrastructure(&error) {
327 return refused(cx, provider, Failure::Unavailable, &next);
328 }
329 return Err(error);
330 }
331 };
332 let Some(user) = user.filter(|user| user.can_access_panel(&panel.prefix)) else {
333 return refused(cx, provider, Failure::Refused, &next);
334 };
335 admit(cx, panel, &*user, next).await
336 })
337}
338
339fn query(cx: &Cx) -> Vec<(String, String)> {
341 uri(cx)
342 .query()
343 .map(|query| {
344 form_urlencoded::parse(query.as_bytes())
345 .into_owned()
346 .collect()
347 })
348 .unwrap_or_default()
349}
350
351fn sealed_cookies(
353 cx: &Cx,
354) -> topcoat::Result<topcoat::cookie::PrivateJar<'_, &topcoat::cookie::CookieJar>> {
355 try_private_cookies(cx).ok_or_else(|| {
356 crate::error::declaration("provider sign-in needs the router's cookie `Key`".to_owned())
357 })
358}
359
360fn refused(
362 cx: &Cx,
363 provider: &OidcProvider,
364 failure: Failure,
365 next: &str,
366) -> topcoat::Result<Response> {
367 let message = match failure {
368 Failure::Refused => "tablo-oidc-failed",
369 Failure::Unavailable => "tablo-oidc-unavailable",
370 };
371 set_notification(
372 cx,
373 Notification::error(crate::t!(
374 cx,
375 message,
376 provider = provider.label.localize(cx)
377 )),
378 );
379 private(see_other(with_next(login_url(cx), next)).into_response(cx)?)
380}
381
382pub(super) fn routes(
384 mut builder: topcoat::router::RouterBuilder,
385 realm: &Realm,
386) -> topcoat::router::RouterBuilder {
387 use topcoat::router::RouteFn;
388
389 let Some(oidc) = realm.auth.oidc() else {
390 return builder;
391 };
392 for provider in &oidc.providers {
393 let path = format!("{}/sign-in/{}", realm.prefix, provider.key);
394 builder = builder
395 .route(RouteFn::new(
396 http::Method::GET,
397 crate::naming::route_path(&path),
398 start,
399 ))
400 .route(RouteFn::new(
401 http::Method::GET,
402 crate::naming::route_path(&format!("{path}/callback")),
403 callback,
404 ));
405 }
406 builder
407}
408
409pub(super) fn declaration_errors(
414 auth: &super::AuthConfig,
415 has_key: bool,
416) -> Vec<crate::DeclarationErrorKind> {
417 use crate::{DeclarationErrorKind, declaration::OidcFault};
418
419 let Some(oidc) = &auth.oidc else {
420 return Vec::new();
421 };
422 if auth.authenticator().is_none() {
423 return vec![DeclarationErrorKind::Oidc(OidcFault::AuthDisabled)];
424 }
425 let mut faults = Vec::new();
426 if auth.external().is_some() {
427 faults.push(OidcFault::ExternalAuth);
428 }
429 if !has_key {
430 faults.push(OidcFault::MissingKey);
431 }
432 let origin = url::Url::parse(&oidc.base_url)
433 .ok()
434 .filter(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none());
435 if !origin.is_some_and(|_| provider::secure_url(&oidc.base_url)) {
436 faults.push(OidcFault::BaseUrl(oidc.base_url.clone()));
437 }
438 if oidc.providers.is_empty() {
439 faults.push(OidcFault::NoProvider);
440 }
441 for (index, provider) in oidc.providers.iter().enumerate() {
442 if !provider.key_is_valid() {
443 faults.push(OidcFault::ProviderKey(provider.key.clone()));
444 } else if oidc.providers[..index]
445 .iter()
446 .any(|earlier| earlier.key == provider.key)
447 {
448 faults.push(OidcFault::DuplicateProvider(provider.key.clone()));
449 }
450 if !provider.issuer_is_valid() {
451 faults.push(OidcFault::Issuer(provider.key.clone()));
452 }
453 for (name, _) in &provider.params {
454 if provider::RESERVED_PARAMS.contains(&name.as_str()) {
455 faults.push(OidcFault::ReservedParam(provider.key.clone(), name.clone()));
456 }
457 }
458 }
459 faults.into_iter().map(DeclarationErrorKind::Oidc).collect()
460}