Skip to main content

tuwunel_api/client/session/sso/
uiaa.rs

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