Skip to main content

tuwunel_api/client/session/sso/
uiaa.rs

1use axum::{extract::State, response::IntoResponse};
2use http::header::{CACHE_CONTROL, CONTENT_TYPE};
3use ruma::api::client::uiaa::{AuthType, UiaaInfo, get_uiaa_fallback_page};
4use serde_json::Value as JsonValue;
5use tuwunel_core::{Err, Result, trace, utils::BoolExt};
6
7use crate::{Ruma, oidc::url_encode};
8
9/// # `GET /_matrix/client/v3/auth/m.login.sso/fallback/web?session={session_id}`
10///
11/// Get UIAA fallback web page for SSO authentication.
12#[tracing::instrument(
13	name = "sso_fallback",
14	level = "debug",
15	skip_all,
16	fields(session = body.body.session),
17)]
18pub(crate) async fn sso_fallback_route(
19	State(services): State<crate::State>,
20	body: Ruma<get_uiaa_fallback_page::v3::Request>,
21) -> Result<get_uiaa_fallback_page::v3::Response> {
22	use get_uiaa_fallback_page::v3::Response;
23
24	let session = &body.body.session;
25
26	// Check if this UIAA session has already been completed via SSO or OAuth
27	let completed = |uiaainfo: &UiaaInfo| {
28		uiaainfo.completed.contains(&AuthType::Sso)
29			|| uiaainfo.completed.contains(&AuthType::OAuth)
30	};
31
32	// Single DB lookup — get_uiaa_session_by_session_id does a full table scan,
33	// so we call it once and reuse the result for both the completion check and
34	// the IdP extraction that follows.
35	let session_data = services
36		.uiaa
37		.get_uiaa_session_by_session_id(session)
38		.await
39		.inspect(|session_data| trace!(?session_data));
40
41	if session_data
42		.as_ref()
43		.is_some_and(|(_, _, uiaainfo)| completed(uiaainfo))
44	{
45		let html = include_str!("complete.html");
46
47		return Ok(Response::html(html.as_bytes().to_vec()));
48	}
49
50	// Check if this UIAA session has any flow with an SSO stage.
51	let has_flow_with_sso_stage = || {
52		session_data
53			.as_ref()
54			.is_some_and(|(_, _, uiaainfo)| {
55				uiaainfo
56					.flows
57					.iter()
58					.any(|flow| flow.stages.contains(&AuthType::Sso))
59			})
60	};
61
62	// Session is not completed yet. Read the IdP that was bound to this UIAA
63	// session at creation time from the stored UiaaInfo params. The IdP must
64	// always be present — auth_uiaa only advertises m.login.sso when it can
65	// determine exactly one provider, so a missing IdP here is a logic error.
66	let idp_id: Option<String> = session_data
67		.as_ref()
68		.map(|(_, _, uiaainfo)| uiaainfo)
69		.inspect(|uiaainfo| trace!(?uiaainfo))
70		.and_then(|uiaainfo| {
71			let raw = uiaainfo.params.as_ref()?.get();
72			let params: JsonValue = serde_json::from_str(raw).ok()?;
73
74			params["m.login.sso"]["identity_providers"]
75				.as_array()?
76				.first()?["id"]
77				.as_str()
78				.map(ToOwned::to_owned)
79		})
80		.or_else(|| {
81			has_flow_with_sso_stage()
82				.is_false()
83				.then_some(String::new())
84		});
85
86	// The IdP MUST have been bound at UIAA session creation time.
87	// If it is missing, auth_uiaa should not have advertised m.login.sso.
88	// Returning an error is safer than routing to an arbitrary provider.
89	let Some(ref idp) = idp_id else {
90		return Err!(Request(Forbidden(
91			"No SSO provider bound to this UIAA session; cannot complete re-authentication"
92		)));
93	};
94
95	let empty_or_slash = idp
96		.is_empty()
97		.then_some(idp.as_str())
98		.unwrap_or("/");
99
100	let url_str = format!(
101		"/_matrix/client/v3/login/sso/redirect{}{}?redirectUrl=uiaa:{}",
102		empty_or_slash,
103		url_encode(idp),
104		url_encode(session)
105	);
106
107	let html = include_str!("required.html");
108	let output = html.replace("{{url_str}}", &url_str);
109
110	Ok(Response::html(output.into_bytes()))
111}
112
113const COMPLETE_JS: &str = include_str!("complete.js");
114
115pub(crate) async fn sso_complete_js_route() -> impl IntoResponse {
116	let content_type = (CONTENT_TYPE, "application/javascript; charset=utf-8");
117	let cache_control = (CACHE_CONTROL, "no-cache");
118
119	([content_type, cache_control], COMPLETE_JS)
120}
121
122const SSO_CSS: &str = include_str!("sso.css");
123
124pub(crate) async fn sso_css_route() -> impl IntoResponse {
125	let content_type = (CONTENT_TYPE, "text/css; charset=utf-8");
126	let cache_control = (CACHE_CONTROL, "no-cache");
127
128	([content_type, cache_control], SSO_CSS)
129}