Skip to content

Commit 6ce5521

Browse files
weilong.yangclaude
andcommitted
feat(setup): skip OAuth sign-in when a live API key is configured
setup forced an OAuth sign-in, and its account-uid lookup (needed for the wallet-binding deeplink) was OAuth-bearer-only — so a user who authenticates via the agent-trade-kit API key could not complete setup without also doing OAuth. Now: - account_query::fetch_account_uid resolves auth like require_auth: OAuth bearer first, else a live HMAC API key from ~/.okx/config.toml signed with OKX OK-ACCESS-* headers (HMAC-SHA256, verified against a reference vector). On the fallback path the base URL is region-derived (US -> us.okx.com, EEA rejected) via the shared util::fallback_rest_base helper. - run_wizard skips the OAuth sign-in step entirely when a live API key is present (the key authenticates the uid lookup), printing an info line. - util: extract fallback_rest_base (region -> REST host, EEA errors) and reuse it in require_auth so REST and the uid lookup share one source of truth. - Cargo: hmac + base64 promoted to direct deps, sha2 made non-optional (all already in the lockfile transitively); auth-installer feature no longer needs dep:sha2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d8fbd14 commit 6ce5521

5 files changed

Lines changed: 145 additions & 43 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,13 @@ hex = "0.4"
6767
# `image` crate pulled in).
6868
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
6969

70-
# SHA-256 verification for the self-installer (auth-installer feature only).
71-
sha2 = { version = "0.10.9", optional = true }
70+
# SHA-256: the self-installer's checksum verifier and the OKX HMAC signature
71+
# used by the API-key fallback in `account_query` (always compiled).
72+
sha2 = { version = "0.10.9" }
73+
# OKX `OK-ACCESS-SIGN` HMAC-SHA256 for the account-uid API-key fallback
74+
# (`account_query`). Both already in the lockfile transitively via the SDK.
75+
hmac = "0.12"
76+
base64 = "0.22"
7277

7378
# Platform IPC for reading the okx-auth access token (see auth_token), plus
7479
# hostname lookup for the encrypted-file keyring's machine identity.
@@ -106,7 +111,7 @@ websocket = ["okx-outcomes-sdk/websocket", "dep:ratatui", "dep:unicode-width"]
106111
# Disable for sandboxed builds that must not self-install (e.g.
107112
# `cargo build --no-default-features --features websocket`); the OKX_AUTH_BIN
108113
# location override stays available regardless.
109-
auth-installer = ["dep:sha2"]
114+
auth-installer = []
110115
# End-to-end self-test runner (`okx-outcomes selftest`). NOT in default — the
111116
# public release binary should not ship an order-placing self-runner. Needs the
112117
# tokio subprocess + io-util APIs and the `ws` surface it exercises.

src/account_query.rs

Lines changed: 93 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
//! OAuth-authenticated query for the caller's OKX account `uid`.
1+
//! Query for the caller's OKX account `uid`.
22
//!
33
//! Intentionally outside the SDK: a self-contained raw HTTP call to OKX's
4-
//! "Get account configuration" endpoint (`GET /api/v5/account/config`) using
5-
//! the OAuth **bearer token** from the `okx-auth` broker, plus parsing to pull
6-
//! the `uid` out of the response. Required by the setup workflow, so it is
7-
//! always compiled (not feature-gated).
4+
//! "Get account configuration" endpoint (`GET /api/v5/account/config`), plus
5+
//! parsing to pull the `uid` out of the response. Required by the setup
6+
//! workflow (the wallet-binding deeplink), so it is always compiled.
7+
//!
8+
//! Auth mirrors [`crate::util::require_auth`]: the OAuth **bearer token** from
9+
//! the `okx-auth` broker when a session exists, otherwise a live **HMAC API
10+
//! key** from `~/.okx/config.toml` (`OK-ACCESS-*` headers). The fallback lets
11+
//! `setup` resolve the uid without an OAuth sign-in when an API key is present.
812
913
use serde::Deserialize;
1014

@@ -69,14 +73,37 @@ fn resolve_api_base() -> String {
6973
base.trim_end_matches('/').to_string()
7074
}
7175

