diff --git a/README.md b/README.md index 17617e1..84b4741 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # qdmp-server-sdk -[千岛小程序开放平台 OpenAPI](https://open.qiandao.com/docs/api/auth-token) 官方 Server SDK,[Node.js](#nodejs)、[Java](#java)、[Go](#go) 三端实现,统一 token 生命周期管理 + 类型安全的业务接口封装。 +[千岛小程序开放平台 OpenAPI](https://open.qiandao.com/docs/api/auth-token) 官方 Server SDK,[Node.js](#nodejs)、[Java](#java)、[Go](#go) 三端实现,类型安全的业务接口封装 + 应用凭证自动缓存。 ## Node.js @@ -16,24 +16,31 @@ const qdmp = new QdmpClient({ appSecret: process.env.QDMP_APP_SECRET!, }) -// 小程序端 wx.login() 拿到 code 后,后端用它换用户 session(不缓存,自己持久化) -const session = await qdmp.auth.code2Session(code) +// 前端 qd.login() 拿到的一次性授权码传到服务端,换用户授权凭证 +const credential = await qdmp.auth.getUserAccessToken(code) +// => { accessToken, refreshToken, expiresAt, openId },自己按 openId 存起来 -// 用户级 accessToken 调业务接口,ctx.accessToken 必填 -const me = await qdmp.user.me({ accessToken: session.accessToken }) +// 调业务接口:accessToken 每次显式传进去 +const me = await qdmp.user.me({ accessToken: credential.accessToken }) +await qdmp.mark.add({ accessToken: credential.accessToken }, { spuId: '123', rating: { value: 5 } }) + +// accessToken 过期了,自己拿 refreshToken 换新的(SDK 不代管,也不自动重试) +const fresh = await qdmp.auth.refreshToken(credential.refreshToken) +await qdmp.user.me({ accessToken: fresh.accessToken }) try { - await qdmp.mark.add({ accessToken: session.accessToken }, { spuId: '123', rating: { value: 5 } }) + await qdmp.wishspu.list({ accessToken: fresh.accessToken }, { offset: '0', limit: '20' }) } catch (err) { if (err instanceof QdmpApiError) console.error(err.code, err.message, err.httpStatus) else if (err instanceof QdmpValidationError) console.error('本地参数错误:', err.message) } -// app 级 token 只在需要时显式换取(例如可选的 app-token 兜底调用场景) -const appToken = await qdmp.auth.getAccessToken() +// 应用凭证,开发调试/后台任务才需要 +const appCredential = await qdmp.auth.getAppAccessToken() +// => { accessToken, expiresAt, refreshToken, openId }(openId 恒为空串) ``` -失败统一抛 `QdmpApiError`(业务失败)或 `QdmpValidationError`(本地参数校验失败,不发请求)。 +失败抛三类错误:`QdmpApiError`(业务失败,响应体 `code` 非 `'0'`)、`QdmpValidationError`(本地参数校验失败,不发请求)、`QdmpTransportError`(传输层问题,如收到重定向、响应体超过 10MB)。底层 `fetch` 本身的网络错误原样透出。 ## Java @@ -50,20 +57,21 @@ QdmpClient qdmp = new QdmpClient( .appSecret(System.getenv("QDMP_APP_SECRET")) .build()); -// 用 code 换用户 session(SDK 不缓存,自行持久化) -Code2SessionResult session = qdmp.auth().code2Session(code); +UserAccessTokenResult credential = qdmp.auth().getUserAccessToken(code); // QdmpContext.of(accessToken) 是唯一入口,空/非法 token 在构造期就失败 -QdmpContext ctx = QdmpContext.of(session.getAccessToken()); +QdmpContext ctx = QdmpContext.of(credential.getAccessToken()); UserMe200ResponseAllOfData me = qdmp.user().me(ctx); - qdmp.mark().add(ctx, new MarkAddRequest().spuId("123")); -// app 级 token 显式换取 -String appToken = qdmp.auth().getAccessToken(); +// 过期了自己换,SDK 不代管 +RefreshTokenResult fresh = qdmp.auth().refreshToken(credential.getRefreshToken()); +qdmp.user().me(QdmpContext.of(fresh.getAccessToken())); + +AppAccessTokenResult appCredential = qdmp.auth().getAppAccessToken(); ``` -业务失败抛 `QdmpApiError`,传输层异常抛 `QdmpTransportException`,本地参数校验失败抛 `QdmpValidationError`(均在 `io.github.echotechfe.qdmp.errors` 包下)。 +业务失败抛 `QdmpApiError`,传输层异常抛 `QdmpTransportException`,`auth.*` 的参数校验失败抛 `QdmpValidationError`(均在 `io.github.echotechfe.qdmp.errors` 包下)。`QdmpContext.of()` 的 token 校验按 Java 惯例抛 `NullPointerException`(null)/ `IllegalArgumentException`(空白或含不能进 HTTP 头的字符)。 ## Go @@ -77,36 +85,48 @@ client, err := qdmp.NewClient(qdmp.ClientOptions{ AppSecret: os.Getenv("QDMP_APP_SECRET"), }) -// 用 code 换用户 session(SDK 不缓存,自行持久化) -session, err := client.Auth.Code2Session(ctx, code) +credential, err := client.Auth.GetUserAccessToken(ctx, code) // WithAccessToken 派生一个绑定了该 token 的子 client,拿不到 token 就构造不出可用的调用入口 -asUser := client.WithAccessToken(session.AccessToken) +asUser := client.WithAccessToken(credential.AccessToken) me, err := asUser.User.Me(ctx) _, err = asUser.Mark.Add(ctx, generated.MarkAddJSONBody{SpuId: "123"}) -// app 级 token 只在根 client 上,显式换取 -appToken, err := client.Auth.GetAccessToken(ctx) +// 过期了自己换,SDK 不代管 +fresh, err := client.Auth.RefreshToken(ctx, credential.RefreshToken) +me, err = client.WithAccessToken(fresh.AccessToken).User.Me(ctx) + +appCredential, err := client.Auth.GetAppAccessToken(ctx) ``` -业务失败返回 `*qdmp.QdmpApiError`,缺 token 返回哨兵错误 `qdmp.ErrAccessTokenRequired`(`errors.Is` 判断),均是普通 `error`,不 panic。 +业务失败返回 `*qdmp.QdmpApiError`,缺凭证返回哨兵错误 `qdmp.ErrAccessTokenRequired`(`errors.Is` 判断),均是普通 `error`,不 panic。 -## Token 生命周期模型 +## 凭证生命周期模型 -- **App 级 token(`CLIENT_CREDENTIALS`)**:SDK 自动缓存 + 到期前 300 秒刷新 + 单飞锁防并发重复换取,持久化走可插拔的 `TokenStore`(默认内存实现,可换 Redis 等共享存储)。调用方只需要 `auth.getAccessToken()`。 -- **用户级 token(`AUTHORIZATION_CODE` 换来的)**:SDK 不缓存,每次调用由调用方显式传入——一个进程要服务海量终端用户,SDK 无法替调用方判断"这次调用是哪个用户",这只有调用方自己的 session/DB 知道。 -- SDK 不做业务请求的自动重试/401 恢复:用户级 token 由调用方持有,SDK 直接把 `QdmpApiError`(`httpStatus=401`)透传给调用方处理。 -- 判断调用是否成功统一看响应体 `code === '0'`,不看 HTTP 状态码——实测 `refreshToken` 过期是 HTTP 200 + `code:10008`,access-token 失效是 HTTP 401 + `code:10005`。 +- **用户授权凭证由调用方自己管**:SDK 不缓存、不代管、不自动续期。每次业务调用把 accessToken 显式传进去。 + 一个进程要服务海量终端用户,哪次调用属于哪个用户、凭证该存到哪、什么时候该续期,只有你自己知道; + 你也可以完全不用 `getUserAccessToken`,自己实现拿凭证那一步,SDK 照样能用。 +- **续期**:`auth.refreshToken(refreshToken)` 换一个新的 accessToken,一次性调用,SDK 不缓存结果。 + `/auth/v1/refresh` 只返回新的 accessToken 和 expiresAt,**refreshToken 保持不变**。 + 它本身失效时返回 HTTP 200 + `10007`/`10008`,此时凭证已无法挽救,需要重新走一次「拿授权码 → 换凭证」。 +- **SDK 不做任何自动重试**:业务调用撞上 HTTP 401 + `10005`/`10006`(access_token 失效/过期)时, + 直接把 `QdmpApiError` 抛/返回给你,由你决定是否续期后重试。 +- **应用凭证**:`getAppAccessToken()` 自动缓存 + 到期前 300 秒重新换取 + 单飞锁防并发重复换取, + 持久化走可插拔的 `TokenStore`(默认内存实现,多实例部署可换 Redis 等共享存储)。 + 它返回的是完整凭证 `{accessToken, expiresAt, refreshToken, openId}`——应用凭证响应里同样带 refreshToken, + 只是 `openId` 恒为空串。SDK 自己不拿它续期(到期直接重换),但如实交给你,要不要自己续期由你决定。 +- **成功判定只看响应体 `code === '0'`,不看 HTTP 状态码**——refreshToken 过期是 HTTP 200 + `10008`, + access_token 失效才是 HTTP 401 + `10006`。 -## 各分组 token 要求 +## 各分组凭证要求 | 分组 | 要求 | |---|---| -| `auth.token` / `auth.refresh` | 不需要 token(用 appId/appSecret) | -| `user.me` / `mark.*` / `wishspu.*` | 必须传用户级 accessToken,缺失本地直接报错,不发请求 | -| `island.*` / `spu.*` / `tag.*` / `genai.*` | 默认必须传用户级 accessToken;无证据表明 app 级 token 能安全调用,v1 不做默认 fallback | +| `auth.*` | 不需要凭证(用 appId/appSecret 或 refreshToken) | +| `user.me` / `mark.*` / `wishspu.*` | 必须是用户授权凭证,缺失时本地直接报错,不发请求 | +| `island.*` / `spu.*` / `tag.*` / `genai.*` | 应用凭证也实测调通过,但 SDK 不做静默 fallback,传哪种由调用方决定 | -上述所有分组都可以显式把 `auth.getAccessToken()` 换出的 app 级 token 当作 accessToken 传入,但 SDK 不会静默做这个 fallback。`x-echo-qdmp-version` 头只在 `standard` 认证方案下发送,`genai` 分组用另一对头,`auth` 分组不需要——三端都从 `shared/generated/route-meta.json` 读取每个 operation 的 `authScheme`/`tokenRequired`。 +`x-echo-qdmp-version` 头只在 `standard` 鉴权方案下发送,`genai` 分组用 `x-openapi-access-token` + `x-openapi-app-id` 另一对头,`auth` 分组不需要——三端都从 `shared/generated/route-meta.json` 读取每个 operation 的 `authScheme`/`tokenRequired`。 ## 底层依赖 diff --git a/go/auth.go b/go/auth.go index 26780cc..e2d5e5f 100644 --- a/go/auth.go +++ b/go/auth.go @@ -17,10 +17,10 @@ import ( // token is proactively refreshed. const tokenRefreshBufferSeconds = 300 -// Code2SessionResult is the one-shot AUTHORIZATION_CODE exchange result. -// The SDK never caches this — the caller's own session/DB persists it, +// UserAccessTokenResult is the one-shot AUTHORIZATION_CODE exchange result. +// The SDK never caches this — the caller's own store/DB persists it, // scoped to whichever end-user the code belonged to. -type Code2SessionResult struct { +type UserAccessTokenResult struct { AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` // ExpiresAt is the absolute Unix-seconds expiry timestamp, confirmed by @@ -32,6 +32,29 @@ type Code2SessionResult struct { OpenID string `json:"openId"` } +// AppAccessTokenResult is the CLIENT_CREDENTIALS ("应用凭证") exchange result. +// +// Real traffic capture confirms this grant type returns the very same data +// shape as the user-level one — {accessToken, expiresAt, refreshToken, +// openId} — with a genuinely non-empty refreshToken and an empty openId +// (an app-level token belongs to no user). All four fields are handed back +// verbatim so the caller can see the real expiry and decide for itself +// whether to use the refreshToken; the SDK's own renewal strategy for this +// credential is unchanged (re-exchange via CLIENT_CREDENTIALS ahead of +// expiry, never via refreshToken). +// +// Validation is deliberately strict only on accessToken/expiresAt: openId +// is empty by design here, and refreshToken is passed through as received +// without being required. +type AppAccessTokenResult struct { + AccessToken string `json:"accessToken"` + // ExpiresAt is the absolute Unix-seconds expiry timestamp as a string, + // matching the wire shape (see UserAccessTokenResult.ExpiresAt). + ExpiresAt string `json:"expiresAt"` + RefreshToken string `json:"refreshToken"` + OpenID string `json:"openId"` +} + // RefreshTokenResult is the one-shot refreshToken exchange result. Also // never cached by the SDK. type RefreshTokenResult struct { @@ -43,22 +66,48 @@ type RefreshTokenResult struct { // fmt.Println(result), fmt.Printf("%v"/"%+v", result), or most structured // loggers that fall back to %v/%+v) never leaks the raw AccessToken/ // RefreshToken values. This is a value-receiver method, so it is also picked -// up for *Code2SessionResult (a pointer's method set includes all +// up for *UserAccessTokenResult (a pointer's method set includes all // value-receiver methods of the pointed-to type). // // This must never affect json.Marshal(result): encoding/json only consults // the json.Marshaler interface (MarshalJSON), not fmt.Stringer, so callers // that persist the real values via JSON — the entire point of -// Code2Session/RefreshToken existing — are unaffected. Direct field access +// GetUserAccessToken/RefreshToken existing — are unaffected. Direct field access // (result.AccessToken) is likewise untouched. -func (r Code2SessionResult) String() string { +func (r UserAccessTokenResult) String() string { return fmt.Sprintf( - "Code2SessionResult{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OpenID:%q}", + "UserAccessTokenResult{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OpenID:%q}", "[REDACTED]", "[REDACTED]", r.ExpiresAt, r.OpenID, ) } -// String implements fmt.Stringer — see Code2SessionResult.String for why. +// String implements fmt.Stringer — see UserAccessTokenResult.String for why. +func (r AppAccessTokenResult) String() string { + return fmt.Sprintf( + "AppAccessTokenResult{AccessToken:%q, ExpiresAt:%q, RefreshToken:%q, OpenID:%q}", + "[REDACTED]", r.ExpiresAt, "[REDACTED]", r.OpenID, + ) +} + +// GoString implements fmt.GoStringer — see UserAccessTokenResult.GoString for why. +func (r AppAccessTokenResult) GoString() string { + return fmt.Sprintf( + "qdmp.AppAccessTokenResult{AccessToken:%q, ExpiresAt:%q, RefreshToken:%q, OpenID:%q}", + "[REDACTED]", r.ExpiresAt, "[REDACTED]", r.OpenID, + ) +} + +// LogValue implements slog.LogValuer — see UserAccessTokenResult.LogValue for why. +func (r AppAccessTokenResult) LogValue() slog.Value { + return slog.GroupValue( + slog.String("AccessToken", "[REDACTED]"), + slog.String("ExpiresAt", r.ExpiresAt), + slog.String("RefreshToken", "[REDACTED]"), + slog.String("OpenID", r.OpenID), + ) +} + +// String implements fmt.Stringer — see UserAccessTokenResult.String for why. func (r RefreshTokenResult) String() string { return fmt.Sprintf( "RefreshTokenResult{AccessToken:%q, ExpiresAt:%q}", @@ -71,14 +120,14 @@ func (r RefreshTokenResult) String() string { // print every exported field (including the raw AccessToken/RefreshToken) // even with String() defined above. GoStringer is the only way to also // redact under %#v. -func (r Code2SessionResult) GoString() string { +func (r UserAccessTokenResult) GoString() string { return fmt.Sprintf( - "qdmp.Code2SessionResult{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OpenID:%q}", + "qdmp.UserAccessTokenResult{AccessToken:%q, RefreshToken:%q, ExpiresAt:%q, OpenID:%q}", "[REDACTED]", "[REDACTED]", r.ExpiresAt, r.OpenID, ) } -// GoString implements fmt.GoStringer — see Code2SessionResult.GoString for why. +// GoString implements fmt.GoStringer — see UserAccessTokenResult.GoString for why. func (r RefreshTokenResult) GoString() string { return fmt.Sprintf( "qdmp.RefreshTokenResult{AccessToken:%q, ExpiresAt:%q}", @@ -93,7 +142,7 @@ func (r RefreshTokenResult) GoString() string { // already redacting the fmt-based paths above. LogValuer is slog's own // redaction hook; it has no effect on json.Marshal (persistence stays // exactly as before). -func (r Code2SessionResult) LogValue() slog.Value { +func (r UserAccessTokenResult) LogValue() slog.Value { return slog.GroupValue( slog.String("AccessToken", "[REDACTED]"), slog.String("RefreshToken", "[REDACTED]"), @@ -102,7 +151,7 @@ func (r Code2SessionResult) LogValue() slog.Value { ) } -// LogValue implements slog.LogValuer — see Code2SessionResult.LogValue for why. +// LogValue implements slog.LogValuer — see UserAccessTokenResult.LogValue for why. func (r RefreshTokenResult) LogValue() slog.Value { return slog.GroupValue( slog.String("AccessToken", "[REDACTED]"), @@ -111,22 +160,45 @@ func (r RefreshTokenResult) LogValue() slog.Value { } // authInflight represents one in-flight app-level token exchange that -// concurrent GetAccessToken callers can wait on instead of each starting -// their own HTTP call (single-flight). +// concurrent GetAppAccessToken callers can wait on instead of each starting +// their own HTTP call (single-flight). The result is held by value so every +// waiter gets its own independent *AppAccessTokenResult copy. type authInflight struct { - done chan struct{} - token string - err error + done chan struct{} + result AppAccessTokenResult + err error +} + +// value converts one in-flight outcome into this caller's own result. +func (f *authInflight) value() (*AppAccessTokenResult, error) { + if f.err != nil { + return nil, f.err + } + result := f.result + return &result, nil +} + +// appResultFromEntry builds the caller-facing app credential from a cached +// TokenEntry. Both the cache-hit path and the fresh-exchange path go through +// this one function, so a caller can never tell the two apart by looking at +// which fields are populated. +func appResultFromEntry(entry TokenEntry) AppAccessTokenResult { + return AppAccessTokenResult{ + AccessToken: entry.AccessToken, + ExpiresAt: strconv.FormatInt(entry.ExpiresAt, 10), + RefreshToken: entry.RefreshToken, + OpenID: entry.OpenID, + } } // AuthService implements the qdmp OAuth2-flavored auth endpoints. // -// GetAccessToken manages the app-level (CLIENT_CREDENTIALS) token: cached +// GetAppAccessToken manages the app-level (CLIENT_CREDENTIALS) token: cached // in TokenStore, refreshed automatically ahead of expiry, with concurrent // callers collapsed into a single outbound HTTP exchange (single-flight) to // avoid a thundering herd against the real qdmp gateway. // -// Code2Session and RefreshToken are one-shot exchanges the SDK never +// GetUserAccessToken and RefreshToken are one-shot exchanges the SDK never // caches — user-level tokens are the calling application's responsibility // to persist, since a single server process serves many end users and the // SDK has no way to know which user a given call belongs to. @@ -138,26 +210,28 @@ type AuthService struct { inflight *authInflight } -// GetAccessToken returns a valid app-level access token, using the cached -// value if it is not within tokenRefreshBufferSeconds of expiry, otherwise -// exchanging for a new one (collapsing concurrent callers via single-flight). -func (a *AuthService) GetAccessToken(ctx context.Context) (string, error) { - if token, ok := a.cachedValid(); ok { - return token, nil +// GetAppAccessToken returns a valid app-level ("应用凭证") credential, using +// the cached value if it is not within tokenRefreshBufferSeconds of expiry, +// otherwise exchanging for a new one (collapsing concurrent callers via +// single-flight). +func (a *AuthService) GetAppAccessToken(ctx context.Context) (*AppAccessTokenResult, error) { + if result, ok := a.cachedValid(); ok { + return result, nil } return a.refreshShared(ctx) } -func (a *AuthService) cachedValid() (string, bool) { +func (a *AuthService) cachedValid() (*AppAccessTokenResult, bool) { entry, ok := a.store.Get() if !ok { - return "", false + return nil, false } now := time.Now().Unix() if entry.ExpiresAt-tokenRefreshBufferSeconds <= now { - return "", false + return nil, false } - return entry.AccessToken, true + result := appResultFromEntry(entry) + return &result, true } // refreshShared performs the single-flight-guarded app-level token @@ -173,16 +247,16 @@ func (a *AuthService) cachedValid() (string, bool) { // the http.Client's own timeout — see NewClient) so that one caller giving // up does not fail the shared result for every other concurrent waiter, and // so the leader's own cancellation can't get attributed to healthy waiters. -func (a *AuthService) refreshShared(ctx context.Context) (string, error) { +func (a *AuthService) refreshShared(ctx context.Context) (*AppAccessTokenResult, error) { a.mu.Lock() if a.inflight != nil { existing := a.inflight a.mu.Unlock() select { case <-existing.done: - return existing.token, existing.err + return existing.value() case <-ctx.Done(): - return "", ctx.Err() + return nil, ctx.Err() } } @@ -190,9 +264,9 @@ func (a *AuthService) refreshShared(ctx context.Context) (string, error) { // completed a refresh (and cleared a.inflight) between our caller's // lock-free cachedValid() check and this point, in which case starting // a brand new exchange here would be a redundant, avoidable request. - if token, ok := a.cachedValid(); ok { + if result, ok := a.cachedValid(); ok { a.mu.Unlock() - return token, nil + return result, nil } inflight := &authInflight{done: make(chan struct{})} @@ -200,26 +274,28 @@ func (a *AuthService) refreshShared(ctx context.Context) (string, error) { a.mu.Unlock() go func() { - token, err := a.exchangeClientCredentials(context.Background()) + result, err := a.exchangeClientCredentials(context.Background()) a.mu.Lock() a.inflight = nil a.mu.Unlock() - inflight.token = token + if result != nil { + inflight.result = *result + } inflight.err = err close(inflight.done) }() select { case <-inflight.done: - return inflight.token, inflight.err + return inflight.value() case <-ctx.Done(): - return "", ctx.Err() + return nil, ctx.Err() } } -func (a *AuthService) exchangeClientCredentials(ctx context.Context) (string, error) { +func (a *AuthService) exchangeClientCredentials(ctx context.Context) (*AppAccessTokenResult, error) { data, err := a.client.doRequest(ctx, requestParams{ method: http.MethodPost, path: "/auth/v1/token", @@ -231,24 +307,26 @@ func (a *AuthService) exchangeClientCredentials(ctx context.Context) (string, er }, }) if err != nil { - return "", err + return nil, err } - var result struct { - AccessToken string `json:"accessToken"` - ExpiresAt string `json:"expiresAt"` - } + // Only accessToken/expiresAt are validated: an app-level credential's + // openId is an empty string by design (it belongs to no user), and its + // refreshToken — non-empty in practice — is passed through as received + // rather than being required, since the SDK never renews this credential + // with it (see AppAccessTokenResult). + var result AppAccessTokenResult if err := json.Unmarshal(data, &result); err != nil { - return "", fmt.Errorf("qdmp: failed to decode auth.token response: %w", err) + return nil, fmt.Errorf("qdmp: failed to decode auth.token response: %w", err) } if result.AccessToken == "" { - return "", fmt.Errorf("qdmp: auth.token response has an empty accessToken") + return nil, fmt.Errorf("qdmp: auth.token response has an empty accessToken") } expiresAt, err := parseExpiresAt(result.ExpiresAt) if err != nil { - return "", fmt.Errorf("qdmp: auth.token response has an invalid expiresAt: %w", err) + return nil, fmt.Errorf("qdmp: auth.token response has an invalid expiresAt: %w", err) } // A non-negative expiresAt can still be already expired, or expiring // within tokenRefreshBufferSeconds (e.g. "1", 1970-01-01, or "a few @@ -260,14 +338,24 @@ func (a *AuthService) exchangeClientCredentials(ctx context.Context) (string, er // the identical predicate here so "fresh enough to hand out" means the // same thing on both the write and read paths. if expiresAt-tokenRefreshBufferSeconds <= time.Now().Unix() { - return "", fmt.Errorf("qdmp: auth.token response's expiresAt (%d) leaves less than the required %ds before expiry", expiresAt, tokenRefreshBufferSeconds) + return nil, fmt.Errorf("qdmp: auth.token response's expiresAt (%d) leaves less than the required %ds before expiry", expiresAt, tokenRefreshBufferSeconds) } - a.store.Set(TokenEntry{AccessToken: result.AccessToken, ExpiresAt: expiresAt}) - return result.AccessToken, nil + entry := TokenEntry{ + AccessToken: result.AccessToken, + ExpiresAt: expiresAt, + RefreshToken: result.RefreshToken, + OpenID: result.OpenID, + } + a.store.Set(entry) + // Build the returned value from the entry that was just cached, so this + // path and the cache-hit path are the same code producing the same + // fields. + cached := appResultFromEntry(entry) + return &cached, nil } -// parseExpiresAt parses a wire expiresAt string (see Code2SessionResult's +// parseExpiresAt parses a wire expiresAt string (see UserAccessTokenResult's // doc comment for why it is a string on the wire) as a non-negative absolute // Unix-seconds timestamp. Anything that fails to parse as a base-10 int64, // or that parses to a negative value, is rejected outright: a @@ -285,9 +373,9 @@ func parseExpiresAt(s string) (int64, error) { return v, nil } -// Code2Session performs a one-shot AUTHORIZATION_CODE exchange. Never +// GetUserAccessToken performs a one-shot AUTHORIZATION_CODE exchange. Never // cached — see the AuthService doc comment. -func (a *AuthService) Code2Session(ctx context.Context, code string) (*Code2SessionResult, error) { +func (a *AuthService) GetUserAccessToken(ctx context.Context, code string) (*UserAccessTokenResult, error) { data, err := a.client.doRequest(ctx, requestParams{ method: http.MethodPost, path: "/auth/v1/token", @@ -303,30 +391,31 @@ func (a *AuthService) Code2Session(ctx context.Context, code string) (*Code2Sess return nil, err } - var result Code2SessionResult + var result UserAccessTokenResult if err := json.Unmarshal(data, &result); err != nil { - return nil, fmt.Errorf("qdmp: failed to decode auth.code2Session response: %w", err) + return nil, fmt.Errorf("qdmp: failed to decode auth.getUserAccessToken response: %w", err) } if result.AccessToken == "" { - return nil, fmt.Errorf("qdmp: auth.code2Session response has an empty accessToken") + return nil, fmt.Errorf("qdmp: auth.getUserAccessToken response has an empty accessToken") } if result.RefreshToken == "" { - return nil, fmt.Errorf("qdmp: auth.code2Session response has an empty refreshToken") + return nil, fmt.Errorf("qdmp: auth.getUserAccessToken response has an empty refreshToken") } if result.OpenID == "" { - return nil, fmt.Errorf("qdmp: auth.code2Session response has an empty openId") + return nil, fmt.Errorf("qdmp: auth.getUserAccessToken response has an empty openId") } expiresAt, err := parseExpiresAt(result.ExpiresAt) if err != nil { - return nil, fmt.Errorf("qdmp: auth.code2Session response has an invalid expiresAt: %w", err) + return nil, fmt.Errorf("qdmp: auth.getUserAccessToken response has an invalid expiresAt: %w", err) } // A non-negative expiresAt can still already be in the past (e.g. "1", // 1970-01-01) — parseExpiresAt only rejects negative/non-numeric values. - // Handing back a session whose access token is already dead on arrival - // would let a caller persist (or start relying on) a permanently-broken - // session, same reasoning as the app-level cached path. + // Handing back a user credential whose access token is already dead on + // arrival would let a caller persist (or start relying on) a + // permanently-broken credential, same reasoning as the app-level cached + // path. if expiresAt <= time.Now().Unix() { - return nil, fmt.Errorf("qdmp: auth.code2Session response has an already-expired expiresAt (%d)", expiresAt) + return nil, fmt.Errorf("qdmp: auth.getUserAccessToken response has an already-expired expiresAt (%d)", expiresAt) } return &result, nil } @@ -357,7 +446,7 @@ func (a *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*R if err != nil { return nil, fmt.Errorf("qdmp: auth.refreshToken response has an invalid expiresAt: %w", err) } - // See the identical check in Code2Session: a non-negative but + // See the identical check in GetUserAccessToken: a non-negative but // already-expired expiresAt must not be handed back as a usable result. if expiresAt <= time.Now().Unix() { return nil, fmt.Errorf("qdmp: auth.refreshToken response has an already-expired expiresAt (%d)", expiresAt) diff --git a/go/auth_empty_access_token_test.go b/go/auth_empty_access_token_test.go index 38de8e8..2893101 100644 --- a/go/auth_empty_access_token_test.go +++ b/go/auth_empty_access_token_test.go @@ -2,7 +2,7 @@ package qdmp_test // Contract exercised by this file (not yet implemented — see the codex // review round1 fix task): AuthService.exchangeClientCredentials (used by -// GetAccessToken), Code2Session, and RefreshToken currently only validate +// GetAppAccessToken), GetUserAccessToken, and RefreshToken currently only validate // that expiresAt parses as an integer; none of them validate that // accessToken itself is non-empty. A malformed-but-code=0 response // (accessToken: "") must be rejected as an error, not treated as a @@ -16,14 +16,14 @@ import ( qdmp "github.com/EchoTechFE/qdmp-server-sdk/go" ) -// TestAuthGetAccessToken_EmptyAccessTokenRejected drives the app-level -// CLIENT_CREDENTIALS exchange (GetAccessToken -> exchangeClientCredentials) +// TestAuthGetAppAccessToken_EmptyAccessTokenRejected drives the app-level +// CLIENT_CREDENTIALS exchange (GetAppAccessToken -> exchangeClientCredentials) // through a response that reports success (code=0) with a valid expiresAt // but an empty accessToken. The SDK must return a non-nil error and must // not write the empty token through to the TokenStore (an empty "cached" // token would be indistinguishable from "no token" on the next read, or // worse, would be handed to a caller as if it were usable). -func TestAuthGetAccessToken_EmptyAccessTokenRejected(t *testing.T) { +func TestAuthGetAppAccessToken_EmptyAccessTokenRejected(t *testing.T) { counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-empty-token-1", map[string]any{ "accessToken": "", @@ -42,23 +42,23 @@ func TestAuthGetAccessToken_EmptyAccessTokenRejected(t *testing.T) { t.Fatalf("qdmp.NewClient() error = %v", err) } - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err == nil { - t.Fatalf("GetAccessToken() error = nil, want an error because accessToken is empty despite code=0") + t.Fatalf("GetAppAccessToken() error = nil, want an error because accessToken is empty despite code=0") } - if token != "" { - t.Fatalf("GetAccessToken() = %q, want the zero value on failure", token) + if appToken != nil { + t.Fatalf("GetAppAccessToken() = %+v, want nil on failure", appToken) } if store.setHits != 0 { t.Fatalf("TokenStore.Set() called %d times, want 0 (an empty accessToken must never be cached)", store.setHits) } } -// TestAuthCode2Session_EmptyAccessTokenRejected is Code2Session's +// TestAuthGetUserAccessToken_EmptyAccessTokenRejected is GetUserAccessToken's // counterpart: a response reporting code=0 with all other fields populated // but accessToken empty must still surface as an error, not a -// *Code2SessionResult carrying an unusable empty AccessToken. -func TestAuthCode2Session_EmptyAccessTokenRejected(t *testing.T) { +// *UserAccessTokenResult carrying an unusable empty AccessToken. +func TestAuthGetUserAccessToken_EmptyAccessTokenRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-empty-token-2", map[string]any{ "accessToken": "", @@ -69,12 +69,12 @@ func TestAuthCode2Session_EmptyAccessTokenRejected(t *testing.T) { })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "some-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "some-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because accessToken is empty despite code=0") + t.Fatalf("GetUserAccessToken() error = nil, want an error because accessToken is empty despite code=0") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } } diff --git a/go/auth_expiresat_validation_test.go b/go/auth_expiresat_validation_test.go index bc37197..1c721f8 100644 --- a/go/auth_expiresat_validation_test.go +++ b/go/auth_expiresat_validation_test.go @@ -2,8 +2,8 @@ package qdmp_test // Contract exercised by this file (not yet implemented): // -// AuthService.Code2Session, AuthService.RefreshToken, and the cached -// GetAccessToken (via exchangeClientCredentials) currently only check that +// AuthService.GetUserAccessToken, AuthService.RefreshToken, and the cached +// GetAppAccessToken (via exchangeClientCredentials) currently only check that // AccessToken is non-empty; none of them validate that ExpiresAt actually // parses as a valid, non-negative integer. exchangeClientCredentials (see // auth.go ~line 177) does call strconv.ParseInt on it, but happily accepts a @@ -13,8 +13,8 @@ package qdmp_test // response should be rejected outright rather than silently limping along). // // This file requires: any expiresAt that fails to parse as a non-negative -// base-10 int64 must produce a non-nil error from Code2Session/RefreshToken/ -// GetAccessToken, and — critically for GetAccessToken — a rejected exchange +// base-10 int64 must produce a non-nil error from GetUserAccessToken/RefreshToken/ +// GetAppAccessToken, and — critically for GetAppAccessToken — a rejected exchange // must never be written through to the TokenStore, so the very next call // still issues a real HTTP request instead of treating the poisoned result as // a fresh cache hit. @@ -26,12 +26,12 @@ import ( "testing" ) -// TestAuthCode2Session_NonNumericExpiresAtRejected drives Code2Session +// TestAuthGetUserAccessToken_NonNumericExpiresAtRejected drives GetUserAccessToken // through a response with an accessToken/refreshToken/openId all populated, // but an expiresAt that isn't a number at all. -func TestAuthCode2Session_NonNumericExpiresAtRejected(t *testing.T) { +func TestAuthGetUserAccessToken_NonNumericExpiresAtRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-badexp-c2s-1", map[string]any{ + businessEnvelope(w, http.StatusOK, "0", "ok", "req-badexp-usertoken-1", map[string]any{ "accessToken": "tok", "refreshToken": "r", "expiresAt": "garbage", @@ -40,21 +40,21 @@ func TestAuthCode2Session_NonNumericExpiresAtRejected(t *testing.T) { })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "some-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "some-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because expiresAt %q does not parse as a number", "garbage") + t.Fatalf("GetUserAccessToken() error = nil, want an error because expiresAt %q does not parse as a number", "garbage") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } } -// TestAuthCode2Session_NegativeExpiresAtRejected drives Code2Session through +// TestAuthGetUserAccessToken_NegativeExpiresAtRejected drives GetUserAccessToken through // a response whose expiresAt parses fine as an integer but is negative — a // value that must never be treated as a usable absolute Unix-seconds expiry. -func TestAuthCode2Session_NegativeExpiresAtRejected(t *testing.T) { +func TestAuthGetUserAccessToken_NegativeExpiresAtRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, "0", "ok", "req-badexp-c2s-2", map[string]any{ + businessEnvelope(w, http.StatusOK, "0", "ok", "req-badexp-usertoken-2", map[string]any{ "accessToken": "tok", "refreshToken": "r", "expiresAt": "-100", @@ -63,17 +63,17 @@ func TestAuthCode2Session_NegativeExpiresAtRejected(t *testing.T) { })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "some-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "some-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because expiresAt is negative (-100)") + t.Fatalf("GetUserAccessToken() error = nil, want an error because expiresAt is negative (-100)") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } } // TestAuthRefreshToken_NonNumericExpiresAtRejected is RefreshToken's -// counterpart to TestAuthCode2Session_NonNumericExpiresAtRejected. +// counterpart to TestAuthGetUserAccessToken_NonNumericExpiresAtRejected. func TestAuthRefreshToken_NonNumericExpiresAtRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-badexp-rt-1", map[string]any{ @@ -93,7 +93,7 @@ func TestAuthRefreshToken_NonNumericExpiresAtRejected(t *testing.T) { } // TestAuthRefreshToken_NegativeExpiresAtRejected is RefreshToken's -// counterpart to TestAuthCode2Session_NegativeExpiresAtRejected. +// counterpart to TestAuthGetUserAccessToken_NegativeExpiresAtRejected. func TestAuthRefreshToken_NegativeExpiresAtRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-badexp-rt-2", map[string]any{ @@ -112,16 +112,16 @@ func TestAuthRefreshToken_NegativeExpiresAtRejected(t *testing.T) { } } -// TestAuthGetAccessToken_NegativeExpiresAtRejectedAndNotCached is the most +// TestAuthGetAppAccessToken_NegativeExpiresAtRejectedAndNotCached is the most // important test in this file: it proves a rejected (negative-expiresAt) // app-level token exchange is never written through to the TokenStore. The // mock server returns a poisoned response (expiresAt: "-100") on its first // hit, then a legitimate response on every hit after. If the SDK incorrectly // cached the poisoned exchange as "fresh" (e.g. by caching before validating, -// or by treating the error path as success), the second GetAccessToken call +// or by treating the error path as success), the second GetAppAccessToken call // would either wrongly succeed with the poisoned token or short-circuit // without ever hitting the server again. Both would defeat this assertion. -func TestAuthGetAccessToken_NegativeExpiresAtRejectedAndNotCached(t *testing.T) { +func TestAuthGetAppAccessToken_NegativeExpiresAtRejectedAndNotCached(t *testing.T) { var calls int64 srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt64(&calls, 1) @@ -139,20 +139,20 @@ func TestAuthGetAccessToken_NegativeExpiresAtRejectedAndNotCached(t *testing.T) })) client := newTestClient(t, srv.URL) - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err == nil { - t.Fatalf("first GetAccessToken() error = nil, want an error because expiresAt is negative (-100)") + t.Fatalf("first GetAppAccessToken() error = nil, want an error because expiresAt is negative (-100)") } - if token != "" { - t.Fatalf("first GetAccessToken() = %q, want the zero value on failure", token) + if appToken != nil { + t.Fatalf("first GetAppAccessToken() = %+v, want nil on failure", appToken) } - token, err = client.Auth.GetAccessToken(context.Background()) + appToken, err = client.Auth.GetAppAccessToken(context.Background()) if err != nil { - t.Fatalf("second GetAccessToken() error = %v, want nil", err) + t.Fatalf("second GetAppAccessToken() error = %v, want nil", err) } - if token != "token-from-good-response" { - t.Fatalf("second GetAccessToken() = %q, want %q", token, "token-from-good-response") + if appToken.AccessToken != "token-from-good-response" { + t.Fatalf("second GetAppAccessToken().AccessToken = %q, want %q", appToken.AccessToken, "token-from-good-response") } if got := atomic.LoadInt64(&calls); got != 2 { t.Fatalf("server hit %d times, want exactly 2 (the first poisoned response must not have been cached as a fresh token, forcing a real second exchange)", got) diff --git a/go/auth_result_stringer_redaction_test.go b/go/auth_result_stringer_redaction_test.go index 98689a1..8c2a130 100644 --- a/go/auth_result_stringer_redaction_test.go +++ b/go/auth_result_stringer_redaction_test.go @@ -1,17 +1,17 @@ package qdmp_test // Regression test for a real security finding (Fable5 design-consistency -// review): Code2SessionResult and RefreshTokenResult are plain structs with +// review): UserAccessTokenResult and RefreshTokenResult are plain structs with // no fmt.Stringer implementation, so fmt.Println(result)/fmt.Printf("%v"/ // "%+v", result) and most structured loggers (which fall back to %v/%+v) // would print AccessToken/RefreshToken in the clear — an easy way for an -// integrator's incidental debug log line to leak a live user session token. +// integrator's incidental debug log line to leak a live user access token. // -// The fix (auth.go, Code2SessionResult.String / RefreshTokenResult.String) +// The fix (auth.go, UserAccessTokenResult.String / RefreshTokenResult.String) // must redact AccessToken/RefreshToken in the %v/%+v/Println path while // leaving json.Marshal and direct field access completely untouched: the -// entire reason Code2Session/RefreshToken exist is so the caller can -// persist the real values into its own session store, so over-redacting the +// entire reason GetUserAccessToken/RefreshToken exist is so the caller can +// persist the real values into its own credential store, so over-redacting the // serialization path would make the SDK unusable. import ( @@ -30,8 +30,8 @@ const ( sentinelRefreshToken = "sentinel-refresh-token-do-not-leak-xyz123" ) -func TestCode2SessionResult_StringRedactsTokens(t *testing.T) { - result := &qdmp.Code2SessionResult{ +func TestUserAccessTokenResult_StringRedactsTokens(t *testing.T) { + result := &qdmp.UserAccessTokenResult{ AccessToken: sentinelAccessToken, RefreshToken: sentinelRefreshToken, ExpiresAt: "1785508950", @@ -41,17 +41,17 @@ func TestCode2SessionResult_StringRedactsTokens(t *testing.T) { for _, format := range []string{"%v", "%+v"} { out := fmt.Sprintf(format, result) if strings.Contains(out, sentinelAccessToken) { - t.Fatalf("fmt.Sprintf(%q, *Code2SessionResult) = %q, leaked the raw accessToken", format, out) + t.Fatalf("fmt.Sprintf(%q, *UserAccessTokenResult) = %q, leaked the raw accessToken", format, out) } if strings.Contains(out, sentinelRefreshToken) { - t.Fatalf("fmt.Sprintf(%q, *Code2SessionResult) = %q, leaked the raw refreshToken", format, out) + t.Fatalf("fmt.Sprintf(%q, *UserAccessTokenResult) = %q, leaked the raw refreshToken", format, out) } if !strings.Contains(out, "[REDACTED]") { - t.Fatalf("fmt.Sprintf(%q, *Code2SessionResult) = %q, want it to contain the [REDACTED] placeholder", format, out) + t.Fatalf("fmt.Sprintf(%q, *UserAccessTokenResult) = %q, want it to contain the [REDACTED] placeholder", format, out) } // Non-secret fields must still be visible. if !strings.Contains(out, "1785508950") || !strings.Contains(out, "open-id-1") { - t.Fatalf("fmt.Sprintf(%q, *Code2SessionResult) = %q, want ExpiresAt/OpenID still printed", format, out) + t.Fatalf("fmt.Sprintf(%q, *UserAccessTokenResult) = %q, want ExpiresAt/OpenID still printed", format, out) } } @@ -59,18 +59,18 @@ func TestCode2SessionResult_StringRedactsTokens(t *testing.T) { // fmt.Println's default formatting. valueOut := fmt.Sprintf("%v", *result) if strings.Contains(valueOut, sentinelAccessToken) || strings.Contains(valueOut, sentinelRefreshToken) { - t.Fatalf("fmt.Sprintf(%%v, Code2SessionResult value) = %q, leaked a raw token", valueOut) + t.Fatalf("fmt.Sprintf(%%v, UserAccessTokenResult value) = %q, leaked a raw token", valueOut) } } -// TestCode2SessionResult_GoStringRedactsTokens is a regression test for a +// TestUserAccessTokenResult_GoStringRedactsTokens is a regression test for a // follow-up finding from codex's convergence-check review: fmt.Stringer's // String() is NOT consulted by the %#v verb (Go-syntax representation) — // only fmt.GoStringer is. Without a GoString() method, fmt.Printf("%#v", // result) would still print the raw AccessToken/RefreshToken fields even // though %v/%+v are already redacted above. -func TestCode2SessionResult_GoStringRedactsTokens(t *testing.T) { - result := &qdmp.Code2SessionResult{ +func TestUserAccessTokenResult_GoStringRedactsTokens(t *testing.T) { + result := &qdmp.UserAccessTokenResult{ AccessToken: sentinelAccessToken, RefreshToken: sentinelRefreshToken, ExpiresAt: "1785508950", @@ -79,10 +79,10 @@ func TestCode2SessionResult_GoStringRedactsTokens(t *testing.T) { out := fmt.Sprintf("%#v", result) if strings.Contains(out, sentinelAccessToken) || strings.Contains(out, sentinelRefreshToken) { - t.Fatalf("fmt.Sprintf(%%#v, *Code2SessionResult) = %q, leaked a raw token", out) + t.Fatalf("fmt.Sprintf(%%#v, *UserAccessTokenResult) = %q, leaked a raw token", out) } if !strings.Contains(out, "[REDACTED]") { - t.Fatalf("fmt.Sprintf(%%#v, *Code2SessionResult) = %q, want the [REDACTED] placeholder", out) + t.Fatalf("fmt.Sprintf(%%#v, *UserAccessTokenResult) = %q, want the [REDACTED] placeholder", out) } } @@ -101,16 +101,16 @@ func TestRefreshTokenResult_GoStringRedactsTokens(t *testing.T) { } } -// TestCode2SessionResult_SlogRedactsTokens is a regression test for a +// TestUserAccessTokenResult_SlogRedactsTokens is a regression test for a // follow-up finding from codex's second convergence-check review: // fmt.Stringer/fmt.GoStringer are not consulted by log/slog's handlers — // slog.JSONHandler in particular JSON-marshals values directly, so -// slog.Info("session", "value", result) would still emit the raw +// slog.Info("credential", "value", result) would still emit the raw // AccessToken/RefreshToken even with String()/GoString() already redacting // the plain fmt.Printf paths. Only slog.LogValuer (LogValue()) is consulted // by slog itself. -func TestCode2SessionResult_SlogRedactsTokens(t *testing.T) { - result := &qdmp.Code2SessionResult{ +func TestUserAccessTokenResult_SlogRedactsTokens(t *testing.T) { + result := &qdmp.UserAccessTokenResult{ AccessToken: sentinelAccessToken, RefreshToken: sentinelRefreshToken, ExpiresAt: "1785508950", @@ -127,7 +127,7 @@ func TestCode2SessionResult_SlogRedactsTokens(t *testing.T) { for _, h := range handlers { var buf bytes.Buffer logger := slog.New(h.new(&buf)) - logger.Info("session", "value", result) + logger.Info("credential", "value", result) out := buf.String() if strings.Contains(out, sentinelAccessToken) || strings.Contains(out, sentinelRefreshToken) { t.Fatalf("slog with %s leaked a raw token: %s", h.name, out) @@ -158,13 +158,13 @@ func TestRefreshTokenResult_StringRedactsTokens(t *testing.T) { } } -// TestCode2SessionResult_JSONMarshalNotRedacted is the reverse-direction -// guard: json.Marshal is how the caller persists the real session into its -// own DB/session store, so it must keep returning the real values — the +// TestUserAccessTokenResult_JSONMarshalNotRedacted is the reverse-direction +// guard: json.Marshal is how the caller persists the real credential into its +// own DB/credential store, so it must keep returning the real values — the // String()/fmt.Stringer redaction must not leak into encoding/json, which // only consults json.Marshaler (not fmt.Stringer). -func TestCode2SessionResult_JSONMarshalNotRedacted(t *testing.T) { - result := &qdmp.Code2SessionResult{ +func TestUserAccessTokenResult_JSONMarshalNotRedacted(t *testing.T) { + result := &qdmp.UserAccessTokenResult{ AccessToken: sentinelAccessToken, RefreshToken: sentinelRefreshToken, ExpiresAt: "1785508950", @@ -173,14 +173,14 @@ func TestCode2SessionResult_JSONMarshalNotRedacted(t *testing.T) { data, err := json.Marshal(result) if err != nil { - t.Fatalf("json.Marshal(Code2SessionResult) returned unexpected error: %v", err) + t.Fatalf("json.Marshal(UserAccessTokenResult) returned unexpected error: %v", err) } out := string(data) if !strings.Contains(out, sentinelAccessToken) { - t.Fatalf("json.Marshal(Code2SessionResult) = %q, want it to contain the real accessToken for the caller to persist", out) + t.Fatalf("json.Marshal(UserAccessTokenResult) = %q, want it to contain the real accessToken for the caller to persist", out) } if !strings.Contains(out, sentinelRefreshToken) { - t.Fatalf("json.Marshal(Code2SessionResult) = %q, want it to contain the real refreshToken for the caller to persist", out) + t.Fatalf("json.Marshal(UserAccessTokenResult) = %q, want it to contain the real refreshToken for the caller to persist", out) } // Direct field access must also stay the real plaintext value. @@ -213,3 +213,63 @@ func TestRefreshTokenResult_JSONMarshalNotRedacted(t *testing.T) { t.Fatalf("result.AccessToken = %q, want the raw sentinel value %q", result.AccessToken, sentinelAccessToken) } } + +// TestAppAccessTokenResult_RedactsTokens is the app-level credential's +// counterpart to the tests above. It gained a refreshToken field (see +// CONTRACT.md §1.1), so it now carries two secrets through exactly the same +// three debug-print paths — %v/%+v (Stringer), %#v (GoStringer) and log/slog +// (LogValuer) — while json.Marshal must still hand back the real values for +// the caller to persist. +func TestAppAccessTokenResult_RedactsTokens(t *testing.T) { + result := &qdmp.AppAccessTokenResult{ + AccessToken: sentinelAccessToken, + ExpiresAt: "1785508950", + RefreshToken: sentinelRefreshToken, + OpenID: "", + } + + for _, format := range []string{"%v", "%+v", "%#v"} { + out := fmt.Sprintf(format, result) + if strings.Contains(out, sentinelAccessToken) || strings.Contains(out, sentinelRefreshToken) { + t.Fatalf("fmt.Sprintf(%q, *AppAccessTokenResult) = %q, leaked a raw token", format, out) + } + if !strings.Contains(out, "[REDACTED]") { + t.Fatalf("fmt.Sprintf(%q, *AppAccessTokenResult) = %q, want the [REDACTED] placeholder", format, out) + } + if !strings.Contains(out, "1785508950") { + t.Fatalf("fmt.Sprintf(%q, *AppAccessTokenResult) = %q, want ExpiresAt still printed", format, out) + } + } + + valueOut := fmt.Sprintf("%v", *result) + if strings.Contains(valueOut, sentinelAccessToken) || strings.Contains(valueOut, sentinelRefreshToken) { + t.Fatalf("fmt.Sprintf(%%v, AppAccessTokenResult value) = %q, leaked a raw token", valueOut) + } + + for _, h := range []struct { + name string + new func(*bytes.Buffer) slog.Handler + }{ + {"JSONHandler", func(buf *bytes.Buffer) slog.Handler { return slog.NewJSONHandler(buf, nil) }}, + {"TextHandler", func(buf *bytes.Buffer) slog.Handler { return slog.NewTextHandler(buf, nil) }}, + } { + var buf bytes.Buffer + slog.New(h.new(&buf)).Info("credential", "value", result) + out := buf.String() + if strings.Contains(out, sentinelAccessToken) || strings.Contains(out, sentinelRefreshToken) { + t.Fatalf("slog with %s leaked a raw token: %s", h.name, out) + } + if !strings.Contains(out, "[REDACTED]") { + t.Fatalf("slog with %s output should contain the [REDACTED] placeholder, got: %s", h.name, out) + } + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal(AppAccessTokenResult) returned unexpected error: %v", err) + } + out := string(data) + if !strings.Contains(out, sentinelAccessToken) || !strings.Contains(out, sentinelRefreshToken) { + t.Fatalf("json.Marshal(AppAccessTokenResult) = %q, want it to contain the real tokens for the caller to persist", out) + } +} diff --git a/go/auth_stale_expiresat_test.go b/go/auth_stale_expiresat_test.go index e373aa4..b453699 100644 --- a/go/auth_stale_expiresat_test.go +++ b/go/auth_stale_expiresat_test.go @@ -34,18 +34,18 @@ import ( "time" ) -// TestAuthGetAccessToken_StaleExpiresAtRejectedAndNotCached mirrors -// TestAuthGetAccessToken_NegativeExpiresAtRejectedAndNotCached in +// TestAuthGetAppAccessToken_StaleExpiresAtRejectedAndNotCached mirrors +// TestAuthGetAppAccessToken_NegativeExpiresAtRejectedAndNotCached in // auth_expiresat_validation_test.go, but with a non-negative, already- // expired expiresAt ("1") instead of a negative one ("-100"). The mock // server returns the poisoned response on its first hit, then a legitimate // response on every hit after. If the SDK incorrectly accepted/cached the // poisoned exchange as "fresh" (since expiresAt="1" is not negative and // today's parseExpiresAt only rejects negative/non-numeric values), the -// first GetAccessToken call would wrongly succeed with the stale token +// first GetAppAccessToken call would wrongly succeed with the stale token // and/or the second call would short-circuit without ever hitting the // server again. Both would defeat the assertions below. -func TestAuthGetAccessToken_StaleExpiresAtRejectedAndNotCached(t *testing.T) { +func TestAuthGetAppAccessToken_StaleExpiresAtRejectedAndNotCached(t *testing.T) { var calls int64 srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := atomic.AddInt64(&calls, 1) @@ -63,27 +63,27 @@ func TestAuthGetAccessToken_StaleExpiresAtRejectedAndNotCached(t *testing.T) { })) client := newTestClient(t, srv.URL) - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err == nil { - t.Fatalf("first GetAccessToken() error = nil, want an error because expiresAt=\"1\" is already expired (1970-01-01), even though it is not negative") + t.Fatalf("first GetAppAccessToken() error = nil, want an error because expiresAt=\"1\" is already expired (1970-01-01), even though it is not negative") } - if token != "" { - t.Fatalf("first GetAccessToken() = %q, want the zero value on failure", token) + if appToken != nil { + t.Fatalf("first GetAppAccessToken() = %+v, want nil on failure", appToken) } - token, err = client.Auth.GetAccessToken(context.Background()) + appToken, err = client.Auth.GetAppAccessToken(context.Background()) if err != nil { - t.Fatalf("second GetAccessToken() error = %v, want nil", err) + t.Fatalf("second GetAppAccessToken() error = %v, want nil", err) } - if token != "token-from-good-response" { - t.Fatalf("second GetAccessToken() = %q, want %q", token, "token-from-good-response") + if appToken.AccessToken != "token-from-good-response" { + t.Fatalf("second GetAppAccessToken().AccessToken = %q, want %q", appToken.AccessToken, "token-from-good-response") } if got := atomic.LoadInt64(&calls); got != 2 { t.Fatalf("server hit %d times, want exactly 2 (the first stale-expiresAt response must not have been cached as a fresh token, forcing a real second exchange)", got) } } -// TestAuthGetAccessToken_ExpiresWithinBufferRejectedAndNotCached is a +// TestAuthGetAppAccessToken_ExpiresWithinBufferRejectedAndNotCached is a // regression test for a follow-up finding from codex's convergence-check // review: the fix above only rejected expiresAt <= now, but cachedValid's // own freshness predicate is stricter — it requires @@ -94,7 +94,7 @@ func TestAuthGetAccessToken_StaleExpiresAtRejectedAndNotCached(t *testing.T) { // caller as "fresh" — only for cachedValid to immediately (and correctly) // call that same entry stale on the *next* call. The write path must use // the identical predicate as the read path. -func TestAuthGetAccessToken_ExpiresWithinBufferRejectedAndNotCached(t *testing.T) { +func TestAuthGetAppAccessToken_ExpiresWithinBufferRejectedAndNotCached(t *testing.T) { var calls int64 soonExpiresAt := strconv.FormatInt(time.Now().Add(100*time.Second).Unix(), 10) srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -113,20 +113,20 @@ func TestAuthGetAccessToken_ExpiresWithinBufferRejectedAndNotCached(t *testing.T })) client := newTestClient(t, srv.URL) - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err == nil { - t.Fatalf("first GetAccessToken() error = nil, want an error because expiresAt is only 100s away (less than the 300s refresh buffer)") + t.Fatalf("first GetAppAccessToken() error = nil, want an error because expiresAt is only 100s away (less than the 300s refresh buffer)") } - if token != "" { - t.Fatalf("first GetAccessToken() = %q, want the zero value on failure", token) + if appToken != nil { + t.Fatalf("first GetAppAccessToken() = %+v, want nil on failure", appToken) } - token, err = client.Auth.GetAccessToken(context.Background()) + appToken, err = client.Auth.GetAppAccessToken(context.Background()) if err != nil { - t.Fatalf("second GetAccessToken() error = %v, want nil", err) + t.Fatalf("second GetAppAccessToken() error = %v, want nil", err) } - if token != "token-from-good-response" { - t.Fatalf("second GetAccessToken() = %q, want %q", token, "token-from-good-response") + if appToken.AccessToken != "token-from-good-response" { + t.Fatalf("second GetAppAccessToken().AccessToken = %q, want %q", appToken.AccessToken, "token-from-good-response") } if got := atomic.LoadInt64(&calls); got != 2 { t.Fatalf("server hit %d times, want exactly 2 (the first within-buffer response must not have been cached as fresh, forcing a real second exchange)", got) diff --git a/go/auth_test.go b/go/auth_test.go index d410603..d4e1dc3 100644 --- a/go/auth_test.go +++ b/go/auth_test.go @@ -4,11 +4,12 @@ package qdmp_test // phase, not here): // // client, err := qdmp.NewClient(qdmp.ClientOptions{AppID, AppSecret, BaseURL}) -// token, err := client.Auth.GetAccessToken(ctx) // app-level, cached + single-flight -// sess, err := client.Auth.Code2Session(ctx, code) // one-shot, never cached +// app, err := client.Auth.GetAppAccessToken(ctx) // app-level, cached + single-flight +// cred, err := client.Auth.GetUserAccessToken(ctx, code) // one-shot, never cached // res, err := client.Auth.RefreshToken(ctx, refreshToken) // one-shot, never cached // -// qdmp.Code2SessionResult{AccessToken, RefreshToken, ExpiresAt, OpenID string} +// qdmp.AppAccessTokenResult{AccessToken, ExpiresAt, RefreshToken, OpenID string} +// qdmp.UserAccessTokenResult{AccessToken, RefreshToken, ExpiresAt, OpenID string} // qdmp.RefreshTokenResult{AccessToken, ExpiresAt string} // *qdmp.QdmpApiError{Code, Message, RequestID string; HTTPStatus int} @@ -27,9 +28,9 @@ import ( "github.com/EchoTechFE/qdmp-server-sdk/go/generated" ) -// TestAuthGetAccessToken_Success verifies the happy path for the +// TestAuthGetAppAccessToken_Success verifies the happy path for the // CLIENT_CREDENTIALS exchange: correct request shape in, correct token out. -func TestAuthGetAccessToken_Success(t *testing.T) { +func TestAuthGetAppAccessToken_Success(t *testing.T) { counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/auth/v1/token" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) @@ -58,21 +59,21 @@ func TestAuthGetAccessToken_Success(t *testing.T) { srv := startServer(t, counter) client := newTestClient(t, srv.URL) - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err != nil { - t.Fatalf("GetAccessToken() error = %v, want nil", err) + t.Fatalf("GetAppAccessToken() error = %v, want nil", err) } - if token != "app-level-access-token" { - t.Fatalf("GetAccessToken() = %q, want %q", token, "app-level-access-token") + if appToken.AccessToken != "app-level-access-token" { + t.Fatalf("GetAppAccessToken().AccessToken = %q, want %q", appToken.AccessToken, "app-level-access-token") } if counter.Count() != 1 { t.Fatalf("server hit %d times, want 1", counter.Count()) } } -// TestAuthGetAccessToken_CachesUntilExpiry verifies the app-level token is +// TestAuthGetAppAccessToken_CachesUntilExpiry verifies the app-level token is // cached: a second immediate call must not trigger a second HTTP exchange. -func TestAuthGetAccessToken_CachesUntilExpiry(t *testing.T) { +func TestAuthGetAppAccessToken_CachesUntilExpiry(t *testing.T) { counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, float64(0), "ok", "req-1", map[string]any{ "accessToken": "cached-token", @@ -83,28 +84,28 @@ func TestAuthGetAccessToken_CachesUntilExpiry(t *testing.T) { client := newTestClient(t, srv.URL) ctx := context.Background() - first, err := client.Auth.GetAccessToken(ctx) + first, err := client.Auth.GetAppAccessToken(ctx) if err != nil { - t.Fatalf("first GetAccessToken() error = %v", err) + t.Fatalf("first GetAppAccessToken() error = %v", err) } - second, err := client.Auth.GetAccessToken(ctx) + second, err := client.Auth.GetAppAccessToken(ctx) if err != nil { - t.Fatalf("second GetAccessToken() error = %v", err) + t.Fatalf("second GetAppAccessToken() error = %v", err) } - if first != second { - t.Fatalf("cached token changed across calls: %q != %q", first, second) + if *first != *second { + t.Fatalf("cached credential changed across calls: %+v != %+v", first, second) } if counter.Count() != 1 { t.Fatalf("server hit %d times across two calls, want 1 (token should be cached)", counter.Count()) } } -// TestAuthGetAccessToken_ConcurrentSingleFlight simulates N goroutines all +// TestAuthGetAppAccessToken_ConcurrentSingleFlight simulates N goroutines all // racing to fetch the app-level token from a cold cache. Without a // single-flight guard this would hit the token endpoint N times and risk // rate-limiting/thundering-herd against the real qdmp gateway; the SDK must // collapse them into exactly one outbound exchange. -func TestAuthGetAccessToken_ConcurrentSingleFlight(t *testing.T) { +func TestAuthGetAppAccessToken_ConcurrentSingleFlight(t *testing.T) { const goroutines = 50 counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-1", map[string]any{ @@ -117,23 +118,23 @@ func TestAuthGetAccessToken_ConcurrentSingleFlight(t *testing.T) { ctx := context.Background() var wg sync.WaitGroup - results := make([]string, goroutines) + results := make([]*qdmp.AppAccessTokenResult, goroutines) errs := make([]error, goroutines) wg.Add(goroutines) for i := 0; i < goroutines; i++ { go func(i int) { defer wg.Done() - results[i], errs[i] = client.Auth.GetAccessToken(ctx) + results[i], errs[i] = client.Auth.GetAppAccessToken(ctx) }(i) } wg.Wait() for i, err := range errs { if err != nil { - t.Fatalf("goroutine %d: GetAccessToken() error = %v", i, err) + t.Fatalf("goroutine %d: GetAppAccessToken() error = %v", i, err) } - if results[i] != "single-flight-token" { - t.Fatalf("goroutine %d: token = %q, want %q", i, results[i], "single-flight-token") + if results[i].AccessToken != "single-flight-token" { + t.Fatalf("goroutine %d: token = %q, want %q", i, results[i].AccessToken, "single-flight-token") } } if counter.Count() != 1 { @@ -141,14 +142,14 @@ func TestAuthGetAccessToken_ConcurrentSingleFlight(t *testing.T) { } } -// TestAuthCode2Session_Success verifies the AUTHORIZATION_CODE exchange and, +// TestAuthGetUserAccessToken_Success verifies the AUTHORIZATION_CODE exchange and, // critically, that expiresAt round-trips as a *string* end-to-end — this // repository has a confirmed-by-capture rule that int64/uint64 wire values // are strings, and a naive numeric-conversion implementation would corrupt // this value silently. -func TestAuthCode2Session_Success(t *testing.T) { +func TestAuthGetUserAccessToken_Success(t *testing.T) { // Computed relative to time.Now() (rather than a hardcoded absolute - // timestamp) so this test keeps exercising "a genuinely fresh session" + // timestamp) so this test keeps exercising "a genuinely fresh credential" // indefinitely, instead of silently starting to fail once real // wall-clock time catches up to a fixed constant (as happened once // already with a previous hardcoded value here). @@ -161,8 +162,8 @@ func TestAuthCode2Session_Success(t *testing.T) { if body["grantType"] != "AUTHORIZATION_CODE" { t.Fatalf("grantType = %v, want AUTHORIZATION_CODE", body["grantType"]) } - if body["code"] != "wx-login-code-abc" { - t.Fatalf("code = %v, want wx-login-code-abc", body["code"]) + if body["code"] != "auth-code-abc" { + t.Fatalf("code = %v, want auth-code-abc", body["code"]) } businessEnvelope(w, http.StatusOK, "0", "ok", "req-2", map[string]any{ "accessToken": "user-level-access-token", @@ -174,21 +175,21 @@ func TestAuthCode2Session_Success(t *testing.T) { srv := startServer(t, counter) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "wx-login-code-abc") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "auth-code-abc") if err != nil { - t.Fatalf("Code2Session() error = %v, want nil", err) + t.Fatalf("GetUserAccessToken() error = %v, want nil", err) } - if sess.AccessToken != "user-level-access-token" { - t.Fatalf("AccessToken = %q", sess.AccessToken) + if cred.AccessToken != "user-level-access-token" { + t.Fatalf("AccessToken = %q", cred.AccessToken) } - if sess.RefreshToken != "user-level-refresh-token" { - t.Fatalf("RefreshToken = %q", sess.RefreshToken) + if cred.RefreshToken != "user-level-refresh-token" { + t.Fatalf("RefreshToken = %q", cred.RefreshToken) } - if sess.ExpiresAt != wantExpiresAt { - t.Fatalf("ExpiresAt = %q, want the exact wire string %q (must not be converted to a number)", sess.ExpiresAt, wantExpiresAt) + if cred.ExpiresAt != wantExpiresAt { + t.Fatalf("ExpiresAt = %q, want the exact wire string %q (must not be converted to a number)", cred.ExpiresAt, wantExpiresAt) } - if sess.OpenID != "user-openid-1" { - t.Fatalf("OpenID = %q", sess.OpenID) + if cred.OpenID != "user-openid-1" { + t.Fatalf("OpenID = %q", cred.OpenID) } } @@ -222,11 +223,11 @@ func TestAuthRefreshToken_HTTP200BusinessFailure(t *testing.T) { } } -// TestAuthGetAccessToken_ErrorDoesNotLeakAppSecret drives a real failure +// TestAuthGetAppAccessToken_ErrorDoesNotLeakAppSecret drives a real failure // through the exchange (10002: 密钥不匹配) and asserts the resulting error's // message never echoes the appSecret that was sent on the wire, even though // the mock server itself did receive it (sanity-checked below). -func TestAuthGetAccessToken_ErrorDoesNotLeakAppSecret(t *testing.T) { +func TestAuthGetAppAccessToken_ErrorDoesNotLeakAppSecret(t *testing.T) { const secret = "test-app-secret-do-not-leak" var sawSecretOnWire bool srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -236,9 +237,9 @@ func TestAuthGetAccessToken_ErrorDoesNotLeakAppSecret(t *testing.T) { })) client := newTestClient(t, srv.URL) - _, err := client.Auth.GetAccessToken(context.Background()) + _, err := client.Auth.GetAppAccessToken(context.Background()) if err == nil { - t.Fatalf("GetAccessToken() error = nil, want an error because code=10002") + t.Fatalf("GetAppAccessToken() error = nil, want an error because code=10002") } if !sawSecretOnWire { t.Fatalf("test setup broken: mock server never actually received the appSecret") @@ -248,13 +249,13 @@ func TestAuthGetAccessToken_ErrorDoesNotLeakAppSecret(t *testing.T) { } } -// TestAuthGetAccessToken_ProactivelyRefreshesWithinBuffer verifies the +// TestAuthGetAppAccessToken_ProactivelyRefreshesWithinBuffer verifies the // tokenRefreshBufferSeconds (300s) early-refresh boundary in cachedValid: // a token that has not yet technically expired, but sits within the 300s // buffer, must still trigger a fresh exchange rather than being served from // cache — the buffer exists precisely so callers never observe a token that // expires mid-flight. -func TestAuthGetAccessToken_ProactivelyRefreshesWithinBuffer(t *testing.T) { +func TestAuthGetAppAccessToken_ProactivelyRefreshesWithinBuffer(t *testing.T) { now := time.Now().Unix() counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-buffer-1", map[string]any{ @@ -265,7 +266,7 @@ func TestAuthGetAccessToken_ProactivelyRefreshesWithinBuffer(t *testing.T) { srv := startServer(t, counter) // A caller-supplied TokenStore, seeded directly (bypassing any HTTP // exchange) with a token that expires in 250s: inside the 300s buffer, - // so GetAccessToken must treat it as not-cached-valid and refresh, even + // so GetAppAccessToken must treat it as not-cached-valid and refresh, even // though time.Now().Unix() < that expiresAt. store := &customTokenStore{} store.Set(qdmp.TokenEntry{AccessToken: "stale-within-buffer-token", ExpiresAt: now + 250}) @@ -279,22 +280,22 @@ func TestAuthGetAccessToken_ProactivelyRefreshesWithinBuffer(t *testing.T) { t.Fatalf("qdmp.NewClient() error = %v", err) } - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err != nil { - t.Fatalf("GetAccessToken() error = %v, want nil", err) + t.Fatalf("GetAppAccessToken() error = %v, want nil", err) } - if token != "freshly-refreshed-token" { - t.Fatalf("GetAccessToken() = %q, want the proactively refreshed token %q, not the stale within-buffer one", token, "freshly-refreshed-token") + if appToken.AccessToken != "freshly-refreshed-token" { + t.Fatalf("GetAppAccessToken().AccessToken = %q, want the proactively refreshed token %q, not the stale within-buffer one", appToken.AccessToken, "freshly-refreshed-token") } if counter.Count() != 1 { t.Fatalf("server hit %d times, want exactly 1 (proactive refresh must happen)", counter.Count()) } } -// TestAuthGetAccessToken_CachedOutsideBuffer is the complementary boundary +// TestAuthGetAppAccessToken_CachedOutsideBuffer is the complementary boundary // case: a token expiring just outside the 300s buffer must be served from // cache with zero HTTP calls. -func TestAuthGetAccessToken_CachedOutsideBuffer(t *testing.T) { +func TestAuthGetAppAccessToken_CachedOutsideBuffer(t *testing.T) { now := time.Now().Unix() counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { t.Fatalf("server should never have been called: token is valid outside the refresh buffer") @@ -312,12 +313,12 @@ func TestAuthGetAccessToken_CachedOutsideBuffer(t *testing.T) { t.Fatalf("qdmp.NewClient() error = %v", err) } - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err != nil { - t.Fatalf("GetAccessToken() error = %v, want nil", err) + t.Fatalf("GetAppAccessToken() error = %v, want nil", err) } - if token != "still-fresh-token" { - t.Fatalf("GetAccessToken() = %q, want the cached token %q", token, "still-fresh-token") + if appToken.AccessToken != "still-fresh-token" { + t.Fatalf("GetAppAccessToken().AccessToken = %q, want the cached token %q", appToken.AccessToken, "still-fresh-token") } if counter.Count() != 0 { t.Fatalf("server hit %d times, want 0 (token is outside the refresh buffer)", counter.Count()) @@ -328,7 +329,7 @@ func TestAuthGetAccessToken_CachedOutsideBuffer(t *testing.T) { // TestAuthRefreshToken_HTTP200BusinessFailure above (which only covers the // failure path). func TestAuthRefreshToken_Success(t *testing.T) { - // See TestAuthCode2Session_Success for why this is computed relative to + // See TestAuthGetUserAccessToken_Success for why this is computed relative to // time.Now() instead of a hardcoded absolute timestamp. wantExpiresAt := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { @@ -365,22 +366,22 @@ func TestAuthRefreshToken_Success(t *testing.T) { } } -// TestAuthCode2Session_Failure is Code2Session's failure-path counterpart to -// TestAuthCode2Session_Success above (which only covers the happy path): a +// TestAuthGetUserAccessToken_Failure is GetUserAccessToken's failure-path counterpart to +// TestAuthGetUserAccessToken_Success above (which only covers the happy path): a // consumed/invalid AUTHORIZATION_CODE must surface as a *QdmpApiError, never // a zero-value result mistaken for success. -func TestAuthCode2Session_Failure(t *testing.T) { +func TestAuthGetUserAccessToken_Failure(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - businessEnvelope(w, http.StatusOK, float64(10001), "code 已使用或已过期", "req-code2session-fail", nil) + businessEnvelope(w, http.StatusOK, float64(10001), "code 已使用或已过期", "req-user-token-fail", nil) })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "already-consumed-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "already-consumed-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because code=10001") + t.Fatalf("GetUserAccessToken() error = nil, want an error because code=10001") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } apiErr, ok := err.(*qdmp.QdmpApiError) if !ok { @@ -457,7 +458,7 @@ func (s *customTokenStore) Clear() { s.has = false } -// TestNewClient_CustomTokenStore verifies GetAccessToken actually reads from +// TestNewClient_CustomTokenStore verifies GetAppAccessToken actually reads from // and writes to a caller-supplied TokenStore (the multi-instance-deployment // use case documented on the TokenStore type), not just the built-in // in-process default. @@ -480,12 +481,12 @@ func TestNewClient_CustomTokenStore(t *testing.T) { t.Fatalf("qdmp.NewClient() error = %v", err) } - token, err := client.Auth.GetAccessToken(context.Background()) + appToken, err := client.Auth.GetAppAccessToken(context.Background()) if err != nil { - t.Fatalf("GetAccessToken() error = %v, want nil", err) + t.Fatalf("GetAppAccessToken() error = %v, want nil", err) } - if token != "custom-store-token" { - t.Fatalf("GetAccessToken() = %q, want %q", token, "custom-store-token") + if appToken.AccessToken != "custom-store-token" { + t.Fatalf("GetAppAccessToken().AccessToken = %q, want %q", appToken.AccessToken, "custom-store-token") } if store.setHits != 1 { t.Fatalf("custom store Set() called %d times, want 1 (the exchange result must be written through the injected store)", store.setHits) @@ -493,10 +494,62 @@ func TestNewClient_CustomTokenStore(t *testing.T) { // A second call must be served from the injected store's cache, not a // fresh exchange. - if _, err := client.Auth.GetAccessToken(context.Background()); err != nil { - t.Fatalf("second GetAccessToken() error = %v", err) + if _, err := client.Auth.GetAppAccessToken(context.Background()); err != nil { + t.Fatalf("second GetAppAccessToken() error = %v", err) } if counter.Count() != 1 { t.Fatalf("server hit %d times across two calls, want 1 (custom store must be consulted for caching)", counter.Count()) } } + +// TestAuthGetAppAccessToken_CacheHitIsAsCompleteAsFreshExchange pins down +// CONTRACT.md §1.1's core requirement: the app-level credential is returned +// whole (accessToken + expiresAt + refreshToken + openId), and the call +// served from the TokenStore cache returns exactly the same object as the +// call that performed the exchange. Caching only accessToken/expiresAt — the +// shape this replaced — would make the second call silently drop +// refreshToken, so a caller's behavior would depend on whether it happened +// to be the one that triggered the exchange. +// +// The mock mirrors the captured CLIENT_CREDENTIALS response: a genuinely +// non-empty refreshToken and an openId that is the empty string (an +// app-level credential belongs to no user), neither of which may be rejected +// as invalid. +func TestAuthGetAppAccessToken_CacheHitIsAsCompleteAsFreshExchange(t *testing.T) { + wantExpiresAt := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10) + counter := newRequestCounter(func(w http.ResponseWriter, r *http.Request) { + businessEnvelope(w, http.StatusOK, "0", "ok", "req-app-complete-1", map[string]any{ + "accessToken": "app-complete-access-token", + "expiresAt": wantExpiresAt, + "refreshToken": "app-complete-refresh-token", + "openId": "", + }) + }) + srv := startServer(t, counter) + client := newTestClient(t, srv.URL) + + fresh, err := client.Auth.GetAppAccessToken(context.Background()) + if err != nil { + t.Fatalf("first GetAppAccessToken() error = %v, want nil (an empty openId must not be rejected)", err) + } + want := qdmp.AppAccessTokenResult{ + AccessToken: "app-complete-access-token", + ExpiresAt: wantExpiresAt, + RefreshToken: "app-complete-refresh-token", + OpenID: "", + } + if *fresh != want { + t.Fatalf("freshly exchanged credential = %+v, want %+v", *fresh, want) + } + + cached, err := client.Auth.GetAppAccessToken(context.Background()) + if err != nil { + t.Fatalf("second GetAppAccessToken() error = %v, want nil", err) + } + if counter.Count() != 1 { + t.Fatalf("server hit %d times across two calls, want 1 (the second call must come from the cache)", counter.Count()) + } + if *cached != *fresh { + t.Fatalf("cache-hit credential = %+v, want it identical to the freshly exchanged one %+v", *cached, *fresh) + } +} diff --git a/go/auth_code2session_fields_test.go b/go/auth_user_access_token_fields_test.go similarity index 53% rename from go/auth_code2session_fields_test.go rename to go/auth_user_access_token_fields_test.go index 2a82b9d..30ced30 100644 --- a/go/auth_code2session_fields_test.go +++ b/go/auth_user_access_token_fields_test.go @@ -2,13 +2,13 @@ package qdmp_test // Contract exercised by this file (not yet implemented): // -// Code2SessionResult (auth.go ~line 22) declares RefreshToken and OpenID -// fields, but Code2Session (auth.go ~line 188) only ever checks +// UserAccessTokenResult (auth.go ~line 22) declares RefreshToken and OpenID +// fields, but GetUserAccessToken (auth.go ~line 188) only ever checks // result.AccessToken == "". A response reporting code=0 with a populated // accessToken/expiresAt but a missing or empty refreshToken/openId must -// still surface as an error: the caller has no usable user-level session +// still surface as an error: the caller has no usable user-level credential // without a refreshToken to renew it later, or an openId to know whose -// session it is. +// credential it is. import ( "context" @@ -16,9 +16,9 @@ import ( "testing" ) -// TestAuthCode2Session_MissingRefreshTokenRejected drives a response that +// TestAuthGetUserAccessToken_MissingRefreshTokenRejected drives a response that // omits the refreshToken field entirely (as opposed to sending it empty). -func TestAuthCode2Session_MissingRefreshTokenRejected(t *testing.T) { +func TestAuthGetUserAccessToken_MissingRefreshTokenRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-norefresh-1", map[string]any{ "accessToken": "user-level-access-token", @@ -29,18 +29,18 @@ func TestAuthCode2Session_MissingRefreshTokenRejected(t *testing.T) { })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "some-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "some-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because refreshToken is missing from the response") + t.Fatalf("GetUserAccessToken() error = nil, want an error because refreshToken is missing from the response") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } } -// TestAuthCode2Session_EmptyRefreshTokenRejected is the same failure mode +// TestAuthGetUserAccessToken_EmptyRefreshTokenRejected is the same failure mode // but with an explicit empty string rather than an omitted field. -func TestAuthCode2Session_EmptyRefreshTokenRejected(t *testing.T) { +func TestAuthGetUserAccessToken_EmptyRefreshTokenRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-norefresh-2", map[string]any{ "accessToken": "user-level-access-token", @@ -51,18 +51,18 @@ func TestAuthCode2Session_EmptyRefreshTokenRejected(t *testing.T) { })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "some-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "some-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because refreshToken is an empty string") + t.Fatalf("GetUserAccessToken() error = nil, want an error because refreshToken is an empty string") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } } -// TestAuthCode2Session_MissingOpenIDRejected drives a response that omits +// TestAuthGetUserAccessToken_MissingOpenIDRejected drives a response that omits // the openId field entirely. -func TestAuthCode2Session_MissingOpenIDRejected(t *testing.T) { +func TestAuthGetUserAccessToken_MissingOpenIDRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-noopenid-1", map[string]any{ "accessToken": "user-level-access-token", @@ -73,18 +73,18 @@ func TestAuthCode2Session_MissingOpenIDRejected(t *testing.T) { })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "some-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "some-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because openId is missing from the response") + t.Fatalf("GetUserAccessToken() error = nil, want an error because openId is missing from the response") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } } -// TestAuthCode2Session_EmptyOpenIDRejected is the same failure mode but with +// TestAuthGetUserAccessToken_EmptyOpenIDRejected is the same failure mode but with // an explicit empty string rather than an omitted field. -func TestAuthCode2Session_EmptyOpenIDRejected(t *testing.T) { +func TestAuthGetUserAccessToken_EmptyOpenIDRejected(t *testing.T) { srv := startServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { businessEnvelope(w, http.StatusOK, "0", "ok", "req-noopenid-2", map[string]any{ "accessToken": "user-level-access-token", @@ -95,11 +95,11 @@ func TestAuthCode2Session_EmptyOpenIDRejected(t *testing.T) { })) client := newTestClient(t, srv.URL) - sess, err := client.Auth.Code2Session(context.Background(), "some-code") + cred, err := client.Auth.GetUserAccessToken(context.Background(), "some-code") if err == nil { - t.Fatalf("Code2Session() error = nil, want an error because openId is an empty string") + t.Fatalf("GetUserAccessToken() error = nil, want an error because openId is an empty string") } - if sess != nil { - t.Fatalf("Code2Session() result = %+v, want nil on failure", sess) + if cred != nil { + t.Fatalf("GetUserAccessToken() result = %+v, want nil on failure", cred) } } diff --git a/go/client.go b/go/client.go index 0e61389..9dd4c84 100644 --- a/go/client.go +++ b/go/client.go @@ -22,7 +22,7 @@ const defaultQdmpVersion = "1.0" // http.Client (used whenever ClientOptions.HTTPClient is nil). Without this, // a hung token endpoint would block indefinitely: the default *http.Client // has no timeout at all, which combined with AuthService's single-flight -// refresh means every concurrent GetAccessToken caller would pile up behind +// refresh means every concurrent GetAppAccessToken caller would pile up behind // one stuck exchange with no way out other than each caller's own context // deadline. Callers needing a different bound can still override it via // ClientOptions.HTTPClient. @@ -75,7 +75,7 @@ type Client struct { // Auth manages the app-level (CLIENT_CREDENTIALS) token lifecycle // (cached + single-flight refresh) plus the one-shot, - // never-cached code2Session/refreshToken exchanges. + // never-cached getUserAccessToken/refreshToken exchanges. Auth *AuthService } @@ -191,6 +191,11 @@ func isLoopbackHost(host string) bool { // only place business group methods (User, Island, Spu, Tag, Mark, WishSpu, // GenAI) are reachable, by design: obtaining one requires a token, so it is // no longer possible to "forget" to pass one at the call site. +// +// The token it carries is static: this SDK never renews it. When a call +// fails because the token expired or was revoked, the *QdmpApiError is +// returned unchanged and it is up to the caller to obtain a new token (see +// AuthService.RefreshToken) and derive a new UserClient with it. type UserClient struct { client *Client accessToken string diff --git a/go/doc.go b/go/doc.go index 4cf0ff8..0aeaa1d 100644 --- a/go/doc.go +++ b/go/doc.go @@ -1,7 +1,7 @@ // Package qdmp is the Go Server SDK for 千岛小程序开放平台 OpenAPI. // // Construct a client with NewClient, exchange an app-level access token via -// Client.Auth.GetAccessToken (cached and single-flight refreshed +// Client.Auth.GetAppAccessToken (cached and single-flight refreshed // automatically), and derive a per-user client with Client.WithAccessToken // to call the business operation groups (User, Island, Spu, Tag, Mark, // WishSpu, GenAI). See the exported types on Client, AuthService, diff --git a/go/generated/types.gen.go b/go/generated/types.gen.go index cc72f7c..d5b03c5 100644 --- a/go/generated/types.gen.go +++ b/go/generated/types.gen.go @@ -119,10 +119,10 @@ type AuthTokenJSONBody struct { // AppSecret 应用密钥 AppSecret string `json:"appSecret"` - // Code 授权码,来自 qd.login()。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 + // Code 一次性授权码,小程序前端调宿主的 qd.login() 获取,需用户已在 App 内登录;其对应的原生接口是 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:"openapi"},取响应的 data.code),非小程序环境可直接调用。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 Code *string `json:"code,omitempty"` - // GrantType 授权类型:CLIENT_CREDENTIALS 或 AUTHORIZATION_CODE + // GrantType 凭证类型:CLIENT_CREDENTIALS(应用凭证)或 AUTHORIZATION_CODE(用户授权凭证) GrantType AuthTokenJSONBodyGrantType `json:"grantType"` } @@ -249,7 +249,7 @@ type IslandDetailParams struct { // Id Island ID(源类型 int64,线格式为字符串) Id string `form:"id" json:"id"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -292,7 +292,7 @@ type MarkAddJSONBody struct { // MarkAddParams defines parameters for MarkAdd. type MarkAddParams struct { - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -332,7 +332,7 @@ type MarkDetailParams struct { // Offset 历史记录分页偏移量(最多 100,int64 线格式为字符串) Offset string `form:"offset" json:"offset"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -369,7 +369,7 @@ type MarkListParams struct { // Offset 分页偏移量(最多 100,int64 线格式为字符串) Offset string `form:"offset" json:"offset"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -409,7 +409,7 @@ type MarkSearchParams struct { // Offset 分页偏移量(最多 100,int64 线格式为字符串) Offset string `form:"offset" json:"offset"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -443,7 +443,7 @@ type SpuDetailParams struct { // Id SPU ID(源类型 uint64,线格式为字符串) Id string `form:"id" json:"id"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -504,7 +504,7 @@ type SpuSearchParams struct { // Limit 每页数量(offset+limit<=100,uint64 线格式为字符串) Limit *string `form:"limit,omitempty" json:"limit,omitempty"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -563,7 +563,7 @@ type TagDetailParams struct { // Id Tag ID(源类型 uint64,线格式为字符串) Id string `form:"id" json:"id"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -612,7 +612,7 @@ type TagSearchParams struct { // Limit 每页数量(offset+limit<=100,uint64 线格式为字符串) Limit *string `form:"limit,omitempty" json:"limit,omitempty"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -643,7 +643,7 @@ type TagSearch401JSONResponseBody_Code struct { // UserMeParams defines parameters for UserMe. type UserMeParams struct { - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -683,7 +683,7 @@ type WishAddJSONBody struct { // WishAddParams defines parameters for WishAdd. type WishAddParams struct { - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -726,7 +726,7 @@ type WishCancelJSONBody struct { // WishCancelParams defines parameters for WishCancel. type WishCancelParams struct { - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 @@ -769,7 +769,7 @@ type WishListParams struct { // Limit 每页数量(int64 线格式为字符串) Limit string `form:"limit" json:"limit"` - // AccessToken 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 + // AccessToken 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 AccessToken AccessTokenHeader `json:"access-token"` // XEchoQdmpVersion qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 diff --git a/go/tokenstore.go b/go/tokenstore.go index b4cd4e5..463233e 100644 --- a/go/tokenstore.go +++ b/go/tokenstore.go @@ -2,13 +2,23 @@ package qdmp import "sync" -// TokenEntry is a cached app-level access token plus its absolute Unix-second -// expiry timestamp. This mirrors the wire format confirmed by real traffic -// capture: expiresAt is an absolute Unix seconds timestamp, not a relative -// "seconds remaining" duration and not milliseconds. +// TokenEntry is a cached app-level ("应用凭证") credential: the whole thing, +// not just the access token. ExpiresAt mirrors the wire format confirmed by +// real traffic capture — an absolute Unix seconds timestamp, not a relative +// "seconds remaining" duration and not milliseconds — parsed from the +// string the server sends. +// +// RefreshToken and OpenID are cached alongside so that a call served from +// this cache returns exactly the same fields as the call that performed the +// exchange (see AppAccessTokenResult). The CLIENT_CREDENTIALS grant really +// does return a non-empty refreshToken and an empty openId; neither is +// required, and neither changes how the SDK renews this credential (it +// re-exchanges ahead of expiry, it never uses this refreshToken). type TokenEntry struct { - AccessToken string - ExpiresAt int64 + AccessToken string + ExpiresAt int64 + RefreshToken string + OpenID string } // TokenStore is the pluggable persistence interface for the SDK's app-level diff --git a/java/build.gradle.kts b/java/build.gradle.kts index b76093a..1708e24 100644 --- a/java/build.gradle.kts +++ b/java/build.gradle.kts @@ -16,7 +16,7 @@ plugins { } group = "io.github.echotechfe" -version = "0.1.1" +version = "0.2.0" java { sourceCompatibility = JavaVersion.VERSION_11 diff --git a/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/AuthTokenRequest.java b/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/AuthTokenRequest.java index d8e2c3f..b42c623 100644 --- a/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/AuthTokenRequest.java +++ b/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/AuthTokenRequest.java @@ -29,7 +29,7 @@ @javax.annotation.Generated(value = "org.openapitools.codegen.languages.JavaClientCodegen", comments = "Generator version: 7.16.0") public class AuthTokenRequest { /** - * 授权类型:CLIENT_CREDENTIALS 或 AUTHORIZATION_CODE + * 凭证类型:CLIENT_CREDENTIALS(应用凭证)或 AUTHORIZATION_CODE(用户授权凭证) */ public enum GrantTypeEnum { CLIENT_CREDENTIALS(String.valueOf("CLIENT_CREDENTIALS")), @@ -89,7 +89,7 @@ public AuthTokenRequest grantType(@javax.annotation.Nonnull GrantTypeEnum grantT } /** - * 授权类型:CLIENT_CREDENTIALS 或 AUTHORIZATION_CODE + * 凭证类型:CLIENT_CREDENTIALS(应用凭证)或 AUTHORIZATION_CODE(用户授权凭证) * @return grantType */ @javax.annotation.Nonnull @@ -164,7 +164,7 @@ public AuthTokenRequest code(@javax.annotation.Nullable String code) { } /** - * 授权码,来自 qd.login()。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 + * 一次性授权码,小程序前端调宿主的 qd.login() 获取,需用户已在 App 内登录;其对应的原生接口是 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:\"openapi\"},取响应的 data.code),非小程序环境可直接调用。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 * @return code */ @javax.annotation.Nullable diff --git a/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/KnownErrorCodes.java b/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/KnownErrorCodes.java index 303754e..3895fb9 100644 --- a/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/KnownErrorCodes.java +++ b/java/generated/src/main/java/io/github/echotechfe/qdmp/generated/KnownErrorCodes.java @@ -29,7 +29,7 @@ public final class KnownErrorCodes { /** {@code 10002}: 密钥不匹配. Source: 文档 */ public static final int APP_SECRET_MISMATCH = 10002; - /** {@code 10003}: 授权码错误. Source: 文档 */ + /** {@code 10003}: 授权码错误或超过 5 分钟. Source: 文档(实测复核确认:HTTP 200 + 该 code,服务端返回的 message 是「授权码错误或超过 5 分钟」——授权码有 5 分钟有效期,源文档未写明) */ public static final int AUTH_CODE_INVALID = 10003; /** {@code 10004}: 认证类型不支持. Source: 文档 */ diff --git a/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java b/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java index a121a1e..c8489e4 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpClient.java @@ -11,9 +11,9 @@ import okhttp3.OkHttpClient; /** - * Entry point for the qdmp Server SDK. Exposes {@link #auth()} for app-level/user-level token - * management and one accessor per business group (e.g. {@link #mark()}), each of which routes - * through a single shared {@link QdmpTransport} built from the given {@link QdmpClientConfig}. + * Entry point for the qdmp Server SDK. Exposes {@link #auth()} for app/user credential management + * and one accessor per business group (e.g. {@link #mark()}), each of which routes through a single + * shared {@link QdmpTransport} built from the given {@link QdmpClientConfig}. */ public final class QdmpClient { diff --git a/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java b/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java index df0aa15..275a489 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpContext.java @@ -59,6 +59,11 @@ static void requireHeaderSafe(String accessToken) { } } + /** + * Returns the access token this context carries. + * + * @return the access token + */ public String getAccessToken() { return accessToken; } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java b/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java index 003c5df..8165324 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/QdmpTransport.java @@ -32,6 +32,10 @@ * tolerates both real envelope shapes -- {@code {code, message, requestId, data}} and the gateway * {@code {code, message, details}} -- and treats {@code String(code) == "0"} as the only success * signal, never the HTTP status code. + * + *

