1pub(super) mod account;
2pub(super) mod auth_issuer;
3pub(super) mod auth_metadata;
4pub(super) mod authorize;
5pub(super) mod complete;
6pub(super) mod device;
7pub(super) mod jwks;
8pub(super) mod native;
9pub(super) mod registration;
10pub(super) mod revoke;
11pub(super) mod token;
12pub(super) mod userinfo;
13
14#[cfg(test)]
15mod tests;
16
17use std::fmt::Write;
18
19use axum::{Json, body::Body, response::IntoResponse};
20use http::{Response, StatusCode};
21use ruma::OwnedUserId;
22use serde_json::json;
23use tuwunel_core::{Result, err};
24use tuwunel_service::Services;
25use url::Url;
26
27pub(super) use self::{
28 account::*, auth_issuer::*, auth_metadata::*, authorize::*, complete::*, device::*, jwks::*,
29 native::*, registration::*, revoke::*, token::*, userinfo::*,
30};
31
32const OIDC_REQ_ID_LENGTH: usize = 32;
33
34#[derive(Clone, Copy)]
35struct NativeChoice {
36 native_enabled: bool,
37 has_default_idp: bool,
38}
39
40pub(crate) fn url_encode(s: &str) -> String {
41 s.bytes()
42 .fold(String::with_capacity(s.len()), |mut out, b| {
43 if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
44 out.push(b.into());
45 } else {
46 write!(&mut out, "%{b:02X}").ok();
47 }
48
49 out
50 })
51}
52
53fn oauth_error(status: StatusCode, error: &str, description: &str) -> Response<Body> {
54 let body = json!({
55 "error": error,
56 "error_description": description,
57 });
58
59 (status, Json(body)).into_response()
60}
61
62async fn consume_login_token(services: &Services, token: Option<&str>) -> Result<OwnedUserId> {
63 let token = token.ok_or_else(|| err!(Request(Forbidden("Missing login token"))))?;
64
65 services
66 .users
67 .find_from_login_token(token)
68 .await
69 .map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))
70}
71
72async fn peek_login_token(services: &Services, token: Option<&str>) -> Result<OwnedUserId> {
75 let token = token.ok_or_else(|| err!(Request(Forbidden("Missing login token"))))?;
76
77 services
78 .users
79 .peek_login_token(token)
80 .await
81 .map_err(|_| err!(Request(Forbidden("Invalid or expired login token"))))
82}
83
84fn redirect_allowlisted(allowed: &[String], uri: &str) -> bool {
92 Url::parse(uri).is_ok_and(|url| {
93 let name = url.host_str().unwrap_or_else(|| url.scheme());
94
95 allowed
96 .iter()
97 .any(|entry| entry.eq_ignore_ascii_case(name))
98 })
99}
100
101fn should_serve_native(NativeChoice { native_enabled, has_default_idp }: NativeChoice) -> bool {
106 native_enabled && !has_default_idp
107}
108
109fn authorization_sso_url(issuer: &str, idp_id: &str, req_id: &str) -> Result<Url> {
115 let base = issuer.trim_end_matches('/');
116 let complete = format!("{base}/_tuwunel/oidc/_complete");
117 let callback = Url::parse_with_params(&complete, [("oidc_req_id", req_id)])
118 .map_err(|_| err!(error!("Failed to build complete URL")))?;
119
120 sso_redirect_url(base, idp_id, &callback)
121}
122
123fn sso_redirect_url(base: &str, idp_id: &str, callback: &Url) -> Result<Url> {
124 let idp_id_enc = url_encode(idp_id);
125 let mut sso_url =
126 Url::parse(&format!("{base}/_matrix/client/v3/login/sso/redirect/{idp_id_enc}"))
127 .map_err(|_| err!(error!("Failed to build SSO URL")))?;
128
129 sso_url
130 .query_pairs_mut()
131 .append_pair("redirectUrl", callback.as_str());
132
133 Ok(sso_url)
134}