Skip to content

Commit 69255d0

Browse files
committed
cddis: implement bearer-token auth, download validation, and keyring backend
Phase 2 of the project plan. CddisClient (src/cddis/auth.rs) attaches the bearer token to every request via reqwest's blocking client and classifies CDDIS's two distinct auth-failure shapes: a missing token gets a 302 redirect to Earthdata Login, while a present-but-invalid token gets a direct 401/403. Confirmed against the live archive, which also caught a bug during development where verify_token() only checked for the redirect case and silently accepted a garbage token as valid. download.rs validates content-type and gzip magic bytes as defense in depth beyond the auth check. secrets/keyring.rs stores the token in the OS-native keyring via the keyring crate, saving it only after CddisClient::verify_token() confirms it actually works (save-on-first-successful-auth). main.rs now verifies the token on startup instead of only discovering an auth problem later during product download. Unit tests cover the auth/download classification logic against a local one-shot HTTP server; tests/live_cddis.rs (ignored by default, run manually) checks the same classification against the real CDDIS archive. reqwest and keyring are both MIT OR Apache-2.0, compatible with this project's GPLv3 license. Signed-off-by: Oleksandr Suvorov <cryosay@gmail.com>
1 parent 59bfffd commit 69255d0

11 files changed

Lines changed: 2778 additions & 208 deletions

File tree

‎Cargo.lock‎

Lines changed: 2377 additions & 172 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ clap = { version = "4.6", features = ["derive"] }
1212
hifitime = "4.3"
1313
thiserror = "2.0"
1414
rpassword = "7.5"
15+
reqwest = { version = "0.13", features = ["blocking"] }
16+
keyring = "4.1"
1517

1618
[package.metadata.deb]
1719
maintainer = "Oleksandr Suvorov <cryosay@gmail.com>"

‎src/cddis/auth.rs‎

