Skip to main content

tuwunel_api_client/session/
sso.rs

1mod uiaa;
2
3use std::{borrow::Cow, collections::BTreeMap, net::IpAddr, time::Duration};
4
5use axum::{
6	extract::State,
7	response::{Html, IntoResponse, Response},
8};
9use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
10use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD as b64};
11use const_str::format as const_format;
12use futures::{TryFutureExt, future::try_join};
13use reqwest::header::{
14	CACHE_CONTROL, CONTENT_SECURITY_POLICY, CONTENT_TYPE, HeaderValue, REFERRER_POLICY,
15	SET_COOKIE,
16};
17use ruma::{
18	Mxc, OwnedMxcUri, OwnedUserId, ServerName, UserId,
19	api::{
20		client::{
21			session::{
22				SsoRedirectAction,
23				sso_callback::{self, unstable::Response as SsoCallbackResponse},
24				sso_login, sso_login_with_provider,
25			},
26			uiaa::AuthType,
27		},
28		error::ErrorKind,
29	},
30};
31use serde::{Deserialize, Serialize};
32use serde_html_form::{from_str as from_form_str, to_string as to_form_string};
33use serde_json::Value as JsonValue;
34use tuwunel_core::{
35	Err, Result, at,
36	config::IdentityProvider,
37	debug::INFO_SPAN_LEVEL,
38	debug_info, debug_warn, err, info, is_not_equal_to,
39	itertools::Itertools,
40	utils,
41	utils::{
42		content_disposition::make_content_disposition,
43		deadline_has_passed, deserialize_lenient_bool,
44		hash::sha256,
45		html::escape as html_escape,
46		result::{FlatOk, LogErr},
47		stream::ReadyExt,
48		string::EMPTY,
49		timepoint_from_now,
50		url::redirect_allowlisted,
51	},
52	warn,
53};
54use tuwunel_service::{
55	Services,
56	client::read_response_capped,
57	media::MXC_LENGTH,
58	oauth::{
59		CODE_VERIFIER_LENGTH, Provider, SESSION_ID_LENGTH, Session, TokenResponse, UserInfo,
60		fallback_localpart, unique_id_sub,
61	},
62	threepid::canonicalize_email,
63	users::{PASSWORD_SENTINEL, Register},
64};
65use url::Url;
66
67pub(crate) use self::uiaa::{sso_complete_js_route, sso_css_route, sso_fallback_route};
68use super::TOKEN_LENGTH;
69use crate::{ClientIp, Ruma, RumaResponse, router::ACCOUNT_HEAD, utils::bind_new_account_email};
70
71/// Grant phase query string.
72#[derive(Debug, Serialize)]
73struct GrantQuery<'a> {
74	client_id: &'a str,
75	state: &'a str,
76	nonce: &'a str,
77	scope: &'a str,
78	response_type: &'a str,
79	access_type: &'a str,
80	code_challenge_method: &'a str,
81	code_challenge: &'a str,
82	redirect_uri: Option<&'a str>,
83	#[serde(skip_serializing_if = "Option::is_none")]
84	prompt: Option<&'a str>,
85}
86
87#[derive(Debug, Deserialize, Serialize)]
88struct GrantCookie<'a> {
89	client_id: Cow<'a, str>,
90	state: Cow<'a, str>,
91	nonce: Cow<'a, str>,
92	redirect_uri: Cow<'a, str>,
93}
94
95static GRANT_SESSION_COOKIE: &str = "tuwunel_grant_session";
96
97static CONFIRM_HTML: &str = const_format!(
98	r#"
99<!DOCTYPE html>
100<html lang="en">
101	<head>
102		{ACCOUNT_HEAD}
103		<title>Continue sign-in · Tuwunel</title>
104	</head>
105	<body class="auth-page">
106		<main class="auth-card auth-complete" aria-labelledby="auth-title">
107			<h1 id="auth-title">Continue sign-in</h1>
108			<p class="auth-description">
109				You are signing in as <strong>{{user}}</strong> to <strong>{{target}}</strong>.
110			</p>
111			<p class="auth-description">Continue only if you started this sign-in.</p>
112			<a class="auth-continue-link" href="{{location}}">Continue</a>
113		</main>
114	</body>
115</html>"#
116);
117
118fn decode_apple_userinfo_from_id_token(session: &Session) -> Result<UserInfo> {
119	let id_token = session.id_token.as_deref().ok_or_else(|| {
120		err!(Request(Unauthorized("Missing Apple id_token in token response.")))
121	})?;
122
123	let payload_b64 = id_token
124		.split('.')
125		.nth(1)
126		.ok_or_else(|| err!(Request(Unauthorized("Apple id_token is malformed."))))?;
127
128	let payload = b64
129		.decode(payload_b64)
130		.map_err(|_| err!(Request(Unauthorized("Apple id_token payload is invalid base64."))))?;
131
132	let payload: JsonValue = serde_json::from_slice(&payload)
133		.map_err(|_| err!(Request(Unauthorized("Apple id_token payload is not valid JSON."))))?;
134
135	let sub = payload
136		.get("sub")
137		.and_then(JsonValue::as_str)
138		.ok_or_else(|| {
139			err!(Request(Unauthorized("Apple id_token missing required sub claim.")))
140		})?;
141
142	let email = payload
143		.get("email")
144		.and_then(JsonValue::as_str)
145		.map(ToOwned::to_owned);
146
147	let email_verified = payload
148		.get("email_verified")
149		.map(deserialize_lenient_bool)
150		.flat_ok()
151		.flatten();
152
153	let preferred_username = email
154		.as_deref()
155		.and_then(|value| value.split_once('@'))
156		.map(at!(0))
157		.map(ToOwned::to_owned);
158
159	Ok(UserInfo {
160		sub: sub.to_owned(),
161		preferred_username: preferred_username.clone(),
162		username: preferred_username,
163		nickname: None,
164		name: payload
165			.get("name")
166			.and_then(JsonValue::as_str)
167			.map(ToOwned::to_owned),
168		given_name: payload
169			.get("given_name")
170			.and_then(JsonValue::as_str)
171			.map(ToOwned::to_owned),
172		family_name: payload
173			.get("family_name")
174			.and_then(JsonValue::as_str)
175			.map(ToOwned::to_owned),
176		email,
177		email_verified,
178		avatar_url: None,
179		picture: None,
180	})
181}
182
183/// # `GET /_matrix/client/v3/login/sso/redirect`
184///
185/// A web-based Matrix client should instruct the user’s browser to navigate to
186/// this endpoint in order to log in via SSO.
187#[tracing::instrument(
188	name = "sso_login",
189	level = "debug",
190	skip_all,
191	fields(%client),
192)]
193pub(crate) async fn sso_login_route(
194	State(services): State<crate::State>,
195	ClientIp(client): ClientIp,
196	body: Ruma<sso_login::v3::Request>,
197) -> Result<sso_login::v3::Response> {
198	if services
199		.config
200		.authentication
201		.login
202		.sso_custom_providers_page
203	{
204		return Err!(Request(NotImplemented(
205			"authentication.login.sso_custom_providers_page has been enabled but this URL has \
206			 not been overridden with any custom page listing the available providers..."
207		)));
208	}
209
210	let redirect_url = body.body.redirect_url;
211	let action = body.body.action;
212	let default_idp_id = services
213		.oauth
214		.providers
215		.get_default_id()
216		.unwrap_or_default();
217
218	handle_sso_login(&services, &client, default_idp_id, redirect_url, None, action)
219		.map_ok(|response| sso_login::v3::Response {
220			location: response.location,
221			cookie: response.cookie,
222		})
223		.await
224}
225
226/// # `GET /_matrix/client/v3/login/sso/redirect/{idpId}`
227///
228/// This endpoint is the same as /login/sso/redirect, though with an IdP ID from
229/// the original identity_providers array to inform the server of which IdP the
230/// client/user would like to continue with.
231#[tracing::instrument(
232	name = "sso_login_with_provider",
233	level = "info",
234	skip_all,
235	err(level = "debug"),
236	fields(
237		%client,
238		idp_id = body.body.idp_id,
239	),
240)]
241pub(crate) async fn sso_login_with_provider_route(
242	State(services): State<crate::State>,
243	ClientIp(client): ClientIp,
244	body: Ruma<sso_login_with_provider::v3::Request>,
245) -> Result<sso_login_with_provider::v3::Response> {
246	let idp_id = body.body.idp_id;
247	let redirect_url = body.body.redirect_url;
248	let action = body.body.action;
249
250	handle_sso_login(&services, &client, idp_id, redirect_url, None, action).await
251}
252
253async fn handle_sso_login(
254	services: &Services,
255	_client: &IpAddr,
256	idp_id: String,
257	redirect_url: String,
258	user_id: Option<OwnedUserId>,
259	action: Option<SsoRedirectAction>,
260) -> Result<sso_login_with_provider::v3::Response> {
261	let redirect_url: Url = redirect_url.parse().map_err(|e| {
262		err!(Request(InvalidParam(debug_warn!(
263			?e,
264			?redirect_url,
265			"Failed to parse redirect_url.",
266		))))
267	})?;
268
269	validate_redirect_url(&redirect_url)?;
270
271	let provider = services.oauth.providers.get(&idp_id).await?;
272	let sess_id = utils::random_string(SESSION_ID_LENGTH);
273	let query_nonce = utils::random_string(CODE_VERIFIER_LENGTH);
274	let cookie_nonce = utils::random_string(CODE_VERIFIER_LENGTH);
275	let code_verifier = utils::random_string(CODE_VERIFIER_LENGTH);
276	let code_challenge = b64.encode(sha256::hash(code_verifier.as_bytes()));
277	let callback_uri = provider.callback_url.as_ref().map(Url::as_str);
278	let scope = provider.scope.iter().join(" ");
279	let prompt = action
280		.filter(|_| provider.forward_action_prompt)
281		.and_then(|action| matches!(action, SsoRedirectAction::Register).then_some("create"));
282
283	let query = GrantQuery {
284		client_id: &provider.client_id,
285		state: &sess_id,
286		nonce: &query_nonce,
287		access_type: "online",
288		response_type: "code",
289		code_challenge_method: "S256",
290		code_challenge: &code_challenge,
291		redirect_uri: callback_uri,
292		prompt,
293		scope: scope
294			.is_empty()
295			.then_some("openid email profile")
296			.unwrap_or(scope.as_str()),
297	};
298
299	let location = provider
300		.authorization_url
301		.clone()
302		.map(|mut location| {
303			location.set_query(to_form_string(&query).ok().as_deref());
304			if !provider.extra_authorization_parameters.is_empty() {
305				// Base wins on key collision so extras cannot disable CSRF/PKCE.
306				let merged: BTreeMap<String, String> = provider
307					.extra_authorization_parameters
308					.clone()
309					.into_iter()
310					.chain(
311						location
312							.query_pairs()
313							.map(|(k, v)| (k.into_owned(), v.into_owned())),
314					)
315					.collect();
316
317				location.set_query(None);
318				location.query_pairs_mut().extend_pairs(&merged);
319			}
320			location
321		})
322		.ok_or_else(|| {
323			err!(Config("authorization_url", "Missing required IdentityProvider config"))
324		})?;
325
326	let cookie_val = GrantCookie {
327		client_id: query.client_id.into(),
328		state: query.state.into(),
329		nonce: cookie_nonce.as_str().into(),
330		redirect_uri: redirect_url.as_str().into(),
331	};
332
333	let cookie = grant_session_cookie(&cookie_val, &provider)?;
334
335	let session = Session {
336		idp_id: Some(idp_id),
337		sess_id: Some(sess_id.clone()),
338		redirect_url: Some(redirect_url),
339		code_verifier: Some(code_verifier),
340		query_nonce: Some(query_nonce),
341		cookie_nonce: Some(cookie_nonce),
342		authorize_expires_at: provider
343			.grant_session_duration
344			.map(Duration::from_secs)
345			.map(timepoint_from_now)
346			.transpose()?,
347
348		user_id,
349		..Default::default()
350	};
351
352	services.oauth.sessions.put(&session).await;
353
354	Ok(sso_login_with_provider::v3::Response {
355		location: location.into(),
356		cookie: Some(cookie.into()),
357	})
358}
359
360fn grant_session_cookie(value: &GrantCookie<'_>, provider: &Provider) -> Result<String> {
361	let max_age = provider
362		.grant_session_duration
363		.map(Duration::from_secs)
364		.expect("Defaulted to Some value during configure_idp()")
365		.try_into()
366		.expect("std::time::Duration to time::Duration conversion failure");
367
368	// CookieJar percent-decodes the value on read; encode it so the form survives.
369	let cookie = Cookie::build((GRANT_SESSION_COOKIE, to_form_string(value)?))
370		.path(grant_session_cookie_path(provider.callback_url.as_ref()))
371		.max_age(max_age)
372		.same_site(SameSite::None)
373		.secure(true)
374		.http_only(true)
375		.build()
376		.encoded()
377		.to_string();
378
379	Ok(cookie)
380}
381
382/// Path attribute for the grant-session cookie. The set and removal cookies
383/// must use the same value: a cookie is only replaced (and thus removed) when
384/// its name, domain and path all match (RFC 6265 5.3), and a Set-Cookie
385/// without an explicit path defaults to the request-URI's directory.
386fn grant_session_cookie_path(callback_url: Option<&Url>) -> &str {
387	callback_url.map(Url::path).unwrap_or("/")
388}
389
390#[tracing::instrument(
391	name = "sso_callback"
392	level = "debug",
393	skip_all,
394	fields(
395		%client,
396		idp_id = ?body.body.idp_id,
397		state = ?body.body.state,
398	),
399)]
400pub(crate) async fn sso_callback_route(
401	State(services): State<crate::State>,
402	ClientIp(client): ClientIp,
403	body: Ruma<sso_callback::unstable::Request>,
404) -> Result<Response> {
405	let sess_id = body
406		.body
407		.state
408		.as_deref()
409		.ok_or_else(|| err!(Request(Forbidden("Missing sess_id in callback."))))?;
410
411	let code = body
412		.body
413		.code
414		.as_deref()
415		.ok_or_else(|| err!(Request(Forbidden("Missing code in callback."))))?;
416
417	let session = services
418		.oauth
419		.sessions
420		.get(sess_id)
421		.map_err(|_| err!(Request(Forbidden("Invalid state in callback"))));
422
423	let provider = services
424		.oauth
425		.providers
426		.get(body.body.idp_id.as_str());
427
428	let (provider, session) = try_join(provider, session).await.log_err()?;
429	let idp_id = provider.id();
430	let next_idp_id = chain_next_idp_id(&services, idp_id);
431
432	if session.sess_id.as_deref() != Some(sess_id) {
433		return Err!(Request(Unauthorized("Session ID {sess_id:?} not recognized.")));
434	}
435
436	if session.idp_id.as_deref() != Some(idp_id) {
437		return Err!(Request(Unauthorized(
438			"Identity Provider {idp_id:?} session not recognized."
439		)));
440	}
441
442	if deadline_has_passed(session.authorize_expires_at) {
443		return Err!(Request(Unauthorized("Authorization grant session has expired.")));
444	}
445
446	if provider.check_cookie || session.user_id.is_some() || next_idp_id.is_some() {
447		validate_session_cookie(&body.cookie, &provider, &session, sess_id)?;
448	}
449
450	let token_response = services
451		.oauth
452		.request_token((&provider, &session), code)
453		.await?;
454
455	let session = apply_token_response(session, token_response)?;
456
457	let userinfo = services
458		.oauth
459		.request_userinfo((&provider, &session))
460		.await
461		.or_else(|error| {
462			if provider.brand != "appleoidc" {
463				return Err(error);
464			}
465
466			debug_warn!(
467				?error,
468				idp_id = provider.id(),
469				"Failed to fetch Apple userinfo endpoint; falling back to id_token claims.",
470			);
471
472			decode_apple_userinfo_from_id_token(&session).map_err(|decode_error| {
473				debug_warn!(
474					?decode_error,
475					idp_id = provider.id(),
476					"Failed to decode Apple id_token fallback.",
477				);
478				error
479			})
480		})?;
481
482	let unique_id = unique_id_sub((&provider, &userinfo.sub))?;
483
484	let complete_identity = async |old_user_id: Option<OwnedUserId>| {
485		let session = Session {
486			user_info: Some(userinfo.clone()),
487			..session
488		};
489
490		let user_id = match (session.user_id, old_user_id) {
491			| (Some(user_id), ..) | (None, Some(user_id)) => user_id,
492			| (None, None) => decide_user_id(&services, &provider, &userinfo, &unique_id).await?,
493		};
494
495		let session = Session {
496			user_id: Some(user_id.clone()),
497			..session
498		};
499
500		if !services.users.exists(&user_id).await {
501			let origin = match provider.registration {
502				| true => "sso",
503				// Present in LDAP is an existing user to provision, not a new registration.
504				| false if ldap_user_exists(&services, &user_id).await => "ldap",
505				| false =>
506					return Err!(Request(Forbidden(
507						"Registration from this provider is disabled"
508					))),
509			};
510
511			register_user(&services, &provider, &session, &userinfo, &user_id, origin).await?;
512		}
513
514		Ok((session, user_id))
515	};
516
517	let (session, user_id, old_sess_id) = services
518		.oauth
519		.sessions
520		.commit_identity_session(&unique_id, complete_identity)
521		.await?;
522
523	if let Some(old) = old_sess_id
524		.as_deref()
525		.filter(is_not_equal_to!(&sess_id))
526		&& let Err(error) = services.oauth.sessions.delete(old).await
527		&& !error.is_missing()
528	{
529		warn!(%old, %error, "previous OAuth session was kept");
530	}
531
532	if !services.users.is_active_local(&user_id).await {
533		return Err!(Request(UserDeactivated("This user has been deactivated.")));
534	}
535
536	services.users.locked_check(&user_id).await?;
537
538	let cookie = Cookie::build((GRANT_SESSION_COOKIE, EMPTY))
539		.path(grant_session_cookie_path(provider.callback_url.as_ref()))
540		.removal()
541		.build()
542		.to_string()
543		.into();
544
545	if let Some(redirect_url) = session
546		.redirect_url
547		.as_ref()
548		.filter(|url| url.scheme() == "uiaa")
549	{
550		return handle_uiaa(&services, &user_id, cookie, redirect_url)
551			.map_ok(|response| RumaResponse(response).into_response())
552			.await;
553	}
554
555	let redirect_url = session
556		.redirect_url
557		.ok_or_else(|| err!(Request(InvalidParam("Missing redirect URL in session data"))))?;
558
559	validate_redirect_url(&redirect_url)?;
560
561	if let Some(next_idp_id) = next_idp_id {
562		let next = handle_sso_login(
563			&services,
564			&client,
565			next_idp_id,
566			redirect_url.into(),
567			Some(user_id),
568			None,
569		)
570		.await?;
571
572		return Ok(RumaResponse(next).into_response());
573	}
574
575	if redirect_trusted(&services, &redirect_url) {
576		let location = finalize_login_redirect(&services, redirect_url, &user_id, Some(idp_id));
577		let response = SsoCallbackResponse { location, cookie: Some(cookie) };
578
579		return Ok(RumaResponse(response).into_response());
580	}
581
582	confirm_login_redirect(&services, redirect_url, &user_id, &cookie, idp_id)
583}
584
585fn validate_session_cookie(
586	cookies: &CookieJar,
587	provider: &Provider,
588	session: &Session,
589	sess_id: &str,
590) -> Result {
591	let client_id = &provider.client_id;
592	let cookie: GrantCookie<'_> = cookies
593		.get(GRANT_SESSION_COOKIE)
594		.map(Cookie::value)
595		.map(from_form_str)
596		.transpose()?
597		.ok_or_else(|| err!(Request(Unauthorized("Missing cookie {GRANT_SESSION_COOKIE:?}"))))?;
598
599	if cookie.client_id.as_ref() != client_id.as_str() {
600		return Err!(Request(Unauthorized("Client ID {client_id:?} cookie mismatch.")));
601	}
602
603	if Some(cookie.nonce.as_ref()) != session.cookie_nonce.as_deref() {
604		return Err!(Request(Unauthorized("Cookie nonce does not match session state.")));
605	}
606
607	if cookie.state.as_ref() != sess_id {
608		return Err!(Request(Unauthorized("Session ID {sess_id:?} cookie mismatch.")));
609	}
610
611	Ok(())
612}
613
614fn apply_token_response(session: Session, token: TokenResponse) -> Result<Session> {
615	let expires_at = token
616		.expires_in
617		.map(Duration::from_secs)
618		.map(timepoint_from_now)
619		.transpose()?;
620
621	let refresh_token_expires_at = token
622		.refresh_token_expires_in
623		.map(Duration::from_secs)
624		.map(timepoint_from_now)
625		.transpose()?;
626
627	Ok(Session {
628		scope: token.scope,
629		token_type: token.token_type,
630		access_token: token.access_token,
631		id_token: token.id_token,
632		expires_at,
633		refresh_token: token.refresh_token,
634		refresh_token_expires_at,
635		..session
636	})
637}
638
639fn chain_next_idp_id(services: &Services, idp_id: &str) -> Option<String> {
640	services
641		.config
642		.identity_provider
643		.values()
644		.filter(|idp| idp.default || services.config.authentication.login.single_sso)
645		.skip_while(|idp| idp.id() != idp_id)
646		.nth(1)
647		.map(IdentityProvider::id)
648		.map(ToOwned::to_owned)
649}
650
651fn finalize_login_redirect(
652	services: &Services,
653	redirect_url: Url,
654	user_id: &UserId,
655	provider_id: Option<&str>,
656) -> String {
657	let login_token = utils::random_string(TOKEN_LENGTH);
658	let _login_token_expires_in =
659		services
660			.users
661			.create_login_token_with_provider(user_id, &login_token, provider_id);
662
663	append_login_token(redirect_url, &login_token)
664}
665
666fn append_login_token(mut redirect_url: Url, login_token: &str) -> String {
667	redirect_url
668		.query_pairs_mut()
669		.append_pair("loginToken", login_token)
670		.finish();
671
672	redirect_url.into()
673}
674
675fn validate_redirect_url(redirect_url: &Url) -> Result {
676	if !redirect_url.username().is_empty() || redirect_url.password().is_some() {
677		return Err!(Request(InvalidParam("redirect_url must not contain userinfo.")));
678	}
679
680	if matches!(
681		redirect_url.scheme(),
682		"javascript" | "vbscript" | "data" | "blob" | "file" | "about"
683	) {
684		return Err!(Request(InvalidParam("Unsupported redirect_url scheme.")));
685	}
686
687	Ok(())
688}
689
690fn redirect_trusted(services: &Services, redirect_url: &Url) -> bool {
691	let allowed = &services
692		.config
693		.authentication
694		.oidc
695		.registration_allowed_redirect_hosts;
696
697	let own_origin = services
698		.config
699		.well_known
700		.client
701		.as_ref()
702		.filter(|client| matches!(client.scheme(), "http" | "https"))
703		.is_some_and(|client| client.origin() == redirect_url.origin());
704
705	own_origin || redirect_allowlisted(allowed, redirect_url.as_str())
706}
707
708fn confirm_login_redirect(
709	services: &Services,
710	redirect_url: Url,
711	user_id: &UserId,
712	cookie: &str,
713	provider_id: &str,
714) -> Result<Response> {
715	let escape = |value: &str| {
716		html_escape(value)
717			.replace('{', "&#123;")
718			.replace('}', "&#125;")
719	};
720
721	let user = escape(user_id.as_str());
722	let target = escape(&display_login_target(redirect_url.clone()));
723	let location = finalize_login_redirect(services, redirect_url, user_id, Some(provider_id));
724	let location = html_escape(&location);
725	let html = CONFIRM_HTML
726		.replace("{user}", &user)
727		.replace("{target}", &target)
728		.replace("{location}", &location);
729
730	let cookie = HeaderValue::from_str(cookie)?;
731	let headers = [
732		(SET_COOKIE, cookie),
733		(CACHE_CONTROL, HeaderValue::from_static("no-store")),
734		(REFERRER_POLICY, HeaderValue::from_static("no-referrer")),
735		(
736			CONTENT_SECURITY_POLICY,
737			HeaderValue::from_static(
738				"default-src 'none'; style-src 'self'; frame-ancestors 'none'; base-uri 'none'",
739			),
740		),
741	];
742
743	Ok((headers, Html(html)).into_response())
744}
745
746fn display_login_target(mut target: Url) -> String {
747	target.set_query(None);
748	target.set_fragment(None);
749	target.into()
750}
751
752async fn handle_uiaa(
753	services: &Services,
754	user_id: &UserId,
755	cookie: Cow<'static, str>,
756	redirect_url: &Url,
757) -> Result<sso_callback::unstable::Response> {
758	let uiaa_session_id = redirect_url.path();
759
760	// Find the UIAA session by its ID. SECURITY: Ensure the user authenticating via
761	// SSO is the owner of the UIAA session
762	let (user_id, device_id, mut uiaainfo) = services
763		.uiaa
764		.get_uiaa_session_by_session_id(uiaa_session_id)
765		.await
766		.filter(|(db_user_id, ..)| user_id.eq(db_user_id))
767		.ok_or_else(|| err!(Request(Forbidden("UIAA session not found."))))?;
768
769	// MSC4312 m.oauth flow → mark OAuth.
770	let has_oauth_flow = uiaainfo
771		.flows
772		.iter()
773		.any(|f| f.stages.contains(&AuthType::OAuth));
774
775	// Mark the completed step based on the UIAA session's flow.
776	if has_oauth_flow && !uiaainfo.completed.contains(&AuthType::OAuth) {
777		// Grant 10-minute bypass for cross-signing key replacement (like Synapse).
778		services
779			.users
780			.allow_cross_signing_replacement(&user_id);
781
782		uiaainfo.completed.push(AuthType::OAuth);
783	}
784
785	// Legacy m.login.sso flow → mark Sso.
786	let has_sso_flow = uiaainfo
787		.flows
788		.iter()
789		.any(|f| f.stages.contains(&AuthType::Sso));
790
791	if has_sso_flow && !uiaainfo.completed.contains(&AuthType::Sso) {
792		uiaainfo.completed.push(AuthType::Sso);
793	}
794
795	services
796		.uiaa
797		.update_uiaa_session(&user_id, &device_id, uiaa_session_id, Some(&uiaainfo));
798
799	// Redirect back to the fallback page to render the success HTML
800	let location =
801		format!("/_matrix/client/v3/auth/m.login.sso/fallback/web?session={uiaa_session_id}");
802
803	Ok(sso_callback::unstable::Response { location, cookie: Some(cookie) })
804}
805
806async fn ldap_user_exists(services: &Services, user_id: &UserId) -> bool {
807	cfg!(feature = "ldap")
808		&& services.config.ldap.enable
809		&& services
810			.users
811			.search_ldap(user_id)
812			.await
813			.log_err()
814			.is_ok_and(|dns| !dns.is_empty())
815}
816
817#[tracing::instrument(
818	name = "register",
819	level = INFO_SPAN_LEVEL,
820	skip_all,
821	fields(user_id, userinfo)
822)]
823async fn register_user(
824	services: &Services,
825	provider: &Provider,
826	session: &Session,
827	userinfo: &UserInfo,
828	user_id: &UserId,
829	origin: &str,
830) -> Result {
831	debug_info!(%user_id, "Creating new user account...");
832
833	services
834		.users
835		.full_register(Register {
836			user_id: Some(user_id),
837			password: Some(PASSWORD_SENTINEL),
838			origin: Some(origin),
839			displayname: userinfo.name.as_deref(),
840			grant_first_user_admin: true,
841			..Default::default()
842		})
843		.await?;
844
845	if let Some(avatar_url) = userinfo
846		.avatar_url
847		.as_deref()
848		.or(userinfo.picture.as_deref())
849	{
850		set_avatar(services, provider, session, userinfo, user_id, avatar_url)
851			.await
852			.ok();
853	}
854
855	if let Some(email) = userinfo.verified_email(provider.trusted) {
856		bind_email(services, user_id, email)
857			.await
858			.inspect_err(|error| warn!(%user_id, %error, "Skipping registration email binding"))
859			.ok();
860	}
861
862	let idp_id = provider.id();
863	let idp_name = provider
864		.name
865		.as_deref()
866		.unwrap_or(provider.brand.as_str());
867
868	// log in conduit admin channel if a non-guest user registered
869	let notice =
870		format!("New user \"{user_id}\" registered on this server via {idp_name} ({idp_id})");
871
872	info!("{notice}");
873	services.admin.notify(&notice).await;
874
875	Ok(())
876}
877
878#[tracing::instrument(level = "debug", skip_all, fields(user_id, avatar_url))]
879async fn set_avatar(
880	services: &Services,
881	_provider: &Provider,
882	_session: &Session,
883	_userinfo: &UserInfo,
884	user_id: &UserId,
885	avatar_url: &str,
886) -> Result {
887	use reqwest::Response;
888
889	let response = services
890		.client
891		.default
892		.get(avatar_url)
893		.send()
894		.await
895		.and_then(Response::error_for_status)?;
896
897	let content_type = response
898		.headers()
899		.get(CONTENT_TYPE)
900		.map(HeaderValue::to_str)
901		.flat_ok()
902		.map(ToOwned::to_owned);
903
904	let mxc = Mxc {
905		server_name: services.globals.server_name(),
906		media_id: &utils::random_string(MXC_LENGTH),
907	};
908
909	let content_disposition = make_content_disposition(None, content_type.as_deref(), None);
910	let limit = services
911		.server
912		.config
913		.network
914		.http
915		.max_response_size;
916
917	let bytes = read_response_capped(response, limit).await?;
918	services
919		.media
920		.create(&mxc, Some(user_id), Some(&content_disposition), content_type.as_deref(), &bytes)
921		.await?;
922
923	let mxc_uri: OwnedMxcUri = mxc.to_string().into();
924	services
925		.profile
926		.set_avatar_url(user_id, Some(&mxc_uri), None)
927		.await?;
928
929	Ok(())
930}
931
932async fn bind_email(services: &Services, user_id: &UserId, email: &str) -> Result {
933	let email = canonicalize_email(email)?;
934
935	bind_new_account_email(services, user_id, &email).await
936}
937
938#[tracing::instrument(
939	level = "debug",
940	ret(level = "debug")
941	skip_all,
942	fields(user),
943)]
944async fn decide_user_id(
945	services: &Services,
946	provider: &Provider,
947	userinfo: &UserInfo,
948	unique_id: &str,
949) -> Result<OwnedUserId> {
950	if let Some(user_id) = services
951		.oauth
952		.sessions
953		.find_user_association_pending(provider.id(), userinfo)
954	{
955		debug_info!(
956			provider = ?provider.id(),
957			?user_id,
958			?userinfo,
959			"Matched pending association"
960		);
961
962		return Ok(user_id);
963	}
964
965	let explicit = |claim: &str| provider.userid_claims.contains(claim);
966
967	let allowed = |claim: &str| provider.userid_claims.is_empty() || explicit(claim);
968
969	let choices = [
970		explicit("sub")
971			.then_some(userinfo.sub.as_str())
972			.map(str::to_lowercase),
973		userinfo
974			.preferred_username
975			.as_deref()
976			.map(str::to_lowercase)
977			.filter(|_| allowed("preferred_username")),
978		userinfo
979			.username
980			.as_deref()
981			.map(str::to_lowercase)
982			.filter(|_| allowed("username")),
983		userinfo
984			.nickname
985			.as_deref()
986			.map(str::to_lowercase)
987			.filter(|_| allowed("nickname")),
988		provider
989			.brand
990			.eq(&"github")
991			.then_some(userinfo.sub.as_str())
992			.map(str::to_lowercase)
993			.filter(|_| allowed("login")),
994		userinfo
995			.email
996			.as_deref()
997			.and_then(|email| email.split_once('@'))
998			.map(at!(0))
999			.map(str::to_lowercase)
1000			.filter(|_| allowed("email")),
1001	];
1002
1003	for choice in choices.into_iter().flatten() {
1004		if let Some(user_id) = try_user_id(services, provider, &choice, false).await {
1005			return Ok(user_id);
1006		}
1007	}
1008
1009	let localpart = fallback_localpart(unique_id);
1010	if let Some(user_id) = try_user_id(services, provider, &localpart, true).await {
1011		return Ok(user_id);
1012	}
1013
1014	Err!(Request(UserInUse("User ID is not available.")))
1015}
1016
1017#[tracing::instrument(level = "debug", skip_all, fields(username))]
1018async fn try_user_id(
1019	services: &Services,
1020	provider: &Provider,
1021	username: &str,
1022	unique_id: bool,
1023) -> Option<OwnedUserId> {
1024	let server_name = services.globals.server_name();
1025	let user_id = parse_user_id(server_name, username)
1026		.inspect_err(|e| warn!(?username, "Username invalid: {e}"))
1027		.ok()?;
1028
1029	if services
1030		.config
1031		.registration
1032		.forbidden_usernames
1033		.is_match(username)
1034	{
1035		warn!(?username, "Username forbidden.");
1036		return None;
1037	}
1038
1039	if services.users.exists(&user_id).await {
1040		if provider.trusted {
1041			info!(
1042				?username,
1043				provider = ?provider.brand,
1044				"Authorizing trusted provider access to existing account."
1045			);
1046
1047			return Some(user_id);
1048		}
1049
1050		if services
1051			.users
1052			.origin(&user_id)
1053			.await
1054			.ok()
1055			.is_none_or(|origin| origin != "sso")
1056		{
1057			debug_warn!(?username, "Existing username has non-sso origin.");
1058			return None;
1059		}
1060
1061		if !unique_id {
1062			debug_warn!(?username, "Username exists.");
1063			return None;
1064		}
1065
1066		// Only an identity without an account reaches its fallback, so an account
1067		// linked to any identity belongs to another one. An unreadable link counts.
1068		if services
1069			.oauth
1070			.sessions
1071			.get_sess_id_by_user(&user_id)
1072			.ready_any(|sess_id| match sess_id {
1073				| Ok(_) => true,
1074				| Err(e) if e.is_missing() => false,
1075				| Err(e) => {
1076					warn!(?username, error = %e, "Unreadable identity links.");
1077					true
1078				},
1079			})
1080			.await
1081		{
1082			debug_warn!(?username, "Existing username is linked to another identity.");
1083			return None;
1084		}
1085	} else {
1086		if unique_id && !provider.unique_id_fallbacks {
1087			debug_warn!(
1088				?username,
1089				provider = ?provider.brand,
1090				"Unique ID fallbacks disabled.",
1091			);
1092
1093			return None;
1094		}
1095
1096		// Keep the candidate on other errors because account creation retries the gate.
1097		if services
1098			.users
1099			.check_creation(&user_id)
1100			.await
1101			.is_err_and(|error| error.kind() == ErrorKind::UserInUse)
1102		{
1103			return None;
1104		}
1105	}
1106
1107	Some(user_id)
1108}
1109
1110fn parse_user_id(server_name: &ServerName, username: &str) -> Result<OwnedUserId> {
1111	match UserId::parse_with_server_name(username, server_name) {
1112		| Err(e) => {
1113			Err!(Request(InvalidUsername(debug_error!("Username {username} is not valid: {e}"))))
1114		},
1115		| Ok(user_id) => match user_id.validate_strict() {
1116			| Ok(()) => Ok(user_id),
1117			| Err(e) => Err!(Request(InvalidUsername(debug_error!(
1118				"Username {username} contains disallowed characters or spaces: {e}"
1119			)))),
1120		},
1121	}
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126	use reqwest::header::{COOKIE, HeaderMap};
1127	use serde_json::json;
1128
1129	use super::*;
1130
1131	const STATE: &str = "sess";
1132	const NONCE: &str = "nonce";
1133
1134	fn apple_session_with_claims(claims: &serde_json::Value) -> Session {
1135		let payload = b64.encode(serde_json::to_vec(claims).expect("serialize claims"));
1136
1137		Session {
1138			id_token: Some(format!("header.{payload}.signature")),
1139			..Default::default()
1140		}
1141	}
1142
1143	#[test]
1144	fn decode_apple_userinfo_from_id_token_extracts_expected_claims() {
1145		let session = apple_session_with_claims(&json!({
1146			"sub": "apple-user-123",
1147			"email": "alice@example.com",
1148			"name": "Alice Example",
1149			"given_name": "Alice",
1150			"family_name": "Example"
1151		}));
1152
1153		let userinfo =
1154			decode_apple_userinfo_from_id_token(&session).expect("decode Apple id_token claims");
1155
1156		assert_eq!(userinfo.sub, "apple-user-123");
1157		assert_eq!(userinfo.email.as_deref(), Some("alice@example.com"));
1158		assert_eq!(userinfo.preferred_username.as_deref(), Some("alice"));
1159		assert_eq!(userinfo.username.as_deref(), Some("alice"));
1160		assert_eq!(userinfo.name.as_deref(), Some("Alice Example"));
1161		assert_eq!(userinfo.given_name.as_deref(), Some("Alice"));
1162		assert_eq!(userinfo.family_name.as_deref(), Some("Example"));
1163	}
1164
1165	#[test]
1166	fn decode_apple_userinfo_from_id_token_requires_sub_claim() {
1167		let session = apple_session_with_claims(&json!({
1168			"email": "alice@example.com"
1169		}));
1170
1171		let error = decode_apple_userinfo_from_id_token(&session)
1172			.expect_err("missing sub claim should fail");
1173
1174		let message = format!("{error}");
1175		assert!(message.contains("sub claim"), "unexpected error: {message}");
1176	}
1177
1178	#[test]
1179	fn decode_apple_userinfo_from_id_token_requires_id_token() {
1180		let session = Session::default();
1181
1182		let error = decode_apple_userinfo_from_id_token(&session)
1183			.expect_err("missing id_token should fail");
1184
1185		let message = format!("{error}");
1186		assert!(message.contains("Missing Apple id_token"), "unexpected error: {message}");
1187	}
1188
1189	#[test]
1190	fn decode_apple_userinfo_from_id_token_rejects_invalid_payload() {
1191		let session = Session {
1192			id_token: Some("header.!.signature".to_owned()),
1193			..Default::default()
1194		};
1195
1196		let error = decode_apple_userinfo_from_id_token(&session)
1197			.expect_err("invalid id_token payload should fail");
1198
1199		let message = format!("{error}");
1200		assert!(message.contains("invalid base64"), "unexpected error: {message}");
1201	}
1202
1203	#[test]
1204	fn grant_session_cookie_path_uses_the_callback_path() {
1205		let callback = Url::parse(
1206			"https://matrix.example/_matrix/client/unstable/login/sso/callback/tuwunel_abc",
1207		)
1208		.expect("valid URL");
1209
1210		assert_eq!(
1211			grant_session_cookie_path(Some(&callback)),
1212			"/_matrix/client/unstable/login/sso/callback/tuwunel_abc"
1213		);
1214		assert_eq!(grant_session_cookie_path(None), "/");
1215	}
1216
1217	#[test]
1218	fn grant_session_removal_cookie_path_matches_the_set_cookie_path() {
1219		let provider = authentik_provider();
1220		let grant = grant_cookie(&provider, "https://client.example/return");
1221		let set_cookie = grant_session_cookie(&grant, &provider).expect("grant session cookie");
1222		let set = Cookie::parse_encoded(set_cookie.as_str()).expect("valid set cookie");
1223		let removal = Cookie::build((GRANT_SESSION_COOKIE, EMPTY))
1224			.path(grant_session_cookie_path(provider.callback_url.as_ref()))
1225			.removal()
1226			.build();
1227
1228		assert_eq!(set.path(), removal.path());
1229		assert_eq!(
1230			(set.secure(), set.http_only(), set.same_site()),
1231			(Some(true), Some(true), Some(SameSite::None))
1232		);
1233
1234		assert!(
1235			removal
1236				.to_string()
1237				.contains("Path=/_matrix/client/unstable/login/sso/callback/tuwunel_abc"),
1238			"unexpected removal cookie: {removal}"
1239		);
1240	}
1241
1242	fn authentik_provider() -> Provider {
1243		serde_json::from_value(json!({
1244			"brand": "authentik",
1245			"client_id": "tuwunel_abc",
1246			"callback_url": "https://matrix.example/_matrix/client/unstable/login/sso/callback/tuwunel_abc",
1247		}))
1248		.expect("valid provider")
1249	}
1250
1251	fn grant_cookie<'a>(provider: &'a Provider, redirect_uri: &'a str) -> GrantCookie<'a> {
1252		GrantCookie {
1253			client_id: provider.client_id.as_str().into(),
1254			state: STATE.into(),
1255			nonce: NONCE.into(),
1256			redirect_uri: redirect_uri.into(),
1257		}
1258	}
1259
1260	#[test]
1261	fn grant_session_cookie_survives_a_redirect_url_repeating_its_keys() {
1262		let redirect_uri = "https://client.example/return?next=%2Froom%2B1&state=abc&client_id=x&nonce=y&redirect_uri=z";
1263		let provider = authentik_provider();
1264		let grant = grant_cookie(&provider, redirect_uri);
1265		let session = Session {
1266			cookie_nonce: Some(NONCE.to_owned()),
1267			..Default::default()
1268		};
1269
1270		let set_cookie = grant_session_cookie(&grant, &provider).expect("grant session cookie");
1271		let (pair, _) = set_cookie
1272			.split_once(';')
1273			.expect("cookie attributes");
1274
1275		let header = HeaderValue::from_str(pair).expect("valid header value");
1276		let cookies = CookieJar::from_headers(&HeaderMap::from_iter([(COOKIE, header)]));
1277
1278		let expected = to_form_string(&grant).expect("serialize");
1279		let echoed = cookies
1280			.get(GRANT_SESSION_COOKIE)
1281			.map(Cookie::value);
1282
1283		assert_eq!(echoed, Some(expected.as_str()));
1284
1285		validate_session_cookie(&cookies, &provider, &session, STATE).expect("cookie validates");
1286	}
1287}