72-
/// Fetch the caller's account `uid` using the OAuth bearer token from the
73-
/// `okx-auth` broker (the token reflects the session established at `login`).
76+
/// Fetch the caller's account `uid`, OAuth-bearer-first with an HMAC API-key
77+
/// fallback (mirrors [`crate::util::require_auth`]).
7478
///
75-
/// The base URL comes from config.json (else [`DEFAULT_API_BASE`]).
76-
/// The bearer token is never logged.
79+
/// On the fallback path the base URL is derived from the profile's region
80+
/// (`config.json` wins if set), since the HMAC key is region-scoped. No
81+
/// credential value is ever logged.
7782
pub async fn fetch_account_uid() -> Result<String, String> {
78-
let token = super::auth_token::fetch_token().map_err(|e| e.to_string())?;
79-
let url = format!("{}{ACCOUNT_CONFIG_PATH}", resolve_api_base());
83+
// Pick the auth method and the matching base URL.
84+
let (auth, base) = match super::auth_token::fetch_token() {
85+
Ok(token) => (Auth::Bearer(token), resolve_api_base()),
86+
Err(oauth_err) => match crate::util::live_api_key_lookup() {
87+
crate::util::ApiKeyLookup::Found { creds, site } => {
88+
let base = match crate::util::resolve_api_base_opt() {
89+
Some(b) => b.trim_end_matches('/').to_string(),
90+
None => crate::util::fallback_rest_base(site)?,
91+
};
92+
(Auth::Hmac(creds), base)
93+
}
94+
crate::util::ApiKeyLookup::DemoOnly => {
95+
return Err(
96+
"found only a demo profile in ~/.okx/config.toml — the outcomes API has \
97+
no simulated-trading mode. Configure a live (non-demo) profile, or run \
98+
`okx-outcomes auth login`."
99+
.to_string(),
100+
);
101+
}
102+
crate::util::ApiKeyLookup::None => return Err(oauth_err.to_string()),
103+
},
104+
};
105+
106+
let url = format!("{base}{ACCOUNT_CONFIG_PATH}");
80107

81108
// Honor a caller-installed HTTP client (see `crate::set_http_client`) so
82109
// this raw call shares the same routing as the SDK — e.g. a client with
@@ -90,9 +117,24 @@ pub async fn fetch_account_uid() -> Result<String, String> {
90117
.build()
91118
.map_err(|e| format!("failed to build HTTP client: {e}"))?,
92119
};
93-
let resp = client
94-
.get(&url)
95-
.bearer_auth(&token)
120+
121+
let req = client.get(&url);
122+
let req = match &auth {
123+
Auth::Bearer(token) => req.bearer_auth(token),
124+
Auth::Hmac(creds) => {
125+
// OKX HMAC: prehash = timestamp + METHOD + requestPath (+ body).
126+
// No body or query string on this GET.
127+
let timestamp = okx_timestamp();
128+
let prehash = format!("{timestamp}GET{ACCOUNT_CONFIG_PATH}");
129+
let sign = hmac_sha256_base64(&creds.secret_key, &prehash);
130+
req.header("OK-ACCESS-KEY", &creds.api_key)
131+
.header("OK-ACCESS-SIGN", sign)
132+
.header("OK-ACCESS-TIMESTAMP", &timestamp)
133+
.header("OK-ACCESS-PASSPHRASE", &creds.passphrase)
134+
}
135+
};
136+
137+
let resp = req
96138
.send()
97139
.await
98140
.map_err(|e| format!("request failed: {e}"))?;
@@ -108,6 +150,32 @@ pub async fn fetch_account_uid() -> Result<String, String> {
108150
parse_uid(&body)
109151
}
110152

153+
/// Account auth for the uid lookup: an OAuth bearer token, or HMAC API-key
154+
/// credentials for the `OK-ACCESS-*` signed-request fallback.
155+
enum Auth {
156+
Bearer(String),
157+
Hmac(okx_outcomes_sdk::ApiCredentials),
158+
}
159+
160+
/// OKX request timestamp: ISO-8601 UTC with millisecond precision, e.g.
161+
/// `2020-12-08T09:08:57.715Z` — the form `OK-ACCESS-TIMESTAMP` expects.
162+
fn okx_timestamp() -> String {
163+
chrono::Utc::now()
164+
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
165+
.to_string()
166+
}
167+
168+
/// `OK-ACCESS-SIGN` = Base64(HMAC-SHA256(secret_key, prehash)).
169+
fn hmac_sha256_base64(secret_key: &str, prehash: &str) -> String {
170+
use base64::Engine as _;
171+
use hmac::{Hmac, Mac};
172+
use sha2::Sha256;
173+
let mut mac = <Hmac<Sha256>>::new_from_slice(secret_key.as_bytes())
174+
.expect("HMAC-SHA256 accepts a key of any length");
175+
mac.update(prehash.as_bytes());
176+
base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes())
177+
}
178+
111179
#[cfg(test)]
112180
#[allow(clippy::unwrap_used, clippy::expect_used)]
113181
mod tests {
@@ -141,4 +209,15 @@ mod tests {
141209
.unwrap_err()
142210
.contains("parse response JSON"));
143211
}
212+
213+
#[test]
214+
fn hmac_sign_matches_reference_vector() {
215+
// Reference computed with Python's hmac/hashlib (RFC 2104 HMAC-SHA256,
216+
// base64-encoded) — must match OKX's `OK-ACCESS-SIGN` scheme.
217+
let sign = super::hmac_sha256_base64(
218+
"test-secret-key",
219+
"2020-12-08T09:08:57.715ZGET/api/v5/account/config",
220+
);
221+
assert_eq!(sign, "JhnPMxZeBAZBDiFbXMp1ijlwS2s/WCCTsq6+8s0aTd8=");
222+
}
144223
}

