kkyygg commited on
Commit
5bf2819
·
unverified ·
1 Parent(s): 843450a

feat: 支持凭据级 region/machineId 配置及自动认证方式检测 (#24)

Browse files

* feat: 支持凭据级 region/machineId 配置及自动认证方式检测

凭据级配置:
- 新增 credentials.region 字段,用于 OIDC token 刷新时指定 endpoint 区域
- 新增 credentials.machineId 字段,支持凭据级机器码配置
- 优先级: 凭据级 > config.json 全局配置 > refreshToken 派生

machineId 格式兼容:
- 支持 64 字符十六进制格式(原有)
- 支持 UUID 格式(如 2582956e-cc88-4669-b546-07adbffcb894),自动转换为 64 字符

认证方式自动检测:
- 当 authMethod 未指定时,根据 clientId/clientSecret 存在与否自动判断
- 有 clientId + clientSecret → idc 认证
- 否则 → social 认证

文件变更:
- src/kiro/machine_id.rs: 添加 normalize_machine_id 函数
- src/kiro/token_manager.rs: 添加 region 优先级逻辑和 authMethod 自动检测
- src/kiro/model/credentials.rs: 新增 region/machineId 字段及测试
- src/admin/types.rs, src/admin/service.rs: Admin API 支持新字段
- README.md, credentials.example.*.json: 文档和示例更新

* feat: 支持凭据级 region/machineId 配置及自动认证方式检测

凭据级配置:
- 新增 credentials.region 字段,用于 OIDC token 刷新时指定 endpoint 区域
- 新增 credentials.machineId 字段,支持凭据级机器码配置
- 优先级: 凭据级 > config.json 全局配置 > refreshToken 派生

machineId 格式兼容:
- 支持 64 字符十六进制格式(原有)
- 支持 UUID 格式(如 2582956e-cc88-4669-b546-07adbffcb894),自动转换为 64 字符

认证方式自动检测:
- 当 authMethod 未指定时,根据 clientId/clientSecret 存在与否自动判断
- 有 clientId + clientSecret → idc 认证
- 否则 → social 认证

文件变更:
- src/kiro/machine_id.rs: 添加 normalize_machine_id 函数
- src/kiro/token_manager.rs: 添加 region 优先级逻辑和 authMethod 自动检测
- src/kiro/model/credentials.rs: 新增 region/machineId 字段及测试
- src/admin/types.rs, src/admin/service.rs: Admin API 支持新字段
- README.md, credentials.example.*.json: 文档和示例更新

README.md CHANGED
@@ -101,6 +101,7 @@ cargo build --release
101
  "authMethod": "idc",
102
  "clientId": "xxxxxxxxx",
103
  "clientSecret": "xxxxxxxxx",
 
104
  "priority": 1
105
  }
106
  ]
@@ -111,6 +112,7 @@ cargo build --release
111
  > - 单凭据最多重试 3 次,单请求最多重试 9 次
112
  > - 自动故障转移到下一个可用凭据
113
  > - 多凭据格式下 Token 刷新后自动回写到源文件
 
114
 
115
  最小启动配置(social):
116
  ```json
@@ -194,6 +196,8 @@ curl http://127.0.0.1:8990/v1/messages \
194
  | `clientId` | string | IdC 登录的客户端 ID(可选) |
195
  | `clientSecret` | string | IdC 登录的客户端密钥(可选) |
196
  | `priority` | number | 凭据优先级,数字越小越优先,默认为 0(多凭据格式时有效)|
 
 
197
 
198
  ## 模型映射
199
 
@@ -344,4 +348,4 @@ MIT
344
  - [kiro2api](https://github.com/caidaoli/kiro2api)
345
  - [proxycast](https://github.com/aiclientproxy/proxycast)
346
 
347
- 本项目部分逻辑参考了以上的项目, 再次由衷的感谢!
 
101
  "authMethod": "idc",
102
  "clientId": "xxxxxxxxx",
103
  "clientSecret": "xxxxxxxxx",
104
+ "region": "us-east-2",
105
  "priority": 1
106
  }
107
  ]
 
112
  > - 单凭据最多重试 3 次,单请求最多重试 9 次
113
  > - 自动故障转移到下一个可用凭据
114
  > - 多凭据格式下 Token 刷新后自动回写到源文件
115
+ > - 可选的 `region` 字段:用于 OIDC token 刷新时指定 endpoint 区域,未配置时回退到 config.json 的 region
116
 
117
  最小启动配置(social):
118
  ```json
 
