Skip to content

Commit 65a249a

Browse files
authored
Merge pull request #54 from zaghaghi/feat/security-schemes
feat: OpenAPI security schemes (authenticate once per API)
2 parents 5f86cbc + 2be61e5 commit 65a249a

12 files changed

Lines changed: 499 additions & 48 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ tracing-error = "0.2.0"
5656
tracing-subscriber = { version = "0.3.17", features = ["env-filter", "serde"] }
5757
tui-input = "0.15.0"
5858
ratatui-textarea = "0.8.0"
59+
base64 = "0.22.1"
5960

6061
[build-dependencies]
6162
anyhow = "1.0.86"

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ Terminal UI to list, browse and run APIs defined with OpenAPI v3.0 and v3.1 spec
1111
❯ openapi-tui --help
1212
This TUI allows you to list and browse APIs described by the openapi specification.
1313

14-
Usage: openapi-tui --input <PATH>
14+
Usage: openapi-tui [OPTIONS] --input <PATH>
1515

1616
Options:
17-
-i, --input <PATH> Input file or url, in json or yaml format with openapi specification
18-
-h, --help Print help
19-
-V, --version Print version
17+
-i, --input <PATH> Input file or url, in json or yaml format with openapi specification
18+
-H, --header <NAME: VALUE> Global header to attach to every request, in `Name: Value` form. May be repeated.
19+
-h, --help Print help
20+
-V, --version Print version
2021
```
2122

2223
## Examples
@@ -29,6 +30,9 @@ Options:
2930

3031
# open remote file
3132
❯ openapi-tui -i https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.yaml
33+
34+
# attach default headers to every request
35+
❯ openapi-tui -i examples/petstore.json -H 'Authorization: Bearer xyz' -H 'X-Env: dev'
3236
```
3337

3438

@@ -161,12 +165,14 @@ Then, add `openapi-tui` to your `configuration.nix`
161165
| `q` | Quit |
162166
| `request`, `r` | Go to request page|
163167
| `history` | Request history|
168+
| `auth` | Open authentication popup to set credentials for `components.securitySchemes` |
164169

165170
# Commands Request Page
166171
| Command | Description |
167172
|:--------|:------------|
168173
| `q` | Quit |
169174
| `send`, `s` | Send request |
175+
| `auth` | Open authentication popup to set credentials for `components.securitySchemes` |
170176
| `query`, `q` | Add or remove query strings. sub-commands are `add` or `rm`. e.g. `query add page` |
171177
| `header`, `h` | Add or remove headers. sub-commands are `add` or `rm`. e.g. `header add x-api-key` |
172178
| `request`, `r` | Load request payload. e.g. `request open /home/hamed/payload.json` |

