Skip to main content

tuwunel_api/oidc/
mod.rs

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
72/// Verify a login token without consuming it; it is consumed later when the
73/// confirmation form is submitted.
74async 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
84/// Whether a redirect URI is covered by the operator's redirect allowlist.
85///
86/// A URI carrying a host matches an allowlist entry naming that host. A
87/// private-use scheme carries no host at all (RFC 8252 ยง7.1, as in
88/// `io.element.android:/callback`), so it matches an entry naming the scheme
89/// instead, which is what lets one list cover both a web and a mobile client.
90/// Either way the comparison ignores case.
91fn 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
101/// Whether a flow with no provider chooser serves the native page.
102///
103/// Native applies only when native auth is enabled and no default provider is
104/// configured; every other flow goes through single sign-on.
105fn should_serve_native(NativeChoice { native_enabled, has_default_idp }: NativeChoice) -> bool {
106	native_enabled && !has_default_idp
107}
108
109/// Build the upstream SSO redirect URL for a pending authorization request.
110///
111/// The provider hands the browser back to the completion route carrying the
112/// request id, where the authorization code is minted. A trailing slash on the
113/// issuer is ignored.
114fn 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}