196
  | `clientId` | string | IdC 登录的客户端 ID(可选) |
197
  | `clientSecret` | string | IdC 登录的客户端密钥(可选) |
198
  | `priority` | number | 凭据优先级,数字越小越优先,默认为 0(多凭据格式时有效)|
199
+ | `region` | string | 凭据级 region(可选),用于 OIDC token 刷新时指定 endpoint 的区域。未配置时回退到 config.json 的 region。注意:API 调用始终使用 config.json 的 region |
200
+ | `machineId` | string | 凭据级机器码(可选,64位十六进制)。未配置时回退到 config.json 的 machineId;都未配置时由 refreshToken 派生 |
201
 
202
  ## 模型映射
203
 
 
348
  - [kiro2api](https://github.com/caidaoli/kiro2api)
349
  - [proxycast](https://github.com/aiclientproxy/proxycast)
350
 
351
+ 本项目部分逻辑参考了以上的项目, 再次由衷的感谢!
credentials.example.idc.json CHANGED
@@ -3,5 +3,7 @@
3
  "expiresAt": "2025-12-31T02:32:45.144Z",
4
  "authMethod": "idc",
5
  "clientId": "xxxxxxxxx",
6
- "clientSecret": "xxxxxxxxx"
7
- }
 
 
 
3
  "expiresAt": "2025-12-31T02:32:45.144Z",
4
  "authMethod": "idc",
5
  "clientId": "xxxxxxxxx",
6
+ "clientSecret": "xxxxxxxxx",
7
+ "region": "us-east-2",
8
+ "machineId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
9
+ }
credentials.example.multiple.json CHANGED
@@ -3,6 +3,7 @@
3
  "refreshToken": "xxxxxxxxxxxxxxxxxxxx",
4
  "expiresAt": "2025-12-31T02:32:45.144Z",
5
  "authMethod": "social",
 
6
  "priority": 0
7
  },
8
  {
@@ -11,6 +12,8 @@
11
  "authMethod": "idc",
12
  "clientId": "xxxxxxxxx",
13
  "clientSecret": "xxxxxxxxx",
 
 
14
  "priority": 1
15
  }
16
  ]
 
3
  "refreshToken": "xxxxxxxxxxxxxxxxxxxx",
4
  "expiresAt": "2025-12-31T02:32:45.144Z",
5
  "authMethod": "social",
6
+ "machineId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
7
  "priority": 0
8
  },
9
  {
 
12
  "authMethod": "idc",
13
  "clientId": "xxxxxxxxx",
14
  "clientSecret": "xxxxxxxxx",
15
+ "region": "us-east-2",
16
+ "machineId": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
17
  "priority": 1
18
  }
19
  ]
credentials.example.social.json CHANGED
@@ -1,5 +1,6 @@
1
  {
2
  "refreshToken": "xxxxxxxxxxxxxxxxxxxx",
3
  "expiresAt": "2025-12-31T02:32:45.144Z",
4
- "authMethod": "social"
5
- }
 
 
1
  {
2
  "refreshToken": "xxxxxxxxxxxxxxxxxxxx",
3
  "expiresAt": "2025-12-31T02:32:45.144Z",
4
+ "authMethod": "social",
5
+ "machineId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
6
+ }
src/admin/service.rs CHANGED
@@ -128,6 +128,8 @@ impl AdminService {
128
  client_id: req.client_id,
129
  client_secret: req.client_secret,
130
  priority: req.priority,
 
 
131
  };
132
 
133
  // 调用 token_manager 添加凭据
 
128
  client_id: req.client_id,
129
  client_secret: req.client_secret,
130
  priority: req.priority,
131
+ region: req.region,
132
+ machine_id: req.machine_id,
133
  };
134
 
135
  // 调用 token_manager 添加凭据
src/admin/types.rs CHANGED
@@ -78,6 +78,14 @@ pub struct AddCredentialRequest {
78
  /// 优先级(可选,默认 0)
79
  #[serde(default)]
80
  pub priority: u32,
 
 
 
 
 
 
 
 
81
  }
