chore: added support for Bearer tokens and API key auth 6a9de02d
Steve Simkins · 2026-08-08 00:40 16 file(s) · +643 −114
AGENTS.md +21 −6
12 12
├── input.rs           # keymap: Normal / Insert / Command modes, popups
13 13
├── ui.rs               # rendering: sidebar, url bar, editor tabs, response, popups
14 14
├── highlight.rs         # JSON/XML tokenizer -> styled ratatui Lines
15 -
├── model.rs            # Collection / SavedRequest / KeyValueRow / FieldDoc / OAuthConfig (serde)
15 +
├── model.rs            # Collection / SavedRequest / KeyValueRow / FieldDoc / OAuthConfig+AuthKind (serde)
16 16
├── store.rs            # ~/.config/cielago persistence, AppConfig
17 17
├── openapi/
18 18
│   ├── loader.rs        # load spec from file path or http(s) URL, JSON/YAML
23 23
└── http/
24 24
    ├── client.rs           # reqwest request building + response capture
25 25
    ├── oauth.rs              # client-credentials token exchange
26 -
    ├── send.rs                # send_with_auth: cache/refresh token, 401 retry
26 +
    ├── secret.rs              # $(cmd) secret resolution via `sh -c`
27 +
    ├── send.rs                # send_with_auth: pick scheme; oauth cache/refresh + 401 retry
27 28
    ├── url_input.rs            # pasted URL -> origin / path / query (inverse of build_url)
28 29
    └── vars.rs                 # {{name}} + dynamic ({{uuid}}, {{randomInt}}…) substitution
29 30
```
44 45
- **No remote `$ref`s.** `openapi::resolve` only follows local
45 46
  `#/components/...` JSON pointers. Specs that split across files aren't
46 47
  supported — bundle them first if you hit this.
47 -
- **Secrets are plaintext.** `OAuthConfig.client_secret` is saved as-is in
48 -
  the collection JSON (explicit user choice, not an oversight). The
49 -
  in-memory `OAuthToken` obtained from it is never persisted.
48 +
- **Auth is one struct, three schemes.** `OAuthConfig` (aliased `AuthConfig`)
49 +
  carries every scheme's fields, discriminated by `AuthKind` (`bearer` /
50 +
  `apikey` / `oauth2`). It stays a single flat JSON object so collections
51 +
  written before bearer/api-key support — whose `auth` has no `kind` — still
52 +
  deserialize (missing `kind` defaults to `oauth2`, which is what they were).
53 +
  The popup (`A`) builds its rows from `App::auth_fields` per kind; the first
54 +
  row is always the kind toggle. `send::send_with_auth` branches on the kind:
55 +
  bearer/apikey resolve their secret and send (no token cache, no 401 retry),
56 +
  oauth2 keeps the cache-and-retry path.
57 +
- **Secrets are plaintext, but can be indirected.** Secret fields
58 +
  (`token`, `client_secret`) are saved as-is in the collection JSON (explicit
59 +
  user choice). To avoid that, a field may hold a single `$(…)` command
60 +
  substitution — `http::resolve_secret` runs it through `sh -c` at send time
61 +
  and uses the trimmed stdout. Only a value that is *entirely* `$(…)` is
62 +
  executed, never one embedded in a longer string. The in-memory `OAuthToken`
63 +
  is never persisted.
50 64
- **Tags become sidebar groups**, first tag only; untagged requests land in
51 65
  a `default` group. This wasn't asked for explicitly but was cheap and
52 66
  matches how most specs are organized.
145 159
146 160
## Known gaps (intentionally out of scope for v1)
147 161
148 -
Swagger 2.0, non-client-credentials OAuth flows (auth-code, API key),
162 +
Swagger 2.0, interactive OAuth flows (auth-code / device / implicit — only
163 +
client-credentials is automated; bearer and API-key are static),
149 164
collection folders beyond tag grouping, request history/response diffing.
150 165
The Docs tab covers request inputs only — response schemas and status codes
151 166
aren't imported.
README.md +6 −1
15 15
  navigation, `:` command line, `/` incremental search.
16 16
- **Variables** — `{{name}}` from the collection, plus dynamic ones like
17 17
  `{{uuid}}`, `{{timestamp}}`, `{{randomInt(1,100)}}`.
18 -
- **OAuth2 client credentials** — per collection, tokens cached in memory only.
18 +
- **Auth per collection** — a fixed bearer token, an API key in a header of
19 +
  your choice, or the OAuth2 client-credentials flow (tokens cached in memory
20 +
  only). Press `A` to configure.
21 +
- **Secrets from your shell** — any secret field (bearer token, API key,
22 +
  OAuth client secret) can be a `$(…)` command, resolved at send time, e.g.
23 +
  `$(op read "op://vault/item/field")`.
19 24
- **Server switcher** — swap base URLs so requests stay portable across envs.
20 25
- **Syntax highlighting** — JSON and XML in both request bodies and responses.
21 26
- **Plain JSON storage** — collections live in `~/.config/cielago/collections/`.
src/app.rs +128 −32
20 20
use uuid::Uuid;
21 21
22 22
use crate::http::{HttpResponse, OAuthToken, SendOutcome, send_with_auth, split_url_input};
23 -
use crate::model::{Collection, KeyValueRow, LabelMode, OAuthConfig, SavedRequest, variables_map};
23 +
use crate::model::{
24 +
    AuthKind, Collection, KeyValueRow, LabelMode, OAuthConfig, SavedRequest, variables_map,
25 +
};
24 26
use crate::store::{self, AppConfig};
25 27
use crate::{input, ui};
26 28
134 136
    /// sets the collection's server — see [`App::apply_url_input`].
135 137
    Url,
136 138
    EnvNew,
139 +
    /// Index into [`App::auth_fields`] for the current auth kind. An index (not
140 +
    /// the [`AuthField`] itself) so the enum stays `Copy`-cheap and the cursor
141 +
    /// and edit target share one notion of "which row".
137 142
    AuthField(usize),