The {@link QdmpContext}-taking overloads are a convenience for the business groups: they read + * the token off the context and send the request exactly once. The SDK never renews a credential on + * the caller's behalf, so an HTTP 401 reaches the caller untouched. */ public final class QdmpTransport { @@ -117,6 +121,27 @@ public T get( return execute(requestBuilder.build(), dataType); } + /** + * Issues a GET request authenticated with the token carried by a {@link QdmpContext}. + * + * @param path the operation path, e.g. {@code /user/v1/me} + * @param scheme which header pair to attach + * @param ctx the calling context, carrying the token to authenticate with + * @param query optional query parameters; values may be scalars or {@link List}s (repeated + * params) + * @param dataType the response {@code data} POJO type + * @param the response {@code data} type + * @return the deserialized {@code data} payload + */ + public T get( + String path, + AuthScheme scheme, + QdmpContext ctx, + Map query, + Class dataType) { + return get(path, scheme, ctx.getAccessToken(), query, dataType); + } + /** * Issues a POST request with a JSON body and parses the {@code data} field of a successful * business envelope into {@code dataType}. @@ -131,16 +156,34 @@ public T get( */ public T post( String path, AuthScheme scheme, String accessToken, Object body, Class dataType) { - RequestBody requestBody; + Request.Builder requestBuilder = + new Request.Builder().url(pathUrlBuilder(path).build()).post(jsonBody(body)); + applyAuthHeaders(requestBuilder, scheme, accessToken); + return execute(requestBuilder.build(), dataType); + } + + /** + * Issues a POST request authenticated with the token carried by a {@link QdmpContext}. + * + * @param path the operation path, e.g. {@code /mark/v1/add} + * @param scheme which header pair to attach + * @param ctx the calling context, carrying the token to authenticate with + * @param body the request body, serialized as JSON + * @param dataType the response {@code data} POJO type + * @param the response {@code data} type + * @return the deserialized {@code data} payload + */ + public T post( + String path, AuthScheme scheme, QdmpContext ctx, Object body, Class dataType) { + return post(path, scheme, ctx.getAccessToken(), body, dataType); + } + + private static RequestBody jsonBody(Object body) { try { - requestBody = RequestBody.create(JSON.writeValueAsBytes(body), JSON_MEDIA_TYPE); + return RequestBody.create(JSON.writeValueAsBytes(body), JSON_MEDIA_TYPE); } catch (IOException e) { throw new QdmpTransportException("failed to serialize request body", e); } - Request.Builder requestBuilder = - new Request.Builder().url(pathUrlBuilder(path).build()).post(requestBody); - applyAuthHeaders(requestBuilder, scheme, accessToken); - return execute(requestBuilder.build(), dataType); } private HttpUrl.Builder pathUrlBuilder(String path) { diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/AppAccessTokenResult.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/AppAccessTokenResult.java new file mode 100644 index 0000000..c66cd76 --- /dev/null +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/AppAccessTokenResult.java @@ -0,0 +1,107 @@ +package io.github.echotechfe.qdmp.auth; + +/** + * The full app credential returned by {@link QdmpAuth#getAppAccessToken()}: access token, expiry, + * refresh token, and open ID. + * + *

