Skip to content

Commit 4a1ec91

Browse files
committed
Merge branch 'feat/require-auth-apikey-fallback' into 'main'
feat(auth): fall back to live API key from ~/.okx/config.toml when no OAuth session See merge request mobilex/web3/OKOutcomes!16
2 parents 084d721 + 6ce5521 commit 4a1ec91

10 files changed

Lines changed: 633 additions & 89 deletions

File tree

Cargo.lock

Lines changed: 3 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: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ alloy-signer-local = { version = "2", default-features = false }
4848
chrono = { version = "0.4", default-features = false, features = ["serde", "clock"] }
4949
dirs = "6"
5050
thiserror = "2.0.17"
51+
# Parse the shared OKX config (`~/.okx/config.toml`, written by the
52+
# agent-trade-kit CLI) for the HMAC API-key fallback in `require_auth`.
53+
# `toml_edit` is already in the lock transitively; `parse`-only keeps it minimal.
54+
toml_edit = { version = "0.25", default-features = false, features = ["parse"] }
5155

5256
# Credential storage: OS keyring (macOS Keychain / Windows Credential Manager /
5357
# Linux libsecret) with AES-256-GCM + scrypt encrypted file fallback.
@@ -63,8 +67,13 @@ hex = "0.4"
6367
# `image` crate pulled in).
6468
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
6569

66-
# SHA-256 verification for the self-installer (auth-installer feature only).
67-
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"
6877

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

skills/okx-outcomes-setup/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ terminal on the user's side.**
263263
```
264264
stdout contains:
265265
```json
266-
{"address":"0x...","deeplink":"https://okx.com/ul/3OauBX?eoa=0x...&uid=...","wallet":"created"}
266+
{"address":"0x...","deeplink":"https://okx.com/ul/28G0qj?eoa=0x...&uid=...","deeplink_backup":"https://oyidl.net/ul/28G0qj?eoa=0x...&uid=...","wallet":"created"}
267267
```
268268
Use `-j` (not the default plain output) — `-j` returns clean JSON the
269269
agent can parse, while the default output renders a terminal QR code

skills/okx-outcomes-setup/references/setup-flow.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,16 @@ The per-step state command the skill drives off of (implemented in
5656
is generated locally and never shown; only the address (public) is surfaced.
5757
- **Bind short-link shape** (built from account uid + EOA address):
5858
```
59-
https://okx.com/ul/3OauBX?eoa=<eoa>&uid=<uid>
59+
https://okx.com/ul/28G0qj?eoa=<eoa>&uid=<uid>
6060
```
61-
The user taps it on their phone to open the **OKX mobile app**, or
62-
pastes it into any browser to open the web fallback. If the link is
63-
unreachable, the user can copy the EOA address and bind it manually
61+
A backup short link with the same query is also shown, for when the
62+
primary redirector is unreachable:
63+
```
64+
https://oyidl.net/ul/28G0qj?eoa=<eoa>&uid=<uid>
65+
```
66+
The user taps either on their phone to open the **OKX mobile app**, or
67+
pastes it into any browser to open the web fallback. If neither link is
68+
reachable, the user can copy the EOA address and bind it manually
6469
in the OKX app: **Outcomes → Profile → Settings → API Bind Wallet**.
6570
- **Command:** `okx-outcomes setup bind`**regenerates a fresh signing
6671
wallet by default** and prints address + short link + QR (requires

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/auth_token.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
//! token; short-lived caching (< 5 min) is safe but rarely necessary.
2323
2424
use std::io::Read;
25-
use std::process::Command;
25+
use std::process::{Command, Stdio};
2626

2727
/// Resolve the token-broker binary, in priority order:
2828
/// 1. `OKX_AUTH_BIN` — explicit override (absolute path or PATH-resolvable name),
@@ -159,6 +159,12 @@ fn check_exit(code: Option<i32>) -> Result<(), TokenError> {
159159
fn token_command() -> Command {
160160
let mut cmd = Command::new(okx_auth_bin());
161161
cmd.arg("token");
162+
// Suppress the broker's own stderr (e.g. "Not logged in — run okx-auth
163+
// login first"): its exit code is already mapped to a `TokenError` with a
164+
// clear message, and on the API-key fallback path that error isn't even
165+
// surfaced — so the raw broker stderr is pure noise on the terminal. The
166+
// token itself travels over fd 3 / the named pipe, not stderr.
167+
cmd.stderr(Stdio::null());
162168
cmd
163169
}
164170

0 commit comments

Comments
 (0)