Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ parseable:
## Add environment variables to the Parseable Deployment
env:
RUST_LOG: warn
# Set this when Parseable is accessed through a reverse proxy/TLS terminator.
# Must match the public URL users access.
# P_ORIGIN_URI: "https://parseable.example.com"
## Enable to create a log stream and then add retention configuration
## for that log stream
# logstream:
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ pub struct Options {
long = "origin",
env = "P_ORIGIN_URI",
value_parser = validation::url,
help = "Parseable server global domain address"
help = "Public canonical origin for Parseable, used for OIDC redirects. Set this when running behind a reverse proxy or TLS terminator"
)]
pub domain_address: Option<Url>,

Expand Down
178 changes: 139 additions & 39 deletions src/handlers/http/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ pub fn set_cookie_cross_site(enabled: bool) {
}
use chrono::{Duration, TimeDelta};
use openid::Bearer;
use regex::Regex;
use serde::Deserialize;
use ulid::Ulid;
use url::Url;
Expand Down Expand Up @@ -65,37 +64,38 @@ pub struct Login {
pub code: String,
pub state: Option<String>,
}

/// Struct representing query param when visiting /login
/// Caller can set the state for code auth flow and this is
/// at the end used as target for redirect
#[derive(Deserialize, Debug)]
pub struct RedirectAfterLogin {
pub redirect: Url,
#[serde(default)]
pub redirect: String,
}

pub async fn login(
req: HttpRequest,
query: web::Query<RedirectAfterLogin>,
) -> Result<HttpResponse, OIDCError> {
let conn = req.connection_info().clone();
let base_url_without_scheme = format!("{}/", conn.host());
if !is_valid_redirect_url(&base_url_without_scheme, query.redirect.as_str()) {
return Err(OIDCError::BadRequest(
"Bad Request, Invalid Redirect URL!".to_string(),
));
}
let canonical_origin = get_canonical_origin();
let redirect =
is_valid_redirect_url_safe(&canonical_origin, &query.redirect).map_err(|_| {
tracing::warn!("Invalid redirect URL provided: '{}'", query.redirect);
OIDCError::BadRequest("Invalid Redirect URL".to_string())
})?;

let oidc_client = OIDC_CLIENT.get();

let session_key = extract_session_key_from_req(&req).ok();
let (session_key, oidc_client) = match (session_key, oidc_client) {
(None, None) => return Ok(redirect_no_oauth_setup(query.redirect.clone())),
(None, None) => return Ok(redirect_no_oauth_setup(&canonical_origin)),
(None, Some(client)) => {
let redirect = query.into_inner().redirect.to_string();

let scope = PARSEABLE.options.scope.to_string();
let mut auth_url: String = client.read().await.auth_url(&scope, Some(redirect)).into();
let mut auth_url: String = client
.read()
.await
.auth_url(&scope, Some(redirect.clone()))
.into();

auth_url.push_str("&access_type=offline&prompt=consent");
return Ok(HttpResponse::TemporaryRedirect()
Expand Down Expand Up @@ -134,7 +134,7 @@ pub async fn login(
);

Ok(redirect_to_client(
query.redirect.as_str(),
&redirect,
[user_cookie, user_id_cookie, session_cookie],
))
}
Expand All @@ -143,35 +143,45 @@ pub async fn login(
// if it's a valid active session, just redirect back
key @ SessionKey::SessionId(_) => {
let resp = if Users.session_exists(&key) {
redirect_to_client(query.redirect.as_str(), None)
redirect_to_client(&redirect, None)
} else {
Users.remove_session(&key);
if let Some(oidc_client) = oidc_client {
let redirect = query.into_inner().redirect.to_string();
let scope = PARSEABLE.options.scope.to_string();
let mut auth_url: String = oidc_client
.read()
.await
.auth_url(&scope, Some(redirect))
.auth_url(&scope, Some(redirect.clone()))
.into();
auth_url.push_str("&access_type=offline&prompt=consent");
HttpResponse::TemporaryRedirect()
.insert_header((actix_web::http::header::LOCATION, auth_url))
.finish()
} else {
redirect_to_client(query.redirect.as_str(), None)
redirect_to_client(&redirect, None)
}
};
Ok(resp)
}
}
}

pub async fn logout(req: HttpRequest, query: web::Query<RedirectAfterLogin>) -> HttpResponse {
pub async fn logout(
req: HttpRequest,
query: web::Query<RedirectAfterLogin>,
) -> Result<HttpResponse, OIDCError> {
let oidc_client = OIDC_CLIENT.get();

let canonical_origin = get_canonical_origin();
let redirect =
is_valid_redirect_url_safe(&canonical_origin, &query.redirect).map_err(|_| {
tracing::warn!(
"Invalid redirect URL provided in logout: '{}'",
query.redirect
);
OIDCError::BadRequest("Invalid Redirect URL".to_string())
})?;
let Some(session) = extract_session_key_from_req(&req).ok() else {
return redirect_to_client(query.redirect.as_str(), None);
return Ok(redirect_to_client(&redirect, None));
};
let tenant_id = get_tenant_id_from_key(&session);
let user = Users.remove_session(&session);
Expand All @@ -181,14 +191,14 @@ pub async fn logout(req: HttpRequest, query: web::Query<RedirectAfterLogin>) ->
None
};

match (user, logout_endpoint) {
Ok(match (user, logout_endpoint) {
(Some(username), Some(logout_endpoint))
if Users.is_oauth(&username, &tenant_id).unwrap_or_default() =>
{
redirect_to_oidc_logout(logout_endpoint, &query.redirect)
redirect_to_oidc_logout(logout_endpoint, &redirect, &canonical_origin)
}
_ => redirect_to_client(query.redirect.as_str(), None),
}
_ => redirect_to_client(&redirect, None),
})
}

/// Handler for code callback
Expand Down Expand Up @@ -364,11 +374,18 @@ fn build_login_response(
"user_id": user_id,
}))
} else {
let redirect_url = login_query
.state
.clone()
.unwrap_or_else(|| PARSEABLE.options.address.to_string());

let canonical_origin = get_canonical_origin();
let redirect_url = match login_query.state.as_deref() {
Some(state) => match is_valid_redirect_url_safe(&canonical_origin, state) {
Ok(redirect_url) => redirect_url,
Err(_) => {
return HttpResponse::BadRequest().json(serde_json::json!({
"error":"Invalid redirect URL"
}));
}
},
None => canonical_origin.to_string(),
};
redirect_to_client(&redirect_url, cookies)
}
}
Expand Down Expand Up @@ -409,8 +426,17 @@ fn exchange_basic_for_cookie(
cookie_session(id)
}