82
 
83
  fn default_auth_method() -> String {
 
78
  /// 优先级(可选,默认 0)
79
  #[serde(default)]
80
  pub priority: u32,
81
+
82
+ /// 凭据级 Region 配置(用于 OIDC token 刷新)
83
+ /// 未配置时回退到 config.json 的全局 region
84
+ pub region: Option<String>,
85
+
86
+ /// 凭据级 Machine ID(可选,64 位字符串)
87
+ /// 未配置时回退到 config.json 的 machineId
88
+ pub machine_id: Option<String>,
89
  }
90
 
91
  fn default_auth_method() -> String {
src/kiro/machine_id.rs CHANGED
@@ -6,14 +6,47 @@ use sha2::{Digest, Sha256};
6
  use crate::kiro::model::credentials::KiroCredentials;
7
  use crate::model::config::Config;
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  /// 根据凭证信息生成唯一的 Machine ID
10
  ///
11
- /// 优先使用自定义配置,然后使用 refreshToken 生成
12
  pub fn generate_from_credentials(credentials: &KiroCredentials, config: &Config) -> Option<String> {
13
- // 如果配置了自定义 machineId 且长度为 64,优先使用
 
 
 
 
 
 
 
14
  if let Some(ref machine_id) = config.machine_id {
15
- if machine_id.len() == 64 {
16
- return Some(machine_id.clone());
17
  }
18
  }
19
 
@@ -60,10 +93,22 @@ mod tests {
60
  assert_eq!(result, Some("a".repeat(64)));
61
  }
62
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  #[test]
64
  fn test_generate_with_refresh_token() {
65
  let mut credentials = KiroCredentials::default();
66
- credentials.refresh_token = Some("test_refresh_token".to_string());
67
  let config = Config::default();
68
 
69
  let result = generate_from_credentials(&credentials, &config);
@@ -79,4 +124,44 @@ mod tests {
79
  let result = generate_from_credentials(&credentials, &config);
80
  assert!(result.is_none());
81
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  }
 
6
  use crate::kiro::model::credentials::KiroCredentials;
7
  use crate::model::config::Config;
8
 
9
+ /// 标准化 machineId 格式
10
+ ///
11
+ /// 支持以下格式:
12
+ /// - 64 字符十六进制字符串(直接返回)
13
+ /// - UUID 格式(如 "2582956e-cc88-4669-b546-07adbffcb894",移除连字符后补齐到 64 字符)
14
+ fn normalize_machine_id(machine_id: &str) -> Option<String> {
15
+ let trimmed = machine_id.trim();
16
+
17
+ // 如果已经是 64 字符,直接返回
18
+ if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
19
+ return Some(trimmed.to_string());
20
+ }
21
+
22
+ // 尝试解析 UUID 格式(移除连字符)
23
+ let without_dashes: String = trimmed.chars().filter(|c| *c != '-').collect();
24
+
25
+ // UUID 去掉连字符后是 32 字符
26
+ if without_dashes.len() == 32 && without_dashes.chars().all(|c| c.is_ascii_hexdigit()) {
27
+ // 补齐到 64 字符(重复一次)
28
+ return Some(format!("{}{}", without_dashes, without_dashes));
29
+ }
30
+
31
+ // 无法识别的格式
32
+ None
33
+ }
34
+
35
  /// 根据凭证信息生成唯一的 Machine ID
36
  ///
37
+ /// 优先使用凭据级 machineId其次使用 config.machineId,然后使用 refreshToken 生成
38
  pub fn generate_from_credentials(credentials: &KiroCredentials, config: &Config) -> Option<String> {
39
+ // 如果配置了凭据级 machineId,优先使用
40
+ if let Some(ref machine_id) = credentials.machine_id {
41
+ if let Some(normalized) = normalize_machine_id(machine_id) {
42
+ return Some(normalized);
43
+ }
44
+ }
45
+
46
+ // 如果配置了全局 machineId,作为默认值
47
  if let Some(ref machine_id) = config.machine_id {
48
+ if let Some(normalized) = normalize_machine_id(machine_id) {
49
+ return Some(normalized);
50
  }
51
  }
52
 
 
93
  assert_eq!(result, Some("a".repeat(64)));
94
  }
95
 
96
+ #[test]
97
+ fn test_generate_with_credential_machine_id_overrides_config() {
98
+ let mut credentials = KiroCredentials::default();
99
+ credentials.machine_id = Some("b".repeat(64));
100
+
101
+ let mut config = Config::default();
102
+ config.machine_id = Some("a".repeat(64));
103
+
104
+ let result = generate_from_credentials(&credentials, &config);
105
+ assert_eq!(result, Some("b".repeat(64)));
106
+ }
107
+
108
  #[test]
109
  fn test_generate_with_refresh_token() {
110
  let mut credentials = KiroCredentials::default();
111
+ credentials.refresh_token = Some("test_refresh_token".to_string());
112
  let config = Config::default();
113
 
114
  let result = generate_from_credentials(&credentials, &config);
 
124
  let result = generate_from_credentials(&credentials, &config);
125
  assert!(result.is_none());
126
  }
127
+
128
+ #[test]
129
+ fn test_normalize_uuid_format() {
130
+ // UUID 格式应该被转换为 64 字符
131
+ let uuid = "2582956e-cc88-4669-b546-07adbffcb894";
132
+ let result = normalize_machine_id(uuid);
133
+ assert!(result.is_some());
134
+ let normalized = result.unwrap();
135
+ assert_eq!(normalized.len(), 64);
136
+ // UUID 去掉连字符后重复一次
137
+ assert_eq!(normalized, "2582956ecc884669b54607adbffcb8942582956ecc884669b54607adbffcb894");
138
+ }
139
+
140
+ #[test]
141
+ fn test_normalize_64_char_hex() {
142
+ // 64 字符十六进制应该直接返回
143
+ let hex64 = "a".repeat(64);
144
+ let result = normalize_machine_id(&hex64);
145
+ assert_eq!(result, Some(hex64));
146
+ }
147
+
148
+ #[test]
149
+ fn test_normalize_invalid_format() {
150
+ // 无效格式应该返回 None
151
+ assert!(normalize_machine_id("invalid").is_none());
152
+ assert!(normalize_machine_id("too-short").is_none());
153
+ assert!(normalize_machine_id(&"g".repeat(64)).is_none()); // 非十六进制
154
+ }
155
+
156
+ #[test]
157
+ fn test_generate_with_uuid_machine_id() {
158
+ let mut credentials = KiroCredentials::default();
159
+ credentials.machine_id = Some("2582956e-cc88-4669-b546-07adbffcb894".to_string());
160
+
161
+ let config = Config::default();
162
+
163
+ let result = generate_from_credentials(&credentials, &config);
164
+ assert!(result.is_some());
165
+ assert_eq!(result.as_ref().unwrap().len(), 64);
166
+ }
167
  }
src/kiro/model/credentials.rs CHANGED
@@ -47,6 +47,16 @@ pub struct KiroCredentials {
47
  #[serde(default)]
48
  #[serde(skip_serializing_if = "is_zero")]
49
  pub priority: u32,
 
 
 
 
 
 
 
 
 
 
50
  }
51
 
52
  /// 判断是否为零(用于跳过序列化)
@@ -199,6 +209,8 @@ mod tests {
199
  client_id: None,
200
  client_secret: None,
201
  priority: 0,
 
 
202
  };
203
 
204
  let json = creds.to_pretty_json().unwrap();
@@ -265,4 +277,186 @@ mod tests {
265
  assert_eq!(list[1].refresh_token, Some("t3".to_string())); // priority 1
266
  assert_eq!(list[2].refresh_token, Some("t1".to_string())); // priority 2
267
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  }
 
47
  #[serde(default)]
48
  #[serde(skip_serializing_if = "is_zero")]
49
  pub priority: u32,
50
+
51
+ /// 凭据级 Region 配置(用于 OIDC token 刷新)
52
+ /// 未配置时回退到 config.json 的全局 region
53
+ #[serde(skip_serializing_if = "Option::is_none")]
54
+ pub region: Option<String>,
55
+
56
+ /// 凭据级 Machine ID 配置(可选)
57
+ /// 未配置时回退到 config.json 的 machineId;都未配置时由 refreshToken 派生
58
+ #[serde(skip_serializing_if = "Option::is_none")]
59
+ pub machine_id: Option<String>,
60
  }
