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