feat: add AWS Builder ID login and local Kiro cache import

This commit is contained in:
Quorinex
2026-02-04 05:09:42 +08:00
parent 765face800
commit 4f3be1258e
3 changed files with 663 additions and 49 deletions

256
auth/builderid.go Normal file
View File

@@ -0,0 +1,256 @@
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
// BuilderIdSession Builder ID 登录会话
type BuilderIdSession struct {
ID string
ClientID string
ClientSecret string
DeviceCode string
UserCode string
VerificationUri string
Interval int
ExpiresAt time.Time
Region string
}
var (
builderIdSessions = make(map[string]*BuilderIdSession)
builderIdMu sync.RWMutex
)
// StartBuilderIdLogin 开始 Builder ID 登录
func StartBuilderIdLogin(region string) (*BuilderIdSession, error) {
if region == "" {
region = "us-east-1"
}
oidcBase := fmt.Sprintf("https://oidc.%s.amazonaws.com", region)
startUrl := "https://view.awsapps.com/start"
scopes := []string{
"codewhisperer:completions",
"codewhisperer:analysis",
"codewhisperer:conversations",
"codewhisperer:transformations",
"codewhisperer:taskassist",
}
// Step 1: 注册 OIDC 客户端
regPayload := map[string]interface{}{
"clientName": "Kiro API Proxy",
"clientType": "public",
"scopes": scopes,
"grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
"issuerUrl": startUrl,
}
regBody, _ := json.Marshal(regPayload)
regReq, _ := http.NewRequest("POST", oidcBase+"/client/register", bytes.NewReader(regBody))
regReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
regResp, err := client.Do(regReq)
if err != nil {
return nil, fmt.Errorf("register client failed: %v", err)
}
defer regResp.Body.Close()
if regResp.StatusCode != 200 {
respBody, _ := io.ReadAll(regResp.Body)
return nil, fmt.Errorf("register client failed: %d %s", regResp.StatusCode, string(respBody))
}
var regResult struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
if err := json.NewDecoder(regResp.Body).Decode(&regResult); err != nil {
return nil, fmt.Errorf("parse register response failed: %v", err)
}
// Step 2: 发起设备授权
authPayload := map[string]string{
"clientId": regResult.ClientID,
"clientSecret": regResult.ClientSecret,
"startUrl": startUrl,
}
authBody, _ := json.Marshal(authPayload)
authReq, _ := http.NewRequest("POST", oidcBase+"/device_authorization", bytes.NewReader(authBody))
authReq.Header.Set("Content-Type", "application/json")
authResp, err := client.Do(authReq)
if err != nil {
return nil, fmt.Errorf("device authorization failed: %v", err)
}
defer authResp.Body.Close()
if authResp.StatusCode != 200 {
respBody, _ := io.ReadAll(authResp.Body)
return nil, fmt.Errorf("device authorization failed: %d %s", authResp.StatusCode, string(respBody))
}
var authResult struct {
DeviceCode string `json:"deviceCode"`
UserCode string `json:"userCode"`
VerificationUri string `json:"verificationUri"`
VerificationUriComplete string `json:"verificationUriComplete"`
Interval int `json:"interval"`
ExpiresIn int `json:"expiresIn"`
}
if err := json.NewDecoder(authResp.Body).Decode(&authResult); err != nil {
return nil, fmt.Errorf("parse auth response failed: %v", err)
}
if authResult.Interval == 0 {
authResult.Interval = 5
}
if authResult.ExpiresIn == 0 {
authResult.ExpiresIn = 600
}
verificationUri := authResult.VerificationUriComplete
if verificationUri == "" {
verificationUri = authResult.VerificationUri
}
session := &BuilderIdSession{
ID: GenerateAccountID(),
ClientID: regResult.ClientID,
ClientSecret: regResult.ClientSecret,
DeviceCode: authResult.DeviceCode,
UserCode: authResult.UserCode,
VerificationUri: verificationUri,
Interval: authResult.Interval,
ExpiresAt: time.Now().Add(time.Duration(authResult.ExpiresIn) * time.Second),
Region: region,
}
builderIdMu.Lock()
builderIdSessions[session.ID] = session
builderIdMu.Unlock()
// 清理过期会话
go cleanupExpiredBuilderIdSessions()
return session, nil
}
// PollBuilderIdAuth 轮询 Builder ID 授权状态
func PollBuilderIdAuth(sessionID string) (accessToken, refreshToken, clientID, clientSecret, region string, expiresIn int, status string, err error) {
builderIdMu.RLock()
session, exists := builderIdSessions[sessionID]
builderIdMu.RUnlock()
if !exists {
return "", "", "", "", "", 0, "", fmt.Errorf("session not found or expired")
}
if time.Now().After(session.ExpiresAt) {
builderIdMu.Lock()
delete(builderIdSessions, sessionID)
builderIdMu.Unlock()
return "", "", "", "", "", 0, "", fmt.Errorf("authorization expired")
}
oidcBase := fmt.Sprintf("https://oidc.%s.amazonaws.com", session.Region)
tokenPayload := map[string]string{
"clientId": session.ClientID,
"clientSecret": session.ClientSecret,
"grantType": "urn:ietf:params:oauth:grant-type:device_code",
"deviceCode": session.DeviceCode,
}
tokenBody, _ := json.Marshal(tokenPayload)
tokenReq, _ := http.NewRequest("POST", oidcBase+"/token", bytes.NewReader(tokenBody))
tokenReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 30 * time.Second}
tokenResp, err := client.Do(tokenReq)
if err != nil {
return "", "", "", "", "", 0, "", fmt.Errorf("token request failed: %v", err)
}
defer tokenResp.Body.Close()
if tokenResp.StatusCode == 200 {
var tokenResult struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresIn int `json:"expiresIn"`
}
if err := json.NewDecoder(tokenResp.Body).Decode(&tokenResult); err != nil {
return "", "", "", "", "", 0, "", fmt.Errorf("parse token response failed: %v", err)
}
// 清理会话
builderIdMu.Lock()
delete(builderIdSessions, sessionID)
builderIdMu.Unlock()
return tokenResult.AccessToken, tokenResult.RefreshToken, session.ClientID, session.ClientSecret, session.Region, tokenResult.ExpiresIn, "completed", nil
}
if tokenResp.StatusCode == 400 {
var errResult struct {
Error string `json:"error"`
}
json.NewDecoder(tokenResp.Body).Decode(&errResult)
switch errResult.Error {
case "authorization_pending":
return "", "", "", "", "", 0, "pending", nil
case "slow_down":
// 增加轮询间隔
builderIdMu.Lock()
if s, ok := builderIdSessions[sessionID]; ok {
s.Interval += 5
}
builderIdMu.Unlock()
return "", "", "", "", "", 0, "slow_down", nil
case "expired_token":
builderIdMu.Lock()
delete(builderIdSessions, sessionID)
builderIdMu.Unlock()
return "", "", "", "", "", 0, "", fmt.Errorf("device code expired")
case "access_denied":
builderIdMu.Lock()
delete(builderIdSessions, sessionID)
builderIdMu.Unlock()
return "", "", "", "", "", 0, "", fmt.Errorf("user denied authorization")
default:
return "", "", "", "", "", 0, "", fmt.Errorf("authorization error: %s", errResult.Error)
}
}
return "", "", "", "", "", 0, "", fmt.Errorf("unexpected response: %d", tokenResp.StatusCode)
}
// GetBuilderIdSession 获取会话信息
func GetBuilderIdSession(sessionID string) *BuilderIdSession {
builderIdMu.RLock()
defer builderIdMu.RUnlock()
return builderIdSessions[sessionID]
}
// cleanupExpiredBuilderIdSessions 清理过期会话
func cleanupExpiredBuilderIdSessions() {
builderIdMu.Lock()
defer builderIdMu.Unlock()
now := time.Now()
for id, session := range builderIdSessions {
if now.After(session.ExpiresAt) {
delete(builderIdSessions, id)
}
}
}