src/action.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ pub enum Action {
4242
Dial,
4343
History,
4444
CloseHistory,
45+
Auth,
46+
CloseAuth,
4547
AddQuery(String),
4648
RemoveQuery(String),
4749
AddHeader(String),

src/app.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::{
1313
action::Action,
1414
config::Config,
1515
pages::{home::Home, phone::Phone, Page},
16-
panes::{footer::FooterPane, header::HeaderPane, history::HistoryPane, Pane},
16+
panes::{auth::AuthPane, footer::FooterPane, header::HeaderPane, history::HistoryPane, Pane},
1717
request::Request,
1818
response::Response,
1919
state::{InputMode, OperationItemType, State},
@@ -243,6 +243,15 @@ impl App {
243243
Action::CloseHistory => {
244244
self.popup = None;
245245
},
246+
Action::Auth => {
247+
self.popup = Some(Box::new(AuthPane::new(&self.state)));
248+
},
249+
Action::CloseAuth => {
250+
if self.state.input_mode == InputMode::Insert {
251+
self.state.input_mode = InputMode::Normal;
252+
}
253+
self.popup = None;
254+
},
246255
_ => {},
247256
}
248257

src/auth.rs

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
use std::collections::BTreeMap;
2+
3+
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
4+
use reqwest::header::{HeaderName, HeaderValue, AUTHORIZATION, COOKIE};
5+
6+
#[derive(Debug, Clone, PartialEq, Eq)]
7+
pub enum ApiKeyLocation {
8+
Header,
9+
Query,
10+
Cookie,
11+
}
12+
13+
#[derive(Debug, Clone, PartialEq, Eq)]
14+
pub enum AuthKind {
15+
ApiKey { name: String, location: ApiKeyLocation },
16+
HttpBearer,
17+
HttpBasic,
18+
Unsupported(String),
19+
}
20+
21+
#[derive(Debug, Clone)]
22+
pub struct AuthScheme {
23+
pub name: String,
24+
pub kind: AuthKind,
25+
}
26+
27+
impl AuthKind {
28+
pub fn label(&self) -> String {
29+
match self {
30+
AuthKind::ApiKey { name, location } => {
31+
let loc = match location {
32+
ApiKeyLocation::Header => "header",
33+
ApiKeyLocation::Query => "query",
34+
ApiKeyLocation::Cookie => "cookie",
35+
};
36+
format!("apiKey ({loc} {name})")
37+
},
38+
AuthKind::HttpBearer => "http bearer".to_string(),
39+
AuthKind::HttpBasic => "http basic (user:pass)".to_string(),
40+
AuthKind::Unsupported(s) => format!("unsupported: {s}"),
41+
}
42+
}
43+
44+
pub fn is_supported(&self) -> bool {
45+
!matches!(self, AuthKind::Unsupported(_))
46+
}
47+
}
48+
49+
pub fn parse_security_schemes(raw: &serde_yaml::Value) -> Vec<AuthScheme> {
50+
let Some(map) = raw.get("components").and_then(|c| c.get("securitySchemes")).and_then(|s| s.as_mapping()) else {
51+
return Vec::new();
52+
};
53+
54+
let mut out = Vec::new();
55+
for (k, v) in map {
56+
let Some(name) = k.as_str() else { continue };
57+
let Some(obj) = v.as_mapping() else { continue };
58+
let ty = obj.get("type").and_then(|t| t.as_str()).unwrap_or("");
59+
let kind = match ty {
60+
"apiKey" => {
61+
let key_name = obj.get("name").and_then(|n| n.as_str()).unwrap_or("").to_string();
62+
let location = match obj.get("in").and_then(|n| n.as_str()).unwrap_or("header") {
63+
"query" => ApiKeyLocation::Query,
64+
"cookie" => ApiKeyLocation::Cookie,
65+
_ => ApiKeyLocation::Header,
66+
};
67+
AuthKind::ApiKey { name: key_name, location }
68+
},
69+
"http" => {
70+
let scheme = obj.get("scheme").and_then(|n| n.as_str()).unwrap_or("").to_ascii_lowercase();
71+
match scheme.as_str() {
72+
"bearer" => AuthKind::HttpBearer,
73+
"basic" => AuthKind::HttpBasic,
74+
other => AuthKind::Unsupported(format!("http {other}")),
75+
}
76+
},
77+
"oauth2" | "openIdConnect" | "mutualTLS" => AuthKind::Unsupported(ty.to_string()),
78+
other => AuthKind::Unsupported(other.to_string()),
79+
};
80+
out.push(AuthScheme { name: name.to_string(), kind });
81+
}
82+
out
83+
}
84+
85+
/// Parse a `security` node (top-level or per-operation) into the list of OR-ed
86+
/// requirement options. Each option is a map of `scheme_name -> scopes`.
87+
pub fn parse_security_requirements(value: &serde_yaml::Value) -> Option<Vec<BTreeMap<String, Vec<String>>>> {
88+
let arr = value.as_sequence()?;
89+
let mut out: Vec<BTreeMap<String, Vec<String>>> = Vec::with_capacity(arr.len());
90+
for entry in arr {
91+
let Some(m) = entry.as_mapping() else { continue };
92+
let mut req = BTreeMap::new();
93+
for (k, v) in m {
94+
if let Some(name) = k.as_str() {
95+
let scopes = v
96+
.as_sequence()
97+
.map(|seq| seq.iter().filter_map(|s| s.as_str().map(String::from)).collect())
98+
.unwrap_or_default();
99+
req.insert(name.to_string(), scopes);
100+
}
101+
}
102+
out.push(req);
103+
}
104+
Some(out)
105+
}
106+
107+
/// Parse `security` from `serde_json::Value` (used for per-operation entries
108+
/// that openapi-31 already deserialized).
109+
pub fn parse_security_requirements_json(
110+
value: &[BTreeMap<String, serde_json::Value>],
111+
) -> Vec<BTreeMap<String, Vec<String>>> {
112+
value
113+
.iter()
114+
.map(|m| {
115+
m.iter()
116+
.map(|(k, v)| {
117+
let scopes = v
118+
.as_array()
119+
.map(|seq| seq.iter().filter_map(|s| s.as_str().map(String::from)).collect())
120+
.unwrap_or_default();
121+
(k.clone(), scopes)
122+
})
123+
.collect()
124+
})
125+
.collect()
126+
}
127+
128+
/// Pick the first option whose schemes are ALL present in `values`. Returns the
129+
/// list of (scheme_name, value) pairs to apply, or `None` if no option matches.
130+
pub fn select_satisfied_option<'a>(
131+
options: &'a [BTreeMap<String, Vec<String>>],
132+
values: &std::collections::HashMap<String, String>,
133+
) -> Option<Vec<&'a String>> {
134+
for opt in options {
135+
if opt.is_empty() {
136+
// Empty requirement = explicit no-auth; treat as satisfied with nothing.
137+
return Some(Vec::new());
138+
}
139+
if opt.keys().all(|name| values.get(name).is_some_and(|v| !v.is_empty())) {
140+
return Some(opt.keys().collect());
141+
}
142+
}
143+
None
144+
}
145+
146+
/// Apply a single resolved scheme to a `reqwest::RequestBuilder`.
147+
pub fn apply_scheme(request: reqwest::RequestBuilder, scheme: &AuthScheme, value: &str) -> reqwest::RequestBuilder {
148+
match &scheme.kind {
149+
AuthKind::ApiKey { name, location } => match location {
150+
ApiKeyLocation::Header => match (HeaderName::try_from(name.as_str()), HeaderValue::from_str(value)) {
151+
(Ok(n), Ok(v)) => request.header(n, v),
152+
_ => request,
153+
},
154+
ApiKeyLocation::Query => request.query(&[(name.as_str(), value)]),
155+
ApiKeyLocation::Cookie => match HeaderValue::from_str(&format!("{name}={value}")) {
156+
Ok(v) => request.header(COOKIE, v),
157+
Err(_) => request,
158+
},
159+
},
160+
AuthKind::HttpBearer => match HeaderValue::from_str(&format!("Bearer {value}")) {
161+
Ok(v) => request.header(AUTHORIZATION, v),
162+
Err(_) => request,
163+
},
164+
AuthKind::HttpBasic => {
165+
let encoded = BASE64.encode(value.as_bytes());
166+
match HeaderValue::from_str(&format!("Basic {encoded}")) {
167+
Ok(v) => request.header(AUTHORIZATION, v),
168+
Err(_) => request,
169+
}
170+
},
171+
AuthKind::Unsupported(_) => request,
172+
}
173+
}
174+
175+
#[cfg(test)]
176+
mod tests {
177+
use super::*;
178+
179+
fn yaml(src: &str) -> serde_yaml::Value {
180+
serde_yaml::from_str(src).unwrap()
181+
}
182+
183+
#[test]
184+
fn parses_apikey_and_http_schemes() {
185+
let v = yaml(
186+
r#"
187+
components:
188+
securitySchemes:
189+
bearerAuth:
190+
type: http
191+
scheme: bearer
192+
basicAuth:
193+
type: http
194+
scheme: basic
195+
apiKeyHeader:
196+
type: apiKey
197+
in: header
198+
name: X-API-Key
199+
apiKeyQuery:
200+
type: apiKey
201+
in: query
202+
name: api_key
203+
oauth:
204+
type: oauth2
205+
"#,
206+
);
207+
let schemes = parse_security_schemes(&v);
208+
assert_eq!(schemes.len(), 5);
209+
let by_name: std::collections::HashMap<_, _> = schemes.iter().map(|s| (s.name.as_str(), &s.kind)).collect();
210+
assert_eq!(by_name["bearerAuth"], &AuthKind::HttpBearer);
211+
assert_eq!(by_name["basicAuth"], &AuthKind::HttpBasic);
212+
assert!(matches!(by_name["apiKeyHeader"], AuthKind::ApiKey { location: ApiKeyLocation::Header, .. }));
213+
assert!(matches!(by_name["apiKeyQuery"], AuthKind::ApiKey { location: ApiKeyLocation::Query, .. }));
214+
assert!(matches!(by_name["oauth"], AuthKind::Unsupported(_)));
215+
}
216+
217+
#[test]
218+
fn select_satisfied_picks_first_complete_option() {
219+
let options = vec![
220+
[("a".to_string(), vec![]), ("b".to_string(), vec![])].into_iter().collect(),
221+
[("c".to_string(), vec![])].into_iter().collect(),
222+
];
223+
let mut values = std::collections::HashMap::new();
224+
values.insert("c".to_string(), "x".to_string());
225+
let picked = select_satisfied_option(&options, &values).unwrap();
226+
assert_eq!(picked, vec![&"c".to_string()]);
227+
}
228+
229+
#[test]
230+
fn empty_requirement_is_explicit_no_auth() {
231+
let options: Vec<BTreeMap<String, Vec<String>>> = vec![BTreeMap::new()];
232+
let values = std::collections::HashMap::new();
233+
let picked = select_satisfied_option(&options, &values).unwrap();
234+
assert!(picked.is_empty());
235+
}
236+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod action;
22
pub mod app;
3+
pub mod auth;
34
pub mod cli;
45
pub mod components;
56
pub mod config;

src/pages/home.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ impl Page for Home {
131131
.push(Some(Action::NewCall(state.active_operation().and_then(|op| op.operation.operation_id.clone()))));
132132
} else if args.eq("history") {
133133
actions.push(Some(Action::History));
134+
} else if args.eq("auth") {
135+
actions.push(Some(Action::Auth));
134136
} else {
135137
actions.push(Some(Action::TimedStatusLine("unknown command".into(), 1)));
136138
}

0 commit comments

Comments
 (0)