The {@code CLIENT_CREDENTIALS} response carries the same four fields the user-authorization + * response does, so all four are handed to the caller rather than just the access token -- + * otherwise the expiry (needed to schedule anything around it) and the refresh token would be + * silently dropped. Two of them behave differently from their user-authorization counterparts and + * are therefore not validated as non-empty: {@code openId} is an empty string on this path (the + * credential represents the app, not a user), and {@code refreshToken}, while present in practice, + * is passed through as-is. The SDK itself never uses that refresh token -- it renews the app + * credential by re-running {@code CLIENT_CREDENTIALS} 300 seconds before expiry -- but the caller + * is free to. + * + *

Hand-written (not generated) so that {@link #toString()} can omit the actual token values -- + * unlike the openapi-generator DTOs, whose generated {@code toString()} prints every field verbatim + * and would otherwise write plaintext credentials into anything that logs this object (e.g. {@code + * logger.info("credential={}", credential)}). + */ +public final class AppAccessTokenResult { + + private final String accessToken; + private final long expiresAtEpochSeconds; + private final String refreshToken; + private final String openId; + + /** + * Creates a new app credential. + * + * @param accessToken the app-level access token + * @param expiresAtEpochSeconds the absolute expiry of {@code accessToken}, as Unix seconds (not + * milliseconds, and not a relative "seconds remaining" duration) + * @param refreshToken the refresh token reported alongside it, passed through unvalidated + * @param openId the open ID reported alongside it -- an empty string on this path + */ + public AppAccessTokenResult( + String accessToken, long expiresAtEpochSeconds, String refreshToken, String openId) { + this.accessToken = accessToken; + this.expiresAtEpochSeconds = expiresAtEpochSeconds; + this.refreshToken = refreshToken; + this.openId = openId; + } + + /** + * Returns the app-level access token. + * + * @return the access token + */ + public String getAccessToken() { + return accessToken; + } + + /** + * Returns the absolute expiry of the access token, as Unix seconds. + * + * @return the expiry, in epoch seconds + */ + public long getExpiresAtEpochSeconds() { + return expiresAtEpochSeconds; + } + + /** + * Returns the refresh token reported alongside the access token, exactly as the server sent it. + * + * @return the refresh token, or {@code null}/empty if the server omitted it + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Returns the open ID reported alongside the access token, exactly as the server sent it. + * + * @return the open ID -- an empty string on the app-credential path + */ + public String getOpenId() { + return openId; + } + + @Override + public String toString() { + return "AppAccessTokenResult{accessToken=[REDACTED], refreshToken=[REDACTED]," + + " expiresAtEpochSeconds=" + + expiresAtEpochSeconds + + ", openId=" + + sanitizeForToString(openId) + + "}"; + } + + // openId is not validated at all on this path (it is empty by design), so it can carry + // server-controlled control characters such as embedded CR/LF. This class exists specifically so + // logging it is safe, so toString() must never let openId forge extra log lines. getOpenId() + // intentionally still returns the raw value -- this only affects the debug rendering. + private static String sanitizeForToString(String value) { + if (value == null) { + return null; + } + StringBuilder sanitized = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + sanitized.append(c < 0x20 || c == 0x7f ? ' ' : c); + } + return sanitized.toString(); + } +} diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/CachedAppToken.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/CachedAppToken.java index 10c0ae4..d3a66cb 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/CachedAppToken.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/CachedAppToken.java @@ -3,24 +3,37 @@ import java.util.Objects; /** - * An app-level access token (obtained via {@code CLIENT_CREDENTIALS}) together with its absolute - * expiry, as cached by {@link QdmpAuth#getAccessToken()}. + * The app credential (obtained via {@code CLIENT_CREDENTIALS}) as cached by {@link + * QdmpAuth#getAppAccessToken()}. + * + *

Holds every field the server returned -- access token, expiry, refresh token, open ID -- and + * not just the two the SDK itself needs, so that a call served from the cache hands the caller + * exactly the same {@link AppAccessTokenResult} a freshly-exchanged one does. Storing only + * accessToken/expiresAt would make the cached path silently return an object missing two fields. */ public final class CachedAppToken { private final String accessToken; private final long expiresAtEpochSeconds; + private final String refreshToken; + private final String openId; /** - * Creates a new cached token. + * Creates a new cached credential. * * @param accessToken the app-level access token * @param expiresAtEpochSeconds the absolute expiry, as Unix seconds (not milliseconds, and not a * relative "seconds remaining" duration) + * @param refreshToken the refresh token the server reported alongside it, passed through + * unvalidated + * @param openId the open ID the server reported alongside it -- an empty string on this path */ - public CachedAppToken(String accessToken, long expiresAtEpochSeconds) { + public CachedAppToken( + String accessToken, long expiresAtEpochSeconds, String refreshToken, String openId) { this.accessToken = accessToken; this.expiresAtEpochSeconds = expiresAtEpochSeconds; + this.refreshToken = refreshToken; + this.openId = openId; } /** @@ -41,6 +54,24 @@ public long getExpiresAtEpochSeconds() { return expiresAtEpochSeconds; } + /** + * Returns the refresh token reported alongside the access token. + * + * @return the refresh token, or {@code null}/empty if the server omitted it + */ + public String getRefreshToken() { + return refreshToken; + } + + /** + * Returns the open ID reported alongside the access token. + * + * @return the open ID -- an empty string on the app-credential path + */ + public String getOpenId() { + return openId; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -51,11 +82,13 @@ public boolean equals(Object o) { } CachedAppToken other = (CachedAppToken) o; return expiresAtEpochSeconds == other.expiresAtEpochSeconds - && Objects.equals(accessToken, other.accessToken); + && Objects.equals(accessToken, other.accessToken) + && Objects.equals(refreshToken, other.refreshToken) + && Objects.equals(openId, other.openId); } @Override public int hashCode() { - return Objects.hash(accessToken, expiresAtEpochSeconds); + return Objects.hash(accessToken, expiresAtEpochSeconds, refreshToken, openId); } } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/QdmpAuth.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/QdmpAuth.java index d4a3e4d..808ee1e 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/QdmpAuth.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/QdmpAuth.java @@ -14,24 +14,31 @@ /** * Token lifecycle management, per {@code POST /auth/v1/token} and {@code POST /auth/v1/refresh}. * - *

The app-level token ({@code CLIENT_CREDENTIALS}, via {@link #getAccessToken()}) is cached and - * refreshed automatically: cached values are reused until fewer than {@link - * #REFRESH_BUFFER_SECONDS} remain before expiry, refreshes are single-flighted under a lock so + *

The app credential ({@code CLIENT_CREDENTIALS}, via {@link #getAppAccessToken()}) is cached + * and renewed automatically: cached values are reused until fewer than {@link + * #REFRESH_BUFFER_SECONDS} remain before expiry, renewals are single-flighted under a lock so * concurrent callers never trigger more than one network call, and persistence goes through the * pluggable {@link TokenStore}. * - *

User-level tokens ({@code AUTHORIZATION_CODE}, via {@link #code2Session} and {@link - * #refreshToken}) are never cached by the SDK -- a server process serves many end users at once and - * has no way to know, on its own, which one a given call belongs to. Callers must persist these - * themselves. + *

User-authorization credentials ({@code AUTHORIZATION_CODE}, via {@link #getUserAccessToken} + * and {@link #refreshToken}) are never cached by the SDK -- a server process serves many end users + * at once and has no way to know, on its own, which one a given call belongs to. Callers must + * persist these themselves and decide when to renew one, calling {@link #refreshToken} as needed. */ public final class QdmpAuth { /** - * Proactive refresh buffer, in seconds: the app-level token is refreshed once fewer than this + * Proactive renewal buffer, in seconds: the cached app credential is renewed once fewer than this * many seconds remain before its reported expiry, rather than waiting for it to actually expire. */ - private static final long REFRESH_BUFFER_SECONDS = 300; + static final long REFRESH_BUFFER_SECONDS = 300; + + // All three auth endpoints authenticate with appId/appSecret (or the refresh token) in the + // request body -- their route metadata declares authScheme "noAuth", so no token header is + // attached at all. Named rather than passed as a bare `null` because QdmpTransport overloads its + // credential parameter (String vs QdmpContext), which a bare `null` literal would leave + // ambiguous. + private static final String NO_ACCESS_TOKEN = null; private static final RouteMeta.Entry AUTH_TOKEN_ROUTE = RouteMeta.get("authToken"); private static final RouteMeta.Entry AUTH_REFRESH_ROUTE = RouteMeta.get("authRefresh"); @@ -62,19 +69,24 @@ public QdmpAuth( } /** - * Returns a valid app-level access token, reusing/refreshing the cached one as needed. + * Returns a valid app credential, reusing/renewing the cached one as needed. + * + *

The object returned by the cached path and the object returned by the freshly-exchanged path + * are built from the same {@link CachedAppToken} by {@link #toAppAccessTokenResult}, so both + * carry the full set of fields -- a caller can never tell which path served it apart by looking + * at what is populated. * - * @return the app-level access token + * @return the app credential: access token, expiry, refresh token, and open ID */ - public String getAccessToken() { + public AppAccessTokenResult getAppAccessToken() { CachedAppToken cached = tokenStore.get().orElse(null); if (cached != null && hasSufficientTimeRemaining(cached)) { - return cached.getAccessToken(); + return toAppAccessTokenResult(cached); } synchronized (refreshLock) { CachedAppToken recheck = tokenStore.get().orElse(null); if (recheck != null && hasSufficientTimeRemaining(recheck)) { - return recheck.getAccessToken(); + return toAppAccessTokenResult(recheck); } AuthTokenRequest request = new AuthTokenRequest() @@ -85,7 +97,7 @@ public String getAccessToken() { transport.post( AUTH_TOKEN_ROUTE.getPath(), AuthScheme.fromWireValue(AUTH_TOKEN_ROUTE.getAuthScheme()), - null, + NO_ACCESS_TOKEN, request, AuthToken200ResponseAllOfData.class); CachedAppToken fresh = toCachedAppToken(data); @@ -102,10 +114,18 @@ public String getAccessToken() { + "\"); refusing to cache or return it"); } tokenStore.set(fresh); - return fresh.getAccessToken(); + return toAppAccessTokenResult(fresh); } } + private static AppAccessTokenResult toAppAccessTokenResult(CachedAppToken token) { + return new AppAccessTokenResult( + token.getAccessToken(), + token.getExpiresAtEpochSeconds(), + token.getRefreshToken(), + token.getOpenId()); + } + // The server reported code:"0" (success), but the SDK still has to guard against a // structurally malformed body: a null/empty `accessToken` or a non-numeric/negative `expiresAt` // would otherwise surface as a bare NullPointerException/NumberFormatException instead of one of @@ -118,22 +138,27 @@ private static CachedAppToken toCachedAppToken(AuthToken200ResponseAllOfData dat data == null ? null : data.getExpiresAt(), "POST /auth/v1/token"); long expiresAtEpochSeconds = parseExpiresAt(data.getExpiresAt(), "POST /auth/v1/token"); - return new CachedAppToken(data.getAccessToken(), expiresAtEpochSeconds); + // Unlike the user-authorization path, refreshToken/openId are deliberately not required here: + // the CLIENT_CREDENTIALS response reports an empty openId (the credential represents the app, + // not a user), and its refreshToken -- which the SDK never uses, since it renews by re-running + // CLIENT_CREDENTIALS -- is passed through exactly as received rather than validated. + return new CachedAppToken( + data.getAccessToken(), expiresAtEpochSeconds, data.getRefreshToken(), data.getOpenId()); } /** - * Exchanges a one-time {@code AUTHORIZATION_CODE} login code for a user-level session. Never - * cached by the SDK -- persist the result yourself if you need it beyond this call. + * Exchanges a one-time authorization code for a user-authorization credential. Never cached by + * the SDK -- persist the result yourself if you need it beyond this call. * - * @param code the login code, e.g. from {@code qd.login()}; required and must be non-empty for - * grantType {@code AUTHORIZATION_CODE} - * @return the full session: access token, refresh token, expiry, and open ID + * @param code the one-time authorization code obtained by the app/front end; required and must be + * non-empty for grantType {@code AUTHORIZATION_CODE} + * @return the full credential: access token, refresh token, expiry, and open ID * @throws QdmpValidationError if {@code code} is {@code null} or empty */ - public Code2SessionResult code2Session(String code) { + public UserAccessTokenResult getUserAccessToken(String code) { if (code == null || code.isEmpty()) { throw new QdmpValidationError( - "code2Session: \"code\" is required for grantType=AUTHORIZATION_CODE and must be a" + "getUserAccessToken: \"code\" is required for grantType=AUTHORIZATION_CODE and must be a" + " non-empty string"); } AuthTokenRequest request = @@ -146,7 +171,7 @@ public Code2SessionResult code2Session(String code) { transport.post( AUTH_TOKEN_ROUTE.getPath(), AuthScheme.fromWireValue(AUTH_TOKEN_ROUTE.getAuthScheme()), - null, + NO_ACCESS_TOKEN, request, AuthToken200ResponseAllOfData.class); requireNonMalformed( @@ -158,15 +183,15 @@ public Code2SessionResult code2Session(String code) { requireNonEmptyField(data.getRefreshToken(), "refreshToken", "POST /auth/v1/token"); requireNonEmptyField(data.getOpenId(), "openId", "POST /auth/v1/token"); requireNotAlreadyExpired(expiresAtEpochSeconds, "POST /auth/v1/token"); - return new Code2SessionResult( + return new UserAccessTokenResult( data.getAccessToken(), data.getRefreshToken(), expiresAtEpochSeconds, data.getOpenId()); } /** * Exchanges a user-level refresh token for a new access token. Never cached by the SDK. * - * @param refreshToken the refresh token previously obtained from {@link #code2Session}; required - * and must be non-empty + * @param refreshToken the refresh token previously obtained from {@link #getUserAccessToken}; + * required and must be non-empty * @return the new access token and its expiry * @throws QdmpValidationError if {@code refreshToken} is {@code null} or empty */ @@ -180,7 +205,7 @@ public RefreshTokenResult refreshToken(String refreshToken) { transport.post( AUTH_REFRESH_ROUTE.getPath(), AuthScheme.fromWireValue(AUTH_REFRESH_ROUTE.getAuthScheme()), - null, + NO_ACCESS_TOKEN, request, AuthRefresh200ResponseAllOfData.class); requireNonMalformed( @@ -193,10 +218,11 @@ public RefreshTokenResult refreshToken(String refreshToken) { return new RefreshTokenResult(data.getAccessToken(), expiresAtEpochSeconds); } - // Shared guard for all three auth endpoints (CLIENT_CREDENTIALS/code2Session/refreshToken): the - // server reported code:"0" (success), but the SDK still has to protect callers from a - // structurally malformed body -- a null `data` or a null/empty `accessToken`/`expiresAt` must - // never be handed back as a silently-broken session/token object. + // Shared guard for all three auth endpoints + // (CLIENT_CREDENTIALS/getUserAccessToken/refreshToken): the server reported code:"0" (success), + // but the SDK still has to protect callers from a structurally malformed body -- a null `data` or + // a null/empty `accessToken`/`expiresAt` must never be handed back as a silently-broken + // credential object. private static void requireNonMalformed( boolean dataIsNull, String accessToken, String expiresAt, String routeDescription) { if (dataIsNull @@ -232,10 +258,12 @@ private static long parseExpiresAt(String expiresAt, String routeDescription) { return parsed; } - // code2Session's Javadoc promises "the full session: access token, refresh token, expiry, and - // open ID" -- unlike accessToken/expiresAt (checked by requireNonMalformed), refreshToken/openId - // were never validated at all, so a response missing either field would silently hand back a - // session object with a null/empty refresh token or open ID. + // getUserAccessToken's Javadoc promises "the full credential: access token, refresh token, + // expiry, and open ID" -- unlike accessToken/expiresAt (checked by requireNonMalformed), + // refreshToken/openId were never validated at all, so a response missing either field would + // silently hand back a credential object with a null/empty refresh token or open ID. This guard + // is user-authorization-only: on the CLIENT_CREDENTIALS path openId is empty by design (see + // toCachedAppToken). private static void requireNonEmptyField( String value, String fieldName, String routeDescription) { if (value == null || value.isEmpty()) { @@ -248,14 +276,12 @@ private static void requireNonEmptyField( } } - // code2Session/refreshToken are one-shot exchanges the SDK never caches (unlike the app-level + // getUserAccessToken/refreshToken are one-shot exchanges the SDK never caches (unlike the // CLIENT_CREDENTIALS path, guarded by hasSufficientTimeRemaining's stricter - // REFRESH_BUFFER_SECONDS - // buffer), so there is no cache-freshness policy to apply here -- but a non-negative expiresAt - // can - // still already be in the past (e.g. "1", 1970-01-01; parseExpiresAt only rejects negative/ - // non-numeric values). Handing back a session/token that is already dead on arrival would let a - // caller persist (or start relying on) a permanently-broken session. + // REFRESH_BUFFER_SECONDS buffer), so there is no cache-freshness policy to apply here -- but a + // non-negative expiresAt can still already be in the past (e.g. "1", 1970-01-01; parseExpiresAt + // only rejects negative/non-numeric values). Handing back a credential that is already dead on + // arrival would let a caller persist (or start relying on) a permanently-broken one. private void requireNotAlreadyExpired(long expiresAtEpochSeconds, String routeDescription) { if (expiresAtEpochSeconds <= clock.instant().getEpochSecond()) { throw new QdmpTransportException( diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java index 439b7e0..5241998 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/TokenStore.java @@ -3,30 +3,30 @@ import java.util.Optional; /** - * Pluggable persistence for the app-level (CLIENT_CREDENTIALS) token managed by {@link QdmpAuth}. + * Pluggable persistence for the app credential (CLIENT_CREDENTIALS) managed by {@link QdmpAuth}. * The default is an in-process {@link InMemoryTokenStore}; multi-instance deployments should supply * an external implementation (e.g. Redis-backed) so that instances share one cached token instead * of each independently racing to refresh it against the qdmp API. * - *

User-level tokens (from {@code AUTHORIZATION_CODE}) are never stored here -- see {@link - * QdmpAuth#code2Session}. + *

User-authorization credentials (from {@code AUTHORIZATION_CODE}) are never stored here -- see + * {@link QdmpAuth#getUserAccessToken}. */ public interface TokenStore { /** - * Returns the currently stored app-level token, if any. + * Returns the currently stored app credential, if any. * * @return the cached token, or {@link Optional#empty()} if none has been stored yet */ Optional get(); /** - * Replaces the stored app-level token. + * Replaces the stored app credential. * - * @param token the token to store + * @param token the credential to store */ void set(CachedAppToken token); - /** Removes any stored app-level token. */ + /** Removes any stored app credential. */ void clear(); } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/auth/Code2SessionResult.java b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserAccessTokenResult.java similarity index 87% rename from java/src/main/java/io/github/echotechfe/qdmp/auth/Code2SessionResult.java rename to java/src/main/java/io/github/echotechfe/qdmp/auth/UserAccessTokenResult.java index a48dfd5..61cdb5c 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/auth/Code2SessionResult.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/auth/UserAccessTokenResult.java @@ -1,15 +1,15 @@ package io.github.echotechfe.qdmp.auth; /** - * The full user session returned by {@link QdmpAuth#code2Session(String)}: access token, refresh - * token, expiry, and open ID. + * The full user-authorization credential returned by {@link QdmpAuth#getUserAccessToken(String)}: + * access token, refresh token, expiry, and open ID. * *

Hand-written (not generated) so that {@link #toString()} can omit the actual token values -- * unlike the openapi-generator DTOs, whose generated {@code toString()} prints every field verbatim * and would otherwise write plaintext credentials into anything that logs this object (e.g. {@code - * logger.info("session={}", session)}). + * logger.info("credential={}", credential)}). */ -public final class Code2SessionResult { +public final class UserAccessTokenResult { private final String accessToken; private final String refreshToken; @@ -17,7 +17,7 @@ public final class Code2SessionResult { private final String openId; /** - * Creates a new session result. + * Creates a new user-authorization credential. * * @param accessToken the user-level access token * @param refreshToken the refresh token, exchangeable via {@link QdmpAuth#refreshToken(String)} @@ -25,7 +25,7 @@ public final class Code2SessionResult { * milliseconds, and not a relative "seconds remaining" duration) * @param openId the user's open ID, unique within this app */ - public Code2SessionResult( + public UserAccessTokenResult( String accessToken, String refreshToken, long expiresAtEpochSeconds, String openId) { this.accessToken = accessToken; this.refreshToken = refreshToken; @@ -71,7 +71,7 @@ public String getOpenId() { @Override public String toString() { - return "Code2SessionResult{accessToken=[REDACTED], refreshToken=[REDACTED]," + return "UserAccessTokenResult{accessToken=[REDACTED], refreshToken=[REDACTED]," + " expiresAtEpochSeconds=" + expiresAtEpochSeconds + ", openId=" diff --git a/java/src/main/java/io/github/echotechfe/qdmp/errors/QdmpValidationError.java b/java/src/main/java/io/github/echotechfe/qdmp/errors/QdmpValidationError.java index d67273e..b26be01 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/errors/QdmpValidationError.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/errors/QdmpValidationError.java @@ -2,12 +2,12 @@ /** * Thrown for SDK-local parameter validation failures that never reach the network -- e.g. a - * missing/empty {@code code} passed to {@code auth.code2Session} (required for grantType {@code - * AUTHORIZATION_CODE}) or a missing/empty {@code refreshToken} passed to {@code auth.refreshToken}. - * Mirrors the Node SDK's {@code QdmpValidationError} (see {@code node/src/errors.ts}) for - * cross-language behavioral parity. Kept distinct from {@link QdmpApiError}, which always - * represents an actual server response, and from {@link QdmpTransportException}, which represents a - * below-HTTP-layer failure. + * missing/empty {@code code} passed to {@code auth.getUserAccessToken} (required for grantType + * {@code AUTHORIZATION_CODE}) or a missing/empty {@code refreshToken} passed to {@code + * auth.refreshToken}. Mirrors the Node SDK's {@code QdmpValidationError} (see {@code + * node/src/errors.ts}) for cross-language behavioral parity. Kept distinct from {@link + * QdmpApiError}, which always represents an actual server response, and from {@link + * QdmpTransportException}, which represents a below-HTTP-layer failure. */ public final class QdmpValidationError extends RuntimeException { diff --git a/java/src/main/java/io/github/echotechfe/qdmp/groups/GenaiGroup.java b/java/src/main/java/io/github/echotechfe/qdmp/groups/GenaiGroup.java index df76769..70014c2 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/groups/GenaiGroup.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/groups/GenaiGroup.java @@ -36,7 +36,7 @@ public GenaiGenerate200ResponseAllOfData generate(QdmpContext ctx, GenaiGenerate return transport.post( GENERATE_ROUTE.getPath(), AuthScheme.fromWireValue(GENERATE_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, request, GenaiGenerate200ResponseAllOfData.class); } @@ -52,7 +52,7 @@ public GenaiDetail200ResponseAllOfData detail(QdmpContext ctx, String id) { return transport.get( DETAIL_ROUTE.getPath(), AuthScheme.fromWireValue(DETAIL_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, Map.of("id", id), GenaiDetail200ResponseAllOfData.class); } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/groups/IslandGroup.java b/java/src/main/java/io/github/echotechfe/qdmp/groups/IslandGroup.java index 31f4ee2..63c3ca6 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/groups/IslandGroup.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/groups/IslandGroup.java @@ -33,7 +33,7 @@ public IslandDetail200ResponseAllOfData detail(QdmpContext ctx, String id) { return transport.get( DETAIL_ROUTE.getPath(), AuthScheme.fromWireValue(DETAIL_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, Map.of("id", id), IslandDetail200ResponseAllOfData.class); } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/groups/MarkGroup.java b/java/src/main/java/io/github/echotechfe/qdmp/groups/MarkGroup.java index 40ae530..ddea39f 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/groups/MarkGroup.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/groups/MarkGroup.java @@ -40,7 +40,7 @@ public MarkAdd200ResponseAllOfData add(QdmpContext ctx, MarkAddRequest request) return transport.post( ADD_ROUTE.getPath(), AuthScheme.fromWireValue(ADD_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, request, MarkAdd200ResponseAllOfData.class); } @@ -59,7 +59,7 @@ public MarkList200ResponseAllOfData list(QdmpContext ctx, String limit, String o return transport.get( LIST_ROUTE.getPath(), AuthScheme.fromWireValue(LIST_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, Map.of("limit", limit, "offset", offset), MarkList200ResponseAllOfData.class); } @@ -75,7 +75,7 @@ public MarkSearch200ResponseAllOfData search(QdmpContext ctx, MarkSearchParams p return transport.get( SEARCH_ROUTE.getPath(), AuthScheme.fromWireValue(SEARCH_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, params.toQueryMap(), MarkSearch200ResponseAllOfData.class); } @@ -97,7 +97,7 @@ public MarkDetail200ResponseAllOfData detail( return transport.get( DETAIL_ROUTE.getPath(), AuthScheme.fromWireValue(DETAIL_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, Map.of("id", id, "limit", limit, "offset", offset), MarkDetail200ResponseAllOfData.class); } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/groups/SpuGroup.java b/java/src/main/java/io/github/echotechfe/qdmp/groups/SpuGroup.java index 0ca4377..9e4c8a1 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/groups/SpuGroup.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/groups/SpuGroup.java @@ -31,7 +31,7 @@ public SpuDetail200ResponseAllOfData detail(QdmpContext ctx, String id) { return transport.get( DETAIL_ROUTE.getPath(), AuthScheme.fromWireValue(DETAIL_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, Map.of("id", id), SpuDetail200ResponseAllOfData.class); } @@ -48,7 +48,7 @@ public SpuSearch200ResponseAllOfData search(QdmpContext ctx, SpuSearchParams par return transport.get( SEARCH_ROUTE.getPath(), AuthScheme.fromWireValue(SEARCH_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, params.toQueryMap(), SpuSearch200ResponseAllOfData.class); } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/groups/TagGroup.java b/java/src/main/java/io/github/echotechfe/qdmp/groups/TagGroup.java index 2cb5994..8febd90 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/groups/TagGroup.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/groups/TagGroup.java @@ -31,7 +31,7 @@ public TagDetail200ResponseAllOfData detail(QdmpContext ctx, String id) { return transport.get( DETAIL_ROUTE.getPath(), AuthScheme.fromWireValue(DETAIL_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, Map.of("id", id), TagDetail200ResponseAllOfData.class); } @@ -47,7 +47,7 @@ public TagSearch200ResponseAllOfData search(QdmpContext ctx, TagSearchParams par return transport.get( SEARCH_ROUTE.getPath(), AuthScheme.fromWireValue(SEARCH_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, params.toQueryMap(), TagSearch200ResponseAllOfData.class); } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/groups/UserGroup.java b/java/src/main/java/io/github/echotechfe/qdmp/groups/UserGroup.java index ecce33a..438631f 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/groups/UserGroup.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/groups/UserGroup.java @@ -28,7 +28,7 @@ public UserMe200ResponseAllOfData me(QdmpContext ctx) { return transport.get( ME_ROUTE.getPath(), AuthScheme.fromWireValue(ME_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, null, UserMe200ResponseAllOfData.class); } diff --git a/java/src/main/java/io/github/echotechfe/qdmp/groups/WishSpuGroup.java b/java/src/main/java/io/github/echotechfe/qdmp/groups/WishSpuGroup.java index 46207f5..ebc83a2 100644 --- a/java/src/main/java/io/github/echotechfe/qdmp/groups/WishSpuGroup.java +++ b/java/src/main/java/io/github/echotechfe/qdmp/groups/WishSpuGroup.java @@ -36,7 +36,7 @@ public WishAdd200ResponseAllOfData add(QdmpContext ctx, WishAddRequest request) return transport.post( ADD_ROUTE.getPath(), AuthScheme.fromWireValue(ADD_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, request, WishAdd200ResponseAllOfData.class); } @@ -52,7 +52,7 @@ public WishAdd200ResponseAllOfData cancel(QdmpContext ctx, WishCancelRequest req return transport.post( CANCEL_ROUTE.getPath(), AuthScheme.fromWireValue(CANCEL_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, request, WishAdd200ResponseAllOfData.class); } @@ -68,7 +68,7 @@ public WishList200ResponseAllOfData list(QdmpContext ctx, WishListParams params) return transport.get( LIST_ROUTE.getPath(), AuthScheme.fromWireValue(LIST_ROUTE.getAuthScheme()), - ctx.getAccessToken(), + ctx, params.toQueryMap(), WishList200ResponseAllOfData.class); } diff --git a/java/src/test/java/io/github/echotechfe/qdmp/QdmpTransportResponseSizeLimitTest.java b/java/src/test/java/io/github/echotechfe/qdmp/QdmpTransportResponseSizeLimitTest.java index 4966a03..0e19dae 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/QdmpTransportResponseSizeLimitTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/QdmpTransportResponseSizeLimitTest.java @@ -14,11 +14,12 @@ * {@code QdmpTransport#readBody} currently calls {@code body.string()} with no size limit at all -- * a malicious or malfunctioning endpoint can return an arbitrarily large response body and the SDK * will buffer the entire thing into memory before even attempting to parse it. This test drives a - * real {@code code2Session} call against a response whose body is otherwise perfectly well-formed - * (valid JSON, valid accessToken/expiresAt/refreshToken/openId) except for one padding field that - * balloons it to roughly 11 MB, to prove the requirement is about size, not malformed content: - * today this call succeeds (no size cap exists), which is exactly the gap this test should catch - * until a response-size limit is enforced and the oversized response is rejected instead of parsed. + * real {@code getUserAccessToken} call against a response whose body is otherwise perfectly + * well-formed (valid JSON, valid accessToken/expiresAt/refreshToken/openId) except for one padding + * field that balloons it to roughly 11 MB, to prove the requirement is about size, not malformed + * content: today this call succeeds (no size cap exists), which is exactly the gap this test should + * catch until a response-size limit is enforced and the oversized response is rejected instead of + * parsed. */ class QdmpTransportResponseSizeLimitTest { @@ -47,7 +48,7 @@ void oversizedResponseBody_isRejected_ratherThanFullyBufferedAndParsed() { server.enqueue(new MockResponse().setResponseCode(200).setBody(body)); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .as("an ~11MB response body must be rejected, not fully buffered into memory and parsed") .isInstanceOf(RuntimeException.class); } diff --git a/java/src/test/java/io/github/echotechfe/qdmp/auth/InMemoryTokenStoreTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/InMemoryTokenStoreTest.java index 8e9ec8a..79c0687 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/InMemoryTokenStoreTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/InMemoryTokenStoreTest.java @@ -20,7 +20,8 @@ void get_beforeAnyTokenStored_isEmpty() { @Test void set_thenGet_returnsTheSameToken() { TokenStore store = new InMemoryTokenStore(); - CachedAppToken token = new CachedAppToken("app-level-token", 1_900_000_000L); + CachedAppToken token = + new CachedAppToken("app-level-token", 1_900_000_000L, "app-refresh-token", ""); store.set(token); @@ -32,7 +33,7 @@ void set_thenGet_returnsTheSameToken() { @Test void clear_removesTheStoredToken() { TokenStore store = new InMemoryTokenStore(); - store.set(new CachedAppToken("app-level-token", 1_900_000_000L)); + store.set(new CachedAppToken("app-level-token", 1_900_000_000L, "app-refresh-token", "")); store.clear(); @@ -44,7 +45,7 @@ void separateInstances_doNotShareState() { TokenStore a = new InMemoryTokenStore(); TokenStore b = new InMemoryTokenStore(); - a.set(new CachedAppToken("token-a", 1_900_000_000L)); + a.set(new CachedAppToken("token-a", 1_900_000_000L, "app-refresh-token", "")); assertThat(b.get()).isEmpty(); } diff --git a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthExpiresAtValidationTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthExpiresAtValidationTest.java index c0f3794..46fb36d 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthExpiresAtValidationTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthExpiresAtValidationTest.java @@ -12,16 +12,16 @@ import org.junit.jupiter.api.Test; /** - * {@code code2Session}/{@code refreshToken} currently validate {@code data.expiresAt} only via - * {@code requireNonMalformed}, which checks for {@code null}/empty -- it never parses the string as - * a number. That is inconsistent with the CLIENT_CREDENTIALS caching path ({@code + * {@code getUserAccessToken}/{@code refreshToken} currently validate {@code data.expiresAt} only + * via {@code requireNonMalformed}, which checks for {@code null}/empty -- it never parses the + * string as a number. That is inconsistent with the CLIENT_CREDENTIALS caching path ({@code * toCachedAppToken}), which does call {@code Long.parseLong(data.getExpiresAt())} and turns a * {@link NumberFormatException} into a typed {@link * io.github.echotechfe.qdmp.errors.QdmpTransportException}. A non-numeric or negative {@code * expiresAt} must be rejected on every one of the three auth response paths, not just * CLIENT_CREDENTIALS -- and {@code toCachedAppToken}'s existing {@code Long.parseLong} call must * also be tightened to reject negative values, which it currently accepts silently (an existing - * bug, not just a gap in the new code2Session/refreshToken paths). + * bug, not just a gap in the new getUserAccessToken/refreshToken paths). */ class QdmpAuthExpiresAtValidationTest { @@ -39,7 +39,7 @@ void stopServer() throws IOException { } @Test - void code2Session_nonNumericExpiresAt_throws() { + void getUserAccessToken_nonNumericExpiresAt_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -49,12 +49,12 @@ void code2Session_nonNumericExpiresAt_throws() { + "\"openId\":\"open-1\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .isInstanceOf(RuntimeException.class); } @Test - void code2Session_negativeExpiresAt_throws() { + void getUserAccessToken_negativeExpiresAt_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -63,7 +63,7 @@ void code2Session_negativeExpiresAt_throws() { + "\"expiresAt\":\"-100\",\"refreshToken\":\"ref\",\"openId\":\"open-1\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .isInstanceOf(RuntimeException.class); } @@ -99,11 +99,11 @@ void refreshToken_negativeExpiresAt_throws() { * Regression test (codex convergence-check finding): a non-negative but already-expired {@code * expiresAt} (e.g. {@code "1"}, 1970-01-01) passes {@code parseExpiresAt} (which only rejects * negative/non-numeric values) and must still be rejected by {@code requireNotAlreadyExpired} -- - * otherwise a caller could persist or start relying on a session/token that is already dead on + * otherwise a caller could persist or start relying on a credential that is already dead on * arrival. */ @Test - void code2Session_alreadyExpiredExpiresAt_throws() { + void getUserAccessToken_alreadyExpiredExpiresAt_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -112,7 +112,7 @@ void code2Session_alreadyExpiredExpiresAt_throws() { + "\"expiresAt\":\"1\",\"refreshToken\":\"ref\",\"openId\":\"open-1\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .isInstanceOf(RuntimeException.class); } @@ -137,7 +137,7 @@ void refreshToken_alreadyExpiredExpiresAt_throws() { * as if it were valid. */ @Test - void getAccessToken_negativeExpiresAt_throws() { + void getAppAccessToken_negativeExpiresAt_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -146,6 +146,7 @@ void getAccessToken_negativeExpiresAt_throws() { + "\"expiresAt\":\"-100\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().getAccessToken()).isInstanceOf(RuntimeException.class); + assertThatThrownBy(() -> client.auth().getAppAccessToken()) + .isInstanceOf(RuntimeException.class); } } diff --git a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthGetAccessTokenTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthGetAppAccessTokenTest.java similarity index 74% rename from java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthGetAccessTokenTest.java rename to java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthGetAppAccessTokenTest.java index c7a521f..b20b05d 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthGetAccessTokenTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthGetAppAccessTokenTest.java @@ -27,12 +27,12 @@ import org.junit.jupiter.api.Test; /** - * {@code auth.getAccessToken()} manages the app-level (CLIENT_CREDENTIALS) token: cache, + * {@code auth.getAppAccessToken()} manages the app credential (CLIENT_CREDENTIALS): cache, * single-flight refresh, pluggable {@link TokenStore}, and the two real response-envelope shapes * confirmed against the live API (see shared/openapi.yaml BusinessEnvelopeBase / * GatewayErrorEnvelope descriptions). */ -class QdmpAuthGetAccessTokenTest { +class QdmpAuthGetAppAccessTokenTest { private static final ObjectMapper JSON = new ObjectMapper(); @@ -59,9 +59,9 @@ void firstCall_sendsClientCredentialsRequest_andReturnsAccessToken() throws Exce + "\"data\":{\"accessToken\":\"app-token-1\",\"expiresAt\":\"1900000000\"}}")); QdmpClient client = TestClients.create(server); - String token = client.auth().getAccessToken(); + AppAccessTokenResult credential = client.auth().getAppAccessToken(); - assertThat(token).isEqualTo("app-token-1"); + assertThat(credential.getAccessToken()).isEqualTo("app-token-1"); RecordedRequest request = server.takeRequest(); assertThat(request.getMethod()).isEqualTo("POST"); assertThat(request.getPath()).isEqualTo("/auth/v1/token"); @@ -82,7 +82,7 @@ void emptyAccessToken_isRejected_andNeverCached() throws Exception { RecordingTokenStore tokenStore = new RecordingTokenStore(); QdmpClient client = TestClients.create(server, tokenStore); - assertThatThrownBy(() -> client.auth().getAccessToken()) + assertThatThrownBy(() -> client.auth().getAppAccessToken()) .isInstanceOf(QdmpTransportException.class); assertThat(tokenStore.getSetCallCount()).isZero(); } @@ -98,9 +98,9 @@ void numericZeroCode_isTreatedAsSuccessJustLikeStringZero() throws Exception { + "\"expiresAt\":\"1900000000\"}}")); QdmpClient client = TestClients.create(server); - String token = client.auth().getAccessToken(); + AppAccessTokenResult credential = client.auth().getAppAccessToken(); - assertThat(token).isEqualTo("app-token-numeric"); + assertThat(credential.getAccessToken()).isEqualTo("app-token-numeric"); } @Test @@ -113,11 +113,11 @@ void secondCallBeforeExpiry_isServedFromCache_noSecondRequest() throws Exception + "{\"accessToken\":\"app-token-1\",\"expiresAt\":\"9999999999\"}}")); QdmpClient client = TestClients.create(server); - String first = client.auth().getAccessToken(); - String second = client.auth().getAccessToken(); + AppAccessTokenResult first = client.auth().getAppAccessToken(); + AppAccessTokenResult second = client.auth().getAppAccessToken(); - assertThat(first).isEqualTo("app-token-1"); - assertThat(second).isEqualTo("app-token-1"); + assertThat(first.getAccessToken()).isEqualTo("app-token-1"); + assertThat(second.getAccessToken()).isEqualTo("app-token-1"); assertThat(server.getRequestCount()).isEqualTo(1); } @@ -136,7 +136,7 @@ void withinRefreshBuffer_triggersProactiveRefresh_beforeExpiry() throws Exceptio + "\"}}")); QdmpClient client = TestClients.create(server, null, clock); - assertThat(client.auth().getAccessToken()).isEqualTo("token-a"); + assertThat(client.auth().getAppAccessToken().getAccessToken()).isEqualTo("token-a"); assertThat(server.getRequestCount()).isEqualTo(1); // Advance past (expiresAt - 300s buffer): 400s - 150s = 250s remaining < 300s buffer. @@ -151,9 +151,9 @@ void withinRefreshBuffer_triggersProactiveRefresh_beforeExpiry() throws Exceptio + secondExpiresAt + "\"}}")); - String refreshed = client.auth().getAccessToken(); + AppAccessTokenResult refreshed = client.auth().getAppAccessToken(); - assertThat(refreshed).isEqualTo("token-b"); + assertThat(refreshed.getAccessToken()).isEqualTo("token-b"); assertThat(server.getRequestCount()).isEqualTo(2); } @@ -182,7 +182,7 @@ void concurrentCalls_singleFlight_onlyOneRequestReachesTheServer() throws Except ready.countDown(); try { start.await(); - results.add(client.auth().getAccessToken()); + results.add(client.auth().getAppAccessToken().getAccessToken()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { @@ -213,15 +213,71 @@ void concurrentCalls_singleFlight_onlyOneRequestReachesTheServer() throws Except @Test void prePopulatedTokenStore_isUsedWithoutAnyNetworkCall() throws Exception { RecordingTokenStore tokenStore = new RecordingTokenStore(); - tokenStore.set(new CachedAppToken("seeded-token", 9_999_999_999L)); + tokenStore.set(new CachedAppToken("seeded-token", 9_999_999_999L, "seeded-refresh-token", "")); QdmpClient client = TestClients.create(server, tokenStore); - String token = client.auth().getAccessToken(); + AppAccessTokenResult credential = client.auth().getAppAccessToken(); - assertThat(token).isEqualTo("seeded-token"); + assertThat(credential.getAccessToken()).isEqualTo("seeded-token"); assertThat(server.getRequestCount()).isEqualTo(0); } + /** + * The cache-hit path and the freshly-exchanged path must hand back an equally complete + * credential: if the cached entry only carried accessToken/expiresAt, a caller served from the + * cache would silently lose the refreshToken/openId a caller served by a live exchange gets, and + * could not tell the two apart other than by the missing fields. + */ + @Test + void cachedCredential_carriesEveryFieldTheFreshlyExchangedOneDoes() throws Exception { + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"code\":\"0\",\"message\":\"ok\",\"data\":" + + "{\"accessToken\":\"app-token-1\",\"expiresAt\":\"9999999999\"," + + "\"refreshToken\":\"app-refresh-token-1\",\"openId\":\"\"}}")); + QdmpClient client = TestClients.create(server); + + AppAccessTokenResult fresh = client.auth().getAppAccessToken(); + AppAccessTokenResult cached = client.auth().getAppAccessToken(); + + assertThat(server.getRequestCount()) + .as("the second call must be served from cache") + .isEqualTo(1); + assertThat(fresh.getAccessToken()).isEqualTo("app-token-1"); + assertThat(fresh.getExpiresAtEpochSeconds()).isEqualTo(9_999_999_999L); + assertThat(fresh.getRefreshToken()).isEqualTo("app-refresh-token-1"); + assertThat(fresh.getOpenId()).isEmpty(); + assertThat(cached.getAccessToken()).isEqualTo(fresh.getAccessToken()); + assertThat(cached.getExpiresAtEpochSeconds()).isEqualTo(fresh.getExpiresAtEpochSeconds()); + assertThat(cached.getRefreshToken()).isEqualTo(fresh.getRefreshToken()); + assertThat(cached.getOpenId()).isEqualTo(fresh.getOpenId()); + } + + /** + * The app-credential response reports an empty {@code openId} and is not contractually required + * to report a {@code refreshToken} at all, so -- unlike the user-authorization path -- neither + * field may be validated as non-empty. A response carrying only accessToken/expiresAt must still + * succeed. + */ + @Test + void missingRefreshTokenAndOpenId_areToleratedOnTheAppCredentialPath() { + server.enqueue( + new MockResponse() + .setResponseCode(200) + .setBody( + "{\"code\":\"0\",\"message\":\"ok\",\"data\":" + + "{\"accessToken\":\"app-token-1\",\"expiresAt\":\"9999999999\"}}")); + QdmpClient client = TestClients.create(server); + + AppAccessTokenResult credential = client.auth().getAppAccessToken(); + + assertThat(credential.getAccessToken()).isEqualTo("app-token-1"); + assertThat(credential.getRefreshToken()).isNull(); + assertThat(credential.getOpenId()).isNull(); + } + @Test void businessEnvelope_nonZeroCode_throwsQdmpApiError_httpStatus200() { server.enqueue( @@ -230,7 +286,7 @@ void businessEnvelope_nonZeroCode_throwsQdmpApiError_httpStatus200() { .setBody("{\"code\":10002,\"message\":\"密钥不匹配\",\"requestId\":\"req-err\"}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().getAccessToken()) + assertThatThrownBy(() -> client.auth().getAppAccessToken()) .isInstanceOf(QdmpApiError.class) .satisfies( e -> { @@ -251,7 +307,7 @@ void gatewayErrorEnvelope_hasNoDataOrRequestId_stillParsesAndThrows() { .setBody("{\"code\":13,\"message\":\"query parse error\"}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().getAccessToken()) + assertThatThrownBy(() -> client.auth().getAppAccessToken()) .isInstanceOf(QdmpApiError.class) .satisfies( e -> { @@ -271,7 +327,7 @@ void successCode_withNullData_throwsQdmpTransportException_notBareNpe() { new MockResponse().setResponseCode(200).setBody("{\"code\":\"0\",\"message\":\"ok\"}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().getAccessToken()) + assertThatThrownBy(() -> client.auth().getAppAccessToken()) .isInstanceOf(QdmpTransportException.class) .isNotInstanceOf(NullPointerException.class); } @@ -290,7 +346,7 @@ void successCode_withNonNumericExpiresAt_throwsQdmpTransportException_notBareNfe + "{\"accessToken\":\"tok\",\"expiresAt\":\"not-a-number\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().getAccessToken()) + assertThatThrownBy(() -> client.auth().getAppAccessToken()) .isInstanceOf(QdmpTransportException.class) .isNotInstanceOf(NumberFormatException.class); } @@ -315,7 +371,7 @@ void freshResponse_withAlreadyExpiredExpiresAt_isRejected_andNeverCached() throw RecordingTokenStore tokenStore = new RecordingTokenStore(); QdmpClient client = TestClients.create(server, tokenStore); - assertThatThrownBy(() -> client.auth().getAccessToken()) + assertThatThrownBy(() -> client.auth().getAppAccessToken()) .isInstanceOf(QdmpTransportException.class); assertThat(tokenStore.getSetCallCount()).isZero(); } @@ -335,7 +391,7 @@ void failure_doesNotLeakAppSecretInExceptionMessageOrToString() { QdmpApiError error = (QdmpApiError) - org.assertj.core.api.Assertions.catchThrowable(() -> client.auth().getAccessToken()); + org.assertj.core.api.Assertions.catchThrowable(() -> client.auth().getAppAccessToken()); assertThat(error).isNotNull(); assertThat(error.toString()).doesNotContain(secretAppSecret); diff --git a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthResultTypesTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthResultTypesTest.java index ef5c1a4..db534af 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthResultTypesTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthResultTypesTest.java @@ -12,16 +12,17 @@ import org.junit.jupiter.api.Test; /** - * {@code code2Session}/{@code refreshToken} currently return the raw openapi-generator DTOs {@code - * AuthToken200ResponseAllOfData}/{@code AuthRefresh200ResponseAllOfData}. Those generated classes' - * {@code toString()} is produced by the openapi-generator template and prints every field verbatim, - * including {@code accessToken}/{@code refreshToken} -- so a caller doing the extremely common - * {@code logger.info("session={}", session)} would write the plaintext token straight into logs. - * Generated files can't be hand-edited (they get overwritten by the next {@code ./gradlew codegen} - * run), so the fix is to introduce hand-written result types, matching the naming already used by - * the Go SDK: {@link Code2SessionResult} (accessToken/refreshToken/expiresAtEpochSeconds/openId) - * and {@link RefreshTokenResult} (accessToken/expiresAtEpochSeconds), neither of which may print - * the actual token values from {@code toString()}. + * {@code getUserAccessToken}/{@code refreshToken} currently return the raw openapi-generator DTOs + * {@code AuthToken200ResponseAllOfData}/{@code AuthRefresh200ResponseAllOfData}. Those generated + * classes' {@code toString()} is produced by the openapi-generator template and prints every field + * verbatim, including {@code accessToken}/{@code refreshToken} -- so a caller doing the extremely + * common {@code logger.info("userCredential={}", userCredential)} would write the plaintext token + * straight into logs. Generated files can't be hand-edited (they get overwritten by the next {@code + * ./gradlew codegen} run), so the fix is to introduce hand-written result types, matching the + * naming already used by the Go SDK: {@link UserAccessTokenResult} + * (accessToken/refreshToken/expiresAtEpochSeconds/openId) and {@link RefreshTokenResult} + * (accessToken/expiresAtEpochSeconds), neither of which may print the actual token values from + * {@code toString()}. */ class QdmpAuthResultTypesTest { @@ -42,7 +43,7 @@ void stopServer() throws IOException { } @Test - void code2Session_returnsCode2SessionResult_withMatchingGetters() { + void getUserAccessToken_returnsUserAccessTokenResult_withMatchingGetters() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -54,16 +55,16 @@ void code2Session_returnsCode2SessionResult_withMatchingGetters() { + "\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult session = client.auth().code2Session("some-code"); + UserAccessTokenResult userCredential = client.auth().getUserAccessToken("some-code"); - assertThat(session.getAccessToken()).isEqualTo(SENTINEL_ACCESS_TOKEN); - assertThat(session.getRefreshToken()).isEqualTo(SENTINEL_REFRESH_TOKEN); - assertThat(session.getExpiresAtEpochSeconds()).isEqualTo(1900000000L); - assertThat(session.getOpenId()).isEqualTo("open-id-1"); + assertThat(userCredential.getAccessToken()).isEqualTo(SENTINEL_ACCESS_TOKEN); + assertThat(userCredential.getRefreshToken()).isEqualTo(SENTINEL_REFRESH_TOKEN); + assertThat(userCredential.getExpiresAtEpochSeconds()).isEqualTo(1900000000L); + assertThat(userCredential.getOpenId()).isEqualTo("open-id-1"); } @Test - void code2Session_toString_doesNotLeakAccessTokenOrRefreshToken() { + void getUserAccessToken_toString_doesNotLeakAccessTokenOrRefreshToken() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -75,22 +76,22 @@ void code2Session_toString_doesNotLeakAccessTokenOrRefreshToken() { + "\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult session = client.auth().code2Session("some-code"); + UserAccessTokenResult userCredential = client.auth().getUserAccessToken("some-code"); - assertThat(session.toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); - assertThat(session.toString()).doesNotContain(SENTINEL_REFRESH_TOKEN); + assertThat(userCredential.toString()).doesNotContain(SENTINEL_ACCESS_TOKEN); + assertThat(userCredential.toString()).doesNotContain(SENTINEL_REFRESH_TOKEN); } /** * Regression test (codex convergence-check finding): {@code openId} is only validated as * non-empty (see {@code QdmpAuth.requireNonEmptyField}), so a server-controlled value containing * embedded CR/LF would, without this fix, survive verbatim into {@code toString()} and forge - * extra log lines via {@code logger.info("session={}", session)} -- the exact log-injection class - * of bug already fixed for message/code/requestId elsewhere. {@code getOpenId()} must still - * return the raw value unchanged; only the debug rendering is sanitized. + * extra log lines via {@code logger.info("userCredential={}", userCredential)} -- the exact + * log-injection class of bug already fixed for message/code/requestId elsewhere. {@code + * getOpenId()} must still return the raw value unchanged; only the debug rendering is sanitized. */ @Test - void code2Session_toString_doesNotLeakRawCrLfInOpenId() { + void getUserAccessToken_toString_doesNotLeakRawCrLfInOpenId() { String injectedOpenId = "open-id-1\\r\\nFAKE injected log line"; server.enqueue( new MockResponse() @@ -105,11 +106,11 @@ void code2Session_toString_doesNotLeakRawCrLfInOpenId() { + "\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult session = client.auth().code2Session("some-code"); + UserAccessTokenResult userCredential = client.auth().getUserAccessToken("some-code"); - assertThat(session.toString()).doesNotContain("\r").doesNotContain("\n"); + assertThat(userCredential.toString()).doesNotContain("\r").doesNotContain("\n"); // Reverse-direction guard: getOpenId() must still return the raw value untouched. - assertThat(session.getOpenId()).isEqualTo("open-id-1\r\nFAKE injected log line"); + assertThat(userCredential.getOpenId()).isEqualTo("open-id-1\r\nFAKE injected log line"); } @Test diff --git a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthCodeSessionAndRefreshTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenAndRefreshTest.java similarity index 79% rename from java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthCodeSessionAndRefreshTest.java rename to java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenAndRefreshTest.java index 9869652..49f81ad 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthCodeSessionAndRefreshTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenAndRefreshTest.java @@ -19,12 +19,12 @@ import org.junit.jupiter.api.Test; /** - * {@code auth.code2Session(code)} (AUTHORIZATION_CODE grant) and {@code + * {@code auth.getUserAccessToken(code)} (AUTHORIZATION_CODE grant) and {@code * auth.refreshToken(refreshToken)} are one-shot calls: the SDK never caches the resulting * user-level tokens, unlike the app-level CLIENT_CREDENTIALS flow covered in {@link - * QdmpAuthGetAccessTokenTest}. + * QdmpAuthGetAppAccessTokenTest}. */ -class QdmpAuthCodeSessionAndRefreshTest { +class QdmpAuthUserAccessTokenAndRefreshTest { private static final ObjectMapper JSON = new ObjectMapper(); @@ -42,7 +42,8 @@ void stopServer() throws IOException { } @Test - void code2Session_success_sendsAuthorizationCodeGrant_andReturnsFullSession() throws Exception { + void getUserAccessToken_success_sendsAuthorizationCodeGrant_andReturnsFullCredential() + throws Exception { server.enqueue( new MockResponse() .setResponseCode(200) @@ -52,20 +53,20 @@ void code2Session_success_sendsAuthorizationCodeGrant_andReturnsFullSession() th + "\"refreshToken\":\"user-refresh-token\",\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult session = client.auth().code2Session("wx-login-code-abc"); + UserAccessTokenResult userCredential = client.auth().getUserAccessToken("auth-code-abc"); - assertThat(session.getAccessToken()).isEqualTo("user-access-token"); - assertThat(session.getExpiresAtEpochSeconds()).isEqualTo(1900000000L); - assertThat(session.getRefreshToken()).isEqualTo("user-refresh-token"); - assertThat(session.getOpenId()).isEqualTo("open-id-1"); + assertThat(userCredential.getAccessToken()).isEqualTo("user-access-token"); + assertThat(userCredential.getExpiresAtEpochSeconds()).isEqualTo(1900000000L); + assertThat(userCredential.getRefreshToken()).isEqualTo("user-refresh-token"); + assertThat(userCredential.getOpenId()).isEqualTo("open-id-1"); RecordedRequest request = server.takeRequest(); JsonNode body = JSON.readTree(request.getBody().readUtf8()); assertThat(body.get("grantType").asText()).isEqualTo("AUTHORIZATION_CODE"); - assertThat(body.get("code").asText()).isEqualTo("wx-login-code-abc"); + assertThat(body.get("code").asText()).isEqualTo("auth-code-abc"); } @Test - void code2Session_isNeverCached_repeatedCallsAlwaysHitTheNetwork() throws Exception { + void getUserAccessToken_isNeverCached_repeatedCallsAlwaysHitTheNetwork() throws Exception { server.enqueue( new MockResponse() .setResponseCode(200) @@ -82,8 +83,8 @@ void code2Session_isNeverCached_repeatedCallsAlwaysHitTheNetwork() throws Except + "\"openId\":\"o-2\"}}")); QdmpClient client = TestClients.create(server); - Code2SessionResult first = client.auth().code2Session("code-1"); - Code2SessionResult second = client.auth().code2Session("code-2"); + UserAccessTokenResult first = client.auth().getUserAccessToken("code-1"); + UserAccessTokenResult second = client.auth().getUserAccessToken("code-2"); assertThat(first.getAccessToken()).isEqualTo("tok-1"); assertThat(second.getAccessToken()).isEqualTo("tok-2"); @@ -91,12 +92,12 @@ void code2Session_isNeverCached_repeatedCallsAlwaysHitTheNetwork() throws Except } @Test - void code2Session_invalidCode_throwsQdmpApiError() { + void getUserAccessToken_invalidCode_throwsQdmpApiError() { server.enqueue( new MockResponse().setResponseCode(200).setBody("{\"code\":10003,\"message\":\"授权码错误\"}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("bad-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("bad-code")) .isInstanceOf(QdmpApiError.class) .satisfies(e -> assertThat(((QdmpApiError) e).getCode()).isEqualTo("10003")); } @@ -148,19 +149,19 @@ void refreshToken_expired_isHttp200ButMustStillThrow() { * Node SDK's {@code QdmpValidationError} check (see node/src/auth.ts). */ @Test - void code2Session_nullCode_throwsQdmpValidationError_sendsNoRequest() { + void getUserAccessToken_nullCode_throwsQdmpValidationError_sendsNoRequest() { QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session(null)) + assertThatThrownBy(() -> client.auth().getUserAccessToken(null)) .isInstanceOf(QdmpValidationError.class); assertThat(server.getRequestCount()).isEqualTo(0); } @Test - void code2Session_emptyCode_throwsQdmpValidationError_sendsNoRequest() { + void getUserAccessToken_emptyCode_throwsQdmpValidationError_sendsNoRequest() { QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("")) .isInstanceOf(QdmpValidationError.class); assertThat(server.getRequestCount()).isEqualTo(0); } @@ -197,29 +198,30 @@ void refreshToken_invalid_throwsWithCode10007() { } /** - * {@code code2Session} must reject a malformed "success" body the same way {@code - * getAccessToken()}'s {@code toCachedAppToken} already does (see {@link - * QdmpAuthGetAccessTokenTest#successCode_withNullData_throwsQdmpTransportException_notBareNpe}): - * code:"0" but no {@code data} at all must never be handed back to the caller as a silent {@code - * null} return value -- it must surface as a {@link QdmpTransportException}. + * {@code getUserAccessToken} must reject a malformed "success" body the same way {@code + * getAppAccessToken()}'s {@code toCachedAppToken} already does (see {@code + * successCode_withNullData_throwsQdmpTransportException_notBareNpe} in {@link + * QdmpAuthGetAppAccessTokenTest}): code:"0" but no {@code data} at all must never be handed back + * to the caller as a silent {@code null} return value -- it must surface as a {@link + * QdmpTransportException}. */ @Test - void code2Session_successCode_withNullData_throwsQdmpTransportException() { + void getUserAccessToken_successCode_withNullData_throwsQdmpTransportException() { server.enqueue( new MockResponse().setResponseCode(200).setBody("{\"code\":\"0\",\"message\":\"ok\"}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("wx-login-code-abc")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("auth-code-abc")) .isInstanceOf(QdmpTransportException.class); } /** * Same malformed-success guard as above, but for the more subtle case: {@code data} is present - * but its {@code accessToken} is missing. The caller must never receive a session object whose + * but its {@code accessToken} is missing. The caller must never receive a credential object whose * {@code accessToken} is silently {@code null}. */ @Test - void code2Session_successCode_withDataMissingAccessToken_throwsQdmpTransportException() { + void getUserAccessToken_successCode_withDataMissingAccessToken_throwsQdmpTransportException() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -229,7 +231,7 @@ void code2Session_successCode_withDataMissingAccessToken_throwsQdmpTransportExce + "\"openId\":\"open-id-1\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("wx-login-code-abc")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("auth-code-abc")) .isInstanceOf(QdmpTransportException.class); } diff --git a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthCode2SessionFieldValidationTest.java b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenFieldValidationTest.java similarity index 65% rename from java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthCode2SessionFieldValidationTest.java rename to java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenFieldValidationTest.java index 8c9daa6..34b8e7f 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthCode2SessionFieldValidationTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/auth/QdmpAuthUserAccessTokenFieldValidationTest.java @@ -12,15 +12,16 @@ import org.junit.jupiter.api.Test; /** - * {@link QdmpAuth#code2Session}'s Javadoc promises "the full session: access token, refresh token, - * expiry, and open ID", but the current implementation's malformed-body guard ({@code - * requireNonMalformed}) only checks {@code accessToken}/{@code expiresAt} -- it never looks at - * {@code data.refreshToken} or {@code data.openId} at all. A response missing either field is - * currently handed back to the caller as a session object with a silently-{@code null} (or empty) - * refresh token / open ID, contradicting the documented contract. Both fields must be validated the - * same way {@code accessToken}/{@code expiresAt} already are: missing or empty must throw. + * {@link QdmpAuth#getUserAccessToken} hands back a complete user-authorization credential: access + * token, refresh token, expiry, and open ID. These tests lock in that {@code refreshToken} and + * {@code openId} are validated exactly the way {@code accessToken}/{@code expiresAt} already are -- + * a business-success envelope missing or emptying either one must throw rather than be handed to + * the caller as a credential carrying a silently-{@code null} (or empty) field. + * + *

Note this is deliberately stricter than the app-credential path, where an empty {@code openId} + * is the normal, captured server behaviour and {@code refreshToken} is not required. */ -class QdmpAuthCode2SessionFieldValidationTest { +class QdmpAuthUserAccessTokenFieldValidationTest { private MockWebServer server; @@ -36,7 +37,7 @@ void stopServer() throws IOException { } @Test - void code2Session_missingRefreshToken_throws() { + void getUserAccessToken_missingRefreshToken_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -45,12 +46,12 @@ void code2Session_missingRefreshToken_throws() { + "\"expiresAt\":\"1900000000\",\"openId\":\"open-1\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .isInstanceOf(RuntimeException.class); } @Test - void code2Session_emptyRefreshToken_throws() { + void getUserAccessToken_emptyRefreshToken_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -60,12 +61,12 @@ void code2Session_emptyRefreshToken_throws() { + "\"openId\":\"open-1\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .isInstanceOf(RuntimeException.class); } @Test - void code2Session_missingOpenId_throws() { + void getUserAccessToken_missingOpenId_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -74,12 +75,12 @@ void code2Session_missingOpenId_throws() { + "\"expiresAt\":\"1900000000\",\"refreshToken\":\"ref\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .isInstanceOf(RuntimeException.class); } @Test - void code2Session_emptyOpenId_throws() { + void getUserAccessToken_emptyOpenId_throws() { server.enqueue( new MockResponse() .setResponseCode(200) @@ -88,7 +89,7 @@ void code2Session_emptyOpenId_throws() { + "\"expiresAt\":\"1900000000\",\"refreshToken\":\"ref\",\"openId\":\"\"}}")); QdmpClient client = TestClients.create(server); - assertThatThrownBy(() -> client.auth().code2Session("some-code")) + assertThatThrownBy(() -> client.auth().getUserAccessToken("some-code")) .isInstanceOf(RuntimeException.class); } } diff --git a/java/src/test/java/io/github/echotechfe/qdmp/errors/QdmpApiErrorSanitizationTest.java b/java/src/test/java/io/github/echotechfe/qdmp/errors/QdmpApiErrorSanitizationTest.java index c7061a2..9acaa00 100644 --- a/java/src/test/java/io/github/echotechfe/qdmp/errors/QdmpApiErrorSanitizationTest.java +++ b/java/src/test/java/io/github/echotechfe/qdmp/errors/QdmpApiErrorSanitizationTest.java @@ -49,7 +49,7 @@ void message_withEmbeddedCrLf_isSanitized_inToStringAndGetMessage() { QdmpClient client = TestClients.create(server); QdmpApiError error = - (QdmpApiError) Assertions.catchThrowable(() -> client.auth().getAccessToken()); + (QdmpApiError) Assertions.catchThrowable(() -> client.auth().getAppAccessToken()); assertThat(error).isNotNull(); assertThat(error.toString()).doesNotContain("\r").doesNotContain("\n"); @@ -67,7 +67,7 @@ void veryLongMessage_isBounded_inToString() { QdmpClient client = TestClients.create(server); QdmpApiError error = - (QdmpApiError) Assertions.catchThrowable(() -> client.auth().getAccessToken()); + (QdmpApiError) Assertions.catchThrowable(() -> client.auth().getAppAccessToken()); assertThat(error).isNotNull(); assertThat(error.toString().length()) diff --git a/node/package.json b/node/package.json index 6f47489..f959b02 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@qdmp/qdmp-server-sdk", - "version": "0.1.1", + "version": "0.2.0", "description": "千岛小程序开放平台 OpenAPI 官方 Node.js Server SDK", "license": "MIT", "repository": { diff --git a/node/src/auth.ts b/node/src/auth.ts index f879a87..787408d 100644 --- a/node/src/auth.ts +++ b/node/src/auth.ts @@ -1,52 +1,33 @@ /** * Token lifecycle management: * - * - getAccessToken(): app-level (CLIENT_CREDENTIALS) token, auto-cached via - * the pluggable TokenStore, single-flight refresh, proactively renewed - * 300 seconds before real expiry (not reactive to a 401). - * - code2Session(): one-shot AUTHORIZATION_CODE exchange. Never cached — - * the calling backend owns per-end-user session persistence. + * - getAppAccessToken(): 应用凭证 (CLIENT_CREDENTIALS), auto-cached via the + * pluggable TokenStore, single-flight refresh, proactively renewed 300 + * seconds before real expiry (not reactive to a 401). + * - getUserAccessToken(): one-shot AUTHORIZATION_CODE exchange producing a + * 用户授权凭证. Never cached — the calling backend owns per-end-user + * credential persistence. * - refreshToken(): one-shot refresh of a caller-supplied refreshToken. * Never cached either. */ -import {inspect} from 'node:util'; - import type {HttpClient} from './http.js'; -import type {AppTokenData, RefreshTokenData, SessionData} from './types.js'; +import type { + AppAccessTokenData, + RefreshTokenData, + UserAccessTokenData, +} from './types.js'; import type {StoredToken, TokenStore} from './token-store.js'; import {getRouteMeta} from './generated/route-meta.js'; import {QdmpTransportError, QdmpValidationError} from './errors.js'; +import {withRedactedInspect} from './redact.js'; /** Refresh this many seconds before the token's real expiresAt. */ const APP_TOKEN_REFRESH_BUFFER_SECONDS = 300; -/** - * Attaches a non-enumerable [util.inspect.custom] renderer to `value` so - * that `console.log(value)`/`util.inspect(value)` — an easy accidental - * debug-print path — never shows the raw accessToken/refreshToken. This is - * deliberately done via Object.defineProperty on an already-constructed, - * already-typed value rather than as an object-literal property: adding - * `[inspect.custom]: ...` directly to a `SessionData`/`RefreshTokenData` - * literal would trip TypeScript's excess-property check (neither interface - * declares a symbol-keyed member). - * - * This must never affect JSON.stringify(value) or direct property access - * (value.accessToken): JSON.stringify only consults toJSON(), not - * util.inspect.custom, and the defined property here is non-enumerable so - * it does not add a spurious key to the object either. Persisting the real - * values is the entire point of code2Session()/refreshToken() existing, so - * that path must stay untouched. - */ -function withRedactedInspect( - value: T, - render: () => string, -): T { - Object.defineProperty(value, inspect.custom, { - value: render, - enumerable: false, - }); - return value; -} +/** A still-unvalidated {@link AppAccessTokenData} exactly as it arrived off + * the wire: every field is optional here because a business-success envelope + * is no guarantee that `data` actually carries them. */ +type RawAppAccessTokenData = Partial; /** Guards against a malformed "success" response: HTTP 200 + business * code='0' but with a missing/empty accessToken, or an expiresAt that can't @@ -84,12 +65,12 @@ function assertWellFormedTokenResponse( } /** Guards against a syntactically well-formed but already-expired-on-arrival - * expiresAt in a one-shot (never-cached) code2Session()/refreshToken() + * expiresAt in a one-shot (never-cached) getUserAccessToken()/refreshToken() * response. assertWellFormedTokenResponse() only checks that expiresAt * parses as a non-negative safe integer — a value like "1" (1970-01-01) - * passes that check but describes a session/token that is already dead, and + * passes that check but describes a credential that is already dead, and * must not be handed back to the caller as if it were usable (they might - * persist or start relying on it). Unlike getAccessToken()'s cached path, + * persist or start relying on it). Unlike getAppAccessToken()'s cached path, * this only rejects an already-past expiresAt, not one merely within the * app-token refresh buffer — these one-shot results are never cached by the * SDK, so there is no cache-freshness policy to apply here. */ @@ -105,15 +86,16 @@ function assertNotAlreadyExpired( } } -/** Guards against a malformed code2Session() response: HTTP 200 + business - * code='0' but with a missing/empty refreshToken or openId. Unlike +/** Guards against a malformed getUserAccessToken() response: HTTP 200 + + * business code='0' but with a missing/empty refreshToken or openId. Unlike * accessToken/expiresAt (validated by assertWellFormedTokenResponse() and - * shared with getAccessToken()/refreshToken()), refreshToken/openId are only - * present in the AUTHORIZATION_CODE grant's response, so this check is - * intentionally separate and only ever called from code2Session(). Must be + * shared with getAppAccessToken()/refreshToken()), refreshToken/openId only + * carry meaning in the AUTHORIZATION_CODE grant's response, so this check is + * intentionally separate and only ever called from getUserAccessToken() — + * the CLIENT_CREDENTIALS grant legitimately returns an empty openId. Must be * called before the result is resolved to the caller — otherwise an empty * openId could silently end up persisted as a user identifier. */ -function assertWellFormedSessionResponse( +function assertWellFormedUserAccessTokenResponse( data: {refreshToken?: string; openId?: string} | undefined, context: string, ): void { @@ -133,6 +115,29 @@ function assertWellFormedSessionResponse( } } +/** Renders a cached entry as the complete 应用凭证 callers see. Both the + * cache-hit and the just-exchanged paths go through here, so neither can + * return a credential that is less complete than the other. */ +function toAppAccessTokenData(token: StoredToken): AppAccessTokenData { + const credential: AppAccessTokenData = { + accessToken: token.accessToken, + expiresAt: token.expiresAt, + refreshToken: token.refreshToken ?? '', + openId: token.openId ?? '', + }; + return withRedactedInspect( + credential, + // JSON.stringify (not raw template interpolation) for the non-secret + // fields: expiresAt is our own validated digit-only string, but openId + // is server-controlled and not validated for this grant — an embedded + // CR/LF there would otherwise become a raw line break in this debug + // rendering, the same log-injection class of bug already fixed for + // message/code/requestId elsewhere. + () => + `AppAccessTokenData { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(credential.expiresAt)}, openId: ${JSON.stringify(credential.openId)} }`, + ); +} + export interface AuthModuleConfig { http: HttpClient; appId: string; @@ -145,7 +150,7 @@ export class AuthModule { private readonly appId: string; private readonly appSecret: string; private readonly tokenStore: TokenStore; - private inFlight: Promise | undefined; + private inFlight: Promise | undefined; /** In-process mirror of the last token this instance itself fetched or * observed as fresh, set synchronously the moment a refresh HTTP call * resolves (see refreshAppToken()) — i.e. before awaiting tokenStore.set() @@ -164,14 +169,14 @@ export class AuthModule { this.tokenStore = config.tokenStore; } - async getAccessToken(): Promise { + async getAppAccessToken(): Promise { if (this.cachedToken && this.isFresh(this.cachedToken)) { - return this.cachedToken.accessToken; + return toAppAccessTokenData(this.cachedToken); } const cached = await Promise.resolve(this.tokenStore.get()); if (cached && this.isFresh(cached)) { this.cachedToken = cached; - return cached.accessToken; + return toAppAccessTokenData(cached); } return this.refreshAppToken(); } @@ -189,28 +194,39 @@ export class AuthModule { * * Re-checks `this.cachedToken` freshness right before deciding whether to * start a new flight. This closes a race with an async/remote TokenStore: - * a caller can reach here (via `getAccessToken()`'s `await + * a caller can reach here (via `getAppAccessToken()`'s `await * tokenStore.get()`) *after* another caller's in-flight refresh has * already completed and cleared `inFlight` — if that other refresh * happened to run entirely while this caller's `tokenStore.get()` was * still pending, `this.cachedToken` was already updated in the meantime * and must be honored here instead of blindly starting a second, * redundant CLIENT_CREDENTIALS exchange. */ - private refreshAppToken(): Promise { + private refreshAppToken(): Promise { if (this.cachedToken && this.isFresh(this.cachedToken)) { - return Promise.resolve(this.cachedToken.accessToken); + return Promise.resolve(toAppAccessTokenData(this.cachedToken)); } if (!this.inFlight) { this.inFlight = this.requestClientCredentialsToken() - .then(async token => { - assertWellFormedTokenResponse(token, 'auth.getAccessToken'); + .then(async raw => { + assertWellFormedTokenResponse(raw, 'auth.getAppAccessToken'); + // refreshToken/openId are deliberately not validated here: the + // CLIENT_CREDENTIALS grant really does return an empty openId, + // and its refreshToken is not used by the SDK's own renewal + // strategy (which re-runs the grant instead). Whatever the server + // sent is passed through as-is. + const token: StoredToken = { + accessToken: raw.accessToken as string, + expiresAt: raw.expiresAt as string, + refreshToken: raw.refreshToken, + openId: raw.openId, + }; // A token that is syntactically well-formed can still be // expired-on-arrival (e.g. a server bug returns an `expiresAt` // far in the past). Such a token must never be cached or handed // back to the caller as if it were freshly usable. if (!this.isFresh(token)) { throw new QdmpTransportError( - 'auth.getAccessToken: server response was a business-success envelope but the returned token is already expired (or within the refresh buffer) on arrival', + 'auth.getAppAccessToken: server response was a business-success envelope but the returned token is already expired (or within the refresh buffer) on arrival', ); } // Only trust the local mirror once the store write actually @@ -222,7 +238,7 @@ export class AuthModule { // is left untouched and this call's own promise rejects too. await Promise.resolve(this.tokenStore.set(token)); this.cachedToken = token; - return token.accessToken; + return toAppAccessTokenData(token); }) .finally(() => { this.inFlight = undefined; @@ -231,9 +247,9 @@ export class AuthModule { return this.inFlight; } - private requestClientCredentialsToken(): Promise { + private requestClientCredentialsToken(): Promise { const route = getRouteMeta('authToken'); - return this.http.request({ + return this.http.request({ method: route.method, path: route.path, authScheme: route.authScheme, @@ -245,14 +261,14 @@ export class AuthModule { }); } - async code2Session(code: string): Promise { + async getUserAccessToken(code: string): Promise { if (typeof code !== 'string' || code.length === 0) { throw new QdmpValidationError( - 'code2Session: "code" is required for grantType=AUTHORIZATION_CODE and must be a non-empty string', + 'getUserAccessToken: "code" is required for grantType=AUTHORIZATION_CODE and must be a non-empty string', ); } const route = getRouteMeta('authToken'); - const session = await this.http.request({ + const credential = await this.http.request({ method: route.method, path: route.path, authScheme: route.authScheme, @@ -263,11 +279,14 @@ export class AuthModule { code, }, }); - assertWellFormedTokenResponse(session, 'auth.code2Session'); - assertWellFormedSessionResponse(session, 'auth.code2Session'); - assertNotAlreadyExpired(session, 'auth.code2Session'); + assertWellFormedTokenResponse(credential, 'auth.getUserAccessToken'); + assertWellFormedUserAccessTokenResponse( + credential, + 'auth.getUserAccessToken', + ); + assertNotAlreadyExpired(credential, 'auth.getUserAccessToken'); return withRedactedInspect( - session, + credential, // JSON.stringify (not raw template interpolation) for the non-secret // fields: expiresAt is our own validated digit-only string, but openId // is server-controlled and only validated as "non-empty" — an @@ -275,7 +294,7 @@ export class AuthModule { // this debug rendering, the same log-injection class of bug already // fixed for message/code/requestId elsewhere. () => - `SessionData { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(session.expiresAt)}, openId: ${JSON.stringify(session.openId)} }`, + `UserAccessTokenData { accessToken: '[REDACTED]', refreshToken: '[REDACTED]', expiresAt: ${JSON.stringify(credential.expiresAt)}, openId: ${JSON.stringify(credential.openId)} }`, ); } diff --git a/node/src/generated/error-codes.ts b/node/src/generated/error-codes.ts index a5a0a41..ef2f9c6 100644 --- a/node/src/generated/error-codes.ts +++ b/node/src/generated/error-codes.ts @@ -43,8 +43,8 @@ export const KNOWN_ERROR_CODES: readonly KnownErrorCode[] = [ { code: 10003, name: 'AUTH_CODE_INVALID', - message: "授权码错误", - source: "文档", + message: "授权码错误或超过 5 分钟", + source: "文档(实测复核确认:HTTP 200 + 该 code,服务端返回的 message 是「授权码错误或超过 5 分钟」——授权码有 5 分钟有效期,源文档未写明)", }, { code: 10004, diff --git a/node/src/generated/schema.d.ts b/node/src/generated/schema.d.ts index 2f195c1..7dd588c 100644 --- a/node/src/generated/schema.d.ts +++ b/node/src/generated/schema.d.ts @@ -15,7 +15,7 @@ export interface paths { put?: never; /** * 获取访问令牌 - * @description 支持两种授权模式:CLIENT_CREDENTIALS(客户端凭证)和 AUTHORIZATION_CODE(授权码,用户登录后)。 + * @description 签发两种凭证:CLIENT_CREDENTIALS 换应用凭证,代表这个开放平台应用本身,不代表任何用户,用于开发者内部调试、服务端联调、不需要用户身份的后台任务;AUTHORIZATION_CODE 换用户授权凭证,绑定 openId,代表某个真实登录用户,应用上线后调业务接口全程用它。 */ post: operations["authToken"]; delete?: never; @@ -555,7 +555,7 @@ export interface components { }; responses: never; parameters: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ AccessTokenHeader: string; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ QdmpVersionHeader: string; @@ -581,7 +581,7 @@ export interface operations { content: { "application/json": { /** - * @description 授权类型:CLIENT_CREDENTIALS 或 AUTHORIZATION_CODE + * @description 凭证类型:CLIENT_CREDENTIALS(应用凭证)或 AUTHORIZATION_CODE(用户授权凭证) * @enum {string} */ grantType: "CLIENT_CREDENTIALS" | "AUTHORIZATION_CODE"; @@ -589,7 +589,7 @@ export interface operations { appId: string; /** @description 应用密钥 */ appSecret: string; - /** @description 授权码,来自 qd.login()。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 */ + /** @description 一次性授权码,小程序前端调宿主的 qd.login() 获取,需用户已在 App 内登录;其对应的原生接口是 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:"openapi"},取响应的 data.code),非小程序环境可直接调用。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。 */ code?: string; }; }; @@ -699,7 +699,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -782,7 +782,7 @@ export interface operations { id: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -792,7 +792,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个 token(见 x-qdmp-token-required=true),是否传 app 级还是用户级由调用方决定,不是 SDK 自动 fallback。 */ + /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个凭证(见 x-qdmp-token-required=true),传应用凭证还是用户授权凭证由调用方决定,不是 SDK 自动 fallback。 */ 200: { headers: { [name: string]: unknown; @@ -862,7 +862,7 @@ export interface operations { id: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -976,7 +976,7 @@ export interface operations { limit?: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -986,7 +986,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 可真实调通本接口(HTTP 200 正常业务响应)。 */ + /** @description 已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证可真实调通本接口(HTTP 200 正常业务响应)。 */ 200: { headers: { [name: string]: unknown; @@ -1066,7 +1066,7 @@ export interface operations { id: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1169,7 +1169,7 @@ export interface operations { limit?: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1261,7 +1261,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1347,7 +1347,7 @@ export interface operations { offset: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1449,7 +1449,7 @@ export interface operations { offset: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1551,7 +1551,7 @@ export interface operations { offset: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1654,7 +1654,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1735,7 +1735,7 @@ export interface operations { parameters: { query?: never; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; @@ -1823,7 +1823,7 @@ export interface operations { limit: string; }; header: { - /** @description 用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。 */ + /** @description 用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。 */ "access-token": components["parameters"]["AccessTokenHeader"]; /** @description qdmp 协议版本标识,可由 SDK 配置项覆盖,默认值 "1.0"。仅 standard 鉴权方案需要。 */ "x-echo-qdmp-version": components["parameters"]["QdmpVersionHeader"]; diff --git a/node/src/http.ts b/node/src/http.ts index 98bca67..8a2f61b 100644 --- a/node/src/http.ts +++ b/node/src/http.ts @@ -179,6 +179,10 @@ export class HttpClient { this.dispatcher = config.dispatcher; } + /** Sends the request exactly once and returns the parsed `data` — or + * throws. The SDK never retries: an expired/rejected access-token surfaces + * as a QdmpApiError (HTTP 401 + business code 10005/10006) for the caller + * to handle, e.g. by calling `auth.refreshToken()` themselves. */ async request(options: HttpRequestOptions): Promise { const url = new URL(options.path, this.baseUrl); appendQuery(url, options.query); diff --git a/node/src/redact.ts b/node/src/redact.ts new file mode 100644 index 0000000..7326331 --- /dev/null +++ b/node/src/redact.ts @@ -0,0 +1,29 @@ +import {inspect} from 'node:util'; + +/** + * Attaches a non-enumerable [util.inspect.custom] renderer to `value` so + * that `console.log(value)`/`util.inspect(value)` — an easy accidental + * debug-print path — never shows the raw accessToken/refreshToken. This is + * deliberately done via Object.defineProperty on an already-constructed, + * already-typed value rather than as an object-literal property: adding + * `[inspect.custom]: ...` directly to a `UserAccessTokenData`/ + * `RefreshTokenData` literal would trip TypeScript's excess-property check + * (none of those interfaces declares a symbol-keyed member). + * + * This must never affect JSON.stringify(value) or direct property access + * (value.accessToken): JSON.stringify only consults toJSON(), not + * util.inspect.custom, and the defined property here is non-enumerable so + * it does not add a spurious key to the object either. Persisting the real + * values is the entire point of getUserAccessToken()/refreshToken() + * existing, so that path must stay untouched. + */ +export function withRedactedInspect( + value: T, + render: () => string, +): T { + Object.defineProperty(value, inspect.custom, { + value: render, + enumerable: false, + }); + return value; +} diff --git a/node/src/token-store.ts b/node/src/token-store.ts index 0ee584c..b1fdf81 100644 --- a/node/src/token-store.ts +++ b/node/src/token-store.ts @@ -15,6 +15,15 @@ export interface StoredToken { /** Absolute Unix seconds timestamp, wire format is a string (see * shared/openapi.yaml expiresAt field notes). */ expiresAt: string; + /** The rest of the credential the CLIENT_CREDENTIALS grant returns. + * Cached alongside accessToken/expiresAt so a getAppAccessToken() call + * served from the store returns exactly as complete a credential as one + * that just performed the exchange. Optional because a store populated by + * an older SDK version (or a hand-written store) may not carry them — + * they are then reported as empty strings rather than treated as an + * error. */ + refreshToken?: string; + openId?: string; } export interface TokenStore { diff --git a/node/src/types.ts b/node/src/types.ts index faefe3e..bf857dc 100644 --- a/node/src/types.ts +++ b/node/src/types.ts @@ -18,12 +18,26 @@ export interface QdmpContext { // ---- auth ---- -export interface AppTokenData { +/** The complete 应用凭证 (grantType=CLIENT_CREDENTIALS) handed back to + * callers. The server returns all four fields for this grant too — a real, + * non-empty `refreshToken` and an always-empty `openId` (no end user is + * involved) — so callers get the full credential, not just the token + * string. */ +export interface AppAccessTokenData { accessToken: string; expiresAt: string; + /** Non-empty in practice, but deliberately not validated: the SDK renews + * the 应用凭证 by re-running the CLIENT_CREDENTIALS grant, so this value is + * only passed through for callers who want to renew it themselves. */ + refreshToken: string; + /** Always an empty string for CLIENT_CREDENTIALS — kept so the shape + * matches {@link UserAccessTokenData}. */ + openId: string; } -export interface SessionData { +/** The complete 用户授权凭证 (grantType=AUTHORIZATION_CODE). Bound to a real + * end user, so `refreshToken`/`openId` are both required to be non-empty. */ +export interface UserAccessTokenData { accessToken: string; refreshToken: string; expiresAt: string; @@ -32,7 +46,7 @@ export interface SessionData { export interface RefreshTokenData { accessToken: string; - expiresAt?: string; + expiresAt: string; } // ---- user ---- diff --git a/node/test/auth-expired-fresh-token.test.ts b/node/test/auth-expired-fresh-token.test.ts index a11274f..e56abfe 100644 --- a/node/test/auth-expired-fresh-token.test.ts +++ b/node/test/auth-expired-fresh-token.test.ts @@ -27,7 +27,7 @@ import { expectFailure, } from './helpers/mock-http.js'; -void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过期的时间戳("1",1970年)时应 reject,且不得把这个已过期 token 当作新鲜缓存写入(下一次调用仍会真实重新换取)', async () => { +void test('auth.getAppAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过期的时间戳("1",1970年)时应 reject,且不得把这个已过期 token 当作新鲜缓存写入(下一次调用仍会真实重新换取)', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) @@ -42,7 +42,7 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); // 验证这个"已过期即到达"的坏值没有被当作新鲜 token 缓存下来:只注册这一个 // 新拦截器,若上一次的失败结果被误当成功缓存,这里就不会真的再发一次 HTTP @@ -55,9 +55,9 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过 }), ); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); assert.equal( - token, + token.accessToken, 'app-token-good', '前一次已过期 token 被拒绝之后,下一次调用应当真实重新发起 CLIENT_CREDENTIALS 换取,而不是复用坏缓存', ); @@ -68,11 +68,11 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但 expiresAt 是早已过 ); }); -// 回归测试(codex 收敛检查轮发现):code2Session/refreshToken 这两个"一次性、 +// 回归测试(codex 收敛检查轮发现):getUserAccessToken/refreshToken 这两个"一次性、 // 不缓存"的路径同样只校验 expiresAt 是否语法合法(非负整数字符串),没有校验 // 它是否早已过期——一次性会话/token 虽然不走本 SDK 的缓存,但如果早已过期就 // 返回给调用方,调用方可能会把这个"看起来成功"的死会话持久化下来。 -void test('auth.code2Session: expiresAt 是早已过期的时间戳("1")时应 reject', async () => { +void test('auth.getUserAccessToken: expiresAt 是早已过期的时间戳("1")时应 reject', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -90,7 +90,7 @@ void test('auth.code2Session: expiresAt 是早已过期的时间戳("1")时 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.code2Session('wx-login-code-abc')); + await expectFailure(() => client.auth.getUserAccessToken('auth-code-abc')); }); void test('auth.refreshToken: expiresAt 是早已过期的时间戳("1")时应 reject', async () => { diff --git a/node/test/auth-malformed-success.test.ts b/node/test/auth-malformed-success.test.ts index 8ea4097..404f847 100644 --- a/node/test/auth-malformed-success.test.ts +++ b/node/test/auth-malformed-success.test.ts @@ -5,7 +5,7 @@ * - resolve 成 undefined / 空字符串; * - 把这个坏值写进 tokenStore,污染后续调用的缓存。 * - * 覆盖 getAccessToken()(CLIENT_CREDENTIALS)与 refreshToken()。 + * 覆盖 getAppAccessToken()(CLIENT_CREDENTIALS)与 refreshToken()。 * * 现状(本测试写下时): * - parseEnvelope() 只用 `String(code) === '0'` 判定成功,不检查 data 内容 @@ -33,7 +33,7 @@ function tokenSuccessData(overrides: Record = {}) { }; } -void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段时应 reject,且不得把坏值写进 tokenStore(下一次调用仍会真实重新换取)', async () => { +void test('auth.getAppAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段时应 reject,且不得把坏值写进 tokenStore(下一次调用仍会真实重新换取)', async () => { const {mockAgent, pool} = createMockAgent(); // 故意不用 businessSuccess() 帮助函数:这里就是要构造"code=0 但没有 data 字段" // 这种畸形成功响应。 @@ -47,7 +47,7 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); // 验证失败之后没有任何坏值被当作"已缓存的新鲜 token"留存下来: // 只注册这一个新拦截器,若上一次的失败结果被误当成功缓存,这里就不会真的 @@ -59,9 +59,9 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段 businessSuccess(tokenSuccessData({accessToken: 'app-token-good'})), ); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); assert.equal( - token, + token.accessToken, 'app-token-good', '前一次畸形响应失败之后,下一次调用应当真实重新发起 CLIENT_CREDENTIALS 换取,而不是复用坏缓存', ); @@ -72,7 +72,7 @@ void test('auth.getAccessToken: HTTP 200 + {code:0} 但完全没有 data 字段 ); }); -void test('auth.getAccessToken: data 存在但 data.accessToken 为空字符串时应 reject,不得把空字符串当作合法 token resolve 给调用方', async () => { +void test('auth.getAppAccessToken: data 存在但 data.accessToken 为空字符串时应 reject,不得把空字符串当作合法 token resolve 给调用方', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -88,10 +88,10 @@ void test('auth.getAccessToken: data 存在但 data.accessToken 为空字符串 dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); -void test('auth.getAccessToken: data.expiresAt 为空字符串时应 reject(Number("")===0 曾让这个畸形值被当成"已过期但合法"的数字通过校验)', async () => { +void test('auth.getAppAccessToken: data.expiresAt 为空字符串时应 reject(Number("")===0 曾让这个畸形值被当成"已过期但合法"的数字通过校验)', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -107,10 +107,10 @@ void test('auth.getAccessToken: data.expiresAt 为空字符串时应 reject(Nu dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); -void test('auth.getAccessToken: data.expiresAt 为纯空白字符串时应 reject(Number(" ")===0 曾让它被当成合法数字通过校验)', async () => { +void test('auth.getAppAccessToken: data.expiresAt 为纯空白字符串时应 reject(Number(" ")===0 曾让它被当成合法数字通过校验)', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -126,10 +126,10 @@ void test('auth.getAccessToken: data.expiresAt 为纯空白字符串时应 rejec dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); -void test('auth.getAccessToken: data.expiresAt 为科学计数法(如 "1e100")时应 reject(Go/Java 的严格整数解析都会拒绝这类格式,Node 不应更宽松)', async () => { +void test('auth.getAppAccessToken: data.expiresAt 为科学计数法(如 "1e100")时应 reject(Go/Java 的严格整数解析都会拒绝这类格式,Node 不应更宽松)', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -145,7 +145,7 @@ void test('auth.getAccessToken: data.expiresAt 为科学计数法(如 "1e100" dispatcher: mockAgent, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); }); void test('auth.refreshToken: data.accessToken 为空字符串时应 reject,不得把空字符串 resolve 给调用方', async () => { diff --git a/node/test/auth-result-inspect-redaction.test.ts b/node/test/auth-result-inspect-redaction.test.ts index a193d37..1be44b8 100644 --- a/node/test/auth-result-inspect-redaction.test.ts +++ b/node/test/auth-result-inspect-redaction.test.ts @@ -1,20 +1,20 @@ /** - * 回归测试:code2Session()/refreshToken() 返回值的调试打印脱敏。 + * 回归测试:getUserAccessToken()/refreshToken() 返回值的调试打印脱敏。 * - * 背景(Fable5 设计一致性审查发现的真实安全问题):code2Session()/ + * 背景(Fable5 设计一致性审查发现的真实安全问题):getUserAccessToken()/ * refreshToken() 返回的普通 object literal(类型见 src/types.ts 的 - * SessionData/RefreshTokenData)一旦被 console.log(session)/util.inspect(session) + * UserAccessTokenData/RefreshTokenData)一旦被 console.log(userCredential)/util.inspect(userCredential) * 这类"意外调试打印"路径打印,就会把 accessToken/refreshToken 明文暴露出去。 * - * 修复(src/auth.ts 的 withRedactedInspect())给返回对象挂了一个不可枚举的 + * 修复(src/redact.ts 的 withRedactedInspect())给返回对象挂了一个不可枚举的 * [util.inspect.custom] 方法,只影响 util.inspect()/console.log() 这条路径。 * * 本测试同时覆盖两个方向: - * 1. util.inspect(session) 拿到的字符串表示不能包含真实的 accessToken/refreshToken。 - * 2. 反向验证:JSON.stringify(session) 和直接属性访问(session.accessToken / - * session.refreshToken)必须仍然是原始明文值——因为 code2Session()/ + * 1. util.inspect(userCredential) 拿到的字符串表示不能包含真实的 accessToken/refreshToken。 + * 2. 反向验证:JSON.stringify(userCredential) 和直接属性访问(userCredential.accessToken / + * userCredential.refreshToken)必须仍然是原始明文值——因为 getUserAccessToken()/ * refreshToken() 存在的整个目的就是让调用方把这些真实值持久化到自己的 - * 数据库/session store,如果连 JSON.stringify 都被脱敏了 SDK 就没法用了。 + * 数据库/userCredential store,如果连 JSON.stringify 都被脱敏了 SDK 就没法用了。 */ import assert from 'node:assert/strict'; import {test} from 'node:test'; @@ -36,7 +36,7 @@ function makeClient( }); } -void test('auth.code2Session: util.inspect()/console.log() 路径不得暴露 accessToken/refreshToken 明文', async () => { +void test('auth.getUserAccessToken: util.inspect()/console.log() 路径不得暴露 accessToken/refreshToken 明文', async () => { const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( 200, @@ -49,35 +49,35 @@ void test('auth.code2Session: util.inspect()/console.log() 路径不得暴露 ac ); const client = makeClient(mockAgent); - const session = await client.auth.code2Session('some-auth-code'); + const userCredential = await client.auth.getUserAccessToken('some-auth-code'); - const rendered = inspect(session); + const rendered = inspect(userCredential); assert.ok( !rendered.includes(SENTINEL_ACCESS_TOKEN), - `util.inspect(session) leaked the raw accessToken: ${rendered}`, + `util.inspect(userCredential) leaked the raw accessToken: ${rendered}`, ); assert.ok( !rendered.includes(SENTINEL_REFRESH_TOKEN), - `util.inspect(session) leaked the raw refreshToken: ${rendered}`, + `util.inspect(userCredential) leaked the raw refreshToken: ${rendered}`, ); assert.ok( rendered.includes('[REDACTED]'), - `util.inspect(session) should contain the [REDACTED] placeholder: ${rendered}`, + `util.inspect(userCredential) should contain the [REDACTED] placeholder: ${rendered}`, ); // Reverse-direction guard: JSON.stringify (how the caller persists the - // session) and direct property access must both stay real plaintext. - const json = JSON.stringify(session); + // userCredential) and direct property access must both stay real plaintext. + const json = JSON.stringify(userCredential); assert.ok( json.includes(SENTINEL_ACCESS_TOKEN), - `JSON.stringify(session) must still contain the real accessToken for the caller to persist: ${json}`, + `JSON.stringify(userCredential) must still contain the real accessToken for the caller to persist: ${json}`, ); assert.ok( json.includes(SENTINEL_REFRESH_TOKEN), - `JSON.stringify(session) must still contain the real refreshToken for the caller to persist: ${json}`, + `JSON.stringify(userCredential) must still contain the real refreshToken for the caller to persist: ${json}`, ); - assert.equal(session.accessToken, SENTINEL_ACCESS_TOKEN); - assert.equal(session.refreshToken, SENTINEL_REFRESH_TOKEN); + assert.equal(userCredential.accessToken, SENTINEL_ACCESS_TOKEN); + assert.equal(userCredential.refreshToken, SENTINEL_REFRESH_TOKEN); }); void test('auth.refreshToken: util.inspect()/console.log() 路径不得暴露 accessToken 明文', async () => { @@ -113,9 +113,9 @@ void test('auth.refreshToken: util.inspect()/console.log() 路径不得暴露 ac // 回归测试(codex 收敛检查轮发现):openId 只校验非空字符串,服务端返回一个 // 嵌入 \r\n 的 openId 时,withRedactedInspect() 的渲染函数如果直接把它拼进 -// 模板字符串,util.inspect(session) 的结果就会出现原始换行——伪造出看似 +// 模板字符串,util.inspect(userCredential) 的结果就会出现原始换行——伪造出看似 // 独立的新日志行,和 message/code/requestId 已经修过的日志注入是同一类问题。 -void test('auth.code2Session: openId 里嵌入的 \\r\\n 不得原样出现在 util.inspect() 的调试渲染中(防日志注入)', async () => { +void test('auth.getUserAccessToken: openId 里嵌入的 \\r\\n 不得原样出现在 util.inspect() 的调试渲染中(防日志注入)', async () => { const injectedOpenId = 'open-id-1\r\nFAKE injected log line'; const {mockAgent, pool} = createMockAgent(); pool.intercept({path: '/auth/v1/token', method: 'POST'}).reply( @@ -129,14 +129,14 @@ void test('auth.code2Session: openId 里嵌入的 \\r\\n 不得原样出现在 u ); const client = makeClient(mockAgent); - const session = await client.auth.code2Session('some-auth-code'); + const userCredential = await client.auth.getUserAccessToken('some-auth-code'); - const rendered = inspect(session); + const rendered = inspect(userCredential); assert.ok( !rendered.includes('\r'), - `util.inspect(session) 不应包含 openId 中原始的 \\r 字符,实际: ${JSON.stringify(rendered)}`, + `util.inspect(userCredential) 不应包含 openId 中原始的 \\r 字符,实际: ${JSON.stringify(rendered)}`, ); // 反向验证:JSON.stringify 仍然携带真实的(哪怕含控制字符的)openId,因为 - // 这是调用方持久化 session 的正常路径,不应该被这个调试渲染修复影响到。 - assert.equal(session.openId, injectedOpenId); + // 这是调用方持久化 userCredential 的正常路径,不应该被这个调试渲染修复影响到。 + assert.equal(userCredential.openId, injectedOpenId); }); diff --git a/node/test/auth-tokenstore-write-failure.test.ts b/node/test/auth-tokenstore-write-failure.test.ts index 9d85db5..16d4f0b 100644 --- a/node/test/auth-tokenstore-write-failure.test.ts +++ b/node/test/auth-tokenstore-write-failure.test.ts @@ -54,7 +54,7 @@ class RejectOnceTokenStore implements TokenStore { } } -void test('auth.getAccessToken: tokenStore.set() 第一次写入失败时,该次调用必须 reject,且不得把这个未持久化的值当作本地新鲜缓存复用', async () => { +void test('auth.getAppAccessToken: tokenStore.set() 第一次写入失败时,该次调用必须 reject,且不得把这个未持久化的值当作本地新鲜缓存复用', async () => { const {mockAgent, pool} = createMockAgent(); const tokenStore = new RejectOnceTokenStore(/* rejectFirstSet= */ true); @@ -73,7 +73,7 @@ void test('auth.getAccessToken: tokenStore.set() 第一次写入失败时,该 tokenStore, }); - await expectFailure(() => client.auth.getAccessToken()); + await expectFailure(() => client.auth.getAppAccessToken()); assert.equal( tokenStore.setCallCount, 1, @@ -91,9 +91,9 @@ void test('auth.getAccessToken: tokenStore.set() 第一次写入失败时,该 }), ); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); assert.equal( - token, + token.accessToken, 'token-from-good-write', '第一次写入失败之后,下一次调用应当真实重新发起 CLIENT_CREDENTIALS 换取,而不是复用从未成功持久化的本地缓存', ); diff --git a/node/test/auth-session-fields.test.ts b/node/test/auth-user-access-token-fields.test.ts similarity index 51% rename from node/test/auth-session-fields.test.ts rename to node/test/auth-user-access-token-fields.test.ts index 08726e7..edd1b33 100644 --- a/node/test/auth-session-fields.test.ts +++ b/node/test/auth-user-access-token-fields.test.ts @@ -1,18 +1,18 @@ /** - * auth.code2Session() 字段完整性校验测试。 + * auth.getUserAccessToken() 字段完整性校验测试。 * - * 需求:code2Session() 除了要求 accessToken/expiresAt 合法(已由 + * 需求:getUserAccessToken() 除了要求 accessToken/expiresAt 合法(已由 * assertWellFormedTokenResponse() 覆盖,见 auth-malformed-success.test.ts), * 还必须校验业务层返回的 refreshToken/openId 非空 —— 这两个字段是 - * SessionData 的一部分(见 src/types.ts),调用方(宿主后端)需要拿 + * UserAccessTokenData 的一部分(见 src/types.ts),调用方(宿主后端)需要拿 * refreshToken 去后续换取新 token、拿 openId 去关联本地用户,一旦 * 静默 resolve 出 undefined/空字符串,后续业务逻辑会在错误的地方失败 * (比如把空字符串当 openId 存进数据库)。 * - * 现状(本测试写下时):AuthModule.code2Session()(src/auth.ts 约152-172行) - * 只调用 assertWellFormedTokenResponse(session, ...) 校验 accessToken/expiresAt, - * 完全不检查 session.refreshToken / session.openId 是否存在或非空。 - * 因此下面的测试预期是失败的(红色状态):当前实现会把畸形 session 直接 + * 现状(本测试写下时):AuthModule.getUserAccessToken()(src/auth.ts 约152-172行) + * 只调用 assertWellFormedTokenResponse(userCredential, ...) 校验 accessToken/expiresAt, + * 完全不检查 userCredential.refreshToken / userCredential.openId 是否存在或非空。 + * 因此下面的测试预期是失败的(红色状态):当前实现会把畸形 userCredential 直接 * resolve 给调用方,而不是 reject。 */ import {test} from 'node:test'; @@ -24,12 +24,12 @@ import { expectFailure, } from './helpers/mock-http.js'; -function sessionData( +function userCredentialData( overrides: Record = {}, ): Record { return { - accessToken: 'session-access-abc', - refreshToken: 'session-refresh-abc', + accessToken: 'userCredential-access-abc', + refreshToken: 'userCredential-refresh-abc', expiresAt: String(Math.floor(Date.now() / 1000) + 7200), openId: 'open-id-abc', ...overrides, @@ -46,45 +46,45 @@ function makeClient( }); } -void test('auth.code2Session: refreshToken 字段完全缺失时应 reject', async () => { +void test('auth.getUserAccessToken: refreshToken 字段完全缺失时应 reject', async () => { const {mockAgent, pool} = createMockAgent(); - // sessionData({refreshToken: undefined}) 展开后 refreshToken 的值是 + // userCredentialData({refreshToken: undefined}) 展开后 refreshToken 的值是 // undefined,JSON.stringify(undici MockAgent 序列化 reply body 时会用到) // 会直接丢弃 undefined 值的 key,等价于"响应里完全没有这个字段"。 pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({refreshToken: undefined}))); + .reply(200, businessSuccess(userCredentialData({refreshToken: undefined}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); -void test('auth.code2Session: refreshToken 为空字符串时应 reject,不得把空字符串当作合法 refreshToken resolve 给调用方', async () => { +void test('auth.getUserAccessToken: refreshToken 为空字符串时应 reject,不得把空字符串当作合法 refreshToken resolve 给调用方', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({refreshToken: ''}))); + .reply(200, businessSuccess(userCredentialData({refreshToken: ''}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); -void test('auth.code2Session: openId 字段完全缺失时应 reject', async () => { +void test('auth.getUserAccessToken: openId 字段完全缺失时应 reject', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({openId: undefined}))); + .reply(200, businessSuccess(userCredentialData({openId: undefined}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); -void test('auth.code2Session: openId 为空字符串时应 reject,不得把空字符串当作合法 openId resolve 给调用方', async () => { +void test('auth.getUserAccessToken: openId 为空字符串时应 reject,不得把空字符串当作合法 openId resolve 给调用方', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(sessionData({openId: ''}))); + .reply(200, businessSuccess(userCredentialData({openId: ''}))); const client = makeClient(mockAgent); - await expectFailure(() => client.auth.code2Session('some-auth-code')); + await expectFailure(() => client.auth.getUserAccessToken('some-auth-code')); }); diff --git a/node/test/auth.test.ts b/node/test/auth.test.ts index 50d1884..3159002 100644 --- a/node/test/auth.test.ts +++ b/node/test/auth.test.ts @@ -1,5 +1,5 @@ /** - * auth 模块测试:app 级 token 缓存 + 单飞刷新 + 300 秒缓冲提前刷新 + code2Session + + * auth 模块测试:app 级 token 缓存 + 单飞刷新 + 300 秒缓冲提前刷新 + getUserAccessToken + * refreshToken 一次性换取。均依据 shared/openapi.yaml 的 authToken/authRefresh * operation 的 token 生命周期模型。 * @@ -8,7 +8,7 @@ * - app 级 token 并发单飞(只触发一次真实 HTTP 换取) * - 300 秒缓冲提前刷新(不是等到真正过期才刷新) * - HTTP 200 + code !== '0' 判定为失败(不能只信 HTTP 状态码) - * - code2Session(AUTHORIZATION_CODE)不进入 app 级缓存 + * - getUserAccessToken(AUTHORIZATION_CODE)不进入 app 级缓存 * - refreshToken 一次性刷新,HTTP 200 + code:10008(refreshToken 超期)判定为失败 * - 错误信息不泄露 appSecret */ @@ -108,7 +108,7 @@ function tokenSuccessData(overrides: Record = {}) { }; } -void test('auth.getAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app 级 token', async () => { +void test('auth.getAppAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app 级 token', async () => { const {mockAgent, pool} = createMockAgent(); let capturedBody: unknown; pool @@ -128,9 +128,9 @@ void test('auth.getAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app dispatcher: mockAgent, }); - const token = await client.auth.getAccessToken(); + const token = await client.auth.getAppAccessToken(); - assert.equal(token, 'app-token-abc'); + assert.equal(token.accessToken, 'app-token-abc'); assert.deepEqual(capturedBody, { grantType: 'CLIENT_CREDENTIALS', appId: 'app-id-1', @@ -138,7 +138,7 @@ void test('auth.getAccessToken: 正常路径用 CLIENT_CREDENTIALS 换取 app }); }); -void test('auth.getAccessToken: 并发调用只触发一次真实换取请求(单飞去重)', async () => { +void test('auth.getAppAccessToken: 并发调用只触发一次真实换取请求(单飞去重)', async () => { const {mockAgent, pool} = createMockAgent(); // 只注册一次拦截器:若实现没有做单飞去重,第二/三次并发请求会找不到匹配的 // 拦截器而报错(因为 disableNetConnect),从而暴露问题。 @@ -153,14 +153,14 @@ void test('auth.getAccessToken: 并发调用只触发一次真实换取请求( }); const [t1, t2, t3] = await Promise.all([ - client.auth.getAccessToken(), - client.auth.getAccessToken(), - client.auth.getAccessToken(), + client.auth.getAppAccessToken(), + client.auth.getAppAccessToken(), + client.auth.getAppAccessToken(), ]); - assert.equal(t1, 'app-token-abc'); - assert.equal(t2, 'app-token-abc'); - assert.equal(t3, 'app-token-abc'); + assert.equal(t1.accessToken, 'app-token-abc'); + assert.equal(t2.accessToken, 'app-token-abc'); + assert.equal(t3.accessToken, 'app-token-abc'); assert.equal( mockAgent.pendingInterceptors().length, 0, @@ -168,11 +168,18 @@ void test('auth.getAccessToken: 并发调用只触发一次真实换取请求( ); }); -void test('auth.getAccessToken: 缓存命中时不重复发起 HTTP 请求', async () => { +void test('auth.getAppAccessToken: 缓存命中时不重复发起 HTTP 请求,且缓存命中路径返回的应用凭证与新换取路径同样完整', async () => { const {mockAgent, pool} = createMockAgent(); + // 按实测事实构造:CLIENT_CREDENTIALS 响应同样带 refreshToken(真实非空值) + // 和 openId(空字符串)。 pool .intercept({path: '/auth/v1/token', method: 'POST'}) - .reply(200, businessSuccess(tokenSuccessData())); + .reply( + 200, + businessSuccess( + tokenSuccessData({refreshToken: 'app-refresh-abc', openId: ''}), + ), + ); const client = new QdmpClient({ appId: 'app-id-1', @@ -180,16 +187,27 @@ void test('auth.getAccessToken: 缓存命中时不重复发起 HTTP 请求', asy dispatcher: mockAgent, }); - const first = await client.auth.getAccessToken(); + const first = await client.auth.getAppAccessToken(); // 没有再注册第二个拦截器:如果实现没有缓存、又发起了第二次 HTTP 请求, // disableNetConnect 会让该请求报错,下面这行就会抛出。 - const second = await client.auth.getAccessToken(); + const second = await client.auth.getAppAccessToken(); - assert.equal(first, 'app-token-abc'); - assert.equal(second, 'app-token-abc'); + assert.equal(first.accessToken, 'app-token-abc'); + assert.equal(second.accessToken, 'app-token-abc'); + assert.equal(first.refreshToken, 'app-refresh-abc'); + assert.equal( + first.openId, + '', + '应用凭证的 openId 实测就是空字符串,不应被当作缺失字段报错', + ); + assert.deepEqual( + {...second}, + {...first}, + '缓存命中那一次返回的凭证必须与刚换取那一次逐字段相同,不能少 refreshToken/openId', + ); }); -void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即主动刷新(不是等到真正过期)', async t => { +void test('auth.getAppAccessToken: 距真正过期还有 300 秒缓冲区间内即主动刷新(不是等到真正过期)', async t => { t.mock.timers.enable({apis: ['Date']}); const nowSeconds = Math.floor(Date.now() / 1000); @@ -210,8 +228,8 @@ void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即 dispatcher: mockAgent, }); - const first = await client.auth.getAccessToken(); - assert.equal(first, 'token-1'); + const first = await client.auth.getAppAccessToken(); + assert.equal(first.accessToken, 'token-1'); // 时间前进 150 秒:距真正过期(400 秒后)还剩 250 秒,落在 300 秒缓冲区间内, // 理应主动换新,而不是复用这个"技术上还没过期但快过期"的旧 token。 @@ -227,10 +245,10 @@ void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即 ), ); - const second = await client.auth.getAccessToken(); + const second = await client.auth.getAppAccessToken(); assert.equal( - second, + second.accessToken, 'token-2', '进入 300 秒缓冲期后应当换新 token,而不是继续返回 token-1', ); @@ -241,7 +259,7 @@ void test('auth.getAccessToken: 距真正过期还有 300 秒缓冲区间内即 ); }); -void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧随其后的调用命中进程内缓存而不重复换取', async () => { +void test('auth.getAppAccessToken: 异步 TokenStore 存在"回读延迟"时,紧随其后的调用命中进程内缓存而不重复换取', async () => { const {mockAgent, pool} = createMockAgent(); // 只注册一个拦截器:若第二次调用没有命中本地缓存、退化为重新调用 // tokenStore.get()(此刻仍返回旧值/undefined)再触发 refreshAppToken(), @@ -258,8 +276,8 @@ void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧 tokenStore, }); - const first = await client.auth.getAccessToken(); - assert.equal(first, 'token-1'); + const first = await client.auth.getAppAccessToken(); + assert.equal(first.accessToken, 'token-1'); assert.equal( tokenStore.getCalls, 1, @@ -268,8 +286,8 @@ void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧 // 紧接着再次调用:此时 tokenStore 的 visibleValue 仍是回读延迟窗口内的旧值 // (setTimeout(50ms) 还没触发),只有本地 cachedToken 能证明拿到了新 token。 - const second = await client.auth.getAccessToken(); - assert.equal(second, 'token-1'); + const second = await client.auth.getAppAccessToken(); + assert.equal(second.accessToken, 'token-1'); assert.equal( tokenStore.getCalls, 1, @@ -282,7 +300,7 @@ void test('auth.getAccessToken: 异步 TokenStore 存在"回读延迟"时,紧 ); }); -void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调用者的单飞刷新已完成之后才 resolve(且值仍是刷新前的旧快照)时,不应触发第二次冗余换取', async () => { +void test('auth.getAppAccessToken: 并发调用者的 tokenStore.get() 在另一调用者的单飞刷新已完成之后才 resolve(且值仍是刷新前的旧快照)时,不应触发第二次冗余换取', async () => { const {mockAgent, pool} = createMockAgent(); // 只注册一个拦截器:若实现在这种交错时序下仍触发第二次 CLIENT_CREDENTIALS // 换取,disableNetConnect 会让第二次请求找不到拦截器而报错,从而暴露问题。 @@ -301,14 +319,14 @@ void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调 // 两个调用"同时"发起:两者都在各自看到 this.cachedToken 为空后, // 各自调用了一次 tokenStore.get()(此刻都拿到了「无 token」的快照,都还未 // resolve)。 - const callA = client.auth.getAccessToken(); - const callB = client.auth.getAccessToken(); + const callA = client.auth.getAppAccessToken(); + const callB = client.auth.getAppAccessToken(); // 放行 A 的 get():A 发现无缓存,进入单飞刷新,真实发起(唯一注册的)HTTP // 换取请求,成功后设置 this.cachedToken 并清空 inFlight。 tokenStore.releaseNextGet(); const resultA = await callA; - assert.equal(resultA, 'token-1'); + assert.equal(resultA.accessToken, 'token-1'); // 放行 B 的 get():resolve 的是 B 发起 get() 那一刻捕获的快照(当时仍是 // 「无 token」),复现"回读结果滞后于另一并发调用者已完成的刷新"这一时序。 @@ -318,7 +336,7 @@ void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调 const resultB = await callB; assert.equal( - resultB, + resultB.accessToken, 'token-1', 'B 应当复用 A 已经写入的进程内缓存 token,而不是触发第二次冗余换取', ); @@ -329,7 +347,7 @@ void test('auth.getAccessToken: 并发调用者的 tokenStore.get() 在另一调 ); }); -void test('auth.getAccessToken: HTTP 200 但业务 code !== "0" 判定为失败,不能只信 HTTP 状态码', async () => { +void test('auth.getAppAccessToken: HTTP 200 但业务 code !== "0" 判定为失败,不能只信 HTTP 状态码', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/token', method: 'POST'}) @@ -341,7 +359,7 @@ void test('auth.getAccessToken: HTTP 200 但业务 code !== "0" 判定为失败 dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, `应当抛出 QdmpApiError,实际抛出: ${err}`, @@ -351,7 +369,7 @@ void test('auth.getAccessToken: HTTP 200 但业务 code !== "0" 判定为失败 assert.equal(apiErr.httpStatus, 200); }); -void test('auth.getAccessToken: 换取失败时错误信息不泄露 appSecret 原文', async () => { +void test('auth.getAppAccessToken: 换取失败时错误信息不泄露 appSecret 原文', async () => { const secretMarker = 'DO-NOT-LEAK-app-secret-zzz999'; const {mockAgent, pool} = createMockAgent(); pool @@ -364,7 +382,7 @@ void test('auth.getAccessToken: 换取失败时错误信息不泄露 appSecret dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); const rendered = `${(err as Error).message ?? ''} ${String(err)} ${safeStringify(err)}`; assert.ok( !rendered.includes(secretMarker), @@ -372,7 +390,7 @@ void test('auth.getAccessToken: 换取失败时错误信息不泄露 appSecret ); }); -void test('auth.code2Session: AUTHORIZATION_CODE 正常换取用户级 token,且不进入 app 级缓存', async () => { +void test('auth.getUserAccessToken: AUTHORIZATION_CODE 正常换取用户级 token,且不进入 app 级缓存', async () => { const {mockAgent, pool} = createMockAgent(); let capturedBody: unknown; pool @@ -400,12 +418,12 @@ void test('auth.code2Session: AUTHORIZATION_CODE 正常换取用户级 token, dispatcher: mockAgent, }); - const session = await client.auth.code2Session('login-code-123'); + const userCredential = await client.auth.getUserAccessToken('login-code-123'); - assert.equal(session.accessToken, 'user-token-xyz'); - assert.equal(session.refreshToken, 'refresh-xyz'); - assert.equal(session.openId, 'openid-abc'); - assert.equal(typeof session.expiresAt, 'string'); + assert.equal(userCredential.accessToken, 'user-token-xyz'); + assert.equal(userCredential.refreshToken, 'refresh-xyz'); + assert.equal(userCredential.openId, 'openid-abc'); + assert.equal(typeof userCredential.expiresAt, 'string'); assert.deepEqual(capturedBody, { grantType: 'AUTHORIZATION_CODE', appId: 'app-1', @@ -414,15 +432,15 @@ void test('auth.code2Session: AUTHORIZATION_CODE 正常换取用户级 token, }); // 换取 app 级 token 时必须仍然真实发起一次独立的 CLIENT_CREDENTIALS 请求, - // 证明 code2Session 拿到的用户级 token 没有被误当成 app 级 token 缓存复用。 + // 证明 getUserAccessToken 拿到的用户级 token 没有被误当成 app 级 token 缓存复用。 pool .intercept({path: '/auth/v1/token', method: 'POST'}) .reply( 200, businessSuccess(tokenSuccessData({accessToken: 'app-token-fresh'})), ); - const appToken = await client.auth.getAccessToken(); - assert.equal(appToken, 'app-token-fresh'); + const appToken = await client.auth.getAppAccessToken(); + assert.equal(appToken.accessToken, 'app-token-fresh'); }); void test('auth.refreshToken: 正常路径换取新 accessToken(一次性,不缓存)', async () => { @@ -457,7 +475,7 @@ void test('auth.refreshToken: 正常路径换取新 accessToken(一次性, assert.deepEqual(capturedBody, {refreshToken: 'some-refresh-token'}); }); -void test('auth.refreshToken: 空字符串 refreshToken 本地直接报错,不发起任何 HTTP 请求(与 code2Session 对 code 的校验保持一致)', async () => { +void test('auth.refreshToken: 空字符串 refreshToken 本地直接报错,不发起任何 HTTP 请求(与 getUserAccessToken 对 code 的校验保持一致)', async () => { const {mockAgent, pool} = createMockAgent(); pool .intercept({path: '/auth/v1/refresh', method: 'POST'}) diff --git a/node/test/errors-code-sanitization.test.ts b/node/test/errors-code-sanitization.test.ts index 10bca42..ff7d96a 100644 --- a/node/test/errors-code-sanitization.test.ts +++ b/node/test/errors-code-sanitization.test.ts @@ -41,7 +41,7 @@ void test('QdmpApiError: code 字段中嵌入的 \\r\\n 不得原样出现在 er dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, @@ -84,7 +84,7 @@ void test('QdmpApiError: requestId 字段中嵌入的 \\r\\n 不得原样出现 dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok(err instanceof QdmpApiError); const apiErr = err as QdmpApiError; @@ -116,7 +116,7 @@ void test('HttpClient.request: 响应体的 code 字段是数组/对象(既不 dispatcher: mockAgent, }); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, `应当 reject QdmpApiError,实际抛出: ${String(err)}`, diff --git a/node/test/errors-message-sanitization.test.ts b/node/test/errors-message-sanitization.test.ts index 0641579..0df3af1 100644 --- a/node/test/errors-message-sanitization.test.ts +++ b/node/test/errors-message-sanitization.test.ts @@ -45,7 +45,7 @@ void test('QdmpApiError: message 中嵌入的 \\r\\n 不得原样出现在 error .reply(200, businessFailure('40001', injectedMessage)); const client = makeClient(mockAgent); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, @@ -71,7 +71,7 @@ void test('QdmpApiError: 服务端返回超长 message(约20万字符)时, .reply(200, businessFailure('40002', hugeMessage)); const client = makeClient(mockAgent); - const err = await expectFailure(() => client.auth.getAccessToken()); + const err = await expectFailure(() => client.auth.getAppAccessToken()); assert.ok( err instanceof QdmpApiError, diff --git a/node/test/helpers/mock-http.ts b/node/test/helpers/mock-http.ts index b2af5a2..f58a73d 100644 --- a/node/test/helpers/mock-http.ts +++ b/node/test/helpers/mock-http.ts @@ -62,6 +62,41 @@ export function gatewayFailure( return {code, message, details}; } +/** + * 用 `.reply()` 的回调形式来数"真的发出了几次请求"、顺便抓请求头。 + * + * 不能用 `intercept({headers: h => ...})` 的匹配函数来做这件事:undici 对同一次 + * 请求会多次调用它——挑选候选拦截器时每个未消费的候选各调一次 + * (mock-utils.js 的 getMockDispatch()),回收已消费的拦截器时再调一次 + * (同文件 deleteMockDispatch())。而 `.reply(opts => ...)` 的回调在 + * mockDispatch() 里严格每次真实 dispatch 只跑一次,是唯一可靠的请求计数点。 + */ +export function replyWith( + statusCode: number, + data: Record, + onRequest?: (headers: Record) => void, +): (opts: {headers?: Record | Headers}) => { + statusCode: number; + data: Record; +} { + return opts => { + onRequest?.(toHeaderRecord(opts.headers)); + return {statusCode, data}; + }; +} + +function toHeaderRecord( + headers: Record | Headers | undefined, +): Record { + if (!headers) { + return {}; + } + if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } + return headers; +} + /** * 统一捕获"函数应当失败"这件事,同时兼容同步 throw 和异步 reject 两种实现风格 * ——`assert.rejects()` 只在传入函数返回 rejected promise 时才生效,若实现是同步 diff --git a/shared/generated/error-codes.json b/shared/generated/error-codes.json index 639685d..cdd9edb 100644 --- a/shared/generated/error-codes.json +++ b/shared/generated/error-codes.json @@ -14,8 +14,8 @@ { "code": 10003, "name": "AUTH_CODE_INVALID", - "message": "授权码错误", - "source": "文档" + "message": "授权码错误或超过 5 分钟", + "source": "文档(实测复核确认:HTTP 200 + 该 code,服务端返回的 message 是「授权码错误或超过 5 分钟」——授权码有 5 分钟有效期,源文档未写明)" }, { "code": 10004, diff --git a/shared/openapi.yaml b/shared/openapi.yaml index 2b5e601..96f6b81 100644 --- a/shared/openapi.yaml +++ b/shared/openapi.yaml @@ -27,8 +27,8 @@ { "code": 10003, "name": "AUTH_CODE_INVALID", - "message": "授权码错误", - "source": "文档" + "message": "授权码错误或超过 5 分钟", + "source": "文档(实测复核确认:HTTP 200 + 该 code,服务端返回的 message 是「授权码错误或超过 5 分钟」——授权码有 5 分钟有效期,源文档未写明)" }, { "code": 10004, @@ -103,7 +103,7 @@ "auth" ], "summary": "获取访问令牌", - "description": "支持两种授权模式:CLIENT_CREDENTIALS(客户端凭证)和 AUTHORIZATION_CODE(授权码,用户登录后)。", + "description": "签发两种凭证:CLIENT_CREDENTIALS 换应用凭证,代表这个开放平台应用本身,不代表任何用户,用于开发者内部调试、服务端联调、不需要用户身份的后台任务;AUTHORIZATION_CODE 换用户授权凭证,绑定 openId,代表某个真实登录用户,应用上线后调业务接口全程用它。", "x-qdmp-auth-scheme": "noAuth", "x-qdmp-token-required": false, "parameters": [], @@ -120,7 +120,7 @@ "CLIENT_CREDENTIALS", "AUTHORIZATION_CODE" ], - "description": "授权类型:CLIENT_CREDENTIALS 或 AUTHORIZATION_CODE" + "description": "凭证类型:CLIENT_CREDENTIALS(应用凭证)或 AUTHORIZATION_CODE(用户授权凭证)" }, "appId": { "type": "string", @@ -132,7 +132,7 @@ }, "code": { "type": "string", - "description": "授权码,来自 qd.login()。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。" + "description": "一次性授权码,小程序前端调宿主的 qd.login() 获取,需用户已在 App 内登录;其对应的原生接口是 POST https://api.qiandao.com/signin/openapi/auth/generate-code(body {appId, scene:\"openapi\"},取响应的 data.code),非小程序环境可直接调用。被 Auth 服务消费后立即失效,且 5 分钟内有效(实测超时返回 code=10003「授权码错误或超过 5 分钟」)。仅 grantType=AUTHORIZATION_CODE 时必填;该约束在运行时校验,不在 schema 层强制声明为 required(源文档标注 required 但描述又说明“仅授权码模式必填”,语义矛盾,此处以放宽为可选处理)。" } }, "required": [ @@ -557,7 +557,7 @@ ], "responses": { "200": { - "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个 token(见 x-qdmp-token-required=true),是否传 app 级还是用户级由调用方决定,不是 SDK 自动 fallback。", + "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证也可正常调通本接口(HTTP 200 正常业务响应)。SDK 仍要求调用方显式传入某个凭证(见 x-qdmp-token-required=true),传应用凭证还是用户授权凭证由调用方决定,不是 SDK 自动 fallback。", "content": { "application/json": { "schema": { @@ -1008,7 +1008,7 @@ ], "responses": { "200": { - "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的 app 级 token 可真实调通本接口(HTTP 200 正常业务响应)。", + "description": "已实测确认:使用 CLIENT_CREDENTIALS 换取的应用凭证可真实调通本接口(HTTP 200 正常业务响应)。", "content": { "application/json": { "schema": { @@ -3448,7 +3448,7 @@ "schema": { "type": "string" }, - "description": "用户级或应用级访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个 token”,不区分是应用级(CLIENT_CREDENTIALS)还是用户级(AUTHORIZATION_CODE)换来的——见该 operation 上的 x-qdmp-token-required。" + "description": "用户授权凭证或应用凭证的访问令牌,通过 /auth/v1/token 获取。standard 鉴权方案下必须携带;SDK 层面只要求“传了某个凭证”,不区分是 CLIENT_CREDENTIALS 换的应用凭证还是 AUTHORIZATION_CODE 换的用户授权凭证——见该 operation 上的 x-qdmp-token-required。" }, "QdmpVersionHeader": { "name": "x-echo-qdmp-version",