View File

@@ -842,6 +842,10 @@ func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
h.apiStartIamSso(w, r)
case path == "/auth/iam-sso/complete" && r.Method == "POST":
h.apiCompleteIamSso(w, r)
case path == "/auth/builderid/start" && r.Method == "POST":
h.apiStartBuilderIdLogin(w, r)
case path == "/auth/builderid/poll" && r.Method == "POST":
h.apiPollBuilderIdAuth(w, r)
case path == "/auth/sso-token" && r.Method == "POST":
h.apiImportSsoToken(w, r)
case path == "/auth/credentials" && r.Method == "POST":
@@ -1072,6 +1076,98 @@ func (h *Handler) apiCompleteIamSso(w http.ResponseWriter, r *http.Request) {
})
}
func (h *Handler) apiStartBuilderIdLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Region string `json:"region"`
}
json.NewDecoder(r.Body).Decode(&req)
session, err := auth.StartBuilderIdLogin(req.Region)
if err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"sessionId": session.ID,
"userCode": session.UserCode,
"verificationUri": session.VerificationUri,
"interval": session.Interval,
})
}
func (h *Handler) apiPollBuilderIdAuth(w http.ResponseWriter, r *http.Request) {
var req struct {
SessionID string `json:"sessionId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"})
return
}
accessToken, refreshToken, clientID, clientSecret, region, expiresIn, status, err := auth.PollBuilderIdAuth(req.SessionID)
if err != nil {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": err.Error(),
})
return
}
if status == "pending" || status == "slow_down" {
// 获取当前间隔
interval := 5
if session := auth.GetBuilderIdSession(req.SessionID); session != nil {
interval = session.Interval
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"completed": false,
"status": status,
"interval": interval,
})
return
}
// 授权完成,获取用户信息
email, _, _ := auth.GetUserInfo(accessToken)
// 创建账号
account := config.Account{
ID: auth.GenerateAccountID(),
Email: email,
AccessToken: accessToken,
RefreshToken: refreshToken,
ClientID: clientID,
ClientSecret: clientSecret,
AuthMethod: "idc",
Provider: "BuilderId",
Region: region,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
Enabled: true,
MachineId: config.GenerateMachineId(),
}
if err := config.AddAccount(account); err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
h.pool.Reload()
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"completed": true,
"account": map[string]interface{}{
"id": account.ID,
"email": account.Email,
},
})
}
func (h *Handler) apiImportSsoToken(w http.ResponseWriter, r *http.Request) {
var req struct {
BearerToken string `json:"bearerToken"`
@@ -1089,44 +1185,67 @@ func (h *Handler) apiImportSsoToken(w http.ResponseWriter, r *http.Request) {
return
}
accessToken, refreshToken, clientID, clientSecret, expiresIn, err := auth.ImportFromSsoToken(req.BearerToken, req.Region)
if err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
// 支持批量导入,按行分割
tokens := strings.Split(strings.TrimSpace(req.BearerToken), "\n")
var imported []map[string]interface{}
var errors []string
// 获取用户信息
email, _, _ := auth.GetUserInfo(accessToken)
for _, token := range tokens {
token = strings.TrimSpace(token)
if token == "" {
continue
}
// 创建账号
account := config.Account{
ID: auth.GenerateAccountID(),
Email: email,
AccessToken: accessToken,
RefreshToken: refreshToken,
ClientID: clientID,
ClientSecret: clientSecret,
AuthMethod: "idc",
Region: req.Region,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
Enabled: true,
MachineId: config.GenerateMachineId(),
}
accessToken, refreshToken, clientID, clientSecret, expiresIn, err := auth.ImportFromSsoToken(token, req.Region)
if err != nil {
errors = append(errors, err.Error())
continue
}
if err := config.AddAccount(account); err != nil {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
// 获取用户信息
email, _, _ := auth.GetUserInfo(accessToken)
// 创建账号
account := config.Account{
ID: auth.GenerateAccountID(),
Email: email,
AccessToken: accessToken,
RefreshToken: refreshToken,
ClientID: clientID,
ClientSecret: clientSecret,
AuthMethod: "idc",
Region: req.Region,
ExpiresAt: time.Now().Unix() + int64(expiresIn),
Enabled: true,
MachineId: config.GenerateMachineId(),
}
if err := config.AddAccount(account); err != nil {
errors = append(errors, err.Error())
continue
}
imported = append(imported, map[string]interface{}{
"id": account.ID,
"email": account.Email,
})
}
h.pool.Reload()
if len(imported) == 0 && len(errors) > 0 {
w.WriteHeader(500)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"error": strings.Join(errors, "; "),
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"account": map[string]interface{}{
"id": account.ID,
"email": account.Email,
},
"success": true,
"accounts": imported,
"errors": errors,
})
}

View File

@@ -169,9 +169,7 @@
<div class="card-header">
<span class="card-title">账号列表</span>
<div class="card-actions">
<button class="btn btn-secondary btn-sm" onclick="showModal('credentials')">导入凭证</button>
<button class="btn btn-secondary btn-sm" onclick="showModal('sso')">SSO</button>
<button class="btn btn-primary btn-sm" onclick="showModal('iam')">IAM</button>
<button class="btn btn-primary btn-sm" onclick="showModal('add')">添加账号</button>
</div>
</div>
<div id="accountsList"></div>
@@ -319,13 +317,13 @@
<div class="account-email">${a.email || a.id.substring(0,12)+'...'}</div>
<div class="account-meta">
${getSubBadge(a.subscriptionType)}
<span class="badge badge-info">${a.authMethod || '-'}</span>
<span class="badge badge-info">${formatAuthMethod(a.provider || a.authMethod)}</span>
${getStatusBadge(a)}
</div>
</div>
<div class="account-actions">
<button class="btn btn-sm btn-icon btn-secondary" onclick="refreshAccount('${a.id}')" title="刷新"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6"/><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/></svg></button>
<button class="btn btn-sm btn-icon btn-secondary" onclick="showDetail('${a.id}')" title="详情"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg></button>
<button class="btn btn-sm btn-icon btn-secondary" onclick="showDetail('${a.id}')" title="详情"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
<button class="btn btn-sm ${a.enabled ? 'btn-secondary' : 'btn-primary'}" onclick="toggleAccount('${a.id}',${!a.enabled})">${a.enabled ? '禁用' : '启用'}</button>
<button class="btn btn-sm btn-danger" onclick="deleteAccount('${a.id}')">删除</button>
</div>
@@ -355,6 +353,13 @@
return '<span class="badge badge-free">FREE</span>';
}
function formatAuthMethod(method) {
if (!method) return '-';
if (method === 'idc') return 'Enterprise';
if (method === 'social') return 'Social';
return method;
}
function getStatusBadge(a) {
if (!a.hasToken) return '<span class="badge badge-error">无Token</span>';
if (a.expiresAt && a.expiresAt < Date.now()/1000) return '<span class="badge badge-warning">已过期</span>';
@@ -391,7 +396,7 @@
<div class="detail-grid">
<div class="detail-item"><div class="detail-label">邮箱</div><div class="detail-value">${a.email || '-'}</div></div>
<div class="detail-item"><div class="detail-label">用户ID</div><div class="detail-value">${a.userId || '-'}</div></div>
<div class="detail-item"><div class="detail-label">认证方式</div><div class="detail-value">${a.authMethod || '-'}</div></div>
<div class="detail-item"><div class="detail-label">认证方式</div><div class="detail-value">${formatAuthMethod(a.provider || a.authMethod)}</div></div>
<div class="detail-item"><div class="detail-label">Region</div><div class="detail-value">${a.region || 'us-east-1'}</div></div>
</div>
</div>
@@ -537,33 +542,211 @@
const title = document.getElementById('modalTitle');
const body = document.getElementById('modalBody');
if (type === 'credentials') {
title.textContent = '导入凭证';
if (type === 'add') {
title.textContent = '添加账号';
body.innerHTML = `
<div style="display:flex;flex-direction:column;gap:12px">
<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal('builderid')" onmouseover="this.style.borderColor='#7c3aed'" onmouseout="this.style.borderColor='transparent'">
<div style="font-weight:600;margin-bottom:6px">AWS Builder ID</div>
<div style="font-size:13px;color:#64748b">通过 AWS Builder ID 授权登录添加个人账号</div>
</div>
<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal('iam')" onmouseover="this.style.borderColor='#7c3aed'" onmouseout="this.style.borderColor='transparent'">
<div style="font-weight:600;margin-bottom:6px">IAM Identity Center (企业 SSO) 登录</div>
<div style="font-size:13px;color:#64748b">通过 IAM Identity Center (企业 SSO) 授权添加企业账号</div>
</div>
<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal('sso')" onmouseover="this.style.borderColor='#7c3aed'" onmouseout="this.style.borderColor='transparent'">
<div style="font-weight:600;margin-bottom:6px">SSO Token</div>
<div style="font-size:13px;color:#64748b">通过浏览器 x-amz-sso_authn Token 添加账号</div>
</div>
<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal('local')" onmouseover="this.style.borderColor='#7c3aed'" onmouseout="this.style.borderColor='transparent'">
<div style="font-weight:600;margin-bottom:6px">Kiro 本地缓存</div>
<div style="font-size:13px;color:#64748b">通过 Kiro IDE 本地缓存文件添加账号</div>
</div>
<div class="card" style="margin:0;cursor:pointer;border:2px solid transparent;transition:border-color 0.2s" onclick="showModal('credentials')" onmouseover="this.style.borderColor='#7c3aed'" onmouseout="this.style.borderColor='transparent'">
<div style="font-weight:600;margin-bottom:6px">凭证 JSON</div>
<div style="font-size:13px;color:#64748b">通过 Kiro Account Manager 导出的凭证添加账号</div>
</div>
</div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">取消</button></div>`;
} else if (type === 'builderid') {
title.textContent = 'AWS Builder ID';
body.innerHTML = `
<p style="font-size:13px;color:#64748b;margin-bottom:16px">通过 AWS Builder ID 授权登录添加个人账号</p>
<div id="builderIdStep1">
<div class="form-group">
<label>Region</label>
<input type="text" id="builderIdRegion" value="us-east-1">
</div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal('add')">返回</button><button class="btn btn-primary" onclick="startBuilderIdLogin()">开始登录</button></div>
</div>
<div id="builderIdStep2" class="hidden">
<div class="message" style="background:#ede9fe;color:#7c3aed;text-align:center">
<p style="font-size:18px;font-weight:600;margin-bottom:8px" id="builderIdUserCode"></p>
<p style="font-size:12px">请在浏览器中输入上方验证码</p>
</div>
<div class="form-group" style="margin-top:16px">
<label>验证链接</label>
<div class="endpoint" style="margin-bottom:0"><span id="builderIdVerifyUrl" style="font-size:12px"></span></div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm btn-secondary" style="flex:1" onclick="window.open(document.getElementById('builderIdVerifyUrl').textContent,'_blank')">打开</button>
<button class="btn btn-sm btn-secondary" style="flex:1" onclick="navigator.clipboard.writeText(document.getElementById('builderIdVerifyUrl').textContent);alert('已复制')">复制</button>
</div>
</div>
<p id="builderIdStatus" style="color:#64748b;margin:16px 0;font-size:13px;text-align:center">等待授权中...</p>
<div class="modal-footer"><button class="btn btn-secondary" onclick="cancelBuilderIdLogin()">取消</button></div>
</div>`;
} else if (type === 'local') {
title.textContent = 'Kiro 本地缓存';
body.innerHTML = `
<p style="font-size:13px;color:#64748b;margin-bottom:16px">通过 Kiro IDE 本地缓存文件添加账号</p>
<div style="font-size:13px;color:#64748b;margin-bottom:16px;line-height:1.6">
<p style="margin-bottom:8px"><b>文件位置</b></p>
<p style="margin-bottom:4px">Windows: <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:11px">%USERPROFILE%\\.aws\\sso\\cache\\</code></p>
<p>macOS/Linux: <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:11px">~/.aws/sso/cache/</code></p>
</div>
<div class="form-group">
<label>登录渠道</label>
<select id="localProvider" onchange="updateLocalFields()">
<option value="BuilderId">AWS Builder ID</option>
<option value="Enterprise">IAM Identity Center (企业 SSO)</option>
<option value="Google">Google</option>
<option value="Github">GitHub</option>
</select>
</div>
<div class="form-group">
<label>kiro-auth-token.json <span style="font-weight:normal;color:#64748b;font-size:12px">*必填</span></label>
<div style="display:flex;gap:8px;align-items:stretch">
<textarea id="localTokenJson" placeholder='粘贴文件内容或上传文件' style="flex:1;min-height:80px;font-size:12px"></textarea>
<label class="btn btn-secondary" style="display:flex;align-items:center;cursor:pointer">
上传<input type="file" accept=".json" style="display:none" onchange="loadLocalFile(this,'localTokenJson')">
</label>
</div>
</div>
<div id="localClientGroup" class="form-group">
<label>{hash}.json <span style="font-weight:normal;color:#64748b;font-size:12px">*IdC 登录必填</span></label>
<div style="display:flex;gap:8px;align-items:stretch">
<textarea id="localClientJson" placeholder='粘贴文件内容或上传文件' style="flex:1;min-height:80px;font-size:12px"></textarea>
<label class="btn btn-secondary" style="display:flex;align-items:center;cursor:pointer">
上传<input type="file" accept=".json" style="display:none" onchange="loadLocalFile(this,'localClientJson')">
</label>
</div>
</div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal('add')">返回</button><button class="btn btn-primary" onclick="importLocalKiro()">添加</button></div>`;
} else if (type === 'credentials') {
title.textContent = '凭证 JSON';
body.innerHTML = `
<p style="font-size:13px;color:#64748b;margin-bottom:16px">通过 Kiro Account Manager 导出的凭证添加账号</p>
<div class="form-group"><label>凭证 JSON</label><textarea id="credJson" placeholder='{"refreshToken":"...","clientId":"...","clientSecret":"..."}'></textarea></div>
<div class="form-group"><label>认证方式</label><select id="credAuth"><option value="social">Social</option><option value="idc">IAM IdC</option></select></div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="importCredentials()">导入</button></div>`;
<div class="form-group">
<label>认证方式 <span style="font-weight:normal;color:#64748b;font-size:12px">*影响 Token 刷新方式</span></label>
<select id="credAuth">
<option value="social">Social (AWS Builder ID / Google / GitHub)</option>
<option value="idc">IAM Identity Center (企业 SSO)</option>
</select>
</div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal('add')">返回</button><button class="btn btn-primary" onclick="importCredentials()">添加</button></div>`;
} else if (type === 'sso') {
title.textContent = 'SSO Token';
body.innerHTML = `
<div class="form-group"><label>Bearer Token</label><textarea id="ssoToken" placeholder="从浏览器获取的 Bearer Token"></textarea></div>
<div style="font-size:13px;color:#64748b;margin-bottom:16px;line-height:1.6">
<p style="margin-bottom:8px"><b>如何获取 Token?</b></p>
<ol style="margin:0;padding-left:20px">
<li>在浏览器中访问并登录 <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">view.awsapps.com/start</code></li>
<li>按 F12 打开开发者工具 → Application → Cookies</li>
<li>找到并复制 <code style="background:#f1f5f9;padding:2px 6px;border-radius:4px">x-amz-sso_authn</code> 的值</li>
</ol>
</div>
<div class="form-group"><label>x-amz-sso_authn <span style="font-weight:normal;color:#64748b;font-size:12px">*支持批量导入,每行一个 Token</span></label><textarea id="ssoToken" placeholder="粘贴 x-amz-sso_authn 值" style="min-height:120px"></textarea></div>
<div class="form-group"><label>Region</label><input type="text" id="ssoRegion" value="us-east-1"></div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">取消</button><button class="btn btn-primary" onclick="importSsoToken()">导入</button></div>`;
<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal('add')">返回</button><button class="btn btn-primary" onclick="importSsoToken()">添加</button></div>`;
} else if (type === 'iam') {
title.textContent = 'IAM SSO 登录';
title.textContent = 'IAM Identity Center (企业 SSO) 登录';
body.innerHTML = `
<p style="font-size:13px;color:#64748b;margin-bottom:16px">通过 IAM Identity Center (企业 SSO) 授权登录添加账号</p>
<div class="form-group"><label>Start URL</label><input type="text" id="iamStartUrl" placeholder="https://xxx.awsapps.com/start"></div>
<div class="form-group"><label>Region</label><input type="text" id="iamRegion" value="us-east-1"></div>
<div id="iamStep2" class="hidden">
<div class="form-group">
<label>登录链接</label>
<div class="endpoint" style="margin-bottom:0"><span id="iamAuthUrl" style="font-size:11px"></span></div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm btn-secondary" style="flex:1" onclick="window.open(document.getElementById('iamAuthUrl').textContent,'_blank')">打开</button>
<button class="btn btn-sm btn-secondary" style="flex:1" onclick="navigator.clipboard.writeText(document.getElementById('iamAuthUrl').textContent);alert('已复制')">复制</button>
</div>
</div>
<p style="color:#16a34a;margin:12px 0;font-size:14px">请在浏览器中完成登录,然后粘贴回调 URL</p>
<div class="form-group"><label>回调 URL</label><input type="text" id="iamCallback" placeholder="http://127.0.0.1:xxx/?code=..."></div>
</div>
<div class="modal-footer"><button class="btn btn-secondary" onclick="closeModal()">取消</button><button class="btn btn-primary" id="iamBtn" onclick="startIamSso()">开始登录</button></div>`;
<div class="modal-footer"><button class="btn btn-secondary" onclick="showModal('add')">返回</button><button class="btn btn-primary" id="iamBtn" onclick="startIamSso()">开始登录</button></div>`;
}
modal.classList.add('active');
}
function closeModal() { document.getElementById('addModal').classList.remove('active'); iamSession = ''; }
function closeModal() {
document.getElementById('addModal').classList.remove('active');
iamSession = '';
if (builderIdPollTimer) { clearTimeout(builderIdPollTimer); builderIdPollTimer = null; }
builderIdSession = '';
}
function loadLocalFile(input, targetId) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => { document.getElementById(targetId).value = e.target.result; };
reader.readAsText(file);
}
function updateLocalFields() {
const provider = document.getElementById('localProvider').value;
const clientGroup = document.getElementById('localClientGroup');
if (provider === 'Google' || provider === 'Github') {
clientGroup.style.display = 'none';
} else {
clientGroup.style.display = 'block';
}
}
async function importLocalKiro() {
const provider = document.getElementById('localProvider').value;
const tokenJson = document.getElementById('localTokenJson').value.trim();
const clientJson = document.getElementById('localClientJson').value.trim();
const isSocial = provider === 'Google' || provider === 'Github';
if (!tokenJson) { alert('请提供 kiro-auth-token.json 内容'); return; }
let tokenData, clientData;
try {
tokenData = JSON.parse(tokenJson);
} catch { alert('kiro-auth-token.json 格式错误'); return; }
if (!tokenData.refreshToken) { alert('缺少 refreshToken'); return; }
if (!isSocial) {
if (!clientJson) { alert('IdC 登录需要提供 {hash}.json 内容'); return; }
try {
clientData = JSON.parse(clientJson);
} catch { alert('{hash}.json 格式错误'); return; }
if (!clientData.clientId || !clientData.clientSecret) { alert('缺少 clientId 或 clientSecret'); return; }
}
const payload = {
refreshToken: tokenData.refreshToken,
accessToken: tokenData.accessToken || '',
clientId: clientData?.clientId || '',
clientSecret: clientData?.clientSecret || '',
authMethod: isSocial ? 'social' : 'idc',
provider: provider
};
const res = await fetch('/admin/api/auth/credentials', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify(payload)
});
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert('导入成功: ' + (d.account?.email || d.account?.id)); }
else alert('失败: ' + d.error);
}
async function importCredentials() {
try {
@@ -585,8 +768,64 @@
body: JSON.stringify({ bearerToken: document.getElementById('ssoToken').value, region: document.getElementById('ssoRegion').value })
});
const d = await res.json();
if (d.success) { closeModal(); loadAccounts(); loadStats(); alert('导入成功: ' + (d.account?.email || d.account?.id)); }
else alert('失败: ' + d.error);
if (d.success) {
closeModal(); loadAccounts(); loadStats();
const count = d.accounts?.length || 0;
const errCount = d.errors?.length || 0;
let msg = '成功添加 ' + count + ' 个账号';
if (errCount > 0) msg += '' + errCount + ' 个失败';
alert(msg);
} else alert('失败: ' + d.error);
}
let builderIdSession = '';
let builderIdPollTimer = null;
async function startBuilderIdLogin() {
const region = document.getElementById('builderIdRegion').value || 'us-east-1';
const res = await fetch('/admin/api/auth/builderid/start', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ region })
});
const d = await res.json();
if (d.sessionId) {
builderIdSession = d.sessionId;
document.getElementById('builderIdUserCode').textContent = d.userCode;
document.getElementById('builderIdVerifyUrl').textContent = d.verificationUri;
document.getElementById('builderIdStep1').classList.add('hidden');
document.getElementById('builderIdStep2').classList.remove('hidden');
// 开始轮询
pollBuilderIdAuth(d.interval || 5);
} else alert('失败: ' + d.error);
}
function pollBuilderIdAuth(interval) {
builderIdPollTimer = setTimeout(async () => {
const res = await fetch('/admin/api/auth/builderid/poll', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ sessionId: builderIdSession })
});
const d = await res.json();
if (d.completed) {
closeModal(); loadAccounts(); loadStats();
alert('登录成功: ' + (d.account?.email || d.account?.id));
} else if (d.success && !d.completed) {
document.getElementById('builderIdStatus').textContent = '等待授权中...';
pollBuilderIdAuth(d.interval || interval);
} else {
alert('失败: ' + d.error);
cancelBuilderIdLogin();
}
}, interval * 1000);
}
function cancelBuilderIdLogin() {
if (builderIdPollTimer) {
clearTimeout(builderIdPollTimer);
builderIdPollTimer = null;
}
builderIdSession = '';
showModal('add');
}
let iamSession = '';
@@ -607,7 +846,7 @@
const d = await res.json();
if (d.authorizeUrl) {
iamSession = d.sessionId;
window.open(d.authorizeUrl, '_blank');
document.getElementById('iamAuthUrl').textContent = d.authorizeUrl;
document.getElementById('iamStep2').classList.remove('hidden');
document.getElementById('iamBtn').textContent = '完成登录';
} else alert('失败: ' + d.error);