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#[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#[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#[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 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 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
382fn 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 | 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('{', "{")
718 .replace('}', "}")
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 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 let has_oauth_flow = uiaainfo
771 .flows
772 .iter()
773 .any(|f| f.stages.contains(&AuthType::OAuth));
774
775 if has_oauth_flow && !uiaainfo.completed.contains(&AuthType::OAuth) {
777 services
779 .users
780 .allow_cross_signing_replacement(&user_id);
781
782 uiaainfo.completed.push(AuthType::OAuth);
783 }
784
785 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 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 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(¬ice).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 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 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}