61
 
62
  /// 判断是否为零(用于跳过序列化)
 
209
  client_id: None,
210
  client_secret: None,
211
  priority: 0,
212
+ region: None,
213
+ machine_id: None,
214
  };
215
 
216
  let json = creds.to_pretty_json().unwrap();
 
277
  assert_eq!(list[1].refresh_token, Some("t3".to_string())); // priority 1
278
  assert_eq!(list[2].refresh_token, Some("t1".to_string())); // priority 2
279
  }
280
+
281
+ // ============ Region 字段测试 ============
282
+
283
+ #[test]
284
+ fn test_region_field_parsing() {
285
+ // 测试解析包含 region 字段的 JSON
286
+ let json = r#"{
287
+ "refreshToken": "test_refresh",
288
+ "region": "us-east-1"
289
+ }"#;
290
+
291
+ let creds = KiroCredentials::from_json(json).unwrap();
292
+ assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
293
+ assert_eq!(creds.region, Some("us-east-1".to_string()));
294
+ }
295
+
296
+ #[test]
297
+ fn test_region_field_missing_backward_compat() {
298
+ // 测试向后兼容:不包含 region 字段的旧格式 JSON
299
+ let json = r#"{
300
+ "refreshToken": "test_refresh",
301
+ "authMethod": "social"
302
+ }"#;
303
+
304
+ let creds = KiroCredentials::from_json(json).unwrap();
305
+ assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
306
+ assert_eq!(creds.region, None);
307
+ }
308
+
309
+ #[test]
310
+ fn test_region_field_serialization() {
311
+ // 测试序列化时正确输出 region 字段
312
+ let creds = KiroCredentials {
313
+ id: None,
314
+ access_token: None,
315
+ refresh_token: Some("test".to_string()),
316
+ profile_arn: None,
317
+ expires_at: None,
318
+ auth_method: None,
319
+ client_id: None,
320
+ client_secret: None,
321
+ priority: 0,
322
+ region: Some("eu-west-1".to_string()),
323
+ machine_id: None,
324
+ };
325
+
326
+ let json = creds.to_pretty_json().unwrap();
327
+ assert!(json.contains("region"));
328
+ assert!(json.contains("eu-west-1"));
329
+ }
330
+
331
+ #[test]
332
+ fn test_region_field_none_not_serialized() {
333
+ // 测试 region 为 None 时不序列化
334
+ let creds = KiroCredentials {
335
+ id: None,
336
+ access_token: None,
337
+ refresh_token: Some("test".to_string()),
338
+ profile_arn: None,
339
+ expires_at: None,
340
+ auth_method: None,
341
+ client_id: None,
342
+ client_secret: None,
343
+ priority: 0,
344
+ region: None,
345
+ machine_id: None,
346
+ };
347
+
348
+ let json = creds.to_pretty_json().unwrap();
349
+ assert!(!json.contains("region"));
350
+ }
351
+
352
+ // ============ MachineId 字段测试 ============
353
+
354
+ #[test]
355
+ fn test_machine_id_field_parsing() {
356
+ let machine_id = "a".repeat(64);
357
+ let json = format!(
358
+ r#"{{
359
+ "refreshToken": "test_refresh",
360
+ "machineId": "{machine_id}"
361
+ }}"#
362
+ );
363
+
364
+ let creds = KiroCredentials::from_json(&json).unwrap();
365
+ assert_eq!(creds.refresh_token, Some("test_refresh".to_string()));
366
+ assert_eq!(creds.machine_id, Some(machine_id));
367
+ }
368
+
369
+ #[test]
370
+ fn test_machine_id_field_serialization() {
371
+ let mut creds = KiroCredentials::default();
372
+ creds.refresh_token = Some("test".to_string());
373
+ creds.machine_id = Some("b".repeat(64));
374
+
375
+ let json = creds.to_pretty_json().unwrap();
376
+ assert!(json.contains("machineId"));
377
+ }
378
+
379
+ #[test]
380
+ fn test_machine_id_field_none_not_serialized() {
381
+ let mut creds = KiroCredentials::default();
382
+ creds.refresh_token = Some("test".to_string());
383
+ creds.machine_id = None;
384
+
385
+ let json = creds.to_pretty_json().unwrap();
386
+ assert!(!json.contains("machineId"));
387
+ }
388
+
389
+ #[test]
390
+ fn test_multiple_credentials_with_different_regions() {
391
+ // 测试多凭据场景下不同凭据使用各自的 region
392
+ let json = r#"[
393
+ {"refreshToken": "t1", "region": "us-east-1"},
394
+ {"refreshToken": "t2", "region": "eu-west-1"},
395
+ {"refreshToken": "t3"}
396
+ ]"#;
397
+
398
+ let config: CredentialsConfig = serde_json::from_str(json).unwrap();
399
+ let list = config.into_sorted_credentials();
400
+
401
+ assert_eq!(list[0].region, Some("us-east-1".to_string()));
402
+ assert_eq!(list[1].region, Some("eu-west-1".to_string()));
403
+ assert_eq!(list[2].region, None);
404
+ }
405
+
406
+ #[test]
407
+ fn test_region_field_with_all_fields() {
408
+ // 测试包含所有字段的完整 JSON
409
+ let json = r#"{
410
+ "id": 1,
411
+ "accessToken": "access",
412
+ "refreshToken": "refresh",
413
+ "profileArn": "arn:aws:test",
414
+ "expiresAt": "2025-12-31T00:00:00Z",
415
+ "authMethod": "idc",
416
+ "clientId": "client123",
417
+ "clientSecret": "secret456",
418
+ "priority": 5,
419
+ "region": "ap-northeast-1"
420
+ }"#;
421
+
422
+ let creds = KiroCredentials::from_json(json).unwrap();
423
+ assert_eq!(creds.id, Some(1));
424
+ assert_eq!(creds.access_token, Some("access".to_string()));
425
+ assert_eq!(creds.refresh_token, Some("refresh".to_string()));
426
+ assert_eq!(creds.profile_arn, Some("arn:aws:test".to_string()));
427
+ assert_eq!(creds.expires_at, Some("2025-12-31T00:00:00Z".to_string()));
428
+ assert_eq!(creds.auth_method, Some("idc".to_string()));
429
+ assert_eq!(creds.client_id, Some("client123".to_string()));
430
+ assert_eq!(creds.client_secret, Some("secret456".to_string()));
431
+ assert_eq!(creds.priority, 5);
432
+ assert_eq!(creds.region, Some("ap-northeast-1".to_string()));
433
+ }
434
+
435
+ #[test]
436
+ fn test_region_roundtrip() {
437
+ // 测试序列化和反序列化的往返一致性
438
+ let original = KiroCredentials {
439
+ id: Some(42),
440
+ access_token: Some("token".to_string()),
441
+ refresh_token: Some("refresh".to_string()),
442
+ profile_arn: None,
443
+ expires_at: None,
444
+ auth_method: Some("social".to_string()),
445
+ client_id: None,
446
+ client_secret: None,
447
+ priority: 3,
448
+ region: Some("us-west-2".to_string()),
449
+ machine_id: Some("c".repeat(64)),
450
+ };
451
+
452
+ let json = original.to_pretty_json().unwrap();
453
+ let parsed = KiroCredentials::from_json(&json).unwrap();
454
+
455
+ assert_eq!(parsed.id, original.id);
456
+ assert_eq!(parsed.access_token, original.access_token);
457
+ assert_eq!(parsed.refresh_token, original.refresh_token);
458
+ assert_eq!(parsed.priority, original.priority);
459
+ assert_eq!(parsed.region, original.region);
460
+ assert_eq!(parsed.machine_id, original.machine_id);
461
+ }
462
  }
