Skip to main content

headless_lms_server/domain/oauth/
redirects.rs

1use 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
50/// Adds the issuer as `iss` to a redirect back to the client, so it can detect mix-up attacks
51/// (RFC 9207).
52fn 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
59/// The client redirect carrying the authorization code and `iss`.
60pub 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
80/// The client redirect carrying an authorization error (RFC 6749 §4.1.2.1), and `iss` when
81/// `issuer` is given. `None` if `redirect_uri` is not a URL.
82pub 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
106/// Whether `requested` is one of the client's registered redirect URIs. A public client's loopback
107/// redirect URI matches on any port, because native apps listen on whatever port is free
108/// (RFC 8252 §7.3), but never with a fragment or credentials; a confidential client's matches
109/// only exactly.
110pub 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
139/// Whether the URL's host is a loopback address or `localhost`.
140pub 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}