143 +
}
144 +
145 +
/// One editable row in the auth popup. Which rows show depends on the selected
146 +
/// [`AuthKind`]; [`App::auth_fields`] builds the list.
147 +
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148 +
pub enum AuthField {
149 +
    /// The scheme selector (a toggle, not a text field).
150 +
    Kind,
151 +
    /// Bearer token, or API-key value (secret).
152 +
    Token,
153 +
    /// API-key header name.
154 +
    Header,
155 +
    TokenUrl,
156 +
    ClientId,
157 +
    ClientSecret,
158 +
    Scopes,
159 +
    /// OAuth client-auth placement (a toggle, not a text field).
160 +
    Style,
161 +
}
162 +
163 +
impl AuthField {
164 +
    /// Rows that carry a secret and should render masked.
165 +
    pub fn is_secret(self) -> bool {
166 +
        matches!(self, AuthField::Token | AuthField::ClientSecret)
167 +
    }
168 +
169 +
    /// Rows edited by toggling rather than typing.
170 +
    pub fn is_toggle(self) -> bool {
171 +
        matches!(self, AuthField::Kind | AuthField::Style)
172 +
    }
138 173
}
139 174
140 175
#[derive(Debug, Clone, PartialEq, Eq)]
919 954
920 955
    // ----- auth popup -----
921 956
922 -
    pub const AUTH_FIELDS: [&'static str; 5] = [
923 -
        "Token URL",
924 -
        "Client ID",
925 -
        "Client Secret",
926 -
        "Scopes (space separated)",
927 -
        "Auth style",
928 -
    ];
929 -
930 957
    pub fn open_auth_popup(&mut self) {
931 -
        self.auth_form = self.collection.auth.clone().unwrap_or_default();
958 +
        // A brand-new config defaults to bearer — the simplest scheme, and the
959 +
        // one this popup mostly exists to make reachable. Existing configs open
960 +
        // on whatever `kind` they were saved with.
961 +
        self.auth_form = self.collection.auth.clone().unwrap_or(OAuthConfig {
962 +
            kind: AuthKind::Bearer,
963 +
            ..Default::default()
964 +
        });
932 965
        self.auth_field = 0;
933 966
        self.popup = Popup::Auth;
934 967
    }
935 968
969 +
    /// The rows shown for the form's current auth kind, in display order. Always
970 +
    /// leads with [`AuthField::Kind`] so the scheme is switchable from any state.
971 +
    pub fn auth_fields(&self) -> Vec<AuthField> {
972 +
        let mut fields = vec![AuthField::Kind];
973 +
        match self.auth_form.kind {
974 +
            AuthKind::Bearer => fields.push(AuthField::Token),
975 +
            AuthKind::ApiKey => fields.extend([AuthField::Header, AuthField::Token]),
976 +
            AuthKind::Oauth2 => fields.extend([
977 +
                AuthField::TokenUrl,
978 +
                AuthField::ClientId,
979 +
                AuthField::ClientSecret,
980 +
                AuthField::Scopes,
981 +
                AuthField::Style,
982 +
            ]),
983 +
        }
984 +
        fields
985 +
    }
986 +
987 +
    /// The [`AuthField`] under the cursor, resolving `auth_field` against the
988 +
    /// current kind's row list (clamped, so a stale index never panics).
989 +
    pub fn auth_field_at(&self, i: usize) -> AuthField {
990 +
        let fields = self.auth_fields();
991 +
        fields[i.min(fields.len() - 1)]
992 +
    }
993 +
994 +
    pub fn auth_field_label(&self, field: AuthField) -> &'static str {
995 +
        match field {
996 +
            AuthField::Kind => "Auth type",
997 +
            AuthField::Token => match self.auth_form.kind {
998 +
                AuthKind::ApiKey => "API key value",
999 +
                _ => "Bearer token",
1000 +
            },
1001 +
            AuthField::Header => "Header name",
1002 +
            AuthField::TokenUrl => "Token URL",
1003 +
            AuthField::ClientId => "Client ID",
1004 +
            AuthField::ClientSecret => "Client Secret",
1005 +
            AuthField::Scopes => "Scopes (space separated)",
1006 +
            AuthField::Style => "Auth style",
1007 +
        }
1008 +
    }
