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 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 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