tuwunel_api/oidc/
authorize.rs1use 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 match (¶ms.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, ¶ms).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 | 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 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(¶ms.client_id)
149 .await?
150 .redirect_uris
151 .iter()
152 .any(|uri| redirect_uri_matches(uri, ¶ms.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(®) && 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}