1009 +
936 1010
    pub fn auth_field_value(&self, i: usize) -> String {
937 -
        match i {
938 -
            0 => self.auth_form.token_url.clone(),
939 -
            1 => self.auth_form.client_id.clone(),
940 -
            2 => self.auth_form.client_secret.clone(),
941 -
            3 => self.auth_form.scopes.join(" "),
942 -
            4 => match self.auth_form.auth_style {
1011 +
        match self.auth_field_at(i) {
1012 +
            AuthField::Kind => self.auth_form.kind.title().to_string(),
1013 +
            AuthField::Token => self.auth_form.token.clone(),
1014 +
            AuthField::Header => self.auth_form.header.clone(),
1015 +
            AuthField::TokenUrl => self.auth_form.token_url.clone(),
1016 +
            AuthField::ClientId => self.auth_form.client_id.clone(),
1017 +
            AuthField::ClientSecret => self.auth_form.client_secret.clone(),
1018 +
            AuthField::Scopes => self.auth_form.scopes.join(" "),
1019 +
            AuthField::Style => match self.auth_form.auth_style {
943 1020
                crate::model::AuthStyle::Basic => "basic".into(),
944 1021
                crate::model::AuthStyle::Post => "post".into(),
945 1022
            },
946 -
            _ => String::new(),
947 1023
        }
948 1024
    }
949 1025
950 1026
    pub fn set_auth_field(&mut self, i: usize, value: &str) {
951 -
        match i {
952 -
            0 => self.auth_form.token_url = value.to_string(),
953 -
            1 => self.auth_form.client_id = value.to_string(),
954 -
            2 => self.auth_form.client_secret = value.to_string(),
955 -
            3 => self.auth_form.scopes = value.split_whitespace().map(String::from).collect(),
956 -
            _ => {}
1027 +
        match self.auth_field_at(i) {
1028 +
            AuthField::Token => self.auth_form.token = value.to_string(),
1029 +
            AuthField::Header => self.auth_form.header = value.to_string(),
1030 +
            AuthField::TokenUrl => self.auth_form.token_url = value.to_string(),
1031 +
            AuthField::ClientId => self.auth_form.client_id = value.to_string(),
1032 +
            AuthField::ClientSecret => self.auth_form.client_secret = value.to_string(),
1033 +
            AuthField::Scopes => {
1034 +
                self.auth_form.scopes = value.split_whitespace().map(String::from).collect()
1035 +
            }
1036 +
            // Toggles carry no typed value.
1037 +
            AuthField::Kind | AuthField::Style => {}
957 1038
        }
958 1039
    }
959 1040
960 -
    pub fn toggle_auth_style(&mut self) {
961 -
        self.auth_form.auth_style = match self.auth_form.auth_style {
962 -
            crate::model::AuthStyle::Basic => crate::model::AuthStyle::Post,
963 -
            crate::model::AuthStyle::Post => crate::model::AuthStyle::Basic,
964 -
        };
1041 +
    /// Advance the toggle under the cursor. `Kind` cycles the scheme (which
1042 +
    /// changes the row list — the cursor stays put on `Kind` at index 0), and
1043 +
    /// `Style` flips the OAuth client-auth placement. No-op on text fields.
1044 +
    pub fn toggle_auth_field(&mut self, i: usize) {
1045 +
        match self.auth_field_at(i) {
1046 +
            AuthField::Kind => self.auth_form.kind = self.auth_form.kind.next(),
1047 +
            AuthField::Style => {
1048 +
                self.auth_form.auth_style = match self.auth_form.auth_style {
1049 +
                    crate::model::AuthStyle::Basic => crate::model::AuthStyle::Post,
1050 +
                    crate::model::AuthStyle::Post => crate::model::AuthStyle::Basic,
1051 +
                }
1052 +
            }
1053 +
            _ => {}
1054 +
        }
965 1055
    }
966 1056
967 -
    /// Apply the auth form to the collection (called when the popup closes).
1057 +
    /// Apply the auth form to the collection (called when the popup closes). A
1058 +
    /// form with no meaningful field set clears auth entirely, so cycling to a
1059 +
    /// scheme and leaving it blank doesn't attach an unusable config.
968 1060
    pub fn apply_auth_form(&mut self) {
969 -
        let empty = self.auth_form.token_url.is_empty()
970 -
            && self.auth_form.client_id.is_empty()
971 -
            && self.auth_form.client_secret.is_empty();
1061 +
        let f = &self.auth_form;
1062 +
        let empty = f.token.is_empty()
1063 +
            && f.header.is_empty()
1064 +
            && f.token_url.is_empty()
1065 +
            && f.client_id.is_empty()
1066 +
            && f.client_secret.is_empty()
1067 +
            && f.scopes.is_empty();
972 1068
        let new = if empty {
973 1069
            None
974 1070
        } else {
src/http/client.rs +18 −1
44 44
45 45
/// Send a request against `base_url`, applying `{{variable}}` substitution and
46 46
/// `{pathParam}` replacement. `bearer`, when present, sets the Authorization
47 -
/// header unless the request already defines one.
47 +
/// header unless the request already defines one. `extra_headers` (e.g. an API
48 +
/// key) are added the same way — only for names the request didn't set itself.
48 49
pub async fn send_request(
49 50
    client: &reqwest::Client,
50 51
    base_url: &str,
51 52
    req: &SavedRequest,
52 53
    vars: &HashMap<String, String>,
53 54
    bearer: Option<&str>,
55 +
    extra_headers: &[(String, String)],
54 56
) -> Result<HttpResponse> {
55 57
    let url = build_url(base_url, req, vars);
56 58
71 73
        }
72 74
        if name == CONTENT_TYPE {
73 75
            has_content_type = true;
76 +
        }
77 +
        headers.insert(name, value);
78 +
    }
79 +
80 +
    // Auth-supplied headers defer to anything the request set explicitly.
81 +
    for (key, val) in extra_headers {
82 +
        let name = HeaderName::from_bytes(key.as_bytes())
83 +
            .map_err(|e| anyhow!("invalid header name {key:?}: {e}"))?;
84 +
        if headers.contains_key(&name) {
85 +
            continue;
86 +
        }
87 +
        let value = HeaderValue::from_str(val)
88 +
            .map_err(|e| anyhow!("invalid value for header {key:?}: {e}"))?;
89 +
        if name == AUTHORIZATION {
90 +
            has_auth = true;
74 91
        }
75 92
        headers.insert(name, value);
76 93
    }
src/http/mod.rs +2 −0
1 1
pub mod client;
2 2
pub mod oauth;
3 +
pub mod secret;
3 4
pub mod send;
4 5
pub mod url_input;
5 6
pub mod vars;
6 7
7 8
pub use client::{HttpResponse, send_request};
8 9
pub use oauth::{OAuthToken, fetch_token, token_valid};
10 +
pub use secret::resolve_secret;
9 11
pub use send::{SendOutcome, send_with_auth};
10 12
pub use url_input::{UrlParts, split_url_input};
11 13
pub use vars::{DYNAMIC_VARS, substitute};
src/http/oauth.rs +8 −2
5 5
use anyhow::{Context, Result, anyhow, bail};
6 6
use serde_json::Value;
7 7
8 +
use super::secret::resolve_secret;
8 9
use crate::model::{AuthStyle, OAuthConfig};
9 10
10 11
/// Clock skew so tokens are refreshed slightly before their stated expiry.
31 32
        form.push(("scope", cfg.scopes.join(" ")));
32 33
    }
33 34
35 +
    // The client secret may be a `$(…)` command (e.g. a password manager read);
36 +
    // resolve it just before the exchange so it never sits in memory longer.
37 +
    let client_secret =
38 +
        resolve_secret(&cfg.client_secret).context("resolving OAuth client secret")?;
39 +
34 40
    let mut rb = client.post(&cfg.token_url);
35 41
    match cfg.auth_style {
36 42
        AuthStyle::Basic => {
37 -
            rb = rb.basic_auth(cfg.client_id.clone(), Some(cfg.client_secret.clone()));
43 +
            rb = rb.basic_auth(cfg.client_id.clone(), Some(client_secret));
38 44
        }
39 45
        AuthStyle::Post => {
40 46
            form.push(("client_id", cfg.client_id.clone()));
41 -
            form.push(("client_secret", cfg.client_secret.clone()));
47 +
            form.push(("client_secret", client_secret));
42 48
        }
43 49
    }
44 50
src/http/secret.rs (added) +68 −0
1 +
//! Resolving secret values that shell out, e.g.
2 +
//! `$(op read "op://vault/item/field")`.
3 +
4 +
use anyhow::{Context, Result, bail};
5 +
use std::process::Command;
6 +
7 +
/// If `value` is *entirely* a single `$(…)` command substitution, run the inner
8 +
/// command through `sh -c` and return its trimmed stdout. Anything else is
9 +
/// returned unchanged — the whole value must be the substitution, so a literal
10 +
/// `$(...)` embedded in a longer string is never executed by accident.
11 +
pub fn resolve_secret(value: &str) -> Result<String> {
12 +
    let Some(cmd) = command_substitution(value) else {
13 +
        return Ok(value.to_string());
14 +
    };
15 +
16 +
    let output = Command::new("sh")
17 +
        .arg("-c")
18 +
        .arg(cmd)
19 +
        .output()
20 +
        .with_context(|| format!("running secret command `{cmd}`"))?;
21 +
22 +
    if !output.status.success() {
23 +
        let stderr = String::from_utf8_lossy(&output.stderr);
24 +
        bail!(
25 +
            "secret command `{cmd}` failed ({}): {}",
26 +
            output.status,
27 +
            stderr.trim()
28 +
        );
29 +
    }
30 +
31 +
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
32 +
}
33 +
34 +
/// The command inside a value shaped exactly like `$( … )`, or `None`.
35 +
fn command_substitution(value: &str) -> Option<&str> {
36 +
    let trimmed = value.trim();
37 +
    let inner = trimmed.strip_prefix("$(")?.strip_suffix(')')?.trim();
38 +
    (!inner.is_empty()).then_some(inner)
39 +
}
40 +
41 +
#[cfg(test)]
42 +
mod tests {
43 +
    use super::*;
44 +
45 +
    #[test]
46 +
    fn plain_values_pass_through() {
47 +
        assert_eq!(resolve_secret("hunter2").unwrap(), "hunter2");
48 +
        // A `$(…)` that isn't the whole value is left untouched.
49 +
        assert_eq!(
50 +
            resolve_secret("Bearer $(echo x)").unwrap(),
51 +
            "Bearer $(echo x)"
52 +
        );
53 +
        assert_eq!(resolve_secret("$()").unwrap(), "$()");
54 +
    }
55 +
56 +
    #[test]
57 +
    fn command_substitution_runs_and_trims() {
58 +
        assert_eq!(resolve_secret("$(printf 'sk-123')").unwrap(), "sk-123");
59 +
        // Surrounding whitespace on the value and on the output are both dropped.
60 +
        assert_eq!(resolve_secret("  $(echo padded)  ").unwrap(), "padded");
61 +
    }
62 +
63 +
    #[test]
64 +
    fn failing_command_is_an_error() {
65 +
        let err = resolve_secret("$(exit 3)").unwrap_err().to_string();
66 +
        assert!(err.contains("failed"), "unexpected error: {err}");
67 +
    }
68 +
}
src/http/send.rs +83 −23
1 -
//! App-level send orchestration: OAuth token caching + one 401 retry.
1 +
//! App-level send orchestration: pick the auth scheme, and for OAuth cache the
2 +
//! token + retry once on 401.
2 3
3 4
use std::collections::HashMap;
4 5
5 6
use super::client::{HttpResponse, send_request};
6 7
use super::oauth::{OAuthToken, fetch_token, token_valid};
7 -
use crate::model::{OAuthConfig, SavedRequest};
8 +
use super::secret::resolve_secret;
9 +
use crate::model::{AuthKind, OAuthConfig, SavedRequest};
8 10
9 11
pub struct SendOutcome {
10 12
    pub result: Result<HttpResponse, String>,
11 -
    /// Latest token cache (unchanged on failure, refreshed on (re)fetch).
13 +
    /// Latest OAuth token cache (unchanged on failure, refreshed on (re)fetch).
14 +
    /// Always `None` for the bearer/API-key schemes, which hold no cache.
12 15
    pub token: Option<OAuthToken>,
13 16
}
14 17
15 -
/// Send a request, transparently handling OAuth client-credentials auth:
16 -
/// reuse a cached token while valid, fetch one otherwise, and retry the
17 -
/// request once with a fresh token on a 401 response.
18 +
/// Send a request under the collection's auth scheme:
19 +
/// - **none** — send as-is.
20 +
/// - **bearer** — resolve the token (may be a `$(…)` secret) and send it as
21 +
///   `Authorization: Bearer …`.
22 +
/// - **apikey** — resolve the value and send it in the configured header.
23 +
/// - **oauth2** — reuse a cached client-credentials token while valid, fetch
24 +
///   one otherwise, and retry the request once with a fresh token on a 401.
18 25
pub async fn send_with_auth(
19 26
    client: &reqwest::Client,
20 27
    base_url: &str,
23 30
    auth: Option<&OAuthConfig>,
24 31
    cached: Option<OAuthToken>,
25 32
) -> SendOutcome {
33 +
    let Some(cfg) = auth.filter(|c| c.is_configured()) else {
34 +
        let result = send_request(client, base_url, req, vars, None, &[])
35 +
            .await
36 +
            .map_err(|e| format!("{e:#}"));
37 +
        return SendOutcome {
38 +
            result,
39 +
            token: cached,
40 +
        };
41 +
    };
42 +
43 +
    match cfg.kind {
44 +
        AuthKind::Bearer => {
45 +
            let token = match resolve_secret(&cfg.token) {
46 +
                Ok(t) => t,
47 +
                Err(e) => return secret_error(e, cached),
48 +
            };
49 +
            let result = send_request(client, base_url, req, vars, Some(&token), &[])
50 +
                .await
51 +
                .map_err(|e| format!("{e:#}"));
52 +
            SendOutcome {
53 +
                result,
54 +
                token: cached,
55 +
            }
56 +
        }
57 +
        AuthKind::ApiKey => {
58 +
            let value = match resolve_secret(&cfg.token) {
59 +
                Ok(v) => v,
60 +
                Err(e) => return secret_error(e, cached),
61 +
            };
62 +
            let extra = [(cfg.api_key_header().to_string(), value)];
63 +
            let result = send_request(client, base_url, req, vars, None, &extra)
64 +
                .await
65 +
                .map_err(|e| format!("{e:#}"));
66 +
            SendOutcome {
67 +
                result,
68 +
                token: cached,
69 +
            }
70 +
        }
71 +
        AuthKind::Oauth2 => oauth_send(client, base_url, req, vars, cfg, cached).await,
72 +
    }
73 +
}
74 +
75 +
fn secret_error(e: anyhow::Error, token: Option<OAuthToken>) -> SendOutcome {
76 +
    SendOutcome {
77 +
        result: Err(format!("secret resolution failed: {e:#}")),
78 +
        token,
79 +
    }
80 +
}
81 +
82 +
async fn oauth_send(
83 +
    client: &reqwest::Client,
84 +
    base_url: &str,
85 +
    req: &SavedRequest,
86 +
    vars: &HashMap<String, String>,
87 +
    cfg: &OAuthConfig,
88 +
    cached: Option<OAuthToken>,
89 +
) -> SendOutcome {
26 90
    let mut token = cached;
27 -
    let auth = auth.filter(|c| c.is_configured());
28 91
29 -
    let mut bearer: Option<String> = None;
30 -
    if let Some(cfg) = auth {
31 -
        let stale = token.as_ref().map(|t| !token_valid(t)).unwrap_or(true);
32 -
        if stale {
33 -
            match fetch_token(client, cfg).await {
34 -
                Ok(t) => token = Some(t),
35 -
                Err(e) => {
36 -
                    return SendOutcome {
37 -
                        result: Err(format!("token fetch failed: {e:#}")),
38 -
                        token,
39 -
                    };
40 -
                }
92 +
    let stale = token.as_ref().map(|t| !token_valid(t)).unwrap_or(true);
93 +
    if stale {
94 +
        match fetch_token(client, cfg).await {
95 +
            Ok(t) => token = Some(t),
96 +
            Err(e) => {
97 +
                return SendOutcome {
98 +
                    result: Err(format!("token fetch failed: {e:#}")),
99 +
                    token,
100 +
                };
41 101
            }
42 102
        }
43 -
        bearer = token.as_ref().map(|t| t.access_token.clone());
44 103
    }
104 +
    let mut bearer = token.as_ref().map(|t| t.access_token.clone());
45 105
46 -
    let mut resp = send_request(client, base_url, req, vars, bearer.as_deref())
106 +
    let mut resp = send_request(client, base_url, req, vars, bearer.as_deref(), &[])
47 107
        .await
48 108
        .map_err(|e| format!("{e:#}"));
49 109
50 -
    if let (Ok(r), Some(cfg)) = (&resp, auth)
110 +
    if let Ok(r) = &resp
51 111
        && r.status == 401
52 112
        && let Ok(t) = fetch_token(client, cfg).await
53 113
    {
54 114
        bearer = Some(t.access_token.clone());
55 115
        token = Some(t);
56 -
        resp = send_request(client, base_url, req, vars, bearer.as_deref())
116 +
        resp = send_request(client, base_url, req, vars, bearer.as_deref(), &[])
57 117
            .await
58 118
            .map_err(|e| format!("{e:#}"));
59 119
    }
src/input.rs +25 −21
430 430
            }
431 431
            _ => {}
432 432
        },
433 -
        Popup::Auth => match key.code {
434 -
            KeyCode::Esc => {
435 -
                app.apply_auth_form();
436 -
                app.popup = Popup::None;
437 -
                app.status = "Auth config saved".into();
438 -
            }
439 -
            KeyCode::Char('j') | KeyCode::Down | KeyCode::Tab => {
440 -
                app.auth_field = (app.auth_field + 1) % App::AUTH_FIELDS.len();
441 -
            }
442 -
            KeyCode::Char('k') | KeyCode::Up | KeyCode::BackTab => {
443 -
                app.auth_field =
444 -
                    (app.auth_field + App::AUTH_FIELDS.len() - 1) % App::AUTH_FIELDS.len();
445 -
            }
446 -
            KeyCode::Enter | KeyCode::Char('i') => {
447 -
                if app.auth_field == 4 {
448 -
                    app.toggle_auth_style();
449 -
                } else {
450 -
                    app.start_edit(EditTarget::AuthField(app.auth_field));
433 +
        Popup::Auth => {
434 +
            let count = app.auth_fields().len();
435 +
            match key.code {
436 +
                KeyCode::Esc => {
437 +
                    app.apply_auth_form();
438 +
                    app.popup = Popup::None;
439 +
                    app.status = "Auth config saved".into();
451 440
                }
441 +
                KeyCode::Char('j') | KeyCode::Down | KeyCode::Tab => {
442 +
                    app.auth_field = (app.auth_field + 1) % count;
443 +
                }
444 +
                KeyCode::Char('k') | KeyCode::Up | KeyCode::BackTab => {
445 +
                    app.auth_field = (app.auth_field + count - 1) % count;
446 +
                }
447 +
                KeyCode::Enter | KeyCode::Char('i') => {
448 +
                    if app.auth_field_at(app.auth_field).is_toggle() {
449 +
                        app.toggle_auth_field(app.auth_field);
450 +
                    } else {
451 +
                        app.start_edit(EditTarget::AuthField(app.auth_field));
452 +
                    }
453 +
                }
454 +
                KeyCode::Char(' ') if app.auth_field_at(app.auth_field).is_toggle() => {
455 +
                    app.toggle_auth_field(app.auth_field);
456 +
                }
457 +
                _ => {}
452 458
            }
453 -
            KeyCode::Char(' ') if app.auth_field == 4 => app.toggle_auth_style(),
454 -
            _ => {}
455 -
        },
459 +
        }
456 460
        Popup::None => {}
457 461
    }
458 462
}
src/model.rs +66 −1
118 118
    Post,
119 119
}
120 120
121 +
/// Which authentication scheme a collection uses. `Oauth2` is the default so
122 +
/// collections written before bearer/api-key support (their `auth` object has
123 +
/// no `kind`) keep loading as the client-credentials config they were.
124 +
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
125 +
#[serde(rename_all = "lowercase")]
126 +
pub enum AuthKind {
127 +
    #[default]
128 +
    Oauth2,
129 +
    /// A fixed bearer token sent as `Authorization: Bearer <token>`.
130 +
    Bearer,
131 +
    /// A fixed value sent in an arbitrary header (defaults to `X-API-Key`).
132 +
    #[serde(rename = "apikey")]
133 +
    ApiKey,
134 +
}
135 +
136 +
impl AuthKind {
137 +
    pub const ALL: [AuthKind; 3] = [AuthKind::Bearer, AuthKind::ApiKey, AuthKind::Oauth2];
138 +
139 +
    pub fn next(self) -> Self {
140 +
        let i = Self::ALL.iter().position(|k| *k == self).unwrap_or(0);
141 +
        Self::ALL[(i + 1) % Self::ALL.len()]
142 +
    }
143 +
144 +
    pub fn title(self) -> &'static str {
145 +
        match self {
146 +
            AuthKind::Oauth2 => "oauth2",
147 +
            AuthKind::Bearer => "bearer",
148 +
            AuthKind::ApiKey => "apikey",
149 +
        }
150 +
    }
151 +
}
152 +
153 +
/// Header used for API-key auth when the user leaves the header name blank.
154 +
pub const DEFAULT_API_KEY_HEADER: &str = "X-API-Key";
155 +
156 +
/// Per-collection authentication. One struct carries every scheme's fields
157 +
/// (discriminated by `kind`) so the on-disk shape stays a single flat object
158 +
/// and older OAuth-only collections deserialize unchanged.
159 +
///
160 +
/// Secret-bearing fields (`token`, `client_secret`) may hold a `$(…)` command
161 +
/// substitution, resolved at send time — see [`crate::http::resolve_secret`].
121 162
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
122 163
pub struct OAuthConfig {
123 164
    #[serde(default)]
165 +
    pub kind: AuthKind,
166 +
    /// Bearer token, or API-key value (secret; supports `$(…)`).
167 +
    #[serde(default)]
168 +
    pub token: String,
169 +
    /// Header name for `ApiKey` auth; empty means [`DEFAULT_API_KEY_HEADER`].
170 +
    #[serde(default)]
171 +
    pub header: String,
172 +
    #[serde(default)]
124 173
    pub token_url: String,
125 174
    #[serde(default)]
126 175
    pub client_id: String,
132 181
    pub auth_style: AuthStyle,
133 182
}
134 183
184 +
/// Historical name; `OAuthConfig` now covers every scheme via its `kind`.
185 +
pub type AuthConfig = OAuthConfig;
186 +
135 187
impl OAuthConfig {
188 +
    /// Whether the active scheme has enough filled in to attempt auth.
136 189
    pub fn is_configured(&self) -> bool {
137 -
        !self.token_url.is_empty() && !self.client_id.is_empty()
190 +
        match self.kind {
191 +
            AuthKind::Oauth2 => !self.token_url.is_empty() && !self.client_id.is_empty(),
192 +
            AuthKind::Bearer | AuthKind::ApiKey => !self.token.is_empty(),
193 +
        }
194 +
    }
195 +
196 +
    /// The header name to carry an API key, falling back to the default.
197 +
    pub fn api_key_header(&self) -> &str {
198 +
        if self.header.trim().is_empty() {
199 +
            DEFAULT_API_KEY_HEADER
200 +
        } else {
201 +
            self.header.trim()
202 +
        }
138 203
    }
139 204
}
140 205
src/openapi/import.rs +1 −2
68 68
            .unwrap_or_default();
69 69
        return Some(OAuthConfig {
70 70
            token_url,
71 -
            client_id: String::new(),
72 -
            client_secret: String::new(),
73 71
            scopes,
74 72
            auth_style: AuthStyle::Basic,
73 +
            ..Default::default()
75 74
        });
76 75
    }
77 76
    None
src/store.rs +1 −0
213 213
            client_secret: "secret".into(),
214 214
            scopes: vec!["read".into()],
215 215
            auth_style: crate::model::AuthStyle::Basic,
216 +
            ..Default::default()
216 217
        });
217 218
        let mut r = crate::model::SavedRequest::blank("list pets");
218 219
        r.method = Method::Post;
src/ui.rs +50 −12
574 574
        Line::raw("  [ or H       previous editor tab"),
575 575
        Line::raw("  /            search / filter requests"),
576 576
        Line::raw("  E            servers / base URLs"),
577 -
        Line::raw("  A            OAuth client-credentials config"),
577 +
        Line::raw("  A            auth config (bearer / API key / OAuth2)"),
578 578
        Line::raw("  :            command line (:w save, :q quit, :q! force, :wq)"),
579 579
        Line::raw("  q            quit (warns when unsaved)"),
580 580
        Line::raw(""),
682 682
}
683 683
684 684
fn draw_auth(f: &mut Frame, app: &App, area: Rect) {
685 +
    use crate::app::AuthField;
686 +
    use crate::model::{AuthKind, AuthStyle};
687 +
685 688
    let popup = centered(area, 70, 45);
686 689
    f.render_widget(Clear, popup);
687 690
    let block = Block::default()
688 -
        .title(" OAuth client credentials — j/k: field · i/Enter: edit · space: toggle style · Esc: save & close ")
691 +
        .title(" Auth — j/k: field · i/Enter: edit · space: toggle · Esc: save & close ")
689 692
        .borders(Borders::ALL)
690 693
        .border_style(Style::default().fg(Color::Cyan));
691 694
    let inner = block.inner(popup);
692 695
    f.render_widget(block, popup);
693 696
697 +
    let fields = app.auth_fields();
694 698
    let mut lines = Vec::new();
695 -
    for (i, label) in App::AUTH_FIELDS.iter().enumerate() {
696 -
        let value = if i == 2 && !app.auth_form.client_secret.is_empty() {
697 -
            "••••••••".to_string()
698 -
        } else if i == 4 {
699 -
            match app.auth_form.auth_style {
700 -
                crate::model::AuthStyle::Basic => "[basic]  post".to_string(),
701 -
                crate::model::AuthStyle::Post => " basic  [post]".to_string(),
702 -
            }
703 -
        } else {
704 -
            app.auth_field_value(i)
699 +
    for (i, field) in fields.iter().enumerate() {
700 +
        let value = match field {
701 +
            AuthField::Kind => toggle_row(app.auth_form.kind, AuthKind::ALL, AuthKind::title),
702 +
            AuthField::Style => toggle_row(
703 +
                app.auth_form.auth_style,
704 +
                [AuthStyle::Basic, AuthStyle::Post],
705 +
                |s| match s {
706 +
                    AuthStyle::Basic => "basic",
707 +
                    AuthStyle::Post => "post",
708 +
                },
709 +
            ),
710 +
            f if f.is_secret() && !app.auth_field_value(i).is_empty() => "••••••••".to_string(),
711 +
            _ => app.auth_field_value(i),
705 712
        };
706 713
        let style = if i == app.auth_field {
707 714
            Style::default()
710 717
        } else {
711 718
            Style::default()
712 719
        };
720 +
        let label = app.auth_field_label(*field);
713 721
        lines.push(
714 722
            Line::from(vec![
715 723
                Span::styled(format!(" {label:<28}"), Style::default().fg(Color::Gray)),
718 726
            .style(style),
719 727
        );
720 728
    }
729 +
730 +
    // A hint that secret fields understand `$(…)` command substitution.
731 +
    if fields.iter().any(|f| f.is_secret()) {
732 +
        lines.push(Line::raw(""));
733 +
        lines.push(Line::styled(
734 +
            " secret fields accept $(cmd), e.g. $(op read \"op://vault/item/field\")",
735 +
            Style::default().fg(Color::DarkGray),
736 +
        ));
737 +
    }
738 +
721 739
    f.render_widget(Paragraph::new(lines), inner);
722 740
}
741 +
742 +
/// Render a toggle field as its options with the active one bracketed, e.g.
743 +
/// `[bearer]  apikey  oauth2`.
744 +
fn toggle_row<T: PartialEq + Copy, const N: usize>(
745 +
    current: T,
746 +
    all: [T; N],
747 +
    label: impl Fn(T) -> &'static str,
748 +
) -> String {
749 +
    all.iter()
750 +
        .map(|opt| {
751 +
            let name = label(*opt);
752 +
            if *opt == current {
753 +
                format!("[{name}]")
754 +
            } else {
755 +
                format!(" {name} ")
756 +
            }
757 +
        })
758 +
        .collect::<Vec<_>>()
759 +
        .join(" ")
760 +
}
tests/app_send_tests.rs +1 −0
85 85
        client_secret: "secret".into(),
86 86
        scopes: vec![],
87 87
        auth_style: cielago::model::AuthStyle::Basic,
88 +
        ..Default::default()
88 89
    });
89 90
90 91
    // First send: fetches a token.
tests/http_tests.rs +107 −9
1 1
use std::collections::HashMap;
2 2
3 -
use cielago::http::{fetch_token, send_request};
4 -
use cielago::model::{AuthStyle, KeyValueRow, Method, OAuthConfig, SavedRequest};
3 +
use cielago::http::{fetch_token, send_request, send_with_auth};
4 +
use cielago::model::{AuthKind, AuthStyle, KeyValueRow, Method, OAuthConfig, SavedRequest};
5 5
use wiremock::matchers::{method, path, query_param};
6 6
use wiremock::{Mock, MockServer, ResponseTemplate};
7 7
28 28
29 29
    let vars = HashMap::from([("tenant".to_string(), "acme".to_string())]);
30 30
    let client = reqwest::Client::new();
31 -
    let resp = send_request(&client, &server.uri(), &req, &vars, None)
31 +
    let resp = send_request(&client, &server.uri(), &req, &vars, None, &[])
32 32
        .await
33 33
        .unwrap();
34 34
66 66
        client_secret: "my-secret".into(),
67 67
        scopes: vec!["read".into(), "write".into()],
68 68
        auth_style: AuthStyle::Basic,
69 +
        ..Default::default()
69 70
    };
70 71
    let client = reqwest::Client::new();
71 72
    let token = fetch_token(&client, &cfg).await.unwrap();
108 109
        client_secret: "secret2".into(),
109 110
        scopes: vec![],
110 111
        auth_style: AuthStyle::Post,
112 +
        ..Default::default()
111 113
    };
