headless_lms_server/domain/oauth/
redirects.rs1use headless_lms_models::oauth_client::OAuthClient;
2use url::{Url, form_urlencoded};
3
4use crate::domain::oauth::authorize_query::AuthorizeParams;
5
6pub fn build_authorize_qs(q: &AuthorizeParams) -> String {
7 let mut s = form_urlencoded::Serializer::new(String::new());
8 s.append_pair("client_id", &q.client_id);
9 s.append_pair("scope", &q.scope);
10 s.append_pair("redirect_uri", &q.redirect_uri);
11 s.append_pair("response_type", &q.response_type);
12 if let Some(state) = q.state.as_deref() {
13 s.append_pair("state", state);
14 }
15 if let Some(nonce) = q.nonce.as_deref() {
16 s.append_pair("nonce", nonce);
17 }
18 if let Some(code_challenge) = q.code_challenge.as_deref() {
19 s.append_pair("code_challenge", code_challenge);
20 }
21 if let Some(code_challenge_method) = q.code_challenge_method.as_deref() {
22 s.append_pair("code_challenge_method", code_challenge_method);
23 }
24 if let Some(resource) = &q.resource {
25 s.append_pair("resource", resource.as_str());
26 }
27 s.finish()
28}
29
30pub fn pct_encode(s: &str) -> String {
31 form_urlencoded::byte_serialize(s.as_bytes()).collect()
32}
33
34pub fn build_login_redirect(q: &AuthorizeParams) -> String {
35 let return_to = format!(
36 "/api/v0/main-frontend/oauth/authorize?{}",
37 build_authorize_qs(q)
38 );
39 format!("/login?return_to={}", pct_encode(&return_to))
40}
41
42pub fn build_consent_redirect(q: &AuthorizeParams, return_to: &str) -> String {
43 format!(
44 "/oauth_authorize_scopes?{}&return_to={}",
45 build_authorize_qs(q),
46 pct_encode(return_to)
47 )
48}
49
50fn append_iss<T: form_urlencoded::Target>(
53 pairs: &mut form_urlencoded::Serializer<'_, T>,
54 issuer: &str,
55) {
56 pairs.append_pair("iss", issuer);
57}
58
59pub fn redirect_with_code(
61 redirect_uri: &str,
62 code: &str,
63 state: Option<&str>,
64 issuer: &str,
65) -> String {
66 let mut qs = form_urlencoded::Serializer::new(String::new());
67 qs.append_pair("code", code);
68 if let Some(s) = state {
69 qs.append_pair("state", s);
70 }
71 append_iss(&mut qs, issuer);
72 let qs = qs.finish();
73 if redirect_uri.contains('?') {
74 format!("{redirect_uri}&{qs}")
75 } else {
76 format!("{redirect_uri}?{qs}")
77 }
78}
79
80pub fn error_redirect_url(
83 redirect_uri: &str,
84 error: &str,
85 description: Option<&str>,
86 state: Option<&str>,
87 issuer: Option<&str>,
88) -> Option<String> {
89 let mut url = Url::parse(redirect_uri).ok()?;
90 {
91 let mut pairs = url.query_pairs_mut();
92 pairs.append_pair("error", error);
93 if let Some(description) = description {
94 pairs.append_pair("error_description", description);
95 }
96 if let Some(state) = state {
97 pairs.append_pair("state", state);
98 }
99 if let Some(issuer) = issuer {
100 append_iss(&mut pairs, issuer);
101 }
102 }
103 Some(url.to_string())
104}
105
106pub fn redirect_uri_matches(client: &OAuthClient, requested: &str) -> bool {
111 let registered = &client.redirect_uris;
112 if registered.iter().any(|r| r == requested) {
113 return true;
114 }
115 if !client.is_public() {
116 return false;
117 }
118 let Ok(requested_url) = Url::parse(requested) else {
119 return false;
120 };
121 if requested_url.scheme() != "http"
122 || !is_loopback_host(&requested_url)
123 || requested_url.fragment().is_some()
124 || !requested_url.username().is_empty()
125 || requested_url.password().is_some()
126 {
127 return false;
128 }
129 registered.iter().any(|r| {
130 Url::parse(r).is_ok_and(|registered_url| {
131 registered_url.scheme() == "http"
132 && registered_url.host() == requested_url.host()
133 && registered_url.path() == requested_url.path()
134 && registered_url.query() == requested_url.query()
135 })
136 })
137}
138
139pub fn is_loopback_host(url: &Url) -> bool {
141 match url.host() {
142 Some(url::Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
143 Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
144 Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
145 None => false,
146 }
147}