src/setup_cmds.rs

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -391,18 +391,27 @@ async fn run_wizard() -> Result<(), String> {
391391
// and continue so the user can still save credentials now and sign in
392392
// later with `auth login`.
393393
println!();
394-
println!(
395-
" {} Signing in to your OKX account ({})…",
396-
"->".with(Color::Cyan),
397-
region.label()
398-
);
399-
match super::auth_cmds::perform_login(Some(region.okx_auth_site())).await {
400-
Ok(()) => println!(" {} signed in.", "+".with(Color::Green)),
401-
Err(e) => eprintln!(
402-
" {} sign-in did not complete ({e}). Continuing — an existing session \
403-
(if any) will still be used.",
404-
"!".with(Color::Yellow)
405-
),
394+
if crate::util::has_live_api_key() {
395+
// A live API key in ~/.okx/config.toml already authenticates the account
396+
// (including the uid lookup below), so the OAuth sign-in is unnecessary.
397+
println!(
398+
" {} API key found in ~/.okx/config.toml — skipping OAuth sign-in.",
399+
"i".with(Color::Cyan)
400+
);
401+
} else {
402+
println!(
403+
" {} Signing in to your OKX account ({})…",
404+
"->".with(Color::Cyan),
405+
region.label()
406+
);
407+
match super::auth_cmds::perform_login(Some(region.okx_auth_site())).await {
408+
Ok(()) => println!(" {} signed in.", "+".with(Color::Green)),
409+
Err(e) => eprintln!(
410+
" {} sign-in did not complete ({e}). Continuing — an existing session \
411+
(if any) will still be used.",
412+
"!".with(Color::Yellow)
413+
),
414+
}
406415
}
407416

408417
// Look up the account uid for the address-binding deeplink. This works off

src/util.rs

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -171,20 +171,7 @@ pub fn require_auth(_args: &[String]) -> Result<okx_outcomes_sdk::OutcomesSdkCli
171171
if !base.is_empty() {
172172
builder = builder.base_url(base);
173173
} else if let Some(site) = fallback_site {
174-
match site {
175-
ProfileSite::Eea => {
176-
return Err(format!(
177-
"the API key in ~/.okx/config.toml targets the EEA region (my.okx.com), \
178-
which {APP_NAME} does not support. Use a Global or US profile there, \
179-
or run `{APP_NAME} auth login`."
180-
));
181-
}
182-
ProfileSite::Us => {
183-
builder = builder.base_url(crate::setup_cmds::RegionChoice::Us.rest_host());
184-
}
185-
// Global / Unknown → the SDK default (global, www.okx.com) is correct.
186-
ProfileSite::Global | ProfileSite::Unknown => {}
187-
}
174+
builder = builder.base_url(fallback_rest_base(site)?);
188175
}
189176
let lang = config_value("lang");
190177
if !lang.is_empty() {
@@ -270,6 +257,26 @@ pub(crate) fn has_live_api_key() -> bool {
270257
matches!(live_api_key_lookup(), ApiKeyLookup::Found { .. })
271258
}
272259

260+
/// REST base URL for the API-key fallback path given a profile's region, used
261+
/// when no explicit outcomes `api_base` is configured. The HMAC key is
262+
/// region-scoped, so a US key must hit the US host rather than the default
263+
/// global endpoint. Errors for EEA — the outcomes API has no EEA endpoint.
264+
/// Shared by `require_auth` (REST) and `account_query` (the setup uid lookup).
265+
pub(crate) fn fallback_rest_base(site: ProfileSite) -> Result<String, String> {
266+
use crate::setup_cmds::RegionChoice;
267+
match site {
268+
ProfileSite::Eea => Err(format!(
269+
"the API key in ~/.okx/config.toml targets the EEA region (my.okx.com), \
270+
which {APP_NAME} does not support. Use a Global or US profile there, \
271+
or run `{APP_NAME} auth login`."
272+
)),
273+
ProfileSite::Us => Ok(RegionChoice::Us.rest_host().to_string()),
274+
ProfileSite::Global | ProfileSite::Unknown => {
275+
Ok(RegionChoice::Global.rest_host().to_string())
276+
}
277+
}
278+
}
279+
273280
/// Select the first live (`demo != true`) profile from a `~/.okx/config.toml`
274281
/// document and return its HMAC credentials.
275282
///

0 commit comments

Comments
 (0)