112 114
    let client = reqwest::Client::new();
113 115
    fetch_token(&client, &cfg).await.unwrap();
131 133
132 134
    let client = reqwest::Client::new();
133 135
    let req = SavedRequest::blank("x");
134 -
    send_request(&client, &server.uri(), &req, &HashMap::new(), Some("tok-1"))
135 -
        .await
136 -
        .unwrap();
136 +
    send_request(
137 +
        &client,
138 +
        &server.uri(),
139 +
        &req,
140 +
        &HashMap::new(),
141 +
        Some("tok-1"),
142 +
        &[],
143 +
    )
144 +
    .await
145 +
    .unwrap();
137 146
    let received = server.received_requests().await.unwrap();
138 147
    assert_eq!(
139 148
        received[0]
155 164
        &req2,
156 165
        &HashMap::new(),
157 166
        Some("tok-2"),
167 +
        &[],
158 168
    )
159 169
    .await
160 170
    .unwrap();
170 180
    );
171 181
}
172 182
183 +
#[tokio::test]
184 +
async fn api_key_auth_resolves_shell_secret_into_header() {
185 +
    let server = MockServer::start().await;
186 +
    Mock::given(method("GET"))
187 +
        .and(path("/x"))
188 +
        .respond_with(ResponseTemplate::new(200))
189 +
        .mount(&server)
190 +
        .await;
191 +
192 +
    // Value is a `$(…)` command substitution, resolved at send time.
193 +
    let cfg = OAuthConfig {
194 +
        kind: AuthKind::ApiKey,
195 +
        token: "$(printf 'sk-secret')".into(),
196 +
        header: "X-Api-Key".into(),
197 +
        ..Default::default()
198 +
    };
199 +
    let req = SavedRequest::blank("x");
200 +
    let client = reqwest::Client::new();
201 +
    let outcome = send_with_auth(
202 +
        &client,
203 +
        &server.uri(),
204 +
        &req,
205 +
        &HashMap::new(),
206 +
        Some(&cfg),
207 +
        None,
208 +
    )
209 +
    .await;
210 +
    assert!(outcome.result.is_ok(), "{:?}", outcome.result.err());
211 +
    assert!(outcome.token.is_none());
212 +
213 +
    let received = server.received_requests().await.unwrap();
214 +
    assert_eq!(
215 +
        received[0]
216 +
            .headers
217 +
            .get("x-api-key")
218 +
            .unwrap()
219 +
            .to_str()
220 +
            .unwrap(),
221 +
        "sk-secret"
222 +
    );
223 +
}
224 +
225 +
#[tokio::test]
226 +
async fn bearer_auth_sends_resolved_token() {
227 +
    let server = MockServer::start().await;
228 +
    Mock::given(method("GET"))
229 +
        .and(path("/x"))
230 +
        .respond_with(ResponseTemplate::new(200))
231 +
        .mount(&server)
232 +
        .await;
233 +
234 +
    let cfg = OAuthConfig {
235 +
        kind: AuthKind::Bearer,
236 +
        token: "plain-tok".into(),
237 +
        ..Default::default()
238 +
    };
239 +
    let req = SavedRequest::blank("x");
240 +
    let client = reqwest::Client::new();
241 +
    let outcome = send_with_auth(
242 +
        &client,
243 +
        &server.uri(),
244 +
        &req,
245 +
        &HashMap::new(),
246 +
        Some(&cfg),
247 +
        None,
248 +
    )
249 +
    .await;
250 +
    assert!(outcome.result.is_ok(), "{:?}", outcome.result.err());
251 +
252 +
    let received = server.received_requests().await.unwrap();
253 +
    assert_eq!(
254 +
        received[0]
255 +
            .headers
256 +
            .get("authorization")
257 +
            .unwrap()
258 +
            .to_str()
259 +
            .unwrap(),
260 +
        "Bearer plain-tok"
261 +
    );
262 +
}
263 +
173 264
/// The compose/decompose contract: what `split_url_input` pulls apart,
174 265
/// `build_url` must put back together.
175 266
#[test]
216 307
    req.sync_path_params();
