1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
//! Assembled Wyrd client.
//!
//! [`WyrdClient`] is the single entry point a caller constructs once and then
//! uses for every request. It wires the four client layers together —
//! [`ClientConfig`] resolution, credential resolution, [`AuthMiddleware`] token
//! exchange/refresh, and the [`HttpTransport`] retry loop — behind one
//! constructor, so callers do not hand-assemble the stack per use site.
//!
//! The façade owns no request behavior of its own: `request_json`,
//! `request_arrow`, and `submit_idempotent` delegate to the held
//! [`HttpTransport`], which already injects the `x-wyrd-access-token` bearer and
//! applies the retry policy. gRPC is connected lazily via
//! `WyrdClient::connect_grpc`, sharing the same [`AuthMiddleware`] so the HTTP
//! and gRPC planes authenticate through one token path.
use std::sync::Arc;
use secrecy::SecretString;
#[cfg(feature = "internal")]
use serde::Serialize;
#[cfg(feature = "internal")]
use serde::de::DeserializeOwned;
use wyrd_spec::auth::{SecretBearer, TokenAudience};
use wyrd_spec::error::WyrdError;
#[cfg(feature = "internal")]
use wyrd_spec::request_id::RequestId;
use crate::auth::{AuthError, AuthMiddleware};
use crate::config::ClientConfig;
use crate::environment::Environment;
use crate::error::WyrdClientError;
use crate::global_config::GlobalConfig;
use crate::transport::config::GrpcConfig;
#[cfg(feature = "internal")]
use crate::transport::http::ArrowResponse;
use crate::transport::http::HttpTransport;
/// Assembled Wyrd client: a single handle over the resolved transport + auth
/// stack.
///
/// Construct once with [`WyrdClient::from_global`] (or
/// [`WyrdClient::with_config`]) and reuse it for every request. Cloning is
/// cheap — the underlying `reqwest::Client` and [`AuthMiddleware`] are shared
/// handles, so a clone reuses the same connection pool and token cache rather
/// than re-resolving credentials or redialing.
#[derive(Debug, Clone)]
pub struct WyrdClient {
/// Shared authentication middleware; crate capabilities read its
/// credential and scope from here rather than through the `internal`
/// accessor.
pub(crate) auth: Arc<AuthMiddleware>,
/// Authenticated HTTP transport every crate capability sends its requests
/// through; the public raw-request methods are `internal`-only wrappers
/// over it.
pub(crate) http: HttpTransport,
/// Held for the lazy gRPC dial; only the gRPC plane reads it.
pub(crate) grpc_config: GrpcConfig,
}
impl WyrdClient {
/// Build the ambient client: the one resolution every no-argument
/// constructor uses.
///
/// [`Self::from_environment`] over [`Environment::Process`]: endpoints
/// from the global config file, then environment variables, then
/// defaults, then the effective credential via
/// [`ClientConfig::resolve_credential`], assembled into the auth and HTTP
/// layers. No network call is made here.
///
/// # Errors
/// Returns [`WyrdClientError::Config`] when the global config file exists
/// but cannot be read or parsed, [`WyrdClientError::NoCredentials`] when no
/// credential source is configured, or [`WyrdClientError::TransportDown`]
/// when the HTTP client cannot be built.
pub fn from_global() -> Result<Self, WyrdClientError> {
Self::from_environment(Environment::Process)
}
/// Resolve the ambient client from `environment`: the one resolver every
/// no-client handle `connect` reaches through [`Self::from_global`] with
/// [`Environment::Process`].
///
/// Loads `config.toml` from the configuration directory `environment`
/// names, overlays `environment`'s endpoint variables and defaults, and
/// resolves the credential chain from the same variables. No network call
/// is made here.
///
/// # Arguments
/// * `environment` - Source of every ambient variable, such as a fixed set
/// in a test or a saved local configuration home.
///
/// # Errors
/// As [`Self::from_global`].
pub fn from_environment(environment: Environment) -> Result<Self, WyrdClientError> {
let global = GlobalConfig::load_in(&environment)?;
Self::with_config(ClientConfig::from_environment(
environment,
&global,
None,
None,
))
}
/// Resolve the ambient client and complete any credential exchange it
/// requires: the one path every no-client handle `connect` takes.
///
/// # Errors
/// Returns the configuration or credential-resolution error
/// [`Self::from_global`] reports, or the stable error of a refused or
/// unreachable exchange, as [`Self::connected`].
pub(crate) async fn connect_ambient() -> Result<Self, WyrdError> {
Self::from_global()
.map_err(WyrdError::from)?
.connected()
.await
}
/// Return a clone of this client after completing any credential exchange
/// its credential path requires, for a handle to retain.
///
/// Reads through [`Self::access_token`], so a presented-as-is bearer or a
/// fresh cached token returns without a request, while an API key,
/// workload JWT, renewable saved login, or delegated credential exchanges
/// once and fills the shared cache every clone reads. Later renewal and
/// refusal-triggered re-exchange are unchanged.
///
/// # Errors
/// Returns the server's stable error when it refuses the exchange, or a
/// transport error when `/auth/token` cannot be reached.
pub(crate) async fn connected(&self) -> Result<Self, WyrdError> {
self.access_token().await?;
Ok(self.clone())
}
/// Build a client from an explicit [`ClientConfig`].
///
/// Use this when endpoints or the API key are set programmatically rather
/// than from the environment. Resolution and assembly are identical to
/// [`WyrdClient::from_global`].
///
/// # Arguments
/// * `config` - Endpoints, credential sources, and transport settings for the client.
///
/// # Errors
/// Returns [`WyrdClientError::NoCredentials`] when the config resolves no
/// credential, or [`WyrdClientError::TransportDown`] when the HTTP client
/// cannot be built.
pub fn with_config(config: ClientConfig) -> Result<Self, WyrdClientError> {
let credential = config.resolve_credential()?;
let auth = AuthMiddleware::new(&config, credential)?;
let http = HttpTransport::new(&config.http, Arc::clone(&auth))?;
Ok(Self {
auth,
http,
grpc_config: config.grpc,
})
}
/// Assemble a client from pre-built layers.
///
/// Use this when a caller has already constructed an [`AuthMiddleware`] and
/// an [`HttpTransport`] against them (for example, a test harness dialing
/// a mock server, or an embedder that shares an auth stack across several
/// service clients). The regular [`Self::from_global`] and
/// [`Self::with_config`] paths remain the recommended constructors for
/// production callers.
///
/// # Arguments
/// * `auth` - Shared authentication middleware the client presents bearers from.
/// * `http` - HTTP transport already bound to `auth`.
/// * `grpc_config` - gRPC settings used when a capability dials the gRPC transport.
#[must_use]
#[cfg(feature = "internal")]
pub fn from_parts(
auth: Arc<AuthMiddleware>,
http: HttpTransport,
grpc_config: GrpcConfig,
) -> Self {
Self {
auth,
http,
grpc_config,
}
}
/// Copy this client with an HTTP per-attempt deadline of at least `floor`.
///
/// Shares the auth path, connection pool, and gRPC configuration; only a
/// capability whose server-side deadline exceeds the configured default
/// uses it.
#[must_use]
pub(crate) fn with_min_request_timeout(&self, floor: std::time::Duration) -> Self {
Self {
auth: Arc::clone(&self.auth),
http: self.http.with_min_request_timeout(floor),
grpc_config: self.grpc_config.clone(),
}
}
/// Send a JSON request and decode the JSON response body.
///
/// Delegates to [`HttpTransport::request_json`].
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
/// * `body` - Request body serialized as JSON; `None` sends no body.
///
/// # Errors
/// Non-`2xx` server responses map to [`WyrdError`]; transport failures
/// become [`WyrdError::Internal`].
#[cfg(feature = "internal")]
pub async fn request_json<S, D>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&S>,
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
self.http.request_json(method, path, body).await
}
/// Send a request and return raw Arrow IPC bytes plus metadata headers.
///
/// Delegates to [`HttpTransport::request_arrow`].
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
/// * `body` - Request body serialized as JSON; `None` sends no body.
///
/// # Errors
/// Non-`2xx` server responses map to [`WyrdError`]; transport failures
/// become [`WyrdError::Internal`].
#[cfg(feature = "internal")]
pub async fn request_arrow<S>(
&self,
method: reqwest::Method,
path: &str,
body: Option<&S>,
) -> Result<ArrowResponse, WyrdError>
where
S: Serialize,
{
self.http.request_arrow(method, path, body).await
}
/// Send a request with a stable `Idempotency-Key` minted once and replayed
/// across retries.
///
/// Delegates to [`HttpTransport::submit_idempotent`].
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
/// * `body` - Request body, serialized as JSON.
///
/// # Errors
/// Non-`2xx` server responses map to [`WyrdError`]; transport failures
/// become [`WyrdError::Internal`].
#[cfg(feature = "internal")]
pub async fn submit_idempotent<S, D>(
&self,
method: reqwest::Method,
path: &str,
body: &S,
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
self.http.submit_idempotent(method, path, body).await
}
/// Send a JSON mutation with a caller-supplied idempotency key.
///
/// The key is passed verbatim through the transport retry loop, allowing a
/// deterministic client saga to safely replay a lost response.
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
/// * `body` - Request body, serialized as JSON.
/// * `key` - Idempotency key sent verbatim as `Idempotency-Key` on every attempt.
///
/// # Errors
/// Non-`2xx` server responses map to [`WyrdError`]; serialization and transport failures
/// become [`WyrdError::Internal`].
#[cfg(feature = "internal")]
pub async fn submit_with_idempotency_key<S, D>(
&self,
method: reqwest::Method,
path: &str,
body: &S,
key: &str,
) -> Result<D, WyrdError>
where
S: Serialize,
D: DeserializeOwned,
{
self.http
.submit_with_idempotency_key(method, path, body, key)
.await
}
/// Send an authenticated one-shot streaming request.
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
/// * `body` - One-shot streaming body; it is never retried.
///
/// # Errors
/// Returns a stable Wyrd error for URL, authentication, transport, or HTTP problem
/// responses.
#[cfg(feature = "internal")]
pub async fn request_stream(
&self,
method: reqwest::Method,
path: &str,
body: reqwest::Body,
) -> Result<reqwest::Response, WyrdError> {
self.http.request_stream(method, path, body).await
}
/// Send an authenticated raw request and return its response stream.
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
///
/// # Errors
/// Returns a stable Wyrd error for URL, authentication, transport, or HTTP problem
/// responses.
#[cfg(feature = "internal")]
pub async fn request_raw(
&self,
method: reqwest::Method,
path: &str,
) -> Result<reqwest::Response, WyrdError> {
self.http.request_raw(method, path).await
}
/// Send a cross-origin streaming request through the shared pool without
/// Wyrd credentials.
///
/// Delegates to [`HttpTransport::request_external_stream`]. Used by
/// storage-client dispatch modules for presigned/SAS backend PUTs and
/// GETs.
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `url` - Absolute cross-origin URL, such as a presigned backend URL.
/// * `body` - Optional streaming body; `None` sends no body.
/// * `headers` - Name/value pairs applied verbatim to the request.
///
/// # Errors
/// Transport failures become [`WyrdError::Internal`].
#[cfg(feature = "internal")]
pub async fn request_external_stream(
&self,
method: reqwest::Method,
url: &str,
body: Option<reqwest::Body>,
headers: &[(&str, &str)],
) -> Result<reqwest::Response, WyrdError> {
self.http
.request_external_stream(method, url, body, headers)
.await
}
/// Send an authenticated JSON request and preserve the response as a
/// streaming body for incremental protocol decoding.
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
/// * `body` - Request body, serialized as JSON.
///
/// # Errors
///
/// Returns a stable Wyrd error for serialization, authentication,
/// transport, HTTP problem, or response media-type failures.
#[cfg(feature = "internal")]
pub async fn request_json_stream<S>(
&self,
method: reqwest::Method,
path: &str,
body: &S,
) -> Result<reqwest::Response, WyrdError>
where
S: Serialize,
{
self.http.request_json_stream(method, path, body).await
}
/// Sends a streaming JSON request with a caller-owned request ID and verifies its echo.
///
/// Cancelling this future before return abandons connection setup. After
/// return, dropping the response stops unbuffered body consumption.
///
/// # Arguments
/// * `method` - HTTP method of the request.
/// * `path` - Server-relative request path (leading `/`), joined to the validated
/// deployment origin.
/// * `body` - Request body, serialized as JSON.
/// * `request_id` - Caller-owned request id sent as `wyrd-request-id`; the response must
/// echo it.
///
/// # Errors
///
/// Returns a stable request, authentication, transport, response, or ID-mismatch error.
#[cfg(feature = "internal")]
pub async fn request_json_stream_with_id<S>(
&self,
method: reqwest::Method,
path: &str,
body: &S,
request_id: &RequestId,
) -> Result<reqwest::Response, WyrdError>
where
S: Serialize,
{
self.http
.request_json_stream_with_id(method, path, body, request_id)
.await
}
/// Return a client in which this client acts for the holder of
/// `subject_token`, bound to `audience` (RFC 8693 token exchange).
///
/// This client's own credential is the actor: its current bearer is
/// presented as `actor_token` alongside the inbound `subject_token`. The
/// server verifies both and issues a short-lived token whose subject is
/// the inbound principal, whose outer `act` is this client's principal, and
/// whose permissions are the intersection of both. The first exchange runs
/// here so a refusal surfaces at the call site; the returned client caches
/// the delegated token in memory only and re-exchanges before expiry or
/// after one authentication refusal. It shares this client's connection
/// pools.
///
/// # Arguments
/// * `subject_token` - Inbound principal's token presented as the RFC 8693 subject token.
/// * `audience` - Surface the delegated token is bound to.
///
/// # Errors
/// Returns the server's stable error when the exchange is refused — an
/// invalid subject or actor token, a policy denial, a missing actor — or a
/// transport error when `/auth/token` cannot be reached.
pub async fn on_behalf_of(
&self,
subject_token: SecretString,
audience: TokenAudience,
) -> Result<Self, WyrdError> {
let auth = self.auth.on_behalf_of(subject_token, audience);
auth.bearer().await.map_err(AuthError::into_wyrd)?;
Ok(Self {
http: self.http.with_auth(Arc::clone(&auth)),
auth,
grpc_config: self.grpc_config.clone(),
})
}
/// Return the shared [`AuthMiddleware`] handle.
///
/// Use this to obtain the current bearer for a transport the façade does
/// not wrap, or to share one token path across hand-built clients.
#[must_use]
#[cfg(feature = "internal")]
pub fn auth(&self) -> Arc<AuthMiddleware> {
Arc::clone(&self.auth)
}
/// Return a current bearer for this client's credential.
///
/// Hands the token to a third-party client, such as an OpenAI SDK pointed
/// at the Gateway. The value comes from the shared [`AuthMiddleware`]: a
/// fresh cached token is returned as-is, otherwise the middleware
/// exchanges or renews it first. Nothing is cached here, so a caller that
/// holds the token past its expiry calls this again for a fresh one. The
/// returned [`SecretBearer`] redacts itself in `Debug`.
///
/// # Errors
/// Returns the server's stable error when it refuses the credential, or a
/// transport error when `/auth/token` cannot be reached.
pub async fn access_token(&self) -> Result<SecretBearer, WyrdError> {
self.auth.bearer().await.map_err(AuthError::into_wyrd)
}
/// The effective HTTP server URL this client sends requests to.
#[must_use]
pub fn server_url(&self) -> &str {
self.http.base_url()
}
/// The effective gRPC endpoint the gRPC plane dials: the explicit
/// override when one was configured, else the server URL's host on the
/// public gRPC port.
#[must_use]
pub fn grpc_url(&self) -> &str {
&self.grpc_config.endpoint
}
/// Borrow the underlying [`HttpTransport`].
#[must_use]
#[cfg(feature = "internal")]
pub fn http(&self) -> &HttpTransport {
&self.http
}
/// Dial the gRPC endpoint, sharing this client's [`AuthMiddleware`].
///
/// The connection is established on demand — the façade holds only the
/// [`GrpcConfig`] until this is called. The returned
/// [`GrpcConnection`][crate::transport::grpc::GrpcConnection] carries the
/// same `Arc<AuthMiddleware>` as the HTTP plane, so both authenticate
/// through one token cache.
///
/// # Errors
/// Returns [`WyrdClientError::TransportDown`] when the endpoint URI is
/// invalid or the dial fails on all attempts.
#[cfg(feature = "internal")]
pub async fn connect_grpc(
&self,
) -> Result<crate::transport::grpc::GrpcConnection, WyrdClientError> {
crate::transport::grpc::GrpcConnection::connect(&self.grpc_config, Arc::clone(&self.auth))
.await
}
}
/// Unit tests for the assembled client's token surface.
#[cfg(test)]
mod tests {
use std::sync::Arc;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::WyrdClient;
use crate::auth::AuthMiddleware;
use crate::cards::Cards;
use crate::config::{ClientConfig, TokenCacheMode};
use crate::gateway::Gateway;
use crate::operator_connections::OperatorConnections;
use crate::platform::Platform;
use crate::principals::Principals;
use crate::transport::credential::ResolvedCredential;
use crate::transport::http::HttpTransport;
/// Build a client against `server` presenting `credential`, with an
/// in-memory token cache so no test touches a token file.
///
/// # Panics
/// Panics when the auth middleware or HTTP transport cannot be built for
/// the mock server's URL.
fn client_for(server: &MockServer, credential: ResolvedCredential) -> WyrdClient {
let mut config = ClientConfig::default();
config.http.base_url = server.uri();
config.token_cache = TokenCacheMode::InMemory;
let auth = AuthMiddleware::new(&config, credential).expect("middleware builds");
let http = HttpTransport::new(&config.http, Arc::clone(&auth)).expect("transport builds");
WyrdClient::from_parts(auth, http, config.grpc)
}
/// Every HTTP-only handle's `connect_with` completes the client's required
/// exchange before returning and shares its one token cache: four handles
/// over one API-key client cause exactly one `/auth/token` exchange. A
/// presented-as-is bearer connects every handle with no request at all, a
/// refused exchange fails `connect_with` with the server's stable error,
/// and `Platform` keeps its own platform-session exchange.
///
/// # Panics
/// Panics when a handle fails to connect, a refused exchange connects, or
/// the mock server sees a number of exchanges other than its expectation.
#[tokio::test]
async fn server_handles_connect_eagerly_and_share_the_client_auth_path() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "tenant-bearer",
"token_type": "Bearer",
"expires_in": 3600
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/auth/platform/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "platform-session",
"token_type": "Bearer",
"expires_in": 3600
})))
.expect(1)
.mount(&server)
.await;
let api_key = client_for(
&server,
ResolvedCredential::ApiKey("wyrd_sk_t_v_s".to_owned().into()),
);
Cards::connect_with(&api_key).await.expect("cards connect");
Gateway::connect_with(&api_key)
.await
.expect("gateway connects");
Principals::connect_with(&api_key)
.await
.expect("principals connect");
OperatorConnections::connect_with(&api_key)
.await
.expect("operator connections connect");
let bearer = client_for(
&server,
ResolvedCredential::BearerToken("presented-as-is".to_owned().into()),
);
Cards::connect_with(&bearer).await.expect("cards connect");
Gateway::connect_with(&bearer)
.await
.expect("gateway connects");
Principals::connect_with(&bearer)
.await
.expect("principals connect");
OperatorConnections::connect_with(&bearer)
.await
.expect("operator connections connect");
Platform::connect_with(&bearer)
.await
.expect("platform exchanges its session");
let refusing = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/token"))
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
"type": "about:blank",
"title": "Unauthenticated",
"status": 401,
"code": "WYRD_AUTH_401_UNAUTHENTICATED",
"detail": "credential refused"
})))
.mount(&refusing)
.await;
let refused = client_for(
&refusing,
ResolvedCredential::ApiKey("wyrd_sk_t_v_s".to_owned().into()),
);
let error = Principals::connect_with(&refused)
.await
.expect_err("a refused exchange fails at connect");
assert_eq!(error.status(), 401, "{error:?}");
assert!(
!format!("{error:?}").contains("wyrd_sk_t_v_s"),
"errors never carry the credential"
);
}
/// `access_token` reads through the shared [`AuthMiddleware`]: an API key
/// is exchanged at `/auth/token`, a token inside the refresh skew is
/// re-exchanged on the next call rather than served from any client-side
/// copy (the mock expects exactly two exchanges), and the bearer's `Debug`
/// stays redacted.
#[tokio::test]
async fn access_token_uses_the_shared_refreshing_auth_path() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/auth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "gateway-bearer",
"token_type": "Bearer",
"expires_in": 1
})))
.expect(2)
.mount(&server)
.await;
let mut config = ClientConfig::default();
config.http.base_url = server.uri();
config.token_cache = TokenCacheMode::InMemory;
let auth = AuthMiddleware::new(
&config,
ResolvedCredential::ApiKey("api-key-value".to_owned().into()),
)
.expect("middleware builds");
let http = HttpTransport::new(&config.http, Arc::clone(&auth)).expect("transport builds");
let client = WyrdClient::from_parts(auth, http, config.grpc);
let first = client.access_token().await.expect("first exchange");
let second = client.access_token().await.expect("stale token refreshes");
assert_eq!(first.expose(), "gateway-bearer");
assert_eq!(second.expose(), "gateway-bearer");
assert!(
!format!("{first:?}").contains("gateway-bearer"),
"Debug must redact the bearer"
);
}
}