Skip to main content

tuwunel_api/oidc/
authorize.rs

1use std::{net::IpAddr, 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, result::FlatOk},
11};
12use tuwunel_service::{
13	Services,
14	oauth::server::{AUTH_REQUEST_LIFETIME, AuthRequest},
15};
16use url::Url;
17
18use super::{OIDC_REQ_ID_LENGTH, sso_redirect_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.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 resolved_idp: Option<String> = match params.idp_id.as_deref() {
80		| Some(requested) => services
81			.oauth
82			.providers
83			.get_config(requested)
84			.map(|provider| Some(provider.id().to_owned()))
85			.map_err(|_| err!(Request(InvalidParam("Unrecognized identity provider"))))?,
86
87		| None => services.oauth.providers.get_default_id(),
88	};
89
90	// Native page when native auth is on and no external provider applies, or the
91	// client explicitly requested account creation (prompt=create).
92	let serve_native = params.idp_id.is_none()
93		&& should_serve_native(
94			services.config.oidc_native_auth,
95			resolved_idp.is_some(),
96			params.prompt.as_deref() == Some("create"),
97		);
98
99	let idp_id = match (serve_native, resolved_idp) {
100		| (true, _) => None,
101		| (false, Some(idp_id)) => Some(idp_id),
102		| (false, None) =>
103			return Err!(Config("identity_provider", "No identity provider configured")),
104	};
105
106	let auth_req = AuthRequest {
107		client_id: params.client_id,
108		redirect_uri: params.redirect_uri,
109		scope: params.scope,
110		state: params.state,
111		nonce: params.nonce,
112		code_challenge: params.code_challenge,
113		code_challenge_method: params.code_challenge_method,
114		// The IdP that authenticated the user, tagged on the device at token
115		// exchange; absent in native mode (the account is local).
116		idp_id: idp_id.clone(),
117		response_mode: params.response_mode,
118		created_at: now,
119		expires_at: now
120			.checked_add(AUTH_REQUEST_LIFETIME)
121			.unwrap_or(now),
122	};
123
124	oidc.store_auth_request(&req_id, &auth_req);
125
126	let Some(idp_id) = idp_id else {
127		let view = match params.prompt.as_deref() {
128			| Some("create") => "register",
129			| _ => "login",
130		};
131
132		let native_url = Url::parse(&format!("{base}/_tuwunel/oidc/native"))
133			.map_err(|_| err!(error!("Failed to build native auth URL")))
134			.map(|mut url| {
135				url.query_pairs_mut()
136					.append_pair("oidc_req_id", &req_id)
137					.append_pair("view", view);
138
139				url
140			})?;
141
142		return Ok(Redirect::temporary(native_url.as_str()));
143	};
144
145	let complete_url = Url::parse(&format!("{base}/_tuwunel/oidc/_complete"))
146		.map_err(|_| err!(error!("Failed to build complete URL")))
147		.map(|mut url| {
148			url.query_pairs_mut()
149				.append_pair("oidc_req_id", &req_id);
150
151			url
152		})?;
153
154	let sso_url = sso_redirect_url(base, &idp_id, &complete_url)?;
155
156	Ok(Redirect::temporary(sso_url.as_str()))
157}
158
159/// Decide whether a request with no explicitly-selected provider is served the
160/// native login/register page rather than an upstream-IdP SSO redirect. Native
161/// applies when enabled and either no default IdP is configured or the client
162/// asked to create an account.
163pub(super) fn should_serve_native(
164	native_enabled: bool,
165	has_default_idp: bool,
166	wants_create: bool,
167) -> bool {
168	native_enabled && (!has_default_idp || wants_create)
169}
170
171async fn validate_redirect_uri(services: &Services, params: &AuthorizeParams) -> Result {
172	services
173		.oauth
174		.get_server()
175		.expect("OIDC already configured")
176		.get_client(&params.client_id)
177		.await?
178		.redirect_uris
179		.iter()
180		.any(|uri| redirect_uri_matches(uri, &params.redirect_uri))
181		.into_option()
182		.ok_or_else(|| err!(Request(InvalidParam("redirect_uri not registered for this client"))))
183}
184
185fn redirect_uri_matches(registered: &str, requested: &str) -> bool {
186	match (Url::parse(registered), Url::parse(requested)) {
187		| (..) if registered == requested => true,
188		| (Ok(reg), Ok(req)) if is_loopback_redirect(&reg) && is_loopback_redirect(&req) =>
189			reg.scheme() == req.scheme()
190				&& reg.host_str() == req.host_str()
191				&& reg.path() == req.path()
192				&& reg.query() == req.query()
193				&& reg.fragment() == req.fragment(),
194
195		| _ => false,
196	}
197}
198
199fn is_loopback_redirect(uri: &Url) -> bool {
200	let addr = || uri.host_str().map(str::parse::<IpAddr>).flat_ok();
201
202	uri.scheme() == "http" && matches!(addr(), Some(ip) if ip.is_loopback())
203}
204
205#[cfg(test)]
206mod tests {
207	use super::should_serve_native;
208
209	#[test]
210	fn native_decision_truth_table() {
211		// Native auth disabled: never native.
212		assert!(!should_serve_native(false, false, false));
213		assert!(!should_serve_native(false, true, true));
214
215		// Native-only (no default provider): native.
216		assert!(should_serve_native(true, false, false));
217
218		// An external default is configured, ordinary login: SSO to the default.
219		assert!(!should_serve_native(true, true, false));
220
221		// An external default is configured, prompt=create: native registration.
222		assert!(should_serve_native(true, true, true));
223	}
224}