217 308
218 309
    let client = reqwest::Client::new();
219 -
    let resp = send_request(&client, &parts.origin.unwrap(), &req, &HashMap::new(), None)
220 -
        .await
221 -
        .unwrap();
310 +
    let resp = send_request(
311 +
        &client,
312 +
        &parts.origin.unwrap(),
313 +
        &req,
314 +
        &HashMap::new(),
315 +
        None,
316 +
        &[],
317 +
    )
318 +
    .await
319 +
    .unwrap();
222 320
    assert_eq!(resp.status, 200);
223 321
}
tests/input_tests.rs +58 −4
535 535
536 536
#[test]
537 537
fn auth_popup_edits_and_applies() {
538 +
    use cielago::model::AuthKind;
539 +
538 540
    let mut app = test_app();
539 541
    handle_key(&mut app, char_key('A'));
540 542
    assert_eq!(app.popup, Popup::Auth);
541 -
    // field 0 = token url
543 +
    // Field 0 is the kind toggle, defaulting to bearer for a fresh config.
544 +
    // Cycle it to oauth2 (bearer -> apikey -> oauth2).
545 +
    handle_key(&mut app, char_key(' '));
546 +
    handle_key(&mut app, char_key(' '));
547 +
    assert_eq!(app.auth_form.kind, AuthKind::Oauth2);
548 +
    // Now the oauth rows show: 1 = token url, 2 = client id, 5 = style.
549 +
    handle_key(&mut app, char_key('j'));
542 550
    handle_key(&mut app, char_key('i'));
543 551
    type_str(&mut app, "https://auth.example.com/token");
544 552
    handle_key(&mut app, key(KeyCode::Enter));
545 -
    // move to client id, edit
546 553
    handle_key(&mut app, char_key('j'));
547 554
    handle_key(&mut app, char_key('i'));
548 555
    type_str(&mut app, "my-client");
549 556
    handle_key(&mut app, key(KeyCode::Enter));
550 -
    // style toggle: field 4
557 +
    // style toggle: field 5
551 558
    for _ in 0..3 {
552 559
        handle_key(&mut app, char_key('j'));
553 560
    }
554 -
    assert_eq!(app.auth_field, 4);
561 +
    assert_eq!(app.auth_field, 5);
555 562
    handle_key(&mut app, char_key(' '));
556 563
    // close + apply
557 564
    handle_key(&mut app, key(KeyCode::Esc));
558 565
    assert_eq!(app.popup, Popup::None);
559 566
    let auth = app.collection.auth.as_ref().unwrap();
567 +
    assert_eq!(auth.kind, AuthKind::Oauth2);
560 568
    assert_eq!(auth.token_url, "https://auth.example.com/token");
561 569
    assert_eq!(auth.client_id, "my-client");
562 570
    assert_eq!(auth.auth_style, cielago::model::AuthStyle::Post);
563 571
    assert!(app.dirty);
572 +
}
573 +
574 +
#[test]
575 +
fn auth_popup_sets_bearer_token() {
576 +
    use cielago::model::AuthKind;
577 +
578 +
    let mut app = test_app();
579 +
    handle_key(&mut app, char_key('A'));
580 +
    // Defaults to bearer; field 1 is the token.
581 +
    assert_eq!(app.auth_form.kind, AuthKind::Bearer);
582 +
    handle_key(&mut app, char_key('j'));
583 +
    handle_key(&mut app, char_key('i'));
584 +
    type_str(&mut app, "sk-live-123");
585 +
    handle_key(&mut app, key(KeyCode::Enter));
586 +
    handle_key(&mut app, key(KeyCode::Esc));
587 +
588 +
    let auth = app.collection.auth.as_ref().unwrap();
589 +
    assert_eq!(auth.kind, AuthKind::Bearer);
590 +
    assert_eq!(auth.token, "sk-live-123");
591 +
}
592 +
593 +
#[test]
594 +
fn auth_popup_sets_api_key_header() {
595 +
    use cielago::model::AuthKind;
596 +
597 +
    let mut app = test_app();
598 +
    handle_key(&mut app, char_key('A'));
599 +
    // bearer -> apikey.
600 +
    handle_key(&mut app, char_key(' '));
601 +
    assert_eq!(app.auth_form.kind, AuthKind::ApiKey);
602 +
    // apikey rows: 1 = header name, 2 = value.
603 +
    handle_key(&mut app, char_key('j'));
604 +
    handle_key(&mut app, char_key('i'));
605 +
    type_str(&mut app, "X-Custom-Key");
606 +
    handle_key(&mut app, key(KeyCode::Enter));
607 +
    handle_key(&mut app, char_key('j'));
608 +
    handle_key(&mut app, char_key('i'));
609 +
    type_str(&mut app, "abc123");
610 +
    handle_key(&mut app, key(KeyCode::Enter));
611 +
    handle_key(&mut app, key(KeyCode::Esc));
612 +
613 +
    let auth = app.collection.auth.as_ref().unwrap();
614 +
    assert_eq!(auth.kind, AuthKind::ApiKey);
615 +
    assert_eq!(auth.header, "X-Custom-Key");
616 +
    assert_eq!(auth.token, "abc123");
617 +
    assert_eq!(auth.api_key_header(), "X-Custom-Key");
564 618
}
565 619
566 620
#[test]