Skip to content

Commit 7a4e65a

Browse files
author
QTom
committed
feat: 导入账号时 best-effort 从 id_token 提取用户信息
提取 DecodeIDToken(跳过过期校验)供导入场景使用, ParseIDToken 复用它并保留原有过期检查行为。 导入 OpenAI/Sora OAuth 账号时自动补充缺失的 email、 plan_type、chatgpt_account_id 等字段,不覆盖已有值。
1 parent a582aa8 commit 7a4e65a

2 files changed

Lines changed: 74 additions & 7 deletions

File tree

backend/internal/handler/admin/account_data.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import (
88
"strings"
99
"time"
1010

11+
"log/slog"
12+
13+
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
1114
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
1215
"github.com/Wei-Shaw/sub2api/internal/service"
1316
"github.com/gin-gonic/gin"
@@ -292,6 +295,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
292295
}
293296
}
294297

298+
enrichCredentialsFromIDToken(&item)
299+
295300
accountInput := &service.CreateAccountInput{
296301
Name: item.Name,
297302
Notes: item.Notes,
@@ -535,6 +540,57 @@ func defaultProxyName(name string) string {
535540
return name
536541
}
537542

543+
// enrichCredentialsFromIDToken performs best-effort extraction of user info fields
544+
// (email, plan_type, chatgpt_account_id, etc.) from id_token in credentials.
545+
// Only applies to OpenAI/Sora OAuth accounts. Skips expired token errors silently.
546+
// Existing credential values are never overwritten — only missing fields are filled.
547+
func enrichCredentialsFromIDToken(item *DataAccount) {
548+
if item.Credentials == nil {
549+
return
550+
}
551+
// Only enrich OpenAI/Sora OAuth accounts
552+
platform := strings.ToLower(strings.TrimSpace(item.Platform))
553+
if platform != service.PlatformOpenAI && platform != service.PlatformSora {
554+
return
555+
}
556+
if strings.ToLower(strings.TrimSpace(item.Type)) != service.AccountTypeOAuth {
557+
return
558+
}
559+
560+
idToken, _ := item.Credentials["id_token"].(string)
561+
if strings.TrimSpace(idToken) == "" {
562+
return
563+
}
564+
565+
// DecodeIDToken skips expiry validation — safe for imported data
566+
claims, err := openai.DecodeIDToken(idToken)
567+
if err != nil {
568+
slog.Debug("import_enrich_id_token_decode_failed", "account", item.Name, "error", err)
569+
return
570+
}
571+
572+
userInfo := claims.GetUserInfo()
573+
if userInfo == nil {
574+
return
575+
}
576+
577+
// Fill missing fields only (never overwrite existing values)
578+
setIfMissing := func(key, value string) {
579+
if value == "" {
580+
return
581+
}
582+
if existing, _ := item.Credentials[key].(string); existing == "" {
583+
item.Credentials[key] = value
584+
}
585+
}
586+
587+
setIfMissing("email", userInfo.Email)
588+
setIfMissing("plan_type", userInfo.PlanType)
589+
setIfMissing("chatgpt_account_id", userInfo.ChatGPTAccountID)
590+
setIfMissing("chatgpt_user_id", userInfo.ChatGPTUserID)
591+
setIfMissing("organization_id", userInfo.OrganizationID)
592+
}
593+
538594
func normalizeProxyStatus(status string) string {
539595
normalized := strings.TrimSpace(strings.ToLower(status))
540596
switch normalized {

backend/internal/pkg/openai/oauth.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -326,12 +326,9 @@ func (r *RefreshTokenRequest) ToFormData() string {
326326
return params.Encode()
327327
}
328328

329-
// ParseIDToken parses the ID Token JWT and extracts claims.
330-
// 注意:当前仅解码 payload 并校验 exp,未验证 JWT 签名。
331-
// 生产环境如需用 ID Token 做授权决策,应通过 OpenAI 的 JWKS 端点验证签名:
332-
//
333-
// https://auth.openai.com/.well-known/jwks.json
334-
func ParseIDToken(idToken string) (*IDTokenClaims, error) {
329+
// DecodeIDToken decodes the ID Token JWT payload without validating expiration.
330+
// Use this for best-effort extraction (e.g., during data import) where the token may be expired.
331+
func DecodeIDToken(idToken string) (*IDTokenClaims, error) {
335332
parts := strings.Split(idToken, ".")
336333
if len(parts) != 3 {
337334
return nil, fmt.Errorf("invalid JWT format: expected 3 parts, got %d", len(parts))
@@ -361,14 +358,28 @@ func ParseIDToken(idToken string) (*IDTokenClaims, error) {
361358
return nil, fmt.Errorf("failed to parse JWT claims: %w", err)
362359
}
363360

361+
return &claims, nil
362+
}
363+
364+
// ParseIDToken parses the ID Token JWT and extracts claims.
365+
// 注意:当前仅解码 payload 并校验 exp,未验证 JWT 签名。
366+
// 生产环境如需用 ID Token 做授权决策,应通过 OpenAI 的 JWKS 端点验证签名:
367+
//
368+
// https://auth.openai.com/.well-known/jwks.json
369+
func ParseIDToken(idToken string) (*IDTokenClaims, error) {
370+
claims, err := DecodeIDToken(idToken)
371+
if err != nil {
372+
return nil, err
373+
}
374+
364375
// 校验 ID Token 是否已过期(允许 2 分钟时钟偏差,防止因服务器时钟略有差异误判刚颁发的令牌)
365376
const clockSkewTolerance = 120 // 秒
366377
now := time.Now().Unix()
367378
if claims.Exp > 0 && now > claims.Exp+clockSkewTolerance {
368379
return nil, fmt.Errorf("id_token has expired (exp: %d, now: %d, skew_tolerance: %ds)", claims.Exp, now, clockSkewTolerance)
369380
}
370381

371-
return &claims, nil
382+
return claims, nil
372383
}
373384

374385
// UserInfo represents user information extracted from ID Token claims.

0 commit comments

Comments
 (0)