fn redirect_to_oidc_logout(mut logout_endpoint: Url, redirect: &Url) -> HttpResponse {
logout_endpoint.set_query(Some(&format!("post_logout_redirect_uri={redirect}")));
fn redirect_to_oidc_logout(
mut logout_endpoint: Url,
redirect: &str,
canonical: &Url,
) -> HttpResponse {
let post_logout_redirect =
absolute_redirect_url(canonical, redirect).unwrap_or_else(|| canonical.to_string());
logout_endpoint.set_query(None);
logout_endpoint
.query_pairs_mut()
.append_pair("post_logout_redirect_uri", &post_logout_redirect);
HttpResponse::TemporaryRedirect()
.insert_header((actix_web::http::header::CACHE_CONTROL, "no-store"))
.insert_header((
Expand All @@ -434,8 +460,10 @@ pub fn redirect_to_client(
response.finish()
}

fn redirect_no_oauth_setup(mut url: Url) -> HttpResponse {
url.set_path("oidc-not-configured");
fn redirect_no_oauth_setup(canonical: &Url) -> HttpResponse {
let url = canonical
.join("/oidc-not-configured")
.unwrap_or_else(|_| canonical.clone());
let mut response = HttpResponse::MovedPermanently();
response.insert_header((actix_web::http::header::LOCATION, url.as_str()));
response.insert_header((actix_web::http::header::CACHE_CONTROL, "no-store"));
Expand Down Expand Up @@ -627,10 +655,82 @@ impl actix_web::ResponseError for OIDCError {
.body(self.to_string())
}
}
// get the canonical origin for this PARSEABLE instance
fn get_canonical_origin() -> Url {
if let Some(origin_url) = &PARSEABLE.options.domain_address {
return origin_url.clone();
}
crate::oidc::canonical_local_origin(
&PARSEABLE.options.address,
PARSEABLE.options.tls_cert_path.is_some() && PARSEABLE.options.tls_key_path.is_some(),
)
}

fn is_valid_redirect_url(base_url_without_scheme: &str, redirect_url: &str) -> bool {
let http_scheme_match_regex = Regex::new(r"^(https?://)").unwrap();
let redirect_url_without_scheme = http_scheme_match_regex.replace(redirect_url, "");
fn is_valid_redirect_url_safe(canonical: &Url, redirect: &str) -> Result<String, ()> {
if redirect.is_empty() {
return Ok(canonical.to_string());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if redirect.starts_with('/') && !redirect.starts_with("//") {
return Ok(redirect.to_string());
}
let url = Url::parse(redirect).map_err(|_| ())?;
if !matches!(url.scheme(), "http" | "https") {
return Err(());
}
if url.origin() != canonical.origin() {
return Err(());
}
Ok(url.to_string())
}

fn absolute_redirect_url(canonical: &Url, redirect: &str) -> Option<String> {
if redirect.is_empty() {
return Some(canonical.to_string());
}
if redirect.starts_with('/') && !redirect.starts_with("//") {
canonical.join(redirect).ok().map(|url| url.to_string())
} else {
Url::parse(redirect).ok().map(|url| url.to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn empty_redirect_uses_canonical_origin() {
let canonical = Url::parse("https://parseable.example.com/").unwrap();

assert_eq!(
is_valid_redirect_url_safe(&canonical, "").unwrap(),
canonical.to_string()
);
assert_eq!(
absolute_redirect_url(&canonical, "").unwrap(),
canonical.to_string()
);
}

base_url_without_scheme == redirect_url_without_scheme
#[test]
fn relative_redirect_is_allowed_and_can_be_made_absolute() {
let canonical = Url::parse("https://parseable.example.com/").unwrap();

assert_eq!(
is_valid_redirect_url_safe(&canonical, "/dashboard").unwrap(),
"/dashboard"
);
assert_eq!(
absolute_redirect_url(&canonical, "/dashboard").unwrap(),
"https://parseable.example.com/dashboard"
);
}

#[test]
fn cross_origin_redirect_is_rejected() {
let canonical = Url::parse("https://parseable.example.com/").unwrap();

assert!(is_valid_redirect_url_safe(&canonical, "https://evil.example.com/").is_err());
assert!(is_valid_redirect_url_safe(&canonical, "//evil.example.com/").is_err());
}
}
36 changes: 32 additions & 4 deletions src/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,7 @@ impl OpenidConfig {
redirect_to: &str,
) -> Result<DiscoveredClient, openid::error::Error> {
let redirect_uri = match self.origin {
Origin::Local { socket_addr, https } => {
let protocol = if https { "https" } else { "http" };
url::Url::parse(&format!("{protocol}://{socket_addr}")).expect("valid url")
}
Origin::Local { socket_addr, https } => canonical_local_origin(&socket_addr, https),
Origin::Production(url) => url,
};

Expand All @@ -67,6 +64,37 @@ impl OpenidConfig {
}
}

pub fn canonical_local_origin(socket_addr: &str, https: bool) -> Url {
let scheme = if https { "https" } else { "http" };
let mut url = Url::parse(&format!("{scheme}://{socket_addr}"))
.unwrap_or_else(|_| Url::parse(&format!("{scheme}://localhost:8000")).unwrap());
if matches!(url.host_str(), Some("0.0.0.0") | Some("::")) {
url.set_host(Some("localhost")).expect("localhost is valid");
}
url
}
Comment on lines +67 to +75
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Test url crate IPv6 parsing with various socket address formats

cat > /tmp/test_ipv6_url.rs << 'EOF'
use url::Url;

fn main() {
    let test_cases = vec![
        ("http://[::1]:8000", "bracketed IPv6 loopback"),
        ("http://[::]:8000", "bracketed IPv6 wildcard"),
        ("http://:::8000", "unbracketed IPv6 wildcard with port"),
        ("http://::1:8000", "unbracketed IPv6 loopback with port"),
        ("http://0.0.0.0:8000", "IPv4 wildcard"),
    ];
    
    for (input, desc) in test_cases {
        match Url::parse(input) {
            Ok(url) => {
                println!("✓ {}: parsed", desc);
                println!("  host_str: {:?}", url.host_str());
                println!("  port: {:?}", url.port());
            }
            Err(e) => println!("✗ {}: parse error: {}", desc, e),
        }
    }
}
EOF

# Compile and run the test
rustc --edition 2021 /tmp/test_ipv6_url.rs -o /tmp/test_ipv6_url --extern url=$(fd -t f liburl.*\.rlib | head -1 | sed 's/.*\(\/.*\)/\1/') 2>/dev/null || \
  (cd /tmp && cargo init --name test_ipv6 && \
   echo 'url = "2.4.0"' >> Cargo.toml && \
   cat test_ipv6_url.rs > src/main.rs && \
   cargo run --quiet)

Repository: parseablehq/parseable

Length of output: 312


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "rustc:"; (command -v rustc && rustc --version) || true
echo "cargo:"; (command -v cargo && cargo --version) || true
echo "rustup:"; (command -v rustup && rustup --version) || true
echo
echo "rustup show:"
rustup show || true
echo
echo "Try toolchain-specific versions:"
(rustc +stable --version && cargo +stable --version) || true
(rustc +1.77.0 --version && cargo +1.77.0 --version) || true
(rustc +nightly --version && cargo +nightly --version) || true

Repository: parseablehq/parseable

Length of output: 1803


🌐 Web query:

url crate Url::parse unbracketed IPv6 format '::1:8000' or ':::8000'

💡 Result:

The url crate in Rust does not support unbracketed IPv6 addresses in URLs. According to the URL Standard, which the crate implements, IPv6 literals must be enclosed in square brackets (e.g., [::1]) [1][2][3]. Attempting to parse an invalid format like::1:8000 or:::8000 using Url::parse will result in a ParseError::InvalidIpv6Address [1][4]. The documentation and examples explicitly state that IPv6 addresses are required to be given between '[' and ']' brackets [2][4][5]. For example: assert!(Url::parse("http://[:::1]") == Err(ParseError::InvalidIpv6Address)); [1] If you need to handle IPv6 addresses, ensure they are properly bracketed, such as [2001:db8::1]:8080/path [3].

Citations:


🌐 Web query:

url crate IPv6 parsing rules host_str for bracketed '[::]' and wildcard '::'

💡 Result:

In the Rust url crate: 1) Url::host_str() for bracketed IPv6 - Url::host_str() returns the serialization of an IPv6 host address in square brackets ([ and ]), in RFC 5952 format (lowercase hex with maximal :: compression) [1][2]. - The crate’s host parsing rules state that the host is either an IPv6 address in [...] brackets or a domain name [2]. 2) How bracketed [::] is handled - [::] (inside brackets) is an IPv6 literal host form; since host_str() brackets IPv6 and uses the IPv6 textual representation rules, bracketed [::] will be treated as an IPv6 unspecified-address literal and serialized back with brackets [1][2]. - Invalid bracketed IPv6 literals are rejected; e.g. Url::parse("http://[:::1]") returns ParseError::InvalidIpv6Address, demonstrating the parser enforces IPv6 literal syntax inside [...] [3]. 3) Wildcard :: - The url crate does not document or implement a “wildcard ::” host grammar. The host parser rule is explicitly “IPv6 address in [...] square brackets, or a domain” [2]. - Therefore, :: is treated as just an (invalid) host string unless it appears in the required bracketed IPv6-literal form [...] [2]. Practical guidance - Use bracketed form for IPv6 hosts in URLs: http://[::1]/ and http://[::]/. - Do not use :: as a wildcard host value with the url crate; it’s not part of the documented host parsing model [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Run with explicit toolchain since rustup has no default configured.
TOOLCHAIN="+1.95.0"

cat > /tmp/test_ipv6_url.rs <<'EOF'
use url::Url;

fn main() {
    let test_cases = vec![
        ("http://[::1]:8000", "bracketed IPv6 loopback"),
        ("http://[::]:8000", "bracketed IPv6 wildcard"),
        ("http://:::8000", "unbracketed IPv6 wildcard with port"),
        ("http://::1:8000", "unbracketed IPv6 loopback with port"),
        ("http://0.0.0.0:8000", "IPv4 wildcard"),
    ];

    for (input, desc) in test_cases {
        match Url::parse(input) {
            Ok(url) => {
                println!("✓ {}: parsed => host_str={:?} host={:?} port={:?}", desc, url.host_str(), url.host(), url.port());
            }
            Err(e) => println!("✗ {}: parse error: {}", desc, e),
        }
    }
}
EOF

# Use cargo to fetch the url crate (no manual rlib path hunting).
rm -rf /tmp/test_ipv6_url_proj
mkdir -p /tmp/test_ipv6_url_proj/src
cp /tmp/test_ipv6_url.rs /tmp/test_ipv6_url_proj/src/main.rs

cat > /tmp/test_ipv6_url_proj/Cargo.toml <<'EOF'
[package]
name = "test_ipv6_url"
version = "0.1.0"
edition = "2021"

[dependencies]
url = "2.5.2"
EOF

(cd /tmp/test_ipv6_url_proj && cargo $TOOLCHAIN run --quiet)

Repository: parseablehq/parseable

Length of output: 667


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '1,140p' src/oidc.rs | nl -ba | sed -n '40,110p'

Repository: parseablehq/parseable

Length of output: 108


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n src/oidc.rs | sed -n '40,120p'

Repository: parseablehq/parseable

Length of output: 2716


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "Origin::Local|socket_addr" src

Repository: parseablehq/parseable

Length of output: 710


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n src/option.rs | sed -n '90,170p'

Repository: parseablehq/parseable

Length of output: 3116


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n src/cli.rs | sed -n '640,760p'

Repository: parseablehq/parseable

Length of output: 5048


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "address:|validation::socket_addr|P_.*ADDRESS" src/cli.rs

Repository: parseablehq/parseable

Length of output: 239


🏁 Script executed:

#!/bin/bash
set -euo pipefail
cat -n src/cli.rs | sed -n '110,240p'

Repository: parseablehq/parseable

Length of output: 4405


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat > /tmp/test_to_socket_addrs.rs <<'EOF'
use std::net::ToSocketAddrs;

fn main() {
    let cases = vec![
        ("0.0.0.0:8000", "ipv4 wildcard"),
        ("[::]:8000", "ipv6 wildcard bracketed"),
        ("[::1]:8000", "ipv6 loopback bracketed"),
        ("::1:8000", "ipv6 loopback unbracketed"),
        (":::8000", "ipv6 wildcard+port unbracketed"),
    ];

    for (s, desc) in cases {
        let ok = s.to_socket_addrs().is_ok();
        println!("{} => {}", desc, ok);
    }
}
EOF

rm -rf /tmp/test_to_socket_addrs_proj
mkdir -p /tmp/test_to_socket_addrs_proj/src
cp /tmp/test_to_socket_addrs.rs /tmp/test_to_socket_addrs_proj/src/main.rs
cat > /tmp/test_to_socket_addrs_proj/Cargo.toml <<'EOF'
[package]
name = "test_to_socket_addrs"
version = "0.1.0"
edition = "2021"
[dependencies]
EOF

(cd /tmp/test_to_socket_addrs_proj && cargo +1.95.0 run --quiet)

Repository: parseablehq/parseable

Length of output: 224


Fix IPv6 wildcard + parsing behavior in canonical_local_origin (src/oidc.rs)

  • url::Url::parse only accepts bracketed IPv6 in URLs; unbracketed inputs like ::1:8000 / :::8000 fail and hit the fallback to http(s)://localhost:8000 (hardcoded port).
  • For bracketed wildcard inputs ([::]:PORT), url.host_str() is "[::]", so the current match Some("::") never fires—redirects may still use the wildcard IPv6 host.
  • Update to (a) bracket-wrap IPv6 socket_addr before building the URL, (b) canonicalize when host_str() is "[::]", and (c) add IPv6-focused tests (including configured ports) to prevent regressions.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oidc.rs` around lines 67 - 75, canonical_local_origin fails for
unbracketed IPv6 and doesn't canonicalize bracketed wildcard "[::]"; update
canonical_local_origin to (1) detect IPv6 literal inputs (contain ':' and not
already bracketed) and wrap the address portion in brackets before building the
URL so Url::parse accepts forms like "::1:8000" or ":::8000", (2) preserve and
use any port from socket_addr rather than falling back to hardcoded
localhost:8000, and (3) treat both "::" and "[::]" (and "0.0.0.0") as wildcard
hosts and replace them with "localhost" using url.set_host(Some("localhost")).
Also add unit tests covering unbracketed IPv6 with port (e.g. "::1:PORT"),
bracketed wildcard "[::]:PORT", and IPv4 wildcard "0.0.0.0:PORT" to prevent
regressions; reference canonical_local_origin in src/oidc.rs and its tests.


#[cfg(test)]
mod tests {
use super::*;

#[test]
fn wildcard_ipv4_origin_uses_localhost_with_configured_port() {
assert_eq!(
canonical_local_origin("0.0.0.0:8000", false).as_str(),
"http://localhost:8000/"
);
}

#[test]
fn loopback_ipv4_origin_is_preserved() {
assert_eq!(
canonical_local_origin("127.0.0.1:9000", false).as_str(),
"http://127.0.0.1:9000/"
);
}
}
Comment on lines +77 to +96
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add test coverage for IPv6 wildcard and fallback behavior.

The test suite has the following gaps:

  1. Missing test for :: IPv6 wildcard canonicalization: Line 71 explicitly handles "::" in the match pattern, but there's no test verifying that canonical_local_origin("::", false) or canonical_local_origin(":::8000", false) correctly rewrites to localhost.

  2. Missing test for fallback behavior: The unwrap_or_else fallback on line 69-70 is not tested. Consider adding a test with an invalid socket address (e.g., "invalid_address") to verify it falls back to localhost:8000.

🧪 Suggested test additions
#[test]
fn wildcard_ipv6_origin_uses_localhost_with_configured_port() {
    // Test :: wildcard with port
    assert_eq!(
        canonical_local_origin(":::8000", false).as_str(),
        "http://localhost:8000/"
    );
}

#[test]
fn invalid_socket_addr_falls_back_to_localhost_8000() {
    assert_eq!(
        canonical_local_origin("not_a_valid_address", false).as_str(),
        "http://localhost:8000/"
    );
}

#[test]
fn https_scheme_is_respected() {
    assert_eq!(
        canonical_local_origin("127.0.0.1:9000", true).as_str(),
        "https://127.0.0.1:9000/"
    );
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oidc.rs` around lines 77 - 96, Add unit tests for canonical_local_origin
to cover the IPv6 wildcard and the unwrap_or_else fallback: 1) assert that
canonical_local_origin("::", false) and canonical_local_origin(":::8000", false)
canonicalize to "http://localhost:8000/" (or with provided port), 2) assert that
an invalid socket string like canonical_local_origin("not_a_valid_address",
false) falls back to "http://localhost:8000/", and 3) assert that the https
boolean is respected (e.g., canonical_local_origin("127.0.0.1:9000", true) ->
"https://127.0.0.1:9000/"); add these tests in the same tests module alongside
the existing tests referencing canonical_local_origin.


#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
pub struct Claims {
#[serde(flatten)]
Expand Down
Loading