src/kiro/token_manager.rs CHANGED
@@ -132,7 +132,14 @@ pub(crate) async fn refresh_token(
132
  validate_refresh_token(credentials)?;
133
 
134
  // 根据 auth_method 选择刷新方式
135
- let auth_method = credentials.auth_method.as_deref().unwrap_or("social");
 
 
 
 
 
 
 
136
 
137
  match auth_method.to_lowercase().as_str() {
138
  "idc" | "builder-id" => refresh_idc_token(credentials, config, proxy).await,
@@ -149,7 +156,8 @@ async fn refresh_social_token(
149
  tracing::info!("正在刷新 Social Token...");
150
 
151
  let refresh_token = credentials.refresh_token.as_ref().unwrap();
152
- let region = &config.region;
 
153
 
154
  let refresh_url = format!("https://prod.{}.auth.desktop.kiro.dev/refreshToken", region);
155
  let refresh_domain = format!("prod.{}.auth.desktop.kiro.dev", region);
@@ -232,7 +240,8 @@ async fn refresh_idc_token(
232
  .as_ref()
233
  .ok_or_else(|| anyhow::anyhow!("IdC 刷新需要 clientSecret"))?;
234
 
235
- let region = &config.region;
 
236
  let refresh_url = format!("https://oidc.{}.amazonaws.com/token", region);
237
 
238
  let client = build_client(proxy, 60)?;
@@ -1501,4 +1510,128 @@ mod tests {
1501
  );
1502
  assert_eq!(manager.available_count(), 0);
1503
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1504
  }
 
132
  validate_refresh_token(credentials)?;
133
 
134
  // 根据 auth_method 选择刷新方式
135
+ // 如果未指定 auth_method,根据是否有 clientId/clientSecret 自动判断
136
+ let auth_method = credentials.auth_method.as_deref().unwrap_or_else(|| {
137
+ if credentials.client_id.is_some() && credentials.client_secret.is_some() {
138
+ "idc"
139
+ } else {
140
+ "social"
141
+ }
142
+ });
143
 
144
  match auth_method.to_lowercase().as_str() {
145
  "idc" | "builder-id" => refresh_idc_token(credentials, config, proxy).await,
 
156
  tracing::info!("正在刷新 Social Token...");
157
 
158
  let refresh_token = credentials.refresh_token.as_ref().unwrap();
159
+ // 优先使用凭据级 region,未配置时回退到 config.region
160
+ let region = credentials.region.as_ref().unwrap_or(&config.region);
161
 
162
  let refresh_url = format!("https://prod.{}.auth.desktop.kiro.dev/refreshToken", region);
163
  let refresh_domain = format!("prod.{}.auth.desktop.kiro.dev", region);
 
240
  .as_ref()
241
  .ok_or_else(|| anyhow::anyhow!("IdC 刷新需要 clientSecret"))?;
242
 
243
+ // 优先使用凭据级 region,未配置时回退到 config.region
244
+ let region = credentials.region.as_ref().unwrap_or(&config.region);
245
  let refresh_url = format!("https://oidc.{}.amazonaws.com/token", region);
246
 
247
  let client = build_client(proxy, 60)?;
 
1510
  );
1511
  assert_eq!(manager.available_count(), 0);
1512
  }
1513
+
1514
+ // ============ 凭据级 Region 优先级测试 ============
1515
+
1516
+ /// 辅助函数:获取 OIDC 刷新使用的 region(用于测试)
1517
+ fn get_oidc_region_for_credential<'a>(
1518
+ credentials: &'a KiroCredentials,
1519
+ config: &'a Config,
1520
+ ) -> &'a str {
1521
+ credentials.region.as_ref().unwrap_or(&config.region)
1522
+ }
1523
+
1524
+ #[test]
1525
+ fn test_credential_region_priority_uses_credential_region() {
1526
+ // 凭据配置了 region 时,应使用凭据的 region
1527
+ let mut config = Config::default();
1528
+ config.region = "us-west-2".to_string();
1529
+
1530
+ let mut credentials = KiroCredentials::default();
1531
+ credentials.region = Some("eu-west-1".to_string());
1532
+
1533
+ let region = get_oidc_region_for_credential(&credentials, &config);
1534
+ assert_eq!(region, "eu-west-1");
1535
+ }
1536
+
1537
+ #[test]
1538
+ fn test_credential_region_priority_fallback_to_config() {
1539
+ // 凭据未配置 region 时,应回退到 config.region
1540
+ let mut config = Config::default();
1541
+ config.region = "us-west-2".to_string();
1542
+
1543
+ let credentials = KiroCredentials::default();
1544
+ assert!(credentials.region.is_none());
1545
+
1546
+ let region = get_oidc_region_for_credential(&credentials, &config);
1547
+ assert_eq!(region, "us-west-2");
1548
+ }
1549
+
1550
+ #[test]
1551
+ fn test_multiple_credentials_use_respective_regions() {
1552
+ // 多凭据场景下,不同凭据使用各自的 region
1553
+ let mut config = Config::default();
1554
+ config.region = "ap-northeast-1".to_string();
1555
+
1556
+ let mut cred1 = KiroCredentials::default();
1557
+ cred1.region = Some("us-east-1".to_string());
1558
+
1559
+ let mut cred2 = KiroCredentials::default();
1560
+ cred2.region = Some("eu-west-1".to_string());
1561
+
1562
+ let cred3 = KiroCredentials::default(); // 无 region,使用 config
1563
+
1564
+ assert_eq!(get_oidc_region_for_credential(&cred1, &config), "us-east-1");
1565
+ assert_eq!(get_oidc_region_for_credential(&cred2, &config), "eu-west-1");
1566
+ assert_eq!(
1567
+ get_oidc_region_for_credential(&cred3, &config),
1568
+ "ap-northeast-1"
1569
+ );
1570
+ }
1571
+
1572
+ #[test]
1573
+ fn test_idc_oidc_endpoint_uses_credential_region() {
1574
+ // 验证 IdC OIDC endpoint URL 使用凭据 region
1575
+ let mut config = Config::default();
1576
+ config.region = "us-west-2".to_string();
1577
+
1578
+ let mut credentials = KiroCredentials::default();
1579
+ credentials.region = Some("eu-central-1".to_string());
1580
+
1581
+ let region = get_oidc_region_for_credential(&credentials, &config);
1582
+ let refresh_url = format!("https://oidc.{}.amazonaws.com/token", region);
1583
+
1584
+ assert_eq!(refresh_url, "https://oidc.eu-central-1.amazonaws.com/token");
1585
+ }
1586
+
1587
+ #[test]
1588
+ fn test_social_refresh_endpoint_uses_credential_region() {
1589
+ // 验证 Social refresh endpoint URL 使用凭据 region
1590
+ let mut config = Config::default();
1591
+ config.region = "us-west-2".to_string();
1592
+
1593
+ let mut credentials = KiroCredentials::default();
1594
+ credentials.region = Some("ap-southeast-1".to_string());
1595
+
1596
+ let region = get_oidc_region_for_credential(&credentials, &config);
1597
+ let refresh_url = format!("https://prod.{}.auth.desktop.kiro.dev/refreshToken", region);
1598
+
1599
+ assert_eq!(
1600
+ refresh_url,
1601
+ "https://prod.ap-southeast-1.auth.desktop.kiro.dev/refreshToken"
1602
+ );
1603
+ }
1604
+
1605
+ #[test]
1606
+ fn test_api_call_still_uses_config_region() {
1607
+ // 验证 API 调用(如 getUsageLimits)仍使用 config.region
1608
+ // 这确保只有 OIDC 刷新使用凭据 region,API 调用行为不变
1609
+ let mut config = Config::default();
1610
+ config.region = "us-west-2".to_string();
1611
+
1612
+ let mut credentials = KiroCredentials::default();
1613
+ credentials.region = Some("eu-west-1".to_string());
1614
+
1615
+ // API 调用应使用 config.region,而非 credentials.region
1616
+ let api_region = &config.region;
1617
+ let api_host = format!("q.{}.amazonaws.com", api_region);
1618
+
1619
+ assert_eq!(api_host, "q.us-west-2.amazonaws.com");
1620
+ // 确认凭据 region 不影响 API 调用
1621
+ assert_ne!(api_region, credentials.region.as_ref().unwrap());
1622
+ }
1623
+
1624
+ #[test]
1625
+ fn test_credential_region_empty_string_treated_as_set() {
1626
+ // 空字符串 region 被视为已设置(虽然不推荐,但行为应一致)
1627
+ let mut config = Config::default();
1628
+ config.region = "us-west-2".to_string();
1629
+
1630
+ let mut credentials = KiroCredentials::default();
1631
+ credentials.region = Some("".to_string());
1632
+
1633
+ let region = get_oidc_region_for_credential(&credentials, &config);
1634
+ // 空字符串被视为已设置,不会回退到 config
1635
+ assert_eq!(region, "");
1636
+ }
1637
  }