Skip to main content

tuwunel_api_oidc/
authorize.rs

1use std::time::SystemTime;
2
3use axum::{
4	extract::State,
5	response::{IntoResponse, Redirect},
6};
7use serde::Deserialize;
8use tuwunel_core::{
9	Err, Result, err, utils,
10	utils::{BoolExt, url::is_loopback},
11};
12use tuwunel_service::{
13	Services,
14	oauth::server::{AUTH_REQUEST_LIFETIME, AuthRequest},
15};
16use url::Url;
17
18use super::{OIDC_REQ_ID_LENGTH, authorization_sso_url};
19use crate::ClientIp;
20
21#[derive(Debug, Deserialize)]
22pub(crate) struct AuthorizeParams {
23	client_id: String,
24	redirect_uri: String,
25	response_type: String,
26	response_mode: Option<String>,
27	scope: String,
28	state: Option<String>,
29	nonce: Option<String>,
30	code_challenge: Option<String>,
31	code_challenge_method: Option<String>,
32	#[serde(default)]
33	idp_id: Option<String>,
34	#[serde(default)]
35	prompt: Option<String>,
36}
37
38pub(crate) async fn authorize_route(
39	State(services): State<crate::State>,
40	ClientIp(client): ClientIp,
41	request: axum::extract::Request,
42) -> Result<impl IntoResponse> {
43	let oidc = services.oauth.get_server()?;
44	services.oauth.check_rate_limit(client)?;
45
46	let query = request.uri().query().unwrap_or_default();
47	let params: AuthorizeParams = serde_html_form::from_str(query)?;
48
49	if params.response_type != "code" {
50		return Err!(Request(InvalidParam("Only response_type=code is supported")));
51	}
52
53	let response_mode = params.response_mode.as_deref().unwrap_or("query");
54	if !matches!(response_mode, "query" | "fragment") {
55		return Err!(Request(InvalidParam(
56			"Only response_mode=query or response_mode=fragment is supported"
57		)));
58	}
59
60	// RFC 7636 / MSC2964: require an explicit S256 challenge; bare `plain` is
61	// rejected.
62	match (&params.code_challenge, params.code_challenge_method.as_deref()) {
63		| (None, _) if services.config.authentication.oidc.require_pkce =>
64			return Err!(Request(InvalidParam("code_challenge is required (PKCE with S256)"))),
65
66		| (Some(_), method) if method != Some("S256") =>
67			return Err!(Request(InvalidParam("Only code_challenge_method=S256 is supported"))),
68
69		| _ => {},
70	}
71
72	validate_redirect_uri(&services, &params).await?;
73
74	let now = SystemTime::now();
75	let req_id = utils::random_string(OIDC_REQ_ID_LENGTH);
76	let base = oidc.issuer_url()?;
77	let base = base.trim_end_matches('/');
78
79	let idp_id: Option<String> = match params.idp_id.as_deref() {
80		// The native page offers local accounts alongside every provider.
81		| None if services.config.authentication.oidc.native_auth => None,
82
83		| Some(requested) => services
84			.oauth
85			.providers
86			.find_config(requested)
87			.map(|provider| Some(provider.id().to_owned()))
88			.map_err(|_| err!(Request(InvalidParam("Unrecognized identity provider"))))?,
89
90		| None => services
91			.oauth
92			.providers
93			.get_default_id()
94			.ok_or_else(|| err!(Config("identity_provider", "No identity provider configured")))
95			.map(Some)?,
96	};
97
98	let auth_req = AuthRequest {
99		client_id: params.client_id,
100		redirect_uri: params.redirect_uri,
101		scope: params.scope,
102		state: params.state,
103		nonce: params.nonce,
104		code_challenge: params.code_challenge,
105		code_challenge_method: params.code_challenge_method,
106		// The IdP that authenticated the user, tagged on the device at token
107		// exchange; absent in native mode (the account is local).
108		idp_id,
109		local_auth_selected: false,
110		response_mode: params.response_mode,
111		created_at: now,
112		expires_at: now
113			.checked_add(AUTH_REQUEST_LIFETIME)
114			.unwrap_or(now),
115	};
116
117	oidc.store_auth_request(&req_id, &auth_req);
118
119	let Some(idp_id) = auth_req.idp_id.as_deref() else {
120		let view = match params.prompt.as_deref() {
121			| Some("create") => "register",
122			| _ => "login",
123		};
124
125		let native_url = Url::parse(&format!("{base}/_tuwunel/oidc/native"))
126			.map_err(|_| err!(error!("Failed to build native auth URL")))
127			.map(|mut url| {
128				url.query_pairs_mut()
129					.append_pair("oidc_req_id", &req_id)
130					.append_pair("view", view);
131
132				url
133			})?;
134
135		return Ok(Redirect::temporary(native_url.as_str()));
136	};
137
138	let sso_url = authorization_sso_url(base, idp_id, &req_id)?;
139
140	Ok(Redirect::temporary(sso_url.as_str()))
141}
142
143async fn validate_redirect_uri(services: &Services, params: &AuthorizeParams) -> Result {
144	services
145		.oauth
146		.get_server()
147		.expect("OIDC already configured")
148		.get_client(&params.client_id)
149		.await?
150		.redirect_uris
151		.iter()
152		.any(|uri| redirect_uri_matches(uri, &params.redirect_uri))
153		.into_option()
154		.ok_or_else(|| err!(Request(InvalidParam("redirect_uri not registered for this client"))))
155}
156
157fn redirect_uri_matches(registered: &str, requested: &str) -> bool {
158	match (Url::parse(registered), Url::parse(requested)) {
159		| (..) if registered == requested => true,
160		// MSC2966, RFC 8252 §7.3 and §8.3: a loopback redirect may use any port.
161		| (Ok(reg), Ok(req)) if is_loopback_redirect(&reg) && is_loopback_redirect(&req) =>
162			reg.scheme() == req.scheme()
163				&& reg.username() == req.username()
164				&& reg.password() == req.password()
165				&& reg.host_str() == req.host_str()
166				&& reg.path() == req.path()
167				&& reg.query() == req.query()
168				&& reg.fragment() == req.fragment(),
169
170		| _ => false,
171	}
172}
173
174fn is_loopback_redirect(uri: &Url) -> bool { uri.scheme() == "http" && is_loopback(uri) }
175
176#[cfg(test)]
177mod tests {
178	use super::*;
179
180	#[test]
181	fn redirect_match_accepted() {
182		for (registered, requested) in [
183			("http://localhost/login", "http://localhost:3001/login"),
184			("http://127.0.0.1/cb", "http://127.0.0.1:8080/cb"),
185			("http://[::1]/cb", "http://[::1]:3001/cb"),
186			("https://example.com/cb", "https://example.com/cb"),
187		] {
188			assert!(redirect_uri_matches(registered, requested), "{registered} {requested}");
189		}
190	}
191
192	#[test]
193	fn redirect_mismatch_rejected() {
194		for (registered, requested) in [
195			("http://localhost/login", "http://localhost:3001/other"),
196			("http://localhost/login", "http://127.0.0.1:3001/login"),
197			("http://localhost/login", "http://localhost:3001/login?x=1"),
198			("http://localhost/login", "http://user@localhost:3001/login"),
199			("http://example.com/cb", "http://example.com:3001/cb"),
200			("https://localhost/login", "https://localhost:3001/login"),
201			("https://example.com/cb", "https://example.com:8443/cb"),
202		] {
203			assert!(!redirect_uri_matches(registered, requested), "{registered} {requested}");
204		}
205	}
206}