chore: added support for Bearer tokens and API key auth
6a9de02d
16 file(s) · +643 −114
| 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. |
|
| 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/`. |
| 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 { |
|
| 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 | } |
|
| 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}; |
| 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 | ||
| 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 | + | } |
| 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 | } |
|
| 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 | } |
| 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 | ||
| 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 |
| 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; |
| 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 | + | } |
|
| 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. |
| 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 | } |
|
| 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] |