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, 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.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.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		| (Ok(reg), Ok(req)) if is_loopback_redirect(&reg) && is_loopback_redirect(&req) =>
161			reg.scheme() == req.scheme()
162				&& reg.host_str() == req.host_str()
163				&& reg.path() == req.path()
164				&& reg.query() == req.query()
165				&& reg.fragment() == req.fragment(),
166
167		| _ => false,
168	}
169}
170
171fn is_loopback_redirect(uri: &Url) -> bool {
172	let addr = || uri.host_str().map(str::parse::<IpAddr>).flat_ok();
173
174	uri.scheme() == "http" && matches!(addr(), Some(ip) if ip.is_loopback())
175}