Lines changed: 178 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,181 @@
22
//! `Authorization: Bearer <token>` to CDDIS requests. No cookie jar or
33
//! redirect-following login flow is needed — confirmed against the live
44
//! archive, CDDIS accepts a URS token directly on the file request itself.
5-
//!
6-
//! Phase 2 of the project plan. Not yet implemented.
5+
6+
use reqwest::StatusCode;
7+
use reqwest::blocking::{Client, Response};
8+
use reqwest::header::{AUTHORIZATION, LOCATION, RANGE};
9+
use reqwest::redirect::Policy;
10+
11+
/// A long-archived, permanently stable CDDIS product used only to confirm
12+
/// a bearer token works, without downloading a whole file (see
13+
/// `verify_token`).
14+
const TOKEN_VERIFICATION_URL: &str = "https://cddis.nasa.gov/archive/gnss/data/daily/2020/001/20p/BRDC00IGS_R_20200010000_01D_MN.rnx.gz";
15+
16+
#[derive(Debug, thiserror::Error)]
17+
pub enum CddisAuthError {
18+
/// No `Authorization` header (or a header CDDIS's proxy doesn't even
19+
/// look past) gets a redirect back to the Earthdata Login page instead
20+
/// of the file.
21+
#[error("CDDIS rejected the bearer token (redirected to Earthdata Login: {location})")]
22+
Unauthenticated { location: String },
23+
/// A present but invalid/expired token gets a direct `401`/`403` from
24+
/// CDDIS, not a redirect. Confirmed against the live archive: a
25+
/// syntactically-present garbage token gets `401 Unauthorized`.
26+
#[error("CDDIS rejected the bearer token (HTTP {status})")]
27+
InvalidToken { status: StatusCode },
28+
#[error("request to CDDIS failed: {0}")]
29+
Request(#[from] reqwest::Error),
30+
}
31+
32+
/// HTTP client that attaches a URS bearer token to every CDDIS request and
33+
/// treats a redirect back to Earthdata Login as the auth failure it is,
34+
/// rather than following it (plan §5).
35+
pub struct CddisClient {
36+
http: Client,
37+
token: String,
38+
}
39+
40+
impl CddisClient {
41+
pub fn new(token: String) -> Result<Self, CddisAuthError> {
42+
let http = Client::builder().redirect(Policy::none()).build()?;
43+
Ok(Self { http, token })
44+
}
45+
46+
/// Issues an authenticated GET against a CDDIS URL.
47+
pub fn get(&self, url: &str) -> Result<Response, CddisAuthError> {
48+
self.send(url, None)
49+
}
50+
51+
/// Confirms the token works by requesting a single byte of a stable,
52+
/// long-archived product, rather than downloading a whole file. Used
53+
/// to verify a freshly entered token before it's persisted (see
54+
/// `secrets::keyring`) and to fail fast with a clear error on startup
55+
/// rather than only discovering an auth problem during Phase 3/4
56+
/// product discovery and download.
57+
pub fn verify_token(&self) -> Result<(), CddisAuthError> {
58+
self.send(TOKEN_VERIFICATION_URL, Some("bytes=0-0"))?;
59+
Ok(())
60+
}
61+
62+
fn send(&self, url: &str, range: Option<&str>) -> Result<Response, CddisAuthError> {
63+
let mut request = self
64+
.http
65+
.get(url)
66+
.header(AUTHORIZATION, format!("Bearer {}", self.token));
67+
if let Some(range) = range {
68+
request = request.header(RANGE, range);
69+
}
70+
let response = request.send()?;
71+
let status = response.status();
72+
73+
if status.is_redirection() {
74+
let location = response
75+
.headers()
76+
.get(LOCATION)
77+
.and_then(|value| value.to_str().ok())
78+
.unwrap_or("")
79+
.to_string();
80+
if location.contains("urs.earthdata.nasa.gov") {
81+
return Err(CddisAuthError::Unauthenticated { location });
82+
}
83+
}
84+
85+
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
86+
return Err(CddisAuthError::InvalidToken { status });
87+
}
88+
89+
Ok(response)
90+
}
91+
}
92+
93+
#[cfg(test)]
94+
mod tests {
95+
use super::*;
96+
use std::io::{Read, Write};
97+
use std::net::TcpListener;
98+
use std::thread;
99+
100+
/// Starts a one-shot local HTTP server that replies with `response` to
101+
/// its single connection, and returns the URL to hit. Keeps the auth
102+
/// classification logic testable against real HTTP semantics without
103+
/// pulling in a mocking crate or touching the network.
104+
fn respond_once(response: &'static [u8]) -> String {
105+
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
106+
let addr = listener.local_addr().unwrap();
107+
thread::spawn(move || {
108+
if let Ok((mut stream, _)) = listener.accept() {
109+
let mut buf = [0u8; 4096];
110+
let _ = stream.read(&mut buf);
111+
let _ = stream.write_all(response);
112+
}
113+
});
114+
format!("http://{addr}/probe")
115+
}
116+
117+
#[test]
118+
fn redirect_to_urs_is_reported_as_unauthenticated() {
119+
let url = respond_once(
120+
b"HTTP/1.1 302 Found\r\n\
121+
Location: https://urs.earthdata.nasa.gov/oauth/authorize?client_id=x\r\n\
122+
Content-Length: 0\r\n\
123+
Connection: close\r\n\r\n",
124+
);
125+
126+
let client = CddisClient::new("test-token".to_string()).unwrap();
127+
let err = client.get(&url).unwrap_err();
128+
129+
assert!(matches!(err, CddisAuthError::Unauthenticated { location }
130+
if location.contains("urs.earthdata.nasa.gov")));
131+
}
132+
133+
#[test]
134+
fn success_response_is_returned() {
135+
let url = respond_once(
136+
b"HTTP/1.1 200 OK\r\n\
137+
Content-Type: application/x-gzip\r\n\
138+
Content-Length: 4\r\n\
139+
Connection: close\r\n\r\n\
140+
\x1f\x8b\x08\x00",
141+
);
142+
143+
let client = CddisClient::new("test-token".to_string()).unwrap();
144+
let response = client.get(&url).unwrap();
145+
146+
assert_eq!(response.status(), reqwest::StatusCode::OK);
147+
}
148+
149+
#[test]
150+
fn non_urs_redirect_is_passed_through() {
151+
let url = respond_once(
152+
b"HTTP/1.1 302 Found\r\n\
153+
Location: https://cddis.nasa.gov/elsewhere\r\n\
154+
Content-Length: 0\r\n\
155+
Connection: close\r\n\r\n",
156+
);
157+
158+
let client = CddisClient::new("test-token".to_string()).unwrap();
159+
let response = client.get(&url).unwrap();
160+
161+
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
162+
}
163+
164+
#[test]
165+
fn invalid_token_401_is_reported_as_invalid_token() {
166+
let url = respond_once(
167+
b"HTTP/1.1 401 Unauthorized\r\n\
168+
Content-Length: 0\r\n\
169+
Connection: close\r\n\r\n",
170+
);
171+
172+
let client = CddisClient::new("garbage-token".to_string()).unwrap();
173+
let err = client.get(&url).unwrap_err();
174+
175+
assert!(matches!(
176+
err,
177+
CddisAuthError::InvalidToken {
178+
status: StatusCode::UNAUTHORIZED
179+
}
180+
));
181+
}
182+
}

‎src/cddis/download.rs‎

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,129 @@
1-
//! Retrying, resumable, checksum-verified downloads. A missing or invalid
2-
//! bearer token gets a `302` redirect to `urs.earthdata.nasa.gov` rather
3-
//! than a served file, which is easy to detect on status/`Location` alone;
4-
//! this module also validates that retrieved content is actually
5-
//! gzip/RINEX before treating a request as successful, as defense in depth
6-
//! against any other unexpected response shape.
7-
//!
8-
//! Phase 2-3 of the project plan. Not yet implemented.
1+
//! Downloads a CDDIS product and validates that the response is actually
2+
//! gzip/RINEX data before treating it as successful, as defense in depth:
3+
//! the primary auth-failure signal is the redirect `CddisClient` already
4+
//! detects (plan §5, §8); this guards against any other unexpected
5+
//! response shape. Retrying and resuming partial downloads is Phase 5.
6+
7+
use reqwest::header::CONTENT_TYPE;
8+
9+
use super::auth::{CddisAuthError, CddisClient};
10+
11+
const GZIP_MAGIC: [u8; 2] = [0x1f, 0x8b];
12+
13+
#[derive(Debug, thiserror::Error)]
14+
pub enum DownloadError {
15+
#[error(transparent)]
16+
Auth(#[from] CddisAuthError),
17+
#[error("request to CDDIS failed: {0}")]
18+
Request(#[from] reqwest::Error),
19+
#[error("CDDIS returned an HTML response instead of the requested file")]
20+
UnexpectedHtml,
21+
#[error("response does not start with the gzip magic bytes")]
22+
NotGzip,
23+
}
24+
25+
/// Downloads `url` via `client`, returning the raw (still gzip-compressed)
26+
/// bytes once validated.
27+
pub fn download(client: &CddisClient, url: &str) -> Result<Vec<u8>, DownloadError> {
28+
let response = client.get(url)?.error_for_status()?;
29+
30+
let content_type = response
31+
.headers()
32+
.get(CONTENT_TYPE)
33+
.and_then(|value| value.to_str().ok())
34+
.unwrap_or("")
35+
.to_string();
36+
if content_type.starts_with("text/html") {
37+
return Err(DownloadError::UnexpectedHtml);
38+
}
39+
40+
let bytes = response.bytes()?;
41+
if !bytes.starts_with(&GZIP_MAGIC) {
42+
return Err(DownloadError::NotGzip);
43+
}
44+
45+
Ok(bytes.to_vec())
46+
}
47+
48+
#[cfg(test)]
49+
mod tests {
50+
use super::*;
51+
use std::io::{Read, Write};
52+
use std::net::TcpListener;
53+
use std::thread;
54+
55+
fn respond_once(response: &'static [u8]) -> String {
56+
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
57+
let addr = listener.local_addr().unwrap();
58+
thread::spawn(move || {
59+
if let Ok((mut stream, _)) = listener.accept() {
60+
let mut buf = [0u8; 4096];
61+
let _ = stream.read(&mut buf);
62+
let _ = stream.write_all(response);
63+
}
64+
});
65+
format!("http://{addr}/probe")
66+
}
67+
68+
#[test]
69+
fn gzip_response_is_returned() {
70+
let url = respond_once(
71+
b"HTTP/1.1 200 OK\r\n\
72+
Content-Type: application/x-gzip\r\n\
73+
Content-Length: 4\r\n\
74+
Connection: close\r\n\r\n\
75+
\x1f\x8b\x08\x00",
76+
);
77+
78+
let client = CddisClient::new("test-token".to_string()).unwrap();
79+
let bytes = download(&client, &url).unwrap();
80+
81+
assert_eq!(bytes, vec![0x1f, 0x8b, 0x08, 0x00]);
82+
}
83+
84+
#[test]
85+
fn html_response_is_rejected() {
86+
let url = respond_once(
87+
b"HTTP/1.1 200 OK\r\n\
88+
Content-Type: text/html; charset=UTF-8\r\n\
89+
Content-Length: 13\r\n\
90+
Connection: close\r\n\r\n\
91+
<html></html>",
92+
);
93+
94+
let client = CddisClient::new("test-token".to_string()).unwrap();
95+
let err = download(&client, &url).unwrap_err();
96+
97+
assert!(matches!(err, DownloadError::UnexpectedHtml));
98+
}
99+
100+
#[test]
101+
fn non_gzip_body_is_rejected() {
102+
let url = respond_once(
103+
b"HTTP/1.1 200 OK\r\n\
104+
Content-Type: application/octet-stream\r\n\
105+
Content-Length: 4\r\n\
106+
Connection: close\r\n\r\n\
107+
plai",
108+
);
109+
110+
let client = CddisClient::new("test-token".to_string()).unwrap();
111+
let err = download(&client, &url).unwrap_err();
112+
113+
assert!(matches!(err, DownloadError::NotGzip));
114+
}
115+
116+
#[test]
117+
fn not_found_is_reported_as_request_error() {
118+
let url = respond_once(
119+
b"HTTP/1.1 404 Not Found\r\n\
120+
Content-Length: 0\r\n\
121+
Connection: close\r\n\r\n",
122+
);
123+
124+
let client = CddisClient::new("test-token".to_string()).unwrap();
125+
let err = download(&client, &url).unwrap_err();
126+
127+
assert!(matches!(err, DownloadError::Request(_)));
128+
}
129+
}

‎src/cddis/mod.rs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
//! CDDIS access: Earthdata Login, product discovery, and download.
22
//!
3-
//! Phases 2-3 of the project plan. Not yet implemented.
3+
//! Auth and download are implemented (Phase 2). Discovery is Phase 3.
44
55
/// Earthdata Login (URS) bearer-token auth: attaches the token as an
66
/// `Authorization` header. Phase 2.
77
pub mod auth;
88

99
/// Resolves remote CDDIS paths for nav & obs products, including the
10-
/// `--time now` fallback tiers (final / rapid / ultra-rapid). Phase 3.
10+
/// `--time now` fallback tiers (final / rapid / ultra-rapid). Phase 3. Not
11+
/// yet implemented.
1112
pub mod discovery;
1213

13-
/// Retrying, resumable, checksum-verified downloads, with content-type
14-
/// validation as defense in depth against an unexpected non-file response.
15-
/// Phase 2-3.
14+
/// Downloads a CDDIS product and validates the response, with content-type
15+
/// and magic-byte checks as defense in depth against an unexpected
16+
/// non-file response. Retry/resume is Phase 5. Phase 2.
1617
pub mod download;

‎src/error.rs‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//! error, an unknown station, or a parse/format problem. Module-specific
77
//! error types convert into this one via `#[from]` as each phase lands.
88
9+
use crate::cddis::auth::CddisAuthError;
910
use crate::secrets::CredentialError;
1011
use crate::time::TimeError;
1112

@@ -17,14 +18,12 @@ pub enum RinexFetchError {
1718
#[error("unsupported --rinex-version {0}: only RINEX 4 output is supported")]
1819
UnsupportedRinexVersion(u8),
1920

20-
#[error(
21-
"--credential-provider {0} is not implemented yet (see rinexfetch-project-plan.md Phase 2)"
22-
)]
23-
CredentialBackendNotImplemented(&'static str),
24-
2521
#[error(transparent)]
2622
Time(#[from] TimeError),
2723

2824
#[error(transparent)]
2925
Credential(#[from] CredentialError),
26+
27+
#[error(transparent)]
28+
Auth(#[from] CddisAuthError),
3029
}

0 